Merge branch 'develop-1.1.0-beta2' into staging

This commit is contained in:
Marquis Kurt 2019-12-23 15:49:04 -05:00
commit 338b9118a6
No known key found for this signature in database
GPG Key ID: 725636D259F5402D
19 changed files with 869 additions and 77 deletions

View File

@ -12,8 +12,27 @@ jobs:
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 10.x node-version: 10.x
- name: Change desktop field
run: |
from json import load, dump
json_dict = {}
with open('public/config.json', 'r') as file:
json_dict = load(file)
json_dict["location"] = "desktop"
with open('public/config.json', 'w+') as out:
dump(json_dict, out)
shell: python
- name: Install dependencies and build - name: Install dependencies and build
run: | run: |
npm install npm install
npm run build --if-present npm run build --if-present
npm run build-desktop-win npm run build-desktop-win
- name: Upload Windows executable
uses: actions/upload-artifact@v1
if: success()
with:
name: 'Windows executable (output dir)'
path: dist

7
package-lock.json generated
View File

@ -1,6 +1,6 @@
{ {
"name": "hyperspace", "name": "hyperspace",
"version": "1.1.0-beta1", "version": "1.1.0-beta2",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -16822,6 +16822,11 @@
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"dev": true "dev": true
}, },
"react-masonry-css": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/react-masonry-css/-/react-masonry-css-1.0.14.tgz",
"integrity": "sha512-oAPVOCMApTT0HkxZJy84yU1EWaaQNZnJE0DjDMy/L+LxZoJEph4RRXsT9ppPKbFSo/tCzj+cCLwiBHjZmZ2eXA=="
},
"react-router": { "react-router": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz",

View File

@ -1,7 +1,7 @@
{ {
"name": "hyperspace", "name": "hyperspace",
"productName": "Hyperspace Desktop", "productName": "Hyperspace Desktop",
"version": "1.1.0-beta1", "version": "1.1.0-beta2",
"description": "A beautiful, fluffy client for the fediverse", "description": "A beautiful, fluffy client for the fediverse",
"author": "Marquis Kurt <hyperspacedev@marquiskurt.net>", "author": "Marquis Kurt <hyperspacedev@marquiskurt.net>",
"repository": "https://github.com/hyperspacedev/hyperspace.git", "repository": "https://github.com/hyperspacedev/hyperspace.git",
@ -41,7 +41,8 @@
"dependencies": { "dependencies": {
"electron-notarize": "^0.1.1", "electron-notarize": "^0.1.1",
"electron-updater": "^4.1.2", "electron-updater": "^4.1.2",
"electron-window-state": "^5.0.3" "electron-window-state": "^5.0.3",
"react-masonry-css": "^1.0.14"
}, },
"main": "public/electron.js", "main": "public/electron.js",
"scripts": { "scripts": {

View File

@ -35,7 +35,8 @@ export const styles = (theme: Theme) =>
titleBarText: { titleBarText: {
fontSize: 12, fontSize: 12,
paddingTop: 2, paddingTop: 2,
paddingBottom: 1 paddingBottom: 1,
color: theme.palette.getContrastText(theme.palette.primary.main)
}, },
appBar: { appBar: {
zIndex: 1000, zIndex: 1000,
@ -57,6 +58,10 @@ export const styles = (theme: Theme) =>
display: "none" display: "none"
} }
}, },
appBarBackButton: {
marginLeft: -12,
marginRight: 20
},
appBarTitle: { appBarTitle: {
display: "none", display: "none",
[theme.breakpoints.up("md")]: { [theme.breakpoints.up("md")]: {

View File

@ -44,6 +44,7 @@ import SupervisedUserCircleIcon from "@material-ui/icons/SupervisedUserCircle";
import ExitToAppIcon from "@material-ui/icons/ExitToApp"; import ExitToAppIcon from "@material-ui/icons/ExitToApp";
import TrendingUpIcon from "@material-ui/icons/TrendingUp"; import TrendingUpIcon from "@material-ui/icons/TrendingUp";
import BuildIcon from "@material-ui/icons/Build"; import BuildIcon from "@material-ui/icons/Build";
import ArrowBackIcon from "@material-ui/icons/ArrowBack";
import { styles } from "./AppLayout.styles"; import { styles } from "./AppLayout.styles";
import { MultiAccount, UAccount } from "../../types/Account"; import { MultiAccount, UAccount } from "../../types/Account";
@ -67,30 +68,81 @@ import {
getAccountRegistry, getAccountRegistry,
removeAccountFromRegistry removeAccountFromRegistry
} from "../../utilities/accounts"; } from "../../utilities/accounts";
import { isChildView } from "../../utilities/appbar";
/**
* The pre-define state interface for the app layout.
*/
interface IAppLayoutState { interface IAppLayoutState {
/**
* Whether the account menu is open or not.
*/
acctMenuOpen: boolean; acctMenuOpen: boolean;
/**
* Whether the drawer is open (mobile-only).
*/
drawerOpenOnMobile: boolean; drawerOpenOnMobile: boolean;
/**
* The current user signed in.
*/
currentUser?: UAccount; currentUser?: UAccount;
/**
* The number of notifications received.
*/
notificationCount: number; notificationCount: number;
/**
* Whether the log out dialog is open.
*/
logOutOpen: boolean; logOutOpen: boolean;
/**
* Whether federation has been enabled in the config.
*/
enableFederation?: boolean; enableFederation?: boolean;
/**
* The brand name of the app, if not "Hyperspace".
*/
brandName?: string; brandName?: string;
/**
* Whether the app is in development mode.
*/
developerMode?: boolean; developerMode?: boolean;
} }
/**
* The base app layout class. Responsible for the search bar, navigation menus, etc.
*/
export class AppLayout extends Component<any, IAppLayoutState> { export class AppLayout extends Component<any, IAppLayoutState> {
/**
* The Mastodon client to operate with.
*/
client: Mastodon; client: Mastodon;
/**
* A stream listener to listen for new streaming events from Mastodon.
*/
streamListener: any; streamListener: any;
/**
* Construct the app layout.
* @param props The properties to pass in.
*/
constructor(props: any) { constructor(props: any) {
super(props); super(props);
// Create the Mastodon client
this.client = new Mastodon( this.client = new Mastodon(
localStorage.getItem("access_token") as string, localStorage.getItem("access_token") as string,
(localStorage.getItem("baseurl") as string) + "/api/v1" (localStorage.getItem("baseurl") as string) + "/api/v1"
); );
// Initialize the state
this.state = { this.state = {
drawerOpenOnMobile: false, drawerOpenOnMobile: false,
acctMenuOpen: false, acctMenuOpen: false,
@ -98,14 +150,20 @@ export class AppLayout extends Component<any, IAppLayoutState> {
logOutOpen: false logOutOpen: false
}; };
// Bind functions as properties to this class for reference
this.toggleDrawerOnMobile = this.toggleDrawerOnMobile.bind(this); this.toggleDrawerOnMobile = this.toggleDrawerOnMobile.bind(this);
this.toggleAcctMenu = this.toggleAcctMenu.bind(this); this.toggleAcctMenu = this.toggleAcctMenu.bind(this);
this.clearBadge = this.clearBadge.bind(this); this.clearBadge = this.clearBadge.bind(this);
} }
/**
* Run post-mount tasks such as getting account data and refreshing the config file.
*/
componentDidMount() { componentDidMount() {
// Get the account data.
this.getAccountData(); this.getAccountData();
// Read the config file and then update the state.
getConfig().then((result: any) => { getConfig().then((result: any) => {
if (result !== undefined) { if (result !== undefined) {
let config: Config = result; let config: Config = result;
@ -119,18 +177,25 @@ export class AppLayout extends Component<any, IAppLayoutState> {
} }
}); });
// Listen for notifications.
this.streamNotifications(); this.streamNotifications();
} }
/**
* Get updated credentials from Mastodon or pull information from local storage.
*/
getAccountData() { getAccountData() {
// Try to get updated credentials from Mastodon.
this.client this.client
.get("/accounts/verify_credentials") .get("/accounts/verify_credentials")
.then((resp: any) => { .then((resp: any) => {
// Update the account if possible.
let data: UAccount = resp.data; let data: UAccount = resp.data;
this.setState({ currentUser: data }); this.setState({ currentUser: data });
sessionStorage.setItem("id", data.id); sessionStorage.setItem("id", data.id);
}) })
.catch((err: Error) => { .catch((err: Error) => {
// Otherwise, pull from local storage.
this.props.enqueueSnackbar( this.props.enqueueSnackbar(
"Couldn't find profile info: " + err.name "Couldn't find profile info: " + err.name
); );
@ -140,9 +205,14 @@ export class AppLayout extends Component<any, IAppLayoutState> {
}); });
} }
/**
* Set up a stream listener and listen for notifications.
*/
streamNotifications() { streamNotifications() {
// Set up the stream listener.
this.streamListener = this.client.stream("/streaming/user"); this.streamListener = this.client.stream("/streaming/user");
// Set the count if the user asked to display the total count.
if (getUserDefaultBool("displayAllOnNotificationBadge")) { if (getUserDefaultBool("displayAllOnNotificationBadge")) {
this.client.get("/notifications").then((resp: any) => { this.client.get("/notifications").then((resp: any) => {
let notifArray = resp.data; let notifArray = resp.data;
@ -150,14 +220,17 @@ export class AppLayout extends Component<any, IAppLayoutState> {
}); });
} }
// Listen for notifications.
this.streamListener.on("notification", (notif: Notification) => { this.streamListener.on("notification", (notif: Notification) => {
const notificationCount = this.state.notificationCount + 1; const notificationCount = this.state.notificationCount + 1;
this.setState({ notificationCount }); this.setState({ notificationCount });
// Update the badge on the desktop.
if (isDesktopApp()) { if (isDesktopApp()) {
getElectronApp().setBadgeCount(notificationCount); getElectronApp().setBadgeCount(notificationCount);
} }
// Set up a push notification if the window isn't in focus.
if (!document.hasFocus()) { if (!document.hasFocus()) {
let primaryMessage = ""; let primaryMessage = "";
let secondaryMessage = ""; let secondaryMessage = "";
@ -216,25 +289,39 @@ export class AppLayout extends Component<any, IAppLayoutState> {
break; break;
} }
// Respectfully send the notification request.
sendNotificationRequest(primaryMessage, secondaryMessage); sendNotificationRequest(primaryMessage, secondaryMessage);
} }
}); });
} }
/**
* Toggle the account menu.
*/
toggleAcctMenu() { toggleAcctMenu() {
this.setState({ acctMenuOpen: !this.state.acctMenuOpen }); this.setState({ acctMenuOpen: !this.state.acctMenuOpen });
} }
/**
* Toggle the app drawer, if on mobile.
*/
toggleDrawerOnMobile() { toggleDrawerOnMobile() {
this.setState({ this.setState({
drawerOpenOnMobile: !this.state.drawerOpenOnMobile drawerOpenOnMobile: !this.state.drawerOpenOnMobile
}); });
} }
/**
* Toggle the logout dialog.
*/
toggleLogOutDialog() { toggleLogOutDialog() {
this.setState({ logOutOpen: !this.state.logOutOpen }); this.setState({ logOutOpen: !this.state.logOutOpen });
} }
/**
* Perform a search and redirect to the search page.
* @param what The query input from the search box
*/
searchForQuery(what: string) { searchForQuery(what: string) {
what = what.replace(/^#/g, "tag:"); what = what.replace(/^#/g, "tag:");
console.log(what); console.log(what);
@ -243,9 +330,13 @@ export class AppLayout extends Component<any, IAppLayoutState> {
: "/#/search?query=" + what; : "/#/search?query=" + what;
} }
/**
* Clear login information, remove the account from the registry, and reload the web page.
*/
logOutAndRestart() { logOutAndRestart() {
let loginData = localStorage.getItem("login"); let loginData = localStorage.getItem("login");
if (loginData) { if (loginData) {
// Remove account from the registry.
let registry = getAccountRegistry(); let registry = getAccountRegistry();
registry.forEach((registryItem: MultiAccount, index: number) => { registry.forEach((registryItem: MultiAccount, index: number) => {
@ -257,15 +348,20 @@ export class AppLayout extends Component<any, IAppLayoutState> {
} }
}); });
// Clear some of the local storage fields.
let items = ["login", "account", "baseurl", "access_token"]; let items = ["login", "account", "baseurl", "access_token"];
items.forEach(entry => { items.forEach(entry => {
localStorage.removeItem(entry); localStorage.removeItem(entry);
}); });
// Finally, reload.
window.location.reload(); window.location.reload();
} }
} }
/**
* Clear the notifications badge.
*/
clearBadge() { clearBadge() {
if (!getUserDefaultBool("displayAllOnNotificationBadge")) { if (!getUserDefaultBool("displayAllOnNotificationBadge")) {
this.setState({ notificationCount: 0 }); this.setState({ notificationCount: 0 });
@ -276,6 +372,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
} }
} }
/**
* Render the title bar.
*/
titlebar() { titlebar() {
const { classes } = this.props; const { classes } = this.props;
if (isDarwinApp()) { if (isDarwinApp()) {
@ -307,6 +406,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
} }
} }
/**
* Render the app drawer. On the desktop, this appears as a sidebar in larger layouts.
*/
appDrawer() { appDrawer() {
const { classes } = this.props; const { classes } = this.props;
return ( return (
@ -476,6 +578,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
); );
} }
/**
* Render the entire layout.
*/
render() { render() {
const { classes } = this.props; const { classes } = this.props;
return ( return (
@ -484,6 +589,18 @@ export class AppLayout extends Component<any, IAppLayoutState> {
{this.titlebar()} {this.titlebar()}
<AppBar className={classes.appBar} position="static"> <AppBar className={classes.appBar} position="static">
<Toolbar> <Toolbar>
{isDesktopApp() &&
isChildView(window.location.hash) ? (
<IconButton
className={classes.appBarBackButton}
color="inherit"
aria-label="Go back"
onClick={() => window.history.back()}
>
<ArrowBackIcon />
</IconButton>
) : null}
<IconButton <IconButton
className={classes.appBarMenuButton} className={classes.appBarMenuButton}
color="inherit" color="inherit"
@ -675,7 +792,7 @@ export class AppLayout extends Component<any, IAppLayoutState> {
variant="temporary" variant="temporary"
anchor={"left"} anchor={"left"}
open={this.state.drawerOpenOnMobile} open={this.state.drawerOpenOnMobile}
onClose={this.toggleDrawerOnMobile} onClick={this.toggleDrawerOnMobile}
classes={{ paper: classes.drawerPaper }} classes={{ paper: classes.drawerPaper }}
> >
{this.appDrawer()} {this.appDrawer()}

View File

@ -81,6 +81,15 @@ export const styles = (theme: Theme) =>
paddingTop: theme.spacing.unit, paddingTop: theme.spacing.unit,
paddingBottom: theme.spacing.unit paddingBottom: theme.spacing.unit
}, },
postAuthorAccount: {
color: theme.palette.grey[500],
marginLeft: theme.spacing.unit * 0.5,
},
postReblogIcon: {
marginBottom: theme.spacing.unit * -0.5,
marginLeft: theme.spacing.unit * 0.5,
marginRight: theme.spacing.unit * 0.5,
},
postAuthorEmoji: { postAuthorEmoji: {
height: theme.typography.fontSize, height: theme.typography.fontSize,
verticalAlign: "middle" verticalAlign: "middle"

View File

@ -398,21 +398,34 @@ export class Post extends React.Component<any, IPostState> {
const { classes } = this.props; const { classes } = this.props;
if (post.reblog) { if (post.reblog) {
let author = post.reblog.account; let author = post.reblog.account;
let origString = `<span>${author.display_name ||
author.username} (@${author.acct}) 🔄 ${post.account
.display_name || post.account.username}</span>`;
let emojis = author.emojis; let emojis = author.emojis;
emojis.concat(post.account.emojis); emojis.concat(post.account.emojis);
return emojifyString(origString, emojis, classes.postAuthorEmoji); return (
<>
<span>
{emojifyString(author.display_name || author.username, author.emojis, classes.postAuthorEmoji)}
</span>
<span className={classes.postAuthorAccount}>
@{emojifyString(author.acct, author.emojis, classes.postAuthorEmoji)}
</span>
<AutorenewIcon fontSize='small' className={classes.postReblogIcon} />
<span>
{emojifyString(post.account.display_name || post.account.username, emojis, classes.postAuthorEmoji)}
</span>
</>
)
} else { } else {
let author = post.account; let author = post.account;
let origString = `<span>${author.display_name || return (
author.username} (@${author.acct})</span>`; <>
return emojifyString( <span>
origString, {emojifyString(author.display_name || author.username, author.emojis, classes.postAuthorEmoji)}
author.emojis, </span>
classes.postAuthorEmoji <span className={classes.postAuthorAccount}>
); @{emojifyString(author.acct, author.emojis, classes.postAuthorEmoji)}
</span>
</>
)
} }
} }
@ -656,13 +669,7 @@ export class Post extends React.Component<any, IPostState> {
</IconButton> </IconButton>
</Tooltip> </Tooltip>
} }
title={ title={<Typography>{this.getReblogAuthors(post)}</Typography>}
<Typography
dangerouslySetInnerHTML={{
__html: this.getReblogAuthors(post)
}}
/>
}
subheader={moment(post.created_at).format( subheader={moment(post.created_at).format(
"MMMM Do YYYY [at] h:mm A" "MMMM Do YYYY [at] h:mm A"
)} )}

View File

@ -45,5 +45,25 @@ export const styles = (theme: Theme) =>
}, },
pollWizardFlexGrow: { pollWizardFlexGrow: {
flexGrow: 1 flexGrow: 1
},
draftDisplayArea: {
display: "flex",
paddingLeft: 8,
paddingRight: 8,
paddingTop: 4,
paddingBottom: 4,
borderColor: theme.palette.action.disabledBackground,
borderWidth: 0.25,
borderStyle: "solid",
borderRadius: 2,
verticalAlign: "middle",
marginLeft: 16,
marginRight: 16
},
draftText: {
padding: theme.spacing.unit / 2
},
draftFlexGrow: {
flexGrow: 1
} }
}); });

View File

@ -44,35 +44,106 @@ import {
getConfig, getConfig,
getUserDefaultBool getUserDefaultBool
} from "../utilities/settings"; } from "../utilities/settings";
import { draftExists, writeDraft, loadDraft } from "../utilities/compose";
/**
* The state for the Composer page.
*/
interface IComposerState { interface IComposerState {
/**
* The current user as an Account.
*/
account: UAccount; account: UAccount;
/**
* The visibility of the post.
*/
visibility: Visibility; visibility: Visibility;
/**
* Whether there should be a content warning.
*/
sensitive: boolean; sensitive: boolean;
/**
* The content warning message.
*/
sensitiveText?: string; sensitiveText?: string;
/**
* Whether the visibility drop-down should be visible.
*/
visibilityMenu: boolean; visibilityMenu: boolean;
/**
* The text contents of the post.
*/
text: string; text: string;
/**
* The remaining amount of characters.
*/
remainingChars: number; remainingChars: number;
/**
* An optional reply ID.
*/
reply?: string; reply?: string;
/**
* The account to reply to, if it exists.
*/
acct?: string; acct?: string;
/**
* An optional list of media attachments.
*/
attachments?: [Attachment]; attachments?: [Attachment];
/**
* An optional poll for the post.
*/
poll?: PollWizard; poll?: PollWizard;
/**
* The expiration date of a poll, if it exists.
*/
pollExpiresDate?: any; pollExpiresDate?: any;
/**
* Whether the emoji picker should be visible.
*/
showEmojis: boolean; showEmojis: boolean;
/**
* Whether or not the account's instance is federated.
*/
federated: boolean; federated: boolean;
} }
/**
* The Compose page contains all of the information to create a UI for post creation.
*/
class Composer extends Component<any, IComposerState> { class Composer extends Component<any, IComposerState> {
/**
* The Mastodon client to work with.
*/
client: Mastodon; client: Mastodon;
/**
* Construct the Compose page by generating the Mastodon client and setting default values.
* @param props The properties passed into the Compose component, usually the page queries.
*/
constructor(props: any) { constructor(props: any) {
super(props); super(props);
// Generate the Mastodon client
this.client = new Mastodon( this.client = new Mastodon(
localStorage.getItem("access_token") as string, localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1" localStorage.getItem("baseurl") + "/api/v1"
); );
// Set the initial state
this.state = { this.state = {
account: JSON.parse(localStorage.getItem("account") as string), account: JSON.parse(localStorage.getItem("account") as string),
visibility: getUserDefaultVisibility(), visibility: getUserDefaultVisibility(),
@ -87,13 +158,21 @@ class Composer extends Component<any, IComposerState> {
}; };
} }
/**
* Run any additional state checks and setup once the page has mounted. This includes
* parsing the query parameters and loading the configuration, as well as defining the
* clipboard listener.
*/
componentDidMount() { componentDidMount() {
// Parse the parameters and get the account information if available.
let state = this.getComposerParams(this.props); let state = this.getComposerParams(this.props);
let text = state.acct ? `@${state.acct}: ` : ""; let text = state.acct ? `@${state.acct}: ` : "";
this.client.get("/accounts/verify_credentials").then((resp: any) => { this.client.get("/accounts/verify_credentials").then((resp: any) => {
let account: UAccount = resp.data; let account: UAccount = resp.data;
this.setState({ account }); this.setState({ account });
}); });
// Get the configuration and load the config values.
getConfig().then((config: any) => { getConfig().then((config: any) => {
this.setState({ this.setState({
federated: config.federation.allowPublicPosts, federated: config.federation.allowPublicPosts,
@ -107,6 +186,8 @@ class Composer extends Component<any, IComposerState> {
}); });
}); });
// Attach the paste listener to listen for the clipboard and upload media
// if possible.
window.addEventListener("paste", (evt: Event) => { window.addEventListener("paste", (evt: Event) => {
let thePasteEvent = evt as ClipboardEvent; let thePasteEvent = evt as ClipboardEvent;
let fileList: File[] = []; let fileList: File[] = [];
@ -121,12 +202,21 @@ class Composer extends Component<any, IComposerState> {
} }
} }
} }
this.actuallyUploadMedia(fileList);
if (fileList.length > 0) {
this.uploadMedia(fileList);
}
} }
} }
}); });
} }
/**
* Reload the properties and set the state to those new properties. This usually
* occurs when the page is either reloaded or changes but React doesn't see the
* properties change.
* @param props The properties passed into the Compose component, usually the page queries.
*/
componentWillReceiveProps(props: any) { componentWillReceiveProps(props: any) {
let state = this.getComposerParams(props); let state = this.getComposerParams(props);
let text = state.acct ? `@${state.acct}: ` : ""; let text = state.acct ? `@${state.acct}: ` : "";
@ -141,6 +231,36 @@ class Composer extends Component<any, IComposerState> {
}); });
} }
/**
* Check if there is unsaved text and store it as a draft.
*/
componentWillUnmount() {
if (this.state.text !== "") {
writeDraft(
this.state.text,
this.state.reply ? Number(this.state.reply) : -999
);
this.props.enqueueSnackbar("Draft saved.");
}
}
/**
* Restore the draft from session storage and pre-load it into the state.
*/
restoreDraft() {
const draft = loadDraft();
const text = draft.contents;
const reply =
draft.replyId !== -999 ? draft.replyId.toString() : undefined;
this.setState({ text, reply });
this.props.enqueueSnackbar("Restored draft.");
}
/**
* Check the location string and attempt to parse it into a parsed query.
* @param location The location string from React Router.
* @returns The ParsedQuery object containing all of the parameters.
*/
checkComposerParams(location?: string): ParsedQuery { checkComposerParams(location?: string): ParsedQuery {
let params = ""; let params = "";
if (location !== undefined && typeof location === "string") { if (location !== undefined && typeof location === "string") {
@ -151,6 +271,11 @@ class Composer extends Component<any, IComposerState> {
return parseParams(params); return parseParams(params);
} }
/**
* Check the property's location string, parse it, and return it.
* @param props The properties passed into the Compose component, usually the page queries.
* @returns An object containing the reply ID, reply account, and visibility.
*/
getComposerParams(props: any) { getComposerParams(props: any) {
let params = this.checkComposerParams(props.location); let params = this.checkComposerParams(props.location);
let reply: string = ""; let reply: string = "";
@ -173,6 +298,10 @@ class Composer extends Component<any, IComposerState> {
}; };
} }
/**
* Update the text in the state and calculate the remaining character length.
* @param text The text to update the state to
*/
updateTextFromField(text: string) { updateTextFromField(text: string) {
this.setState({ this.setState({
text, text,
@ -182,20 +311,31 @@ class Composer extends Component<any, IComposerState> {
}); });
} }
/**
* Update the content warning text in the state
* @param sensitiveText The text to update the state to
*/
updateWarningFromField(sensitiveText: string) { updateWarningFromField(sensitiveText: string) {
this.setState({ sensitiveText }); this.setState({ sensitiveText });
} }
/**
* Update the visibility in the state
* @param visibility The visibility to update the state to
*/
changeVisibility(visibility: Visibility) { changeVisibility(visibility: Visibility) {
this.setState({ visibility }); this.setState({ visibility });
} }
uploadMedia() { /**
* Open a file dialog to let the user choose files to upload to the server and then upload them.
*/
promptMediaDialog() {
filedialog({ filedialog({
multiple: false, multiple: false,
accept: ".jpeg,.jpg,.png,.gif,.webm,.mp4,.mov,.ogg,.wav,.mp3,.flac" accept: ".jpeg,.jpg,.png,.gif,.webm,.mp4,.mov,.ogg,.wav,.mp3,.flac"
}) })
.then((media: FileList) => this.actuallyUploadMedia(media)) .then((media: FileList) => this.uploadMedia(media))
.catch((err: Error) => { .catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't get media: " + err.name, { this.props.enqueueSnackbar("Couldn't get media: " + err.name, {
variant: "error" variant: "error"
@ -204,15 +344,27 @@ class Composer extends Component<any, IComposerState> {
}); });
} }
actuallyUploadMedia(media: FileList | File[]) { /**
* Upload a list of files to Mastodon as attachments. Reads the first item in the list.
* This also updates the attachments state after a successful upload.
* @param media The list of files (`FileList` or `File[]`) to send to Mastodon.
*/
uploadMedia(media: FileList | File[]) {
// Create a new FormData for Mastodon
let mediaForm = new FormData(); let mediaForm = new FormData();
mediaForm.append("file", media[0]); mediaForm.append("file", media[0]);
// Let the user know we're uploading the file
this.props.enqueueSnackbar("Uploading media...", { this.props.enqueueSnackbar("Uploading media...", {
persist: true, persist: true,
key: "media-upload" key: "media-upload"
}); });
// Try to upload the media to the server.
this.client this.client
.post("/media", mediaForm) .post("/media", mediaForm)
// If we succeed, get the attachments and update the state.
.then((resp: any) => { .then((resp: any) => {
let attachment: Attachment = resp.data; let attachment: Attachment = resp.data;
let attachments = this.state.attachments; let attachments = this.state.attachments;
@ -225,6 +377,8 @@ class Composer extends Component<any, IComposerState> {
this.props.closeSnackbar("media-upload"); this.props.closeSnackbar("media-upload");
this.props.enqueueSnackbar("Media uploaded."); this.props.enqueueSnackbar("Media uploaded.");
}) })
// If we fail, display an error.
.catch((err: Error) => { .catch((err: Error) => {
this.props.closeSnackbar("media-upload"); this.props.closeSnackbar("media-upload");
this.props.enqueueSnackbar( this.props.enqueueSnackbar(
@ -234,6 +388,10 @@ class Composer extends Component<any, IComposerState> {
}); });
} }
/**
* Iterate through the attachments and grab the attachments' IDs.
* @returns A list of IDs as `string[]`
*/
getOnlyMediaIds() { getOnlyMediaIds() {
let ids: string[] = []; let ids: string[] = [];
if (this.state.attachments) { if (this.state.attachments) {
@ -244,6 +402,10 @@ class Composer extends Component<any, IComposerState> {
return ids; return ids;
} }
/**
* Update the list of attachments by inserting an attachment.
* @param attachment The attachment to insert into the attachments list.
*/
fetchAttachmentAfterUpdate(attachment: Attachment) { fetchAttachmentAfterUpdate(attachment: Attachment) {
let attachments = this.state.attachments; let attachments = this.state.attachments;
if (attachments) { if (attachments) {
@ -256,6 +418,10 @@ class Composer extends Component<any, IComposerState> {
} }
} }
/**
* Remove an attachment from the list of attachments and update the state.
* @param attachment The attachment to remove from the list
*/
deleteMediaAttachment(attachment: Attachment) { deleteMediaAttachment(attachment: Attachment) {
let attachments = this.state.attachments; let attachments = this.state.attachments;
if (attachments) { if (attachments) {
@ -269,6 +435,10 @@ class Composer extends Component<any, IComposerState> {
} }
} }
/**
* Insert an emoji at the end of text string and update the state
* @param e The emoji to insert into the text
*/
insertEmoji(e: any) { insertEmoji(e: any) {
if (e.custom) { if (e.custom) {
let text = this.state.text + e.colons; let text = this.state.text + e.colons;
@ -285,6 +455,9 @@ class Composer extends Component<any, IComposerState> {
} }
} }
/**
* Create an empty poll.
*/
createPoll() { createPoll() {
if (this.state.poll === undefined) { if (this.state.poll === undefined) {
let expiration = new Date(); let expiration = new Date();
@ -304,6 +477,9 @@ class Composer extends Component<any, IComposerState> {
} }
} }
/**
* Insert a new poll item into the poll.
*/
addPollItem() { addPollItem() {
if ( if (
this.state.poll !== undefined && this.state.poll !== undefined &&
@ -326,6 +502,11 @@ class Composer extends Component<any, IComposerState> {
} }
} }
/**
* Edit an existing poll item with new text
* @param position The position of the poll item in the list
* @param newTitle The new text to update
*/
editPollItem(position: number, newTitle: any) { editPollItem(position: number, newTitle: any) {
if (this.state.poll !== undefined) { if (this.state.poll !== undefined) {
let poll = this.state.poll; let poll = this.state.poll;
@ -343,6 +524,10 @@ class Composer extends Component<any, IComposerState> {
} }
} }
/**
* Removes a poll item from the poll
* @param item The item to remove
*/
removePollItem(item: string) { removePollItem(item: string) {
if ( if (
this.state.poll !== undefined && this.state.poll !== undefined &&
@ -369,6 +554,10 @@ class Composer extends Component<any, IComposerState> {
} }
} }
/**
* Set the expiration date of the poll.
* @param date The new expiration date
*/
setPollExpires(date: string) { setPollExpires(date: string) {
let currentDate = new Date(); let currentDate = new Date();
let newDate = new Date(date); let newDate = new Date(date);
@ -388,25 +577,38 @@ class Composer extends Component<any, IComposerState> {
} }
} }
/**
* Remove the poll from the post.
*/
removePoll() { removePoll() {
this.setState({ this.setState({
poll: undefined poll: undefined
}); });
} }
/**
* Check if the user presses the Ctrl/Cmd+Enter key and post to the server if possible.
* @param event The keyboard event
*/
postViaKeyboard(event: any) { postViaKeyboard(event: any) {
if ((event.metaKey || event.ctrlKey) && event.keyCode === 13) { if ((event.metaKey || event.ctrlKey) && event.keyCode === 13) {
this.post(); this.post();
} }
} }
/**
* Send the post to Mastodon and return to the previous page, if possible.
*/
post() { post() {
// First, finalize the poll.
let pollOptions: string[] = []; let pollOptions: string[] = [];
if (this.state.poll) { if (this.state.poll) {
this.state.poll.options.forEach((option: PollWizardOption) => { this.state.poll.options.forEach((option: PollWizardOption) => {
pollOptions.push(option.title); pollOptions.push(option.title);
}); });
} }
// Send a post request to Mastodon.
this.client this.client
.post("/statuses", { .post("/statuses", {
status: this.state.text, status: this.state.text,
@ -423,28 +625,44 @@ class Composer extends Component<any, IComposerState> {
} }
: null : null
}) })
// If we succeed, send a success message and go back.
.then(() => { .then(() => {
this.props.enqueueSnackbar("Posted!"); this.props.enqueueSnackbar("Posted!");
window.history.back(); window.history.back();
}) })
// Otherwise, show an error message and don't do anything.
.catch((err: Error) => { .catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't post: " + err.name); this.props.enqueueSnackbar("Couldn't post: " + err.name);
console.error(err.message); console.error(err.message);
}); });
} }
/**
* Toggle the content warning section.
*/
toggleSensitive() { toggleSensitive() {
this.setState({ sensitive: !this.state.sensitive }); this.setState({ sensitive: !this.state.sensitive });
} }
/**
* Toggle the visibility drop down menu.
*/
toggleVisibilityMenu() { toggleVisibilityMenu() {
this.setState({ visibilityMenu: !this.state.visibilityMenu }); this.setState({ visibilityMenu: !this.state.visibilityMenu });
} }
/**
* Toggle the emoji picker.
*/
toggleEmojis() { toggleEmojis() {
this.setState({ showEmojis: !this.state.showEmojis }); this.setState({ showEmojis: !this.state.showEmojis });
} }
/**
* Render all of the components on the page given a set of classes.
*/
render() { render() {
const { classes } = this.props; const { classes } = this.props;
@ -652,7 +870,7 @@ class Composer extends Component<any, IComposerState> {
<Tooltip title="Add photos, videos, or audio"> <Tooltip title="Add photos, videos, or audio">
<IconButton <IconButton
disabled={this.state.poll !== undefined} disabled={this.state.poll !== undefined}
onClick={() => this.uploadMedia()} onClick={() => this.promptMediaDialog()}
id="compose-media" id="compose-media"
> >
<AttachFileIcon /> <AttachFileIcon />
@ -738,6 +956,21 @@ class Composer extends Component<any, IComposerState> {
) : null} ) : null}
</Menu> </Menu>
</Toolbar> </Toolbar>
{draftExists() ? (
<DialogContent className={classes.draftDisplayArea}>
<Typography className={classes.draftText}>
You have an unsaved post.
</Typography>
<div className={classes.draftFlexGrow} />
<Button
color="primary"
size="small"
onClick={() => this.restoreDraft()}
>
Restore
</Button>
</DialogContent>
) : null}
<DialogActions> <DialogActions>
<Button color="secondary" onClick={() => this.post()}> <Button color="secondary" onClick={() => this.post()}>
Post Post

View File

@ -14,6 +14,8 @@ import Post from "../components/Post";
import { Status } from "../types/Status"; import { Status } from "../types/Status";
import Mastodon, { StreamListener } from "megalodon"; import Mastodon, { StreamListener } from "megalodon";
import { withSnackbar } from "notistack"; import { withSnackbar } from "notistack";
import Masonry from "react-masonry-css";
import { getUserDefaultBool } from "../utilities/settings";
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward"; import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
interface IHomePageState { interface IHomePageState {
@ -23,6 +25,7 @@ interface IHomePageState {
viewDidLoad?: boolean; viewDidLoad?: boolean;
viewDidError?: boolean; viewDidError?: boolean;
viewDidErrorCode?: any; viewDidErrorCode?: any;
isMasonryLayout?: boolean;
} }
class HomePage extends Component<any, IHomePageState> { class HomePage extends Component<any, IHomePageState> {
@ -34,7 +37,8 @@ class HomePage extends Component<any, IHomePageState> {
this.state = { this.state = {
viewIsLoading: true, viewIsLoading: true,
backlogPosts: null backlogPosts: null,
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
}; };
this.client = new Mastodon( this.client = new Mastodon(
@ -154,9 +158,11 @@ class HomePage extends Component<any, IHomePageState> {
render() { render() {
const { classes } = this.props; const { classes } = this.props;
const containerClasses = `${classes.pageLayoutMaxConstraints}${
this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : ""
}`;
return ( return (
<div className={classes.pageLayoutMaxConstraints}> <div className={containerClasses}>
{this.state.backlogPosts ? ( {this.state.backlogPosts ? (
<div className={classes.pageTopChipContainer}> <div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}> <div className={classes.pageTopChips}>
@ -185,15 +191,46 @@ class HomePage extends Component<any, IHomePageState> {
) : null} ) : null}
{this.state.posts ? ( {this.state.posts ? (
<div> <div>
{this.state.posts.map((post: Status) => { {this.state.isMasonryLayout ? (
return ( <Masonry
<Post breakpointCols={{
key={post.id} default: 4,
post={post} 2000: 3,
client={this.client} 1400: 2,
/> 1050: 1
); }}
})} className={classes.masonryGrid}
columnClassName={
classes["my-masonry-grid_column"]
}
>
{this.state.posts.map((post: Status) => {
return (
<div
className={classes.masonryGrid_item}
>
<Post
key={post.id}
post={post}
client={this.client}
/>
</div>
);
})}
</Masonry>
) : (
<div>
{this.state.posts.map((post: Status) => {
return (
<Post
key={post.id}
post={post}
client={this.client}
/>
);
})}
</div>
)}
<br /> <br />
{this.state.viewDidLoad && !this.state.viewDidError ? ( {this.state.viewDidLoad && !this.state.viewDidError ? (
<div <div

View File

@ -14,6 +14,8 @@ import Post from "../components/Post";
import { Status } from "../types/Status"; import { Status } from "../types/Status";
import Mastodon, { StreamListener } from "megalodon"; import Mastodon, { StreamListener } from "megalodon";
import { withSnackbar } from "notistack"; import { withSnackbar } from "notistack";
import Masonry from "react-masonry-css";
import { getUserDefaultBool } from "../utilities/settings";
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward"; import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
interface ILocalPageState { interface ILocalPageState {
@ -23,6 +25,7 @@ interface ILocalPageState {
viewDidLoad?: boolean; viewDidLoad?: boolean;
viewDidError?: boolean; viewDidError?: boolean;
viewDidErrorCode?: any; viewDidErrorCode?: any;
isMasonryLayout?: boolean;
} }
class LocalPage extends Component<any, ILocalPageState> { class LocalPage extends Component<any, ILocalPageState> {
@ -34,7 +37,8 @@ class LocalPage extends Component<any, ILocalPageState> {
this.state = { this.state = {
viewIsLoading: true, viewIsLoading: true,
backlogPosts: null backlogPosts: null,
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
}; };
this.client = new Mastodon( this.client = new Mastodon(
@ -155,9 +159,12 @@ class LocalPage extends Component<any, ILocalPageState> {
render() { render() {
const { classes } = this.props; const { classes } = this.props;
const containerClasses = `${classes.pageLayoutMaxConstraints}${
this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : ""
}`;
return ( return (
<div className={classes.pageLayoutMaxConstraints}> <div className={containerClasses}>
{this.state.backlogPosts ? ( {this.state.backlogPosts ? (
<div className={classes.pageTopChipContainer}> <div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}> <div className={classes.pageTopChips}>
@ -186,15 +193,50 @@ class LocalPage extends Component<any, ILocalPageState> {
) : null} ) : null}
{this.state.posts ? ( {this.state.posts ? (
<div> <div>
{this.state.posts.map((post: Status) => { {this.state.isMasonryLayout ? (
return ( <Masonry
<Post breakpointCols={{
key={post.id} default: 4,
post={post} 2000: 3,
client={this.client} 1400: 2,
/> 1050: 1
); }}
})} className={classes.masonryGrid}
columnClassName={
classes["my-masonry-grid_column"]
}
>
{this.state.posts.map((post: Status) => {
return (
<div
className={classes.masonryGrid_item}
>
<Post
key={post.id}
post={post}
client={this.client}
/>
</div>
);
})}
</Masonry>
) : (
<div>
{this.state.posts.map((post: Status) => {
return (
<div
className={classes.masonryGrid_item}
>
<Post
key={post.id}
post={post}
client={this.client}
/>
</div>
);
})}
</div>
)}
<br /> <br />
{this.state.viewDidLoad && !this.state.viewDidError ? ( {this.state.viewDidLoad && !this.state.viewDidError ? (
<div <div

View File

@ -33,35 +33,83 @@ import NotificationsIcon from "@material-ui/icons/Notifications";
import Mastodon from "megalodon"; import Mastodon from "megalodon";
import { Notification } from "../types/Notification"; import { Notification } from "../types/Notification";
import { Account } from "../types/Account"; import { Account } from "../types/Account";
import { Relationship } from "../types/Relationship";
import { withSnackbar } from "notistack"; import { withSnackbar } from "notistack";
/**
* The state interface for the notifications page.
*/
interface INotificationsPageState { interface INotificationsPageState {
/**
* The list of notifications, if it exists.
*/
notifications?: [Notification]; notifications?: [Notification];
/**
* Whether the view is still loading.
*/
viewIsLoading: boolean; viewIsLoading: boolean;
/**
* Whether the view has loaded.
*/
viewDidLoad?: boolean; viewDidLoad?: boolean;
/**
* Whether the view has loaded but in error.
*/
viewDidError?: boolean; viewDidError?: boolean;
/**
* The error code for an errored state, if possible.
*/
viewDidErrorCode?: string; viewDidErrorCode?: string;
/**
* Whether the delete confirmation dialog should be open.
*/
deleteDialogOpen: boolean; deleteDialogOpen: boolean;
} }
/**
* The notifications page.
*/
class NotificationsPage extends Component<any, INotificationsPageState> { class NotificationsPage extends Component<any, INotificationsPageState> {
/**
* The Mastodon object to perform notification operations on.
*/
client: Mastodon; client: Mastodon;
/**
* The stream listener for tuning in to notifications.
*/
streamListener: any; streamListener: any;
/**
* Construct the notifications page.
* @param props The properties to pass in
*/
constructor(props: any) { constructor(props: any) {
super(props); super(props);
// Create the Mastodon object.
this.client = new Mastodon( this.client = new Mastodon(
localStorage.getItem("access_token") as string, localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1" localStorage.getItem("baseurl") + "/api/v1"
); );
// Initialize the state.
this.state = { this.state = {
viewIsLoading: true, viewIsLoading: true,
deleteDialogOpen: false deleteDialogOpen: false
}; };
} }
/**
* Perform pre-mount tasks.
*/
componentWillMount() { componentWillMount() {
// Get the list of notifications and update the state.
this.client this.client
.get("/notifications") .get("/notifications")
.then((resp: any) => { .then((resp: any) => {
@ -82,10 +130,17 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
}); });
} }
/**
* Perform post-mount tasks.
*/
componentDidMount() { componentDidMount() {
// Start listening for new notifications after fetching.
this.streamNotifications(); this.streamNotifications();
} }
/**
* Set up a stream listener and keep updating notifications.
*/
streamNotifications() { streamNotifications() {
this.streamListener = this.client.stream("/streaming/user"); this.streamListener = this.client.stream("/streaming/user");
@ -98,10 +153,19 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
}); });
} }
/**
* Toggle the state of the delete dialog.
*/
toggleDeleteDialog() { toggleDeleteDialog() {
this.setState({ deleteDialogOpen: !this.state.deleteDialogOpen }); this.setState({ deleteDialogOpen: !this.state.deleteDialogOpen });
} }
/**
* Strip HTML content from a string containing HTML content.
*
* @param text The sanitized HTML to strip
* @returns A string containing the contents of the sanitized HTML
*/
removeHTMLContent(text: string) { removeHTMLContent(text: string) {
const div = document.createElement("div"); const div = document.createElement("div");
div.innerHTML = text; div.innerHTML = text;
@ -111,6 +175,10 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
return innerContent; return innerContent;
} }
/**
* Remove a notification from the server.
* @param id The notification's ID
*/
removeNotification(id: string) { removeNotification(id: string) {
this.client this.client
.post(`/notifications/${id}/dismiss`) .post(`/notifications/${id}/dismiss`)
@ -142,6 +210,9 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
}); });
} }
/**
* Purge all notifications from the server.
*/
removeAllNotifications() { removeAllNotifications() {
this.client this.client
.post("/notifications/clear") .post("/notifications/clear")
@ -159,6 +230,10 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
}); });
} }
/**
* Render a single notification unit to be used in a list
* @param notif The notification to work with.
*/
createNotification(notif: Notification) { createNotification(notif: Notification) {
const { classes } = this.props; const { classes } = this.props;
let primary = ""; let primary = "";
@ -293,23 +368,53 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
); );
} }
/**
* Follow an account from a notification if already not followed.
* @param acct The account to follow, if possible
*/
followMember(acct: Account) { followMember(acct: Account) {
// Get the relationships for this account.
this.client this.client
.post(`/accounts/${acct.id}/follow`) .get(`/accounts/relationships`, { id: acct.id })
.then((resp: any) => { .then((resp: any) => {
this.props.enqueueSnackbar( // Returns a list, so grab only the first item.
"You are now following this account." let relationship: Relationship = resp.data[0];
);
// Follow if not following already.
if (relationship.following == false) {
this.client
.post(`/accounts/${acct.id}/follow`)
.then((resp: any) => {
this.props.enqueueSnackbar(
"You are now following this account."
);
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
"Couldn't follow account: " + err.name,
{ variant: "error" }
);
console.error(err.message);
});
}
// Otherwise notify the user.
else {
this.props.enqueueSnackbar(
"You already follow this account."
);
}
}) })
.catch((err: Error) => { .catch((err: Error) => {
this.props.enqueueSnackbar( this.props.enqueueSnackbar("Couldn't find relationship.", {
"Couldn't follow account: " + err.name, variant: "error"
{ variant: "error" } });
);
console.error(err.message);
}); });
} }
/**
* Render the notification page.
*/
render() { render() {
const { classes } = this.props; const { classes } = this.props;
return ( return (

View File

@ -1,4 +1,4 @@
import { Theme, createStyles } from "@material-ui/core"; import { Theme, createStyles, FormHelperText } from "@material-ui/core";
import { isDarwinApp } from "../utilities/desktop"; import { isDarwinApp } from "../utilities/desktop";
import { isAppbarExpanded } from "../utilities/appbar"; import { isAppbarExpanded } from "../utilities/appbar";
@ -323,5 +323,17 @@ export const styles = (theme: Theme) =>
display: "block" display: "block"
}, },
backgroundColor: theme.palette.primary.main backgroundColor: theme.palette.primary.main
},
pageLayoutMasonry: {
paddingLeft: theme.spacing.unit * 3,
paddingRight: theme.spacing.unit * 3
},
masonryGrid: {
display: "flex",
width: "auto"
},
"my-masonry-grid_column": {
// non-standard name fixes react-masonry-css bug :shrug:
padding: 5
} }
}); });

View File

@ -14,6 +14,8 @@ import Post from "../components/Post";
import { Status } from "../types/Status"; import { Status } from "../types/Status";
import Mastodon, { StreamListener } from "megalodon"; import Mastodon, { StreamListener } from "megalodon";
import { withSnackbar } from "notistack"; import { withSnackbar } from "notistack";
import Masonry from "react-masonry-css";
import { getUserDefaultBool } from "../utilities/settings";
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward"; import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
interface IPublicPageState { interface IPublicPageState {
@ -23,6 +25,7 @@ interface IPublicPageState {
viewDidLoad?: boolean; viewDidLoad?: boolean;
viewDidError?: boolean; viewDidError?: boolean;
viewDidErrorCode?: any; viewDidErrorCode?: any;
isMasonryLayout?: boolean;
} }
class PublicPage extends Component<any, IPublicPageState> { class PublicPage extends Component<any, IPublicPageState> {
@ -34,7 +37,8 @@ class PublicPage extends Component<any, IPublicPageState> {
this.state = { this.state = {
viewIsLoading: true, viewIsLoading: true,
backlogPosts: null backlogPosts: null,
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
}; };
this.client = new Mastodon( this.client = new Mastodon(
@ -154,9 +158,12 @@ class PublicPage extends Component<any, IPublicPageState> {
render() { render() {
const { classes } = this.props; const { classes } = this.props;
const containerClasses = `${classes.pageLayoutMaxConstraints}${
this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : ""
}`;
return ( return (
<div className={classes.pageLayoutMaxConstraints}> <div className={containerClasses}>
{this.state.backlogPosts ? ( {this.state.backlogPosts ? (
<div className={classes.pageTopChipContainer}> <div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}> <div className={classes.pageTopChips}>
@ -185,15 +192,50 @@ class PublicPage extends Component<any, IPublicPageState> {
) : null} ) : null}
{this.state.posts ? ( {this.state.posts ? (
<div> <div>
{this.state.posts.map((post: Status) => { {this.state.isMasonryLayout ? (
return ( <Masonry
<Post breakpointCols={{
key={post.id} default: 4,
post={post} 2000: 3,
client={this.client} 1400: 2,
/> 1050: 1
); }}
})} className={classes.masonryGrid}
columnClassName={
classes["my-masonry-grid_column"]
}
>
{this.state.posts.map((post: Status) => {
return (
<div
className={classes.masonryGrid_item}
>
<Post
key={post.id}
post={post}
client={this.client}
/>
</div>
);
})}
</Masonry>
) : (
<div>
{this.state.posts.map((post: Status) => {
return (
<div
className={classes.masonryGrid_item}
>
<Post
key={post.id}
post={post}
client={this.client}
/>
</div>
);
})}
</div>
)}
<br /> <br />
{this.state.viewDidLoad && !this.state.viewDidError ? ( {this.state.viewDidLoad && !this.state.viewDidError ? (
<div <div

View File

@ -64,6 +64,7 @@ import UndoIcon from "@material-ui/icons/Undo";
import DomainDisabledIcon from "@material-ui/icons/DomainDisabled"; import DomainDisabledIcon from "@material-ui/icons/DomainDisabled";
import AccountSettingsIcon from "mdi-material-ui/AccountSettings"; import AccountSettingsIcon from "mdi-material-ui/AccountSettings";
import AlphabeticalVariantOffIcon from "mdi-material-ui/AlphabeticalVariantOff"; import AlphabeticalVariantOffIcon from "mdi-material-ui/AlphabeticalVariantOff";
import DashboardIcon from "@material-ui/icons/Dashboard";
import { Config } from "../types/Config"; import { Config } from "../types/Config";
import { Account } from "../types/Account"; import { Account } from "../types/Account";
@ -86,6 +87,7 @@ interface ISettingsState {
federated: boolean; federated: boolean;
currentUser?: Account; currentUser?: Account;
imposeCharacterLimit: boolean; imposeCharacterLimit: boolean;
masonryLayout?: boolean;
} }
class SettingsPage extends Component<any, ISettingsState> { class SettingsPage extends Component<any, ISettingsState> {
@ -117,7 +119,8 @@ class SettingsPage extends Component<any, ISettingsState> {
defaultVisibility: getUserDefaultVisibility() || "public", defaultVisibility: getUserDefaultVisibility() || "public",
brandName: "Hyperspace", brandName: "Hyperspace",
federated: true, federated: true,
imposeCharacterLimit: getUserDefaultBool("imposeCharacterLimit") imposeCharacterLimit: getUserDefaultBool("imposeCharacterLimit"),
masonryLayout: getUserDefaultBool("isMasonryLayout")
}; };
this.toggleDarkMode = this.toggleDarkMode.bind(this); this.toggleDarkMode = this.toggleDarkMode.bind(this);
@ -126,6 +129,7 @@ class SettingsPage extends Component<any, ISettingsState> {
this.toggleBadgeCount = this.toggleBadgeCount.bind(this); this.toggleBadgeCount = this.toggleBadgeCount.bind(this);
this.toggleThemeDialog = this.toggleThemeDialog.bind(this); this.toggleThemeDialog = this.toggleThemeDialog.bind(this);
this.toggleVisibilityDialog = this.toggleVisibilityDialog.bind(this); this.toggleVisibilityDialog = this.toggleVisibilityDialog.bind(this);
this.toggleMasonryLayout = this.toggleMasonryLayout.bind(this);
this.changeThemeName = this.changeThemeName.bind(this); this.changeThemeName = this.changeThemeName.bind(this);
this.changeTheme = this.changeTheme.bind(this); this.changeTheme = this.changeTheme.bind(this);
this.setVisibility = this.setVisibility.bind(this); this.setVisibility = this.setVisibility.bind(this);
@ -241,6 +245,11 @@ class SettingsPage extends Component<any, ISettingsState> {
this.setState({ resetSettingsDialog: !this.state.resetSettingsDialog }); this.setState({ resetSettingsDialog: !this.state.resetSettingsDialog });
} }
toggleMasonryLayout() {
this.setState({ masonryLayout: !this.state.masonryLayout });
setUserDefaultBool("isMasonryLayout", !this.state.masonryLayout);
}
changeTheme() { changeTheme() {
setUserDefaultTheme(this.state.selectThemeName); setUserDefaultTheme(this.state.selectThemeName);
window.location.reload(); window.location.reload();
@ -559,7 +568,36 @@ class SettingsPage extends Component<any, ISettingsState> {
</Toolbar> </Toolbar>
</div> </div>
</div> </div>
) : null} ) : (
<div className={classes.pageHeroBackground}>
<div className={classes.pageHeroBackgroundImage} />
<div className={classes.profileContent}>
<br />
<Avatar className={classes.settingsAvatar} />
<div
className={classes.profileUserBox}
style={{ margin: "auto" }}
>
<Typography
className={classes.settingsHeaderText}
color="inherit"
component="h1"
>
{"Loading..."}
</Typography>
<Typography
color="inherit"
className={classes.settingsDetailText}
component="p"
>
@{"..."}
</Typography>
</div>
<div className={classes.pageGrow} />
<Toolbar />
</div>
</div>
)}
<div className={classes.pageContentLayoutConstraints}> <div className={classes.pageContentLayoutConstraints}>
<ListSubheader>Appearance</ListSubheader> <ListSubheader>Appearance</ListSubheader>
<Paper className={classes.pageListConstraints}> <Paper className={classes.pageListConstraints}>
@ -621,6 +659,22 @@ class SettingsPage extends Component<any, ISettingsState> {
</Button> </Button>
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
<ListItem>
<ListItemAvatar>
<DashboardIcon color="action" />
</ListItemAvatar>
<ListItemText
primary="Show more posts"
secondary="Shows additional columns of posts on wider screens"
/>
<ListItemSecondaryAction>
<Switch
checked={this.state.masonryLayout}
onChange={this.toggleMasonryLayout}
/>
</ListItemSecondaryAction>
</ListItem>
</List> </List>
</Paper> </Paper>
<br /> <br />

13
src/types/Draft.tsx Normal file
View File

@ -0,0 +1,13 @@
/**
* Base draft type for a cached draft.
*/
export type Draft = {
/**
* The contents of the draft (i.e, its post text).
*/
contents: string;
/**
* The ID of the post it replies to, if applicable. If there isn't one, it should be set to -999.
*/
replyId: number;
};

View File

@ -1,5 +1,14 @@
import { isDarwinApp } from "./desktop"; import { isDarwinApp } from "./desktop";
/**
* A list containing the types of child views.
*
* This list is used to help determine if a back button is necessary, usually because there
* is no defined way of returning to the parent view without using the menu bar or keyboard
* shortcut in desktop apps.
*/
export const childViews = ["#/profile", "#/conversation"];
/** /**
* Determine whether the title bar is being displayed. * Determine whether the title bar is being displayed.
* This might be useful in cases where styles are dependent on the title bar's visibility, such as heights. * This might be useful in cases where styles are dependent on the title bar's visibility, such as heights.
@ -9,3 +18,20 @@ import { isDarwinApp } from "./desktop";
export function isAppbarExpanded(): boolean { export function isAppbarExpanded(): boolean {
return isDarwinApp() || process.env.NODE_ENV === "development"; return isDarwinApp() || process.env.NODE_ENV === "development";
} }
/**
* Determine whether a path is considered a "child view".
*
* This is often used to determine whether a back button should be rendered or not.
* @param path The path of the page, usually its hash
* @returns Boolean distating if the view is a child view.
*/
export function isChildView(path: string): boolean {
let protocolMatched = false;
childViews.forEach((childViewProtocol: string) => {
if (path.startsWith(childViewProtocol)) {
protocolMatched = true;
}
});
return protocolMatched;
}

43
src/utilities/compose.tsx Normal file
View File

@ -0,0 +1,43 @@
import { Draft } from "../types/Draft";
/**
* Check whether a cached draft exists.
*/
export function draftExists(): boolean {
return sessionStorage.getItem("cachedDraft") !== null;
}
/**
* Write a draft to session storage.
* @param draft The text of the post.
* @param replyId The post's reply ID, if available.
*/
export function writeDraft(draft: string, replyId?: number) {
let cachedDraft = {
contents: draft,
replyId: replyId ? replyId : -999
};
sessionStorage.setItem("cachedDraft", JSON.stringify(cachedDraft));
}
/**
* Return the cached draft and remove it from session storage.
* @returns A Draft object with the draft's contents and reply ID (or -999).
*/
export function loadDraft(): Draft {
let contents = "";
let replyId = -999;
if (draftExists()) {
let draft = sessionStorage.getItem("cachedDraft");
sessionStorage.removeItem("cachedDraft");
if (draft != null) {
const draftObject = JSON.parse(draft);
contents = draftObject.contents;
replyId = draftObject.replyId;
}
}
return {
contents: contents,
replyId: replyId
};
}

View File

@ -101,7 +101,8 @@ export function createUserDefaults() {
clearNotificationsOnRead: false, clearNotificationsOnRead: false,
displayAllOnNotificationBadge: false, displayAllOnNotificationBadge: false,
defaultVisibility: "public", defaultVisibility: "public",
imposeCharacterLimit: true imposeCharacterLimit: true,
isMasonryLayout: false
}; };
let settings = [ let settings = [
@ -110,7 +111,8 @@ export function createUserDefaults() {
"clearNotificationsOnRead", "clearNotificationsOnRead",
"displayAllOnNotificationBadge", "displayAllOnNotificationBadge",
"defaultVisibility", "defaultVisibility",
"imposeCharacterLimit" "imposeCharacterLimit",
"isMasonryLayout"
]; ];
migrateExistingSettings(); migrateExistingSettings();