diff --git a/.github/workflows/ci-win.yml b/.github/workflows/ci-win.yml index 0e82160..17b1736 100644 --- a/.github/workflows/ci-win.yml +++ b/.github/workflows/ci-win.yml @@ -12,8 +12,27 @@ jobs: uses: actions/setup-node@v1 with: 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 run: | npm install npm run build --if-present - npm run build-desktop-win \ No newline at end of file + npm run build-desktop-win + - name: Upload Windows executable + uses: actions/upload-artifact@v1 + if: success() + with: + name: 'Windows executable (output dir)' + path: dist \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 28bc836..1fbea6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "hyperspace", - "version": "1.1.0-beta1", + "version": "1.1.0-beta2", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -16822,6 +16822,11 @@ "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", "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": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz", diff --git a/package.json b/package.json index f1b3b37..ae2a9a4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hyperspace", "productName": "Hyperspace Desktop", - "version": "1.1.0-beta1", + "version": "1.1.0-beta2", "description": "A beautiful, fluffy client for the fediverse", "author": "Marquis Kurt ", "repository": "https://github.com/hyperspacedev/hyperspace.git", @@ -41,7 +41,8 @@ "dependencies": { "electron-notarize": "^0.1.1", "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", "scripts": { diff --git a/src/components/AppLayout/AppLayout.styles.tsx b/src/components/AppLayout/AppLayout.styles.tsx index 8c9824d..fd820ed 100644 --- a/src/components/AppLayout/AppLayout.styles.tsx +++ b/src/components/AppLayout/AppLayout.styles.tsx @@ -35,7 +35,8 @@ export const styles = (theme: Theme) => titleBarText: { fontSize: 12, paddingTop: 2, - paddingBottom: 1 + paddingBottom: 1, + color: theme.palette.getContrastText(theme.palette.primary.main) }, appBar: { zIndex: 1000, @@ -57,6 +58,10 @@ export const styles = (theme: Theme) => display: "none" } }, + appBarBackButton: { + marginLeft: -12, + marginRight: 20 + }, appBarTitle: { display: "none", [theme.breakpoints.up("md")]: { diff --git a/src/components/AppLayout/AppLayout.tsx b/src/components/AppLayout/AppLayout.tsx index a3f89d5..bec8751 100644 --- a/src/components/AppLayout/AppLayout.tsx +++ b/src/components/AppLayout/AppLayout.tsx @@ -44,6 +44,7 @@ import SupervisedUserCircleIcon from "@material-ui/icons/SupervisedUserCircle"; import ExitToAppIcon from "@material-ui/icons/ExitToApp"; import TrendingUpIcon from "@material-ui/icons/TrendingUp"; import BuildIcon from "@material-ui/icons/Build"; +import ArrowBackIcon from "@material-ui/icons/ArrowBack"; import { styles } from "./AppLayout.styles"; import { MultiAccount, UAccount } from "../../types/Account"; @@ -67,30 +68,81 @@ import { getAccountRegistry, removeAccountFromRegistry } from "../../utilities/accounts"; +import { isChildView } from "../../utilities/appbar"; +/** + * The pre-define state interface for the app layout. + */ interface IAppLayoutState { + /** + * Whether the account menu is open or not. + */ acctMenuOpen: boolean; + + /** + * Whether the drawer is open (mobile-only). + */ drawerOpenOnMobile: boolean; + + /** + * The current user signed in. + */ currentUser?: UAccount; + + /** + * The number of notifications received. + */ notificationCount: number; + + /** + * Whether the log out dialog is open. + */ logOutOpen: boolean; + + /** + * Whether federation has been enabled in the config. + */ enableFederation?: boolean; + + /** + * The brand name of the app, if not "Hyperspace". + */ brandName?: string; + + /** + * Whether the app is in development mode. + */ developerMode?: boolean; } +/** + * The base app layout class. Responsible for the search bar, navigation menus, etc. + */ export class AppLayout extends Component { + /** + * The Mastodon client to operate with. + */ client: Mastodon; + + /** + * A stream listener to listen for new streaming events from Mastodon. + */ streamListener: any; + /** + * Construct the app layout. + * @param props The properties to pass in. + */ constructor(props: any) { super(props); + // Create the Mastodon client this.client = new Mastodon( localStorage.getItem("access_token") as string, (localStorage.getItem("baseurl") as string) + "/api/v1" ); + // Initialize the state this.state = { drawerOpenOnMobile: false, acctMenuOpen: false, @@ -98,14 +150,20 @@ export class AppLayout extends Component { logOutOpen: false }; + // Bind functions as properties to this class for reference this.toggleDrawerOnMobile = this.toggleDrawerOnMobile.bind(this); this.toggleAcctMenu = this.toggleAcctMenu.bind(this); this.clearBadge = this.clearBadge.bind(this); } + /** + * Run post-mount tasks such as getting account data and refreshing the config file. + */ componentDidMount() { + // Get the account data. this.getAccountData(); + // Read the config file and then update the state. getConfig().then((result: any) => { if (result !== undefined) { let config: Config = result; @@ -119,18 +177,25 @@ export class AppLayout extends Component { } }); + // Listen for notifications. this.streamNotifications(); } + /** + * Get updated credentials from Mastodon or pull information from local storage. + */ getAccountData() { + // Try to get updated credentials from Mastodon. this.client .get("/accounts/verify_credentials") .then((resp: any) => { + // Update the account if possible. let data: UAccount = resp.data; this.setState({ currentUser: data }); sessionStorage.setItem("id", data.id); }) .catch((err: Error) => { + // Otherwise, pull from local storage. this.props.enqueueSnackbar( "Couldn't find profile info: " + err.name ); @@ -140,9 +205,14 @@ export class AppLayout extends Component { }); } + /** + * Set up a stream listener and listen for notifications. + */ streamNotifications() { + // Set up the stream listener. this.streamListener = this.client.stream("/streaming/user"); + // Set the count if the user asked to display the total count. if (getUserDefaultBool("displayAllOnNotificationBadge")) { this.client.get("/notifications").then((resp: any) => { let notifArray = resp.data; @@ -150,14 +220,17 @@ export class AppLayout extends Component { }); } + // Listen for notifications. this.streamListener.on("notification", (notif: Notification) => { const notificationCount = this.state.notificationCount + 1; this.setState({ notificationCount }); + // Update the badge on the desktop. if (isDesktopApp()) { getElectronApp().setBadgeCount(notificationCount); } + // Set up a push notification if the window isn't in focus. if (!document.hasFocus()) { let primaryMessage = ""; let secondaryMessage = ""; @@ -216,25 +289,39 @@ export class AppLayout extends Component { break; } + // Respectfully send the notification request. sendNotificationRequest(primaryMessage, secondaryMessage); } }); } + /** + * Toggle the account menu. + */ toggleAcctMenu() { this.setState({ acctMenuOpen: !this.state.acctMenuOpen }); } + /** + * Toggle the app drawer, if on mobile. + */ toggleDrawerOnMobile() { this.setState({ drawerOpenOnMobile: !this.state.drawerOpenOnMobile }); } + /** + * Toggle the logout dialog. + */ toggleLogOutDialog() { 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) { what = what.replace(/^#/g, "tag:"); console.log(what); @@ -243,9 +330,13 @@ export class AppLayout extends Component { : "/#/search?query=" + what; } + /** + * Clear login information, remove the account from the registry, and reload the web page. + */ logOutAndRestart() { let loginData = localStorage.getItem("login"); if (loginData) { + // Remove account from the registry. let registry = getAccountRegistry(); registry.forEach((registryItem: MultiAccount, index: number) => { @@ -257,15 +348,20 @@ export class AppLayout extends Component { } }); + // Clear some of the local storage fields. let items = ["login", "account", "baseurl", "access_token"]; items.forEach(entry => { localStorage.removeItem(entry); }); + // Finally, reload. window.location.reload(); } } + /** + * Clear the notifications badge. + */ clearBadge() { if (!getUserDefaultBool("displayAllOnNotificationBadge")) { this.setState({ notificationCount: 0 }); @@ -276,6 +372,9 @@ export class AppLayout extends Component { } } + /** + * Render the title bar. + */ titlebar() { const { classes } = this.props; if (isDarwinApp()) { @@ -307,6 +406,9 @@ export class AppLayout extends Component { } } + /** + * Render the app drawer. On the desktop, this appears as a sidebar in larger layouts. + */ appDrawer() { const { classes } = this.props; return ( @@ -476,6 +578,9 @@ export class AppLayout extends Component { ); } + /** + * Render the entire layout. + */ render() { const { classes } = this.props; return ( @@ -484,6 +589,18 @@ export class AppLayout extends Component { {this.titlebar()} + {isDesktopApp() && + isChildView(window.location.hash) ? ( + window.history.back()} + > + + + ) : null} + { variant="temporary" anchor={"left"} open={this.state.drawerOpenOnMobile} - onClose={this.toggleDrawerOnMobile} + onClick={this.toggleDrawerOnMobile} classes={{ paper: classes.drawerPaper }} > {this.appDrawer()} diff --git a/src/components/Post/Post.styles.tsx b/src/components/Post/Post.styles.tsx index 362d55a..1fa5377 100644 --- a/src/components/Post/Post.styles.tsx +++ b/src/components/Post/Post.styles.tsx @@ -81,6 +81,15 @@ export const styles = (theme: Theme) => paddingTop: 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: { height: theme.typography.fontSize, verticalAlign: "middle" diff --git a/src/components/Post/Post.tsx b/src/components/Post/Post.tsx index a6d2621..cc8fc5b 100644 --- a/src/components/Post/Post.tsx +++ b/src/components/Post/Post.tsx @@ -398,21 +398,34 @@ export class Post extends React.Component { const { classes } = this.props; if (post.reblog) { let author = post.reblog.account; - let origString = `${author.display_name || - author.username} (@${author.acct}) 🔄 ${post.account - .display_name || post.account.username}`; let emojis = author.emojis; emojis.concat(post.account.emojis); - return emojifyString(origString, emojis, classes.postAuthorEmoji); + return ( + <> + + {emojifyString(author.display_name || author.username, author.emojis, classes.postAuthorEmoji)} + + + @{emojifyString(author.acct, author.emojis, classes.postAuthorEmoji)} + + + + {emojifyString(post.account.display_name || post.account.username, emojis, classes.postAuthorEmoji)} + + + ) } else { let author = post.account; - let origString = `${author.display_name || - author.username} (@${author.acct})`; - return emojifyString( - origString, - author.emojis, - classes.postAuthorEmoji - ); + return ( + <> + + {emojifyString(author.display_name || author.username, author.emojis, classes.postAuthorEmoji)} + + + @{emojifyString(author.acct, author.emojis, classes.postAuthorEmoji)} + + + ) } } @@ -656,13 +669,7 @@ export class Post extends React.Component { } - title={ - - } + title={{this.getReblogAuthors(post)}} subheader={moment(post.created_at).format( "MMMM Do YYYY [at] h:mm A" )} diff --git a/src/pages/Compose.styles.tsx b/src/pages/Compose.styles.tsx index 6d354ea..40ea774 100644 --- a/src/pages/Compose.styles.tsx +++ b/src/pages/Compose.styles.tsx @@ -45,5 +45,25 @@ export const styles = (theme: Theme) => }, pollWizardFlexGrow: { 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 } }); diff --git a/src/pages/Compose.tsx b/src/pages/Compose.tsx index fa8915f..d455a03 100644 --- a/src/pages/Compose.tsx +++ b/src/pages/Compose.tsx @@ -44,35 +44,106 @@ import { getConfig, getUserDefaultBool } from "../utilities/settings"; +import { draftExists, writeDraft, loadDraft } from "../utilities/compose"; +/** + * The state for the Composer page. + */ interface IComposerState { + /** + * The current user as an Account. + */ account: UAccount; + + /** + * The visibility of the post. + */ visibility: Visibility; + + /** + * Whether there should be a content warning. + */ sensitive: boolean; + + /** + * The content warning message. + */ sensitiveText?: string; + + /** + * Whether the visibility drop-down should be visible. + */ visibilityMenu: boolean; + + /** + * The text contents of the post. + */ text: string; + + /** + * The remaining amount of characters. + */ remainingChars: number; + + /** + * An optional reply ID. + */ reply?: string; + + /** + * The account to reply to, if it exists. + */ acct?: string; + + /** + * An optional list of media attachments. + */ attachments?: [Attachment]; + + /** + * An optional poll for the post. + */ poll?: PollWizard; + + /** + * The expiration date of a poll, if it exists. + */ pollExpiresDate?: any; + + /** + * Whether the emoji picker should be visible. + */ showEmojis: boolean; + + /** + * Whether or not the account's instance is federated. + */ federated: boolean; } +/** + * The Compose page contains all of the information to create a UI for post creation. + */ class Composer extends Component { + /** + * The Mastodon client to work with. + */ 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) { super(props); + // Generate the Mastodon client this.client = new Mastodon( localStorage.getItem("access_token") as string, localStorage.getItem("baseurl") + "/api/v1" ); + // Set the initial state this.state = { account: JSON.parse(localStorage.getItem("account") as string), visibility: getUserDefaultVisibility(), @@ -87,13 +158,21 @@ class Composer extends Component { }; } + /** + * 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() { + // Parse the parameters and get the account information if available. let state = this.getComposerParams(this.props); let text = state.acct ? `@${state.acct}: ` : ""; this.client.get("/accounts/verify_credentials").then((resp: any) => { let account: UAccount = resp.data; this.setState({ account }); }); + + // Get the configuration and load the config values. getConfig().then((config: any) => { this.setState({ federated: config.federation.allowPublicPosts, @@ -107,6 +186,8 @@ class Composer extends Component { }); }); + // Attach the paste listener to listen for the clipboard and upload media + // if possible. window.addEventListener("paste", (evt: Event) => { let thePasteEvent = evt as ClipboardEvent; let fileList: File[] = []; @@ -121,12 +202,21 @@ class Composer extends Component { } } } - 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) { let state = this.getComposerParams(props); let text = state.acct ? `@${state.acct}: ` : ""; @@ -141,6 +231,36 @@ class Composer extends Component { }); } + /** + * 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 { let params = ""; if (location !== undefined && typeof location === "string") { @@ -151,6 +271,11 @@ class Composer extends Component { 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) { let params = this.checkComposerParams(props.location); let reply: string = ""; @@ -173,6 +298,10 @@ class Composer extends Component { }; } + /** + * Update the text in the state and calculate the remaining character length. + * @param text The text to update the state to + */ updateTextFromField(text: string) { this.setState({ text, @@ -182,20 +311,31 @@ class Composer extends Component { }); } + /** + * Update the content warning text in the state + * @param sensitiveText The text to update the state to + */ updateWarningFromField(sensitiveText: string) { this.setState({ sensitiveText }); } + /** + * Update the visibility in the state + * @param visibility The visibility to update the state to + */ changeVisibility(visibility: 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({ multiple: false, 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) => { this.props.enqueueSnackbar("Couldn't get media: " + err.name, { variant: "error" @@ -204,15 +344,27 @@ class Composer extends Component { }); } - 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(); mediaForm.append("file", media[0]); + + // Let the user know we're uploading the file this.props.enqueueSnackbar("Uploading media...", { persist: true, key: "media-upload" }); + + // Try to upload the media to the server. this.client .post("/media", mediaForm) + + // If we succeed, get the attachments and update the state. .then((resp: any) => { let attachment: Attachment = resp.data; let attachments = this.state.attachments; @@ -225,6 +377,8 @@ class Composer extends Component { this.props.closeSnackbar("media-upload"); this.props.enqueueSnackbar("Media uploaded."); }) + + // If we fail, display an error. .catch((err: Error) => { this.props.closeSnackbar("media-upload"); this.props.enqueueSnackbar( @@ -234,6 +388,10 @@ class Composer extends Component { }); } + /** + * Iterate through the attachments and grab the attachments' IDs. + * @returns A list of IDs as `string[]` + */ getOnlyMediaIds() { let ids: string[] = []; if (this.state.attachments) { @@ -244,6 +402,10 @@ class Composer extends Component { return ids; } + /** + * Update the list of attachments by inserting an attachment. + * @param attachment The attachment to insert into the attachments list. + */ fetchAttachmentAfterUpdate(attachment: Attachment) { let attachments = this.state.attachments; if (attachments) { @@ -256,6 +418,10 @@ class Composer extends Component { } } + /** + * Remove an attachment from the list of attachments and update the state. + * @param attachment The attachment to remove from the list + */ deleteMediaAttachment(attachment: Attachment) { let attachments = this.state.attachments; if (attachments) { @@ -269,6 +435,10 @@ class Composer extends Component { } } + /** + * 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) { if (e.custom) { let text = this.state.text + e.colons; @@ -285,6 +455,9 @@ class Composer extends Component { } } + /** + * Create an empty poll. + */ createPoll() { if (this.state.poll === undefined) { let expiration = new Date(); @@ -304,6 +477,9 @@ class Composer extends Component { } } + /** + * Insert a new poll item into the poll. + */ addPollItem() { if ( this.state.poll !== undefined && @@ -326,6 +502,11 @@ class Composer extends Component { } } + /** + * 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) { if (this.state.poll !== undefined) { let poll = this.state.poll; @@ -343,6 +524,10 @@ class Composer extends Component { } } + /** + * Removes a poll item from the poll + * @param item The item to remove + */ removePollItem(item: string) { if ( this.state.poll !== undefined && @@ -369,6 +554,10 @@ class Composer extends Component { } } + /** + * Set the expiration date of the poll. + * @param date The new expiration date + */ setPollExpires(date: string) { let currentDate = new Date(); let newDate = new Date(date); @@ -388,25 +577,38 @@ class Composer extends Component { } } + /** + * Remove the poll from the post. + */ removePoll() { this.setState({ 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) { if ((event.metaKey || event.ctrlKey) && event.keyCode === 13) { this.post(); } } + /** + * Send the post to Mastodon and return to the previous page, if possible. + */ post() { + // First, finalize the poll. let pollOptions: string[] = []; if (this.state.poll) { this.state.poll.options.forEach((option: PollWizardOption) => { pollOptions.push(option.title); }); } + + // Send a post request to Mastodon. this.client .post("/statuses", { status: this.state.text, @@ -423,28 +625,44 @@ class Composer extends Component { } : null }) + + // If we succeed, send a success message and go back. .then(() => { this.props.enqueueSnackbar("Posted!"); window.history.back(); }) + + // Otherwise, show an error message and don't do anything. .catch((err: Error) => { this.props.enqueueSnackbar("Couldn't post: " + err.name); console.error(err.message); }); } + /** + * Toggle the content warning section. + */ toggleSensitive() { this.setState({ sensitive: !this.state.sensitive }); } + /** + * Toggle the visibility drop down menu. + */ toggleVisibilityMenu() { this.setState({ visibilityMenu: !this.state.visibilityMenu }); } + /** + * Toggle the emoji picker. + */ toggleEmojis() { this.setState({ showEmojis: !this.state.showEmojis }); } + /** + * Render all of the components on the page given a set of classes. + */ render() { const { classes } = this.props; @@ -652,7 +870,7 @@ class Composer extends Component { this.uploadMedia()} + onClick={() => this.promptMediaDialog()} id="compose-media" > @@ -738,6 +956,21 @@ class Composer extends Component { ) : null} + {draftExists() ? ( + + + You have an unsaved post. + +
+ + + ) : null} + + + + + + + + + +
diff --git a/src/types/Draft.tsx b/src/types/Draft.tsx new file mode 100644 index 0000000..9148686 --- /dev/null +++ b/src/types/Draft.tsx @@ -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; +}; diff --git a/src/utilities/appbar.tsx b/src/utilities/appbar.tsx index bd4e8c0..4da82b4 100644 --- a/src/utilities/appbar.tsx +++ b/src/utilities/appbar.tsx @@ -1,5 +1,14 @@ 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. * 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 { 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; +} diff --git a/src/utilities/compose.tsx b/src/utilities/compose.tsx new file mode 100644 index 0000000..114e9f1 --- /dev/null +++ b/src/utilities/compose.tsx @@ -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 + }; +} diff --git a/src/utilities/settings.tsx b/src/utilities/settings.tsx index 43b36ee..b71d1a2 100644 --- a/src/utilities/settings.tsx +++ b/src/utilities/settings.tsx @@ -101,7 +101,8 @@ export function createUserDefaults() { clearNotificationsOnRead: false, displayAllOnNotificationBadge: false, defaultVisibility: "public", - imposeCharacterLimit: true + imposeCharacterLimit: true, + isMasonryLayout: false }; let settings = [ @@ -110,7 +111,8 @@ export function createUserDefaults() { "clearNotificationsOnRead", "displayAllOnNotificationBadge", "defaultVisibility", - "imposeCharacterLimit" + "imposeCharacterLimit", + "isMasonryLayout" ]; migrateExistingSettings();