diff --git a/package-lock.json b/package-lock.json index 1fbea6f..7d9d9be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "hyperspace", - "version": "1.1.0-beta2", + "version": "1.1.0-beta4", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index e636a36..03fc26b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hyperspace", "productName": "Hyperspace Desktop", - "version": "1.1.0-beta3", + "version": "1.1.0-beta4", "description": "A beautiful, fluffy client for the fediverse", "author": "Marquis Kurt ", "repository": "https://github.com/hyperspacedev/hyperspace.git", diff --git a/src/components/AppLayout/AppLayout.tsx b/src/components/AppLayout/AppLayout.tsx index bec8751..3f86ee5 100644 --- a/src/components/AppLayout/AppLayout.tsx +++ b/src/components/AppLayout/AppLayout.tsx @@ -324,7 +324,7 @@ export class AppLayout extends Component { */ searchForQuery(what: string) { what = what.replace(/^#/g, "tag:"); - console.log(what); + // console.log(what); window.location.href = isDesktopApp() ? "hyperspace://hyperspace/app/index.html#/search?query=" + what : "/#/search?query=" + what; diff --git a/src/components/Post/Post.styles.tsx b/src/components/Post/Post.styles.tsx index 1fa5377..328c15e 100644 --- a/src/components/Post/Post.styles.tsx +++ b/src/components/Post/Post.styles.tsx @@ -6,6 +6,28 @@ export const styles = (theme: Theme) => marginTop: theme.spacing.unit, marginBottom: theme.spacing.unit }, + postHeaderContent: { + overflow: "hidden", + whiteSpace: "nowrap" + }, + postHeaderTitle: { + display: "flex", + flexWrap: "wrap", + color: theme.palette.text.secondary + }, + postAuthorNameAndAccount: { + overflow: "hidden", + textOverflow: "ellipsis" + }, + postAuthorName: { + whiteSpace: "nowrap", + color: theme.palette.text.primary + }, + postAuthorAccount: { + overflow: "hidden", + textOverflow: "ellipsis", + marginLeft: theme.spacing.unit * 0.5 + }, postReblogChip: { color: theme.palette.common.white, "&:hover": { @@ -81,14 +103,11 @@ 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, + color: theme.palette.text.primary }, postAuthorEmoji: { height: theme.typography.fontSize, diff --git a/src/components/Post/Post.tsx b/src/components/Post/Post.tsx index 629dcd1..56cae52 100644 --- a/src/components/Post/Post.tsx +++ b/src/components/Post/Post.tsx @@ -101,6 +101,11 @@ export class Post extends React.Component { }); } + shouldComponentUpdate(nextProps: any, nextState: any) { + if (nextState == this.state) return false; + return true; + } + togglePostMenu() { this.setState({ menuIsOpen: !this.state.menuIsOpen }); } @@ -119,7 +124,7 @@ export class Post extends React.Component { }) .catch((err: Error) => { this.props.enqueueSnackbar("Couldn't delete post: " + err.name); - console.log(err.message); + console.error(err.message); }); } @@ -405,33 +410,34 @@ export class Post extends React.Component { emojis.concat(reblogger.emojis); } - console.log(post); - return ( <> - - + + }} + > + + {reblogger ? ( - <> +
{ ) }} > - +
) : null} ); @@ -536,86 +542,63 @@ export class Post extends React.Component { } } + /** + * Get the post's URL + * @param post The post to get the URL from + * @returns A string containing the post's URI + */ getMastodonUrl(post: Status) { - let url = ""; - if (post.reblog) { - url = post.reblog.uri; - } else { - url = post.uri; - } - return url; + return post.reblog ? post.reblog.uri : post.uri; } - toggleFavorited(post: Status) { - let _this = this; - if (post.favourited) { - this.client - .post(`/statuses/${post.id}/unfavourite`) - .then((resp: any) => { - let post: Status = resp.data; - this.setState({ post }); - }) - .catch((err: Error) => { - _this.props.enqueueSnackbar( - `Couldn't unfavorite post: ${err.name}`, - { - variant: "error" - } - ); - console.log(err.message); - }); - } else { - this.client - .post(`/statuses/${post.id}/favourite`) - .then((resp: any) => { - let post: Status = resp.data; - this.setState({ post }); - }) - .catch((err: Error) => { - _this.props.enqueueSnackbar( - `Couldn't favorite post: ${err.name}`, - { - variant: "error" - } - ); - console.log(err.message); - }); + /** + * Tell server a post has been un/favorited and update post state + * @param post The post to un/favorite + */ + async toggleFavorited(post: Status) { + let action: string = post.favourited ? "unfavourite" : "favourite"; + try { + // favorite the original post, not the reblog + let resp: any = await this.client.post( + `/statuses/${post.reblog ? post.reblog.id : post.id}/${action}` + ); + // compensate for slow server update + if (action === "unfavourite") { + resp.data.favourites_count -= 1; + // if you unlike both original and reblog before refresh + // and the post has only one favorite: + if (resp.data.favourites_count < 0) { + resp.data.favourites_count = 0; + } + } + this.setState({ post: resp.data as Status }); + } catch (e) { + this.props.enqueueSnackbar(`Could not ${action} post: ${e.name}`); + console.error(e.message); } } - toggleReblogged(post: Status) { - if (post.reblogged) { - this.client - .post(`/statuses/${post.id}/unreblog`) - .then((resp: any) => { - let post: Status = resp.data; - this.setState({ post }); - }) - .catch((err: Error) => { - this.props.enqueueSnackbar( - `Couldn't unboost post: ${err.name}`, - { - variant: "error" - } - ); - console.log(err.message); - }); - } else { - this.client - .post(`/statuses/${post.id}/reblog`) - .then((resp: any) => { - let post: Status = resp.data; - this.setState({ post }); - }) - .catch((err: Error) => { - this.props.enqueueSnackbar( - `Couldn't boost post: ${err.name}`, - { - variant: "error" - } - ); - console.log(err.message); - }); + /** + * Tell server a post has been un/reblogged and update post state + * @param post The post to un/reblog + */ + async toggleReblogged(post: Status) { + let action: string = + post.reblogged || post.reblog ? "unreblog" : "reblog"; + try { + // modify the original post, not the reblog + let resp: any = await this.client.post( + `/statuses/${post.reblog ? post.reblog.id : post.id}/${action}` + ); + // compensate for slow server update + if (action === "unreblog") { + resp.data.reblogs_count -= 1; + } + if (resp.data.reblog) resp.data = resp.data.reblog; + this.setState({ post: resp.data as Status }); + } catch (e) { + this.props.enqueueSnackbar(`Could not ${action} post: ${e.name}`); + console.error(e.message); } } @@ -660,232 +643,222 @@ export class Post extends React.Component { const { classes } = this.props; const post = this.state.post; return ( - - - - } - action={ - - this.togglePostMenu()} - > - - - - } - title={ - - {this.getReblogAuthors(post)} - - } - subheader={moment(post.created_at).format( - "MMMM Do YYYY [at] h:mm A" - )} - /> - {post.reblog ? this.getReblogOfPost(post.reblog) : null} - {post.sensitive - ? this.getSensitiveContent(post.spoiler_text, post) - : post.reblog - ? null - : this.materializeContent(post)} - {post.reblog && post.reblog.mentions.length > 0 - ? this.getMentions(post.reblog.mentions) - : this.getMentions(post.mentions)} - {post.reblog && post.reblog.tags.length > 0 - ? this.getTags(post.reblog.tags) - : this.getTags(post.tags)} - - - - - - - - {post.reblog - ? post.reblog.replies_count - : post.replies_count} - - + + + } + action={ + this.toggleFavorited(post)} + key={`${post.id}_submenu`} + id={`${post.id}_submenu`} + onClick={() => this.togglePostMenu()} > - + - - {post.reblog - ? post.reblog.favourites_count - : post.favourites_count} - - - this.toggleReblogged(post)} - > - + {post.reblog ? this.getReblogOfPost(post.reblog) : null} + {post.sensitive + ? this.getSensitiveContent(post.spoiler_text, post) + : post.reblog + ? null + : this.materializeContent(post)} + {post.reblog && post.reblog.mentions.length > 0 + ? this.getMentions(post.reblog.mentions) + : this.getMentions(post.mentions)} + {post.reblog && post.reblog.tags.length > 0 + ? this.getTags(post.reblog.tags) + : this.getTags(post.tags)} + + + + + + + + {post.reblog + ? post.reblog.replies_count + : post.replies_count} + + + this.toggleFavorited(post)}> + - - - - {post.reblog - ? post.reblog.reblogs_count - : post.reblogs_count} - - - - - - - + + + + {post.reblog + ? post.reblog.favourites_count + : post.favourites_count} + + + this.toggleReblogged(post)}> + + + + + {post.reblog + ? post.reblog.reblogs_count + : post.reblogs_count} + + + - - - - -
-
- {this.showVisibilityIcon(post.visibility)} -
- - this.togglePostMenu()} + + + + - - this.props.enqueueSnackbar("Post shared!", { - variant: "success" - }), - onShareError: (error: Error) => { - if (error.name != "AbortError") - this.props.enqueueSnackbar( - `Couldn't share post: ${error.name}`, - { variant: "error" } - ); - } - }} - /> - {post.reblog ? ( -
- - View author profile - - - View reblogger profile - -
- ) : ( + + + +
+
+
+ {this.showVisibilityIcon(post.visibility)} +
+ + this.togglePostMenu()} + > + + this.props.enqueueSnackbar("Post shared!", { + variant: "success" + }), + onShareError: (error: Error) => { + if (error.name != "AbortError") + this.props.enqueueSnackbar( + `Couldn't share post: ${error.name}`, + { variant: "error" } + ); + } + }} + /> + {post.reblog ? ( +
- View profile + View author profile - )} -
- - View thread + View reblogger profile +
+ ) : ( + + View profile + + )} +
+ + + View thread + + + Open in Web + +
+ {this.state.myAccount && + post.account.id === this.state.myAccount ? ( +
+ this.togglePostDeleteDialog()} > - Open in Web + Delete
- {this.state.myAccount && - post.account.id === this.state.myAccount ? ( -
- - - this.togglePostDeleteDialog() - } - > - Delete - -
- ) : null} - {this.showDeleteDialog()} -
- - + ) : null} + {this.showDeleteDialog()} +
+ ); } } diff --git a/src/interfaces/overrides.tsx b/src/interfaces/overrides.tsx index f78826b..6fb8e0a 100644 --- a/src/interfaces/overrides.tsx +++ b/src/interfaces/overrides.tsx @@ -79,7 +79,7 @@ export const ProfileRoute = (rest: any, component: Component) => ( export const PrivateRoute = (props: IPrivateRouteProps) => { const { component, render, ...rest } = props; const redir = (comp: any) => - userLoggedIn ? comp : ; + userLoggedIn() ? comp : ; return ( { + [Key: string]: T; +} diff --git a/src/pages/Activity.tsx b/src/pages/Activity.tsx index ad12078..46655dd 100644 --- a/src/pages/Activity.tsx +++ b/src/pages/Activity.tsx @@ -83,7 +83,7 @@ class ActivityPage extends Component { viewLoading: false, viewErrored: true }); - console.log(err.message); + console.error(err.message); }); this.client @@ -101,7 +101,7 @@ class ActivityPage extends Component { viewLoading: false, viewErrored: true }); - console.log(err.message); + console.error(err.message); }); } diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index d11feed..2c52ec7 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -17,24 +17,33 @@ import { DialogContent, DialogContentText, DialogActions, - Tooltip + Tooltip, + Menu, + MenuItem } from "@material-ui/core"; import AssignmentIndIcon from "@material-ui/icons/AssignmentInd"; import PersonIcon from "@material-ui/icons/Person"; import PersonAddIcon from "@material-ui/icons/PersonAdd"; +import PersonRemoveIcon from "mdi-material-ui/AccountMinus"; import DeleteIcon from "@material-ui/icons/Delete"; import { styles } from "./PageLayout.styles"; -import { LinkableIconButton, LinkableAvatar } from "../interfaces/overrides"; +import { + LinkableIconButton, + LinkableAvatar, + LinkableMenuItem +} from "../interfaces/overrides"; import ForumIcon from "@material-ui/icons/Forum"; import ReplyIcon from "@material-ui/icons/Reply"; import NotificationsIcon from "@material-ui/icons/Notifications"; +import MoreVertIcon from "@material-ui/icons/MoreVert"; import Mastodon from "megalodon"; import { Notification } from "../types/Notification"; import { Account } from "../types/Account"; import { Relationship } from "../types/Relationship"; import { withSnackbar } from "notistack"; +import { Dictionary } from "../interfaces/utils"; /** * The state interface for the notifications page. @@ -45,6 +54,11 @@ interface INotificationsPageState { */ notifications?: [Notification]; + /** + * The relationships with all notification accounts + */ + relationships: { [id: string]: Relationship }; + /** * Whether the view is still loading. */ @@ -69,6 +83,11 @@ interface INotificationsPageState { * Whether the delete confirmation dialog should be open. */ deleteDialogOpen: boolean; + + /** + * Whether the menu should be open on smaller devices. + */ + mobileMenuOpen: Dictionary; } /** @@ -101,33 +120,59 @@ class NotificationsPage extends Component { // Initialize the state. this.state = { viewIsLoading: true, - deleteDialogOpen: false + deleteDialogOpen: false, + mobileMenuOpen: {}, + relationships: {} }; } /** - * Perform pre-mount tasks. + * Perform pre-mount tasks */ - componentWillMount() { - // Get the list of notifications and update the state. - this.client - .get("/notifications") - .then((resp: any) => { - let notifications: [Notification] = resp.data; - this.setState({ - notifications, - viewIsLoading: false, - viewDidLoad: true - }); - }) - .catch((err: Error) => { - this.setState({ - viewDidLoad: true, - viewIsLoading: false, - viewDidError: true, - viewDidErrorCode: err.message - }); + async componentWillMount() { + try { + // Get the list of notifications + let resp: any = await this.client.get("/notifications"); + let notifications: [Notification] = resp.data; + + // initialize all menus as closed + let notifMenus: Dictionary = {}; + notifications.forEach( + (n: Notification) => (notifMenus[n.id] = false) + ); + + // compile list of all notification account ids + let accountIds: string[] = []; + notifications.forEach(notif => { + if (!accountIds.includes(notif.account.id)) { + accountIds.push(notif.account.id); + } + }); + + // store relationships in id-relationship pairs + resp = await this.client.get(`/accounts/relationships`, { + id: accountIds + }); + let relationships: Dictionary = {}; + resp.data.forEach((relation: Relationship) => { + relationships[relation.id] = relation; + }); + + this.setState({ + notifications, + relationships, + viewIsLoading: false, + viewDidLoad: true, + mobileMenuOpen: notifMenus + }); + } catch (e) { + this.setState({ + viewDidLoad: true, + viewIsLoading: false, + viewDidError: true, + viewDidErrorCode: e.message }); + } } /** @@ -160,6 +205,12 @@ class NotificationsPage extends Component { this.setState({ deleteDialogOpen: !this.state.deleteDialogOpen }); } + toggleMobileMenu(id: string) { + let mobileMenuOpen = this.state.mobileMenuOpen; + mobileMenuOpen[id] = !mobileMenuOpen[id]; + this.setState({ mobileMenuOpen }); + } + /** * Strip HTML content from a string containing HTML content. * @@ -306,6 +357,108 @@ class NotificationsPage extends Component { } /> + {this.getActions(notif)} + + + ); + } + + /** + * Un/follow an account and update relationships state. + * @param acct The account to un/follow, if possible + */ + async toggleFollow(acct: Account) { + let relationships = this.state.relationships; + if (!relationships[acct.id].following) { + try { + let resp: any = await this.client.post( + `/accounts/${acct.id}/follow` + ); + relationships[acct.id] = resp.data; + this.setState({ relationships }); + this.props.enqueueSnackbar( + "You are now following this account." + ); + } catch (e) { + this.props.enqueueSnackbar( + "Couldn't follow acccount: " + e.name + ); + console.error(e.message); + } + } else { + try { + let resp: any = await this.client.post( + `/accounts/${acct.id}/unfollow` + ); + relationships[acct.id] = resp.data; + this.setState({ relationships }); + this.props.enqueueSnackbar( + "You are no longer following this account." + ); + } catch (e) { + this.props.enqueueSnackbar( + "Couldn't unfollow acccount: " + e.name + ); + console.error(e.message); + } + } + } + + getActions = (notif: Notification) => { + const { classes } = this.props; + return ( + <> + this.toggleMobileMenu(notif.id)} + className={classes.mobileOnly} + id={`notification-list-${notif.id}`} + > + + + this.toggleMobileMenu(notif.id)} + > + {notif.type == "follow" ? ( + <> + + View Profile + + this.toggleFollow(notif.account)} + > + {this.state.relationships[notif.account.id] + .following + ? "Unfollow" + : "Follow"} + + + ) : null} + {notif.type == "mention" && notif.status ? ( + + Reply + + ) : null} + this.removeNotification(notif.id)}> + Remove + + +
{notif.type === "follow" ? ( @@ -315,15 +468,28 @@ class NotificationsPage extends Component { - - - this.followMember(notif.account) - } - > - - - + {!this.state.relationships[notif.account.id] + .following ? ( + + + this.toggleFollow(notif.account) + } + > + + + + ) : ( + + + this.toggleFollow(notif.account) + } + > + + + + )} ) : notif.status ? ( @@ -363,54 +529,10 @@ class NotificationsPage extends Component { - - +
+ ); - } - - /** - * Follow an account from a notification if already not followed. - * @param acct The account to follow, if possible - */ - followMember(acct: Account) { - // Get the relationships for this account. - this.client - .get(`/accounts/relationships`, { id: acct.id }) - .then((resp: any) => { - // Returns a list, so grab only the first item. - 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) => { - this.props.enqueueSnackbar("Couldn't find relationship.", { - variant: "error" - }); - }); - } + }; /** * Render the notification page. diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index 9e00c3e..4baee90 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -159,7 +159,7 @@ class SearchPage extends Component { viewDidLoad: true, viewIsLoading: false }); - console.log(this.state.tagResults); + // console.log(this.state.tagResults); }) .catch((err: Error) => { this.setState({ diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 0dc0efd..348ec55 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -65,6 +65,7 @@ import DomainDisabledIcon from "@material-ui/icons/DomainDisabled"; import AccountSettingsIcon from "mdi-material-ui/AccountSettings"; import AlphabeticalVariantOffIcon from "mdi-material-ui/AlphabeticalVariantOff"; import DashboardIcon from "@material-ui/icons/Dashboard"; +import InfiniteIcon from "@material-ui/icons/AllInclusive"; import { Config } from "../types/Config"; import { Account } from "../types/Account"; @@ -88,6 +89,7 @@ interface ISettingsState { currentUser?: Account; imposeCharacterLimit: boolean; masonryLayout?: boolean; + infiniteScroll?: boolean; } class SettingsPage extends Component { @@ -120,7 +122,8 @@ class SettingsPage extends Component { brandName: "Hyperspace", federated: true, imposeCharacterLimit: getUserDefaultBool("imposeCharacterLimit"), - masonryLayout: getUserDefaultBool("isMasonryLayout") + masonryLayout: getUserDefaultBool("isMasonryLayout"), + infiniteScroll: getUserDefaultBool("isInfiniteScroll") }; this.toggleDarkMode = this.toggleDarkMode.bind(this); @@ -130,6 +133,7 @@ class SettingsPage extends Component { this.toggleThemeDialog = this.toggleThemeDialog.bind(this); this.toggleVisibilityDialog = this.toggleVisibilityDialog.bind(this); this.toggleMasonryLayout = this.toggleMasonryLayout.bind(this); + this.toggleInfiniteScroll = this.toggleInfiniteScroll.bind(this); this.changeThemeName = this.changeThemeName.bind(this); this.changeTheme = this.changeTheme.bind(this); this.setVisibility = this.setVisibility.bind(this); @@ -170,7 +174,7 @@ class SettingsPage extends Component { getConfig().then((result: any) => { if (result !== undefined) { let config: Config = result; - console.log(!config.federation.allowPublicPosts); + // console.log(!config.federation.allowPublicPosts); this.setState({ federated: config.federation.allowPublicPosts }); @@ -250,6 +254,11 @@ class SettingsPage extends Component { setUserDefaultBool("isMasonryLayout", !this.state.masonryLayout); } + toggleInfiniteScroll() { + this.setState({ infiniteScroll: !this.state.infiniteScroll }); + setUserDefaultBool("isInfiniteScroll", !this.state.infiniteScroll); + } + changeTheme() { setUserDefaultTheme(this.state.selectThemeName); window.location.reload(); @@ -675,6 +684,22 @@ class SettingsPage extends Component { /> + + + + + + + + + +
diff --git a/src/pages/Timeline.tsx b/src/pages/Timeline.tsx index d5b686b..6317be7 100644 --- a/src/pages/Timeline.tsx +++ b/src/pages/Timeline.tsx @@ -77,6 +77,11 @@ interface ITimelinePageState { * the user settings. */ isMasonryLayout?: boolean; + + /** + * Whether posts should automatically load when scrolling. + */ + isInfiniteScroll?: boolean; } /** @@ -109,7 +114,8 @@ class TimelinePage extends Component { this.state = { viewIsLoading: true, backlogPosts: null, - isMasonryLayout: getUserDefaultBool("isMasonryLayout") + isMasonryLayout: getUserDefaultBool("isMasonryLayout"), + isInfiniteScroll: getUserDefaultBool("isInfiniteScroll") }; // Generate the client. @@ -120,6 +126,9 @@ class TimelinePage extends Component { // Create the stream listener from the properties. this.streamListener = this.client.stream(this.props.stream); + + this.loadMoreTimelinePieces = this.loadMoreTimelinePieces.bind(this); + this.shouldLoadMorePosts = this.shouldLoadMorePosts.bind(this); } /** @@ -129,8 +138,7 @@ class TimelinePage extends Component { this.streamListener.on("connect", () => { // Get the latest posts from this timeline. this.client - .get(this.props.timeline, { limit: 40 }) - + .get(this.props.timeline, { limit: 50 }) // If we succeeded, update the state and turn off loading. .then((resp: any) => { let statuses: [Status] = resp.data; @@ -198,10 +206,43 @@ class TimelinePage extends Component { } /** - * Halt the stream listener when unmounting the component. + * Insert a delay between repeated function calls + * codeburst.io/throttling-and-debouncing-in-javascript-646d076d0a44 + * @param delay How long to wait before calling function (ms) + * @param fn The function to call + */ + debounced(delay: number, fn: Function) { + let lastCall = 0; + return function(...args: any) { + const now = new Date().getTime(); + if (now - lastCall < delay) { + return; + } + lastCall = now; + return fn(...args); + }; + } + + /** + * Listen for when scroll position changes + */ + componentDidMount() { + if (this.state.isInfiniteScroll) { + window.addEventListener( + "scroll", + this.debounced(200, this.shouldLoadMorePosts) + ); + } + } + + /** + * Halt the stream and scroll listeners when unmounting the component. */ componentWillUnmount() { this.streamListener.stop(); + if (this.state.isInfiniteScroll) { + window.removeEventListener("scroll", this.shouldLoadMorePosts); + } } /** @@ -230,7 +271,7 @@ class TimelinePage extends Component { this.client .get(this.props.timeline, { max_id: this.state.posts[this.state.posts.length - 1].id, - limit: 20 + limit: 50 }) // If we succeeded, append them to the end of the list of posts. @@ -261,6 +302,17 @@ class TimelinePage extends Component { } } + /** + * Load more posts when scroll is near the end of the page + */ + shouldLoadMorePosts(e: Event) { + let difference = + document.body.clientHeight - window.scrollY - window.innerHeight; + if (difference < 10000 && this.state.viewIsLoading === false) { + this.loadMoreTimelinePieces(); + } + } + /** * Render the timeline page. */ @@ -316,9 +368,9 @@ class TimelinePage extends Component { return (
diff --git a/src/pages/Welcome.tsx b/src/pages/Welcome.tsx index 72fef9d..fc66217 100644 --- a/src/pages/Welcome.tsx +++ b/src/pages/Welcome.tsx @@ -46,41 +46,157 @@ import { Account, MultiAccount } from "../types/Account"; import AccountCircleIcon from "@material-ui/icons/AccountCircle"; import CloseIcon from "@material-ui/icons/Close"; +/** + * Basic props for Welcome page + */ interface IWelcomeProps extends withSnackbarProps { classes: any; } +/** + * Basic state for welcome page + */ interface IWelcomeState { + /** + * The custom-defined URL to the logo to display + */ logoUrl?: string; + + /** + * The custom-defined URL to the background image to display + */ backgroundUrl?: string; + + /** + * The custom-defined brand name of this app + */ brandName?: string; + + /** + * The custom-defined server address to register to + */ registerBase?: string; + + /** + * Whether this version of Hyperspace has federation + */ federates?: boolean; + + /** + * Whether Hyperspace is ready to get the auth code + */ proceedToGetCode: boolean; + + /** + * The currently "logged-in" user after the first step + */ user: string; + + /** + * Whether the user's input errors + */ userInputError: boolean; + + /** + * The user input error message, if any + */ userInputErrorMessage: string; + + /** + * The app's client ID, if registered + */ clientId?: string; + + /** + * The app's client secret, if registered + */ clientSecret?: string; + + /** + * The authorization URL provided by Mastodon from the + * client ID and secret + */ authUrl?: string; + + /** + * Whether a previous login attempt is present + */ foundSavedLogin: boolean; + + /** + * Whether Hyperspace is in the process of authorizing + */ authorizing: boolean; + + /** + * The custom-defined license for the Hyperspace source code + */ license?: string; + + /** + * The custom-defined URL to the source code of Hyperspace + */ repo?: string; + + /** + * The default address to redirect to. Used in login inits and + * when the authorization code completes. + */ defaultRedirectAddress: string; + + /** + * Whether the redirect address is set to 'dynamic'. + */ + redirectAddressIsDynamic: boolean; + + /** + * Whether the authorization dialog for the emergency login is + * open. + */ openAuthDialog: boolean; + + /** + * The authorization code to fetch an access token with + */ authCode: string; + + /** + * Whether the Emergency Mode has been initiated + */ emergencyMode: boolean; + + /** + * The current app version + */ version: string; + + /** + * Whether we are in the process of adding a new account or not + */ willAddAccount: boolean; } +/** + * The base class for the Welcome page. + * + * The Welcome page is responsible for handling the registration, + * login, and authorization of accounts into the Hyperspace app. + */ class WelcomePage extends Component { + /** + * The associated Mastodon client to handle logins/authorizations + * with + */ client: any; + /** + * Construct the state and other components of the Welcome page + * @param props The properties passed onto the page + */ constructor(props: any) { super(props); + // Set up our state this.state = { proceedToGetCode: false, user: "", @@ -89,6 +205,7 @@ class WelcomePage extends Component { authorizing: false, userInputErrorMessage: "", defaultRedirectAddress: "", + redirectAddressIsDynamic: false, openAuthDialog: false, authCode: "", emergencyMode: false, @@ -96,15 +213,21 @@ class WelcomePage extends Component { willAddAccount: false }; + // Read the configuration data and update the state getConfig() .then((result: any) => { if (result !== undefined) { let config: Config = result; + + // Warn if the location is dynamic (unexpected behavior) if (result.location === "dynamic") { console.warn( "Redirect URI is set to dynamic, which may affect how sign-in works for some users. Careful!" ); } + + // Reset to mastodon.social if the location is a disallowed + // domain. if ( inDisallowedDomains(result.registration.defaultInstance) ) { @@ -113,6 +236,8 @@ class WelcomePage extends Component { ); result.registration.defaultInstance = "mastodon.social"; } + + // Update the state as per the configuration this.setState({ logoUrl: config.branding ? result.branding.logo @@ -133,10 +258,13 @@ class WelcomePage extends Component { config.location != "dynamic" ? config.location : `https://${window.location.host}`, + redirectAddressIsDynamic: config.location == "dynamic", version: config.version }); } }) + + // Print an error if the config wasn't found. .catch(() => { console.error( "config.json is missing. If you want to customize Hyperspace, please include config.json" @@ -144,6 +272,10 @@ class WelcomePage extends Component { }); } + /** + * Look for any existing logins and tokens before presenting + * the login page + */ componentDidMount() { if (localStorage.getItem("login")) { this.getSavedSession(); @@ -154,18 +286,33 @@ class WelcomePage extends Component { } } + /** + * Update the user field in the state + * @param user The string to update the state to + */ updateUserInfo(user: string) { this.setState({ user }); } + /** + * Update the auth code in the state + * @param code The authorization code to update the state to + */ updateAuthCode(code: string) { this.setState({ authCode: code }); } + /** + * Toggle the visibility of the authorization dialog + */ toggleAuthDialog() { this.setState({ openAuthDialog: !this.state.openAuthDialog }); } + /** + * Determine whether the app is ready to open the authorization + * process. + */ readyForAuth() { if (localStorage.getItem("baseurl")) { return true; @@ -174,11 +321,18 @@ class WelcomePage extends Component { } } + /** + * Clear the current access token and base URL + */ clear() { localStorage.removeItem("access_token"); localStorage.removeItem("baseurl"); } + /** + * Get the current saved session from the previous login + * attempt and update the state + */ getSavedSession() { let loginData = localStorage.getItem("login"); if (loginData) { @@ -192,6 +346,9 @@ class WelcomePage extends Component { } } + /** + * Start the emergency login mode. + */ startEmergencyLogin() { if (!this.state.emergencyMode) { this.createEmergencyLogin(); @@ -199,6 +356,11 @@ class WelcomePage extends Component { this.toggleAuthDialog(); } + /** + * Start the registration process. + * @returns A URL pointing to the signup page of the base as defined + * in the config's `registerBase` field + */ startRegistration() { if (this.state.registerBase) { return "https://" + this.state.registerBase + "/auth/sign_up"; @@ -207,15 +369,33 @@ class WelcomePage extends Component { } } + /** + * Watch the keyboard and start the login procedure if the user + * presses the ENTER/RETURN key + * @param event The keyboard event + */ watchUsernameField(event: any) { if (event.keyCode === 13) this.startLogin(); } + /** + * Watch the keyboard and start the emergency login auth procedure + * if the user presses the ENTER/RETURN key + * @param event The keyboard event + */ watchAuthField(event: any) { if (event.keyCode === 13) this.authorizeEmergencyLogin(); } + /** + * Get the "logged-in" user by reading the username string + * from the first field on the login page. + * @param user The user string to parse + * @returns The base URL of the user + */ getLoginUser(user: string) { + // Did the user include "@"? They probably are not from the + // server defined in config if (user.includes("@")) { if (this.state.federates) { let newUser = user; @@ -235,7 +415,10 @@ class WelcomePage extends Component { : "mastodon.social") ); } - } else { + } + + // Otherwise, treat them as if they're from the server + else { let newUser = `${user}@${ this.state.registerBase ? this.state.registerBase @@ -251,70 +434,104 @@ class WelcomePage extends Component { } } + /** + * Check the user string for any errors and then create a client with an + * ID and secret to start the authorization process. + */ startLogin() { + // Check if we have errored let error = this.checkForErrors(); + + // If we didn't, create the Hyperspace app to register onto that Mastodon + // server. if (!error) { + // Define the app's scopes and base URL const scopes = "read write follow"; const baseurl = this.getLoginUser(this.state.user); localStorage.setItem("baseurl", baseurl); + + // Create the Hyperspace app createHyperspaceApp( this.state.brandName ? this.state.brandName : "Hyperspace", scopes, baseurl, getRedirectAddress(this.state.defaultRedirectAddress) - ).then((resp: any) => { - let saveSessionForCrashing: SaveClientSession = { - clientId: resp.clientId, - clientSecret: resp.clientSecret, - authUrl: resp.url, - emergency: false - }; - localStorage.setItem( - "login", - JSON.stringify(saveSessionForCrashing) - ); - this.setState({ - clientId: resp.clientId, - clientSecret: resp.clientSecret, - authUrl: resp.url, - proceedToGetCode: true + ) + // If we succeeded, create a login attempt for later reference + .then((resp: any) => { + let saveSessionForCrashing: SaveClientSession = { + clientId: resp.clientId, + clientSecret: resp.clientSecret, + authUrl: resp.url, + emergency: false + }; + localStorage.setItem( + "login", + JSON.stringify(saveSessionForCrashing) + ); + + // Finally, update the state + this.setState({ + clientId: resp.clientId, + clientSecret: resp.clientSecret, + authUrl: resp.url, + proceedToGetCode: true + }); }); - }); - } else { } } + /** + * Create an emergency mode login. This is usually initiated when the + * "click-to-authorize" method fails and the user needs to copy and paste + * an authorization code manually. + */ createEmergencyLogin() { console.log("Creating an emergency login..."); + + // Set up the scopes and base URL const scopes = "read write follow"; const baseurl = localStorage.getItem("baseurl") || this.getLoginUser(this.state.user); + + // Register the Mastodon app with the Mastodon server Mastodon.registerApp( this.state.brandName ? this.state.brandName : "Hyperspace", { scopes: scopes }, baseurl - ).then((appData: any) => { - let saveSessionForCrashing: SaveClientSession = { - clientId: appData.clientId, - clientSecret: appData.clientSecret, - authUrl: appData.url, - emergency: true - }; - localStorage.setItem( - "login", - JSON.stringify(saveSessionForCrashing) - ); - this.setState({ - clientId: appData.clientId, - clientSecret: appData.clientSecret, - authUrl: appData.url + ) + // If we succeed, create a login attempt for later reference + .then((appData: any) => { + let saveSessionForCrashing: SaveClientSession = { + clientId: appData.clientId, + clientSecret: appData.clientSecret, + authUrl: appData.url, + emergency: true + }; + localStorage.setItem( + "login", + JSON.stringify(saveSessionForCrashing) + ); + + // Finally, update the state + this.setState({ + clientId: appData.clientId, + clientSecret: appData.clientSecret, + authUrl: appData.url + }); }); - }); } + /** + * Open the URL to redirect to an authorization sequence from an emergency + * login. + * + * Since Hyperspace reads the auth code from the URL, we need to redirect to + * a URL with the code inside to trigger an auth + */ authorizeEmergencyLogin() { let redirAddress = this.state.defaultRedirectAddress === "desktop" @@ -323,6 +540,9 @@ class WelcomePage extends Component { window.location.href = `${redirAddress}/?code=${this.state.authCode}#/`; } + /** + * Restore a login attempt from a session + */ resumeLogin() { let loginData = localStorage.getItem("login"); if (loginData) { @@ -337,10 +557,14 @@ class WelcomePage extends Component { } } + /** + * Check the user input string for any possible errors + */ checkForErrors(): boolean { let userInputError = false; let userInputErrorMessage = ""; + // Is the user string blank? if (this.state.user === "") { userInputError = true; userInputErrorMessage = "Username cannot be blank."; @@ -350,6 +574,8 @@ class WelcomePage extends Component { if (this.state.user.includes("@")) { if (this.state.federates && this.state.federates === true) { let baseUrl = this.state.user.split("@")[1]; + + // Is the user's domain in the disallowed list? if (inDisallowedDomains(baseUrl)) { this.setState({ userInputError: true, @@ -357,6 +583,7 @@ class WelcomePage extends Component { }); return true; } else { + // Are we unable to ping the server? axios .get( "https://instances.social/api/1.0/instances/show?name=" + @@ -403,56 +630,89 @@ class WelcomePage extends Component { } } + /** + * Read the URL and determine whether or not there's an auth code + * in the URL. If there is, try to authorize and get the access + * token for storage. + */ checkForToken() { let location = window.location.href; + + // Is there an auth code? if (location.includes("?code=")) { let code = parseUrl(location).query.code as string; this.setState({ authorizing: true }); let loginData = localStorage.getItem("login"); + + // If there's login data, try to fetch an access token if (loginData) { let clientLoginSession: SaveClientSession = JSON.parse( loginData ); - Mastodon.fetchAccessToken( - clientLoginSession.clientId, - clientLoginSession.clientSecret, - code, - localStorage.getItem("baseurl") as string, - this.state.emergencyMode - ? undefined - : clientLoginSession.authUrl.includes( - "urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob" - ) - ? undefined - : window.location.protocol === "hyperspace:" - ? "hyperspace://hyperspace/app/" - : `https://${window.location.host}` - ) - .then((tokenData: any) => { - localStorage.setItem( - "access_token", - tokenData.access_token - ); - window.location.href = - window.location.protocol === "hyperspace:" - ? "hyperspace://hyperspace/app/" - : `https://${window.location.host}/#/`; - }) - .catch((err: Error) => { - this.props.enqueueSnackbar( - `Couldn't authorize ${ - this.state.brandName - ? this.state.brandName - : "Hyperspace" - }: ${err.name}`, - { variant: "error" } - ); - console.error(err.message); - }); + + getConfig().then((resp: any) => { + if (resp == undefined) { + return; + } + + let conf: Config = resp; + + let redirectUrl: string | undefined = + this.state.emergencyMode || + clientLoginSession.authUrl.includes( + "urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob" + ) + ? undefined + : getRedirectAddress(conf.location); + + Mastodon.fetchAccessToken( + clientLoginSession.clientId, + clientLoginSession.clientSecret, + code, + localStorage.getItem("baseurl") as string, + redirectUrl + ) + .then((tokenData: any) => { + localStorage.setItem( + "access_token", + tokenData.access_token + ); + window.location.href = + window.location.protocol === "hyperspace:" + ? "hyperspace://hyperspace/app/" + : this.state.defaultRedirectAddress; + }) + .catch((err: Error) => { + this.props.enqueueSnackbar( + `Couldn't authorize ${ + this.state.brandName + ? this.state.brandName + : "Hyperspace" + }: ${err.name}`, + { variant: "error" } + ); + console.error(err.message); + }); + }); } } } + /** + * Redirect to the app's main view after a login. + */ + redirectToApp() { + window.location.href = + window.location.protocol === "hyperspace:" + ? "hyperspace://hyperspace/app" + : this.state.redirectAddressIsDynamic + ? `https://${window.location.host}/#/` + : this.state.defaultRedirectAddress + "/#/"; + } + + /** + * Render the title bar for macOS + */ titlebar() { const { classes } = this.props; if (isDarwinApp()) { @@ -468,6 +728,9 @@ class WelcomePage extends Component { } } + /** + * Show the multi-user account panel + */ showMultiAccount() { const { classes } = this.props; return ( @@ -481,11 +744,7 @@ class WelcomePage extends Component { { loginWithAccount(account); - window.location.href = - window.location.protocol === - "hyperspace:" - ? "hyperspace://hyperspace/app/" - : `https://${window.location.host}/#/`; + this.redirectToApp(); }} button={true} > @@ -527,6 +786,9 @@ class WelcomePage extends Component { ); } + /** + * Show the main landing panel + */ showLanding() { const { classes } = this.props; return ( @@ -610,6 +872,9 @@ class WelcomePage extends Component { ); } + /** + * Show the login auth panel + */ showLoginAuth() { const { classes } = this.props; return ( @@ -651,6 +916,9 @@ class WelcomePage extends Component { ); } + /** + * Show the emergency login panel + */ showAuthDialog() { const { classes } = this.props; return ( @@ -705,6 +973,9 @@ class WelcomePage extends Component { ); } + /** + * Show the authorizing panel + */ showAuthorizationLoader() { const { classes } = this.props; return ( @@ -725,6 +996,9 @@ class WelcomePage extends Component { ); } + /** + * Render the page + */ render() { const { classes } = this.props; return ( diff --git a/src/pages/You.tsx b/src/pages/You.tsx index 62aea51..47fcc00 100644 --- a/src/pages/You.tsx +++ b/src/pages/You.tsx @@ -74,7 +74,7 @@ class You extends Component { getAccount() { let acct = localStorage.getItem("account"); - console.log(acct); + // console.log(acct); if (acct) { return JSON.parse(acct); } diff --git a/src/utilities/login.tsx b/src/utilities/login.tsx index 9b0a4bb..c881361 100644 --- a/src/utilities/login.tsx +++ b/src/utilities/login.tsx @@ -44,18 +44,18 @@ export function createHyperspaceApp( /** * Gets the appropriate redirect address. - * @param type The address or configuration to use + * @param url The address or configuration to use */ export function getRedirectAddress( - type: "desktop" | "dynamic" | string + url: "desktop" | "dynamic" | string ): string { - switch (type) { + switch (url) { case "desktop": return "hyperspace://hyperspace/app/"; case "dynamic": return `https://${window.location.host}`; default: - return type; + return url; } } diff --git a/src/utilities/settings.tsx b/src/utilities/settings.tsx index b71d1a2..5dfc4a2 100644 --- a/src/utilities/settings.tsx +++ b/src/utilities/settings.tsx @@ -136,6 +136,21 @@ export function createUserDefaults() { export async function getConfig(): Promise { try { const resp = await axios.get("config.json"); + + let { location } = resp.data; + + if (!location.endsWith("/")) { + console.info( + "Location does not have a backslash, so Hyperspace has added it automatically." + ); + resp.data.location = location + "/"; + } + + if (process.env.NODE_ENV === "development") { + resp.data.location = "http://localhost:3000/"; + console.info("Location field has been updated to localhost:3000."); + } + return resp.data as Config; } catch (err) { console.error(