diff --git a/public/config.json b/public/config.json index c1d7e11..faeb902 100644 --- a/public/config.json +++ b/public/config.json @@ -1,6 +1,6 @@ { "version": "1.0.0beta7", - "location": "desktop", + "location": "https://localhost:3000", "branding": { "name": "Hyperspace", "logo": "logo.svg", diff --git a/public/electron.js b/public/electron.js index 35cdd30..031a124 100644 --- a/public/electron.js +++ b/public/electron.js @@ -20,7 +20,7 @@ let mainWindow; // to when authorizing Hyperspace. protocol.registerSchemesAsPrivileged([ { scheme: 'hyperspace', privileges: { standard: true, secure: true } } -]) +]); /** * Determine whether the desktop app is on macOS @@ -218,15 +218,8 @@ function createMenubar() { click() { safelyGoTo("hyperspace://hyperspace/app/#compose") } - }, - { type: 'separator' }, - { - label: 'Edit Profile', - accelerator: "Shift+CmdOrCtrl+P", - click() { - safelyGoTo("hyperspace://hyperspace/app/#/you") - } - }, + } + ] }, { @@ -284,7 +277,7 @@ function createMenubar() { ] }, { - label: "Places", + label: "Timelines", submenu: [ { label: 'Home', @@ -308,27 +301,53 @@ function createMenubar() { } }, { - label: 'Recommendations', + label: 'Messages', accelerator: "CmdOrCtrl+3", + click() { + safelyGoTo("hyperspace://hyperspace/app/#/messages") + } + } + ] + }, + { + label: "Account", + submenu: [ + { + label: 'Notifications', + accelerator: "Alt+CmdOrCtrl+N", + click() { + safelyGoTo("hyperspace://hyperspace/app/#/notifications") + } + }, + { + label: 'Recommendations...', + accelerator: "Alt+CmdOrCtrl+R", click() { safelyGoTo("hyperspace://hyperspace/app/#/recommended") } }, { type: 'separator' }, { - label: 'Notifications', - accelerator: "CmdOrCtrl+4", + label: 'Edit Profile', + accelerator: "Shift+CmdOrCtrl+P", click() { - safelyGoTo("hyperspace://hyperspace/app/#/notifications") + safelyGoTo("hyperspace://hyperspace/app/#/you") } }, { - label: 'Messages', - accelerator: "CmdOrCtrl+5", + label: 'Blocked Servers', + accelerator: "Shift+CmdOrCtrl+B", click() { - safelyGoTo("hyperspace://hyperspace/app/#/messages") + safelyGoTo("hyperspace://hyperspace/app/#/blocked") } }, + { type: 'separator'}, + { + label: 'Switch Accounts...', + click() { + safelyGoTo("hyperspace://hyperspace/app/#/welcome") + } + } ] }, { @@ -357,7 +376,7 @@ function createMenubar() { } ] } - ] + ]; if (process.platform === 'darwin') { menuBar.unshift({ @@ -386,7 +405,7 @@ function createMenubar() { { type: 'separator' }, { role: 'quit' } ] - }) + }); // Edit menu menuBar[2].submenu.push( @@ -398,10 +417,10 @@ function createMenubar() { { role: 'stopspeaking' } ] } - ) + ); // Window menu - menuBar[5].submenu = [ + menuBar[6].submenu = [ { role: 'close' }, { role: 'minimize' }, { role: 'zoom' }, diff --git a/src/App.tsx b/src/App.tsx index eeafea5..da77bbb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { MuiThemeProvider, CssBaseline, withStyles } from "@material-ui/core"; import { setHyperspaceTheme, darkMode } from "./utilities/themes"; import AppLayout from "./components/AppLayout"; import { styles } from "./App.styles"; -import { Route } from "react-router-dom"; +import { Route, withRouter } from "react-router-dom"; import AboutPage from "./pages/About"; import Settings from "./pages/Settings"; import { getUserDefaultBool, getUserDefaultTheme } from "./utilities/settings"; @@ -27,14 +27,22 @@ import { userLoggedIn } from "./utilities/accounts"; import { isDarwinApp } from "./utilities/desktop"; let theme = setHyperspaceTheme(getUserDefaultTheme()); -class App extends Component { +interface IAppState { + theme: any; + showLayout: boolean; +} + +class App extends Component { offline: any; + unlisten: any; constructor(props: any) { super(props); this.state = { - theme: theme + theme: theme, + showLayout: + userLoggedIn() && !window.location.hash.includes("#/welcome") }; } @@ -43,17 +51,33 @@ class App extends Component { this.state.theme, getUserDefaultBool("darkModeEnabled") ); - this.setState({ theme: newTheme }); + this.setState({ + theme: newTheme, + showLayout: + userLoggedIn() && !window.location.hash.includes("#/welcome") + }); } componentDidMount() { this.removeBodyBackground(); + this.unlisten = this.props.history.listen( + (location: Location, action: any) => { + this.setState({ + showLayout: + userLoggedIn() && + !location.pathname.includes("/welcome") + }); + } + ); } componentDidUpdate() { this.removeBodyBackground(); } + componentWillUnmount() { + this.unlisten(); + } removeBodyBackground() { if (isDarwinApp()) { @@ -71,7 +95,7 @@ class App extends Component {
- {userLoggedIn() ? : null} + {this.state.showLayout ? : null} @@ -91,7 +115,7 @@ class App extends Component { /> - + @@ -105,4 +129,5 @@ class App extends Component { } } -export default withStyles(styles)(withSnackbar(App)); +// @ts-ignore +export default withStyles(styles)(withSnackbar(withRouter(App))); diff --git a/src/components/AppLayout/AppLayout.tsx b/src/components/AppLayout/AppLayout.tsx index afc9f03..2e5f82b 100644 --- a/src/components/AppLayout/AppLayout.tsx +++ b/src/components/AppLayout/AppLayout.tsx @@ -39,10 +39,10 @@ import GroupIcon from "@material-ui/icons/Group"; import SettingsIcon from "@material-ui/icons/Settings"; import InfoIcon from "@material-ui/icons/Info"; import CreateIcon from "@material-ui/icons/Create"; -//import SupervisedUserCircleIcon from '@material-ui/icons/SupervisedUserCircle'; +import SupervisedUserCircleIcon from "@material-ui/icons/SupervisedUserCircle"; import ExitToAppIcon from "@material-ui/icons/ExitToApp"; import { styles } from "./AppLayout.styles"; -import { UAccount } from "../../types/Account"; +import { MultiAccount, UAccount } from "../../types/Account"; import { LinkableListItem, LinkableIconButton, @@ -59,6 +59,10 @@ import { getElectronApp } from "../../utilities/desktop"; import { Config } from "../../types/Config"; +import { + getAccountRegistry, + removeAccountFromRegistry +} from "../../utilities/accounts"; interface IAppLayoutState { acctMenuOpen: boolean; @@ -96,23 +100,7 @@ export class AppLayout extends Component { } componentDidMount() { - let acct = localStorage.getItem("account"); - if (acct) { - this.setState({ currentUser: JSON.parse(acct) }); - } else { - this.client - .get("/accounts/verify_credentials") - .then((resp: any) => { - let data: UAccount = resp.data; - this.setState({ currentUser: data }); - }) - .catch((err: Error) => { - this.props.enqueueSnackbar( - "Couldn't find profile info: " + err.name - ); - console.error(err.message); - }); - } + this.getAccountData(); getConfig().then((result: any) => { if (result !== undefined) { @@ -130,6 +118,24 @@ export class AppLayout extends Component { this.streamNotifications(); } + getAccountData() { + this.client + .get("/accounts/verify_credentials") + .then((resp: any) => { + let data: UAccount = resp.data; + this.setState({ currentUser: data }); + sessionStorage.setItem("id", data.id); + }) + .catch((err: Error) => { + this.props.enqueueSnackbar( + "Couldn't find profile info: " + err.name + ); + console.error(err.message); + let acct = localStorage.getItem("account") as string; + this.setState({ currentUser: JSON.parse(acct) }); + }); + } + streamNotifications() { this.streamListener = this.client.stream("/streaming/user"); @@ -235,10 +241,22 @@ export class AppLayout extends Component { logOutAndRestart() { let loginData = localStorage.getItem("login"); if (loginData) { + let registry = getAccountRegistry(); + + registry.forEach((registryItem: MultiAccount, index: number) => { + if ( + registryItem.access_token === + localStorage.getItem("access_token") + ) { + removeAccountFromRegistry(index); + } + }); + let items = ["login", "account", "baseurl", "access_token"]; items.forEach(entry => { localStorage.removeItem(entry); }); + window.location.reload(); } } @@ -317,10 +335,22 @@ export class AppLayout extends Component { } /> - {/* - - - */} + + + + + 1 + ? "Switch account" + : "Add account" + } + /> + { Edit profile - {/* Switch account */} + + + {getAccountRegistry() + .length > 1 + ? "Switch account" + : "Add account"} + + this.toggleLogOutDialog() @@ -623,48 +660,7 @@ export class AppLayout extends Component {
- this.toggleLogOutDialog()} - > - - Log out of{" "} - {this.state.brandName - ? this.state.brandName - : "Hyperspace"} - - - - You'll need to remove{" "} - {this.state.brandName - ? this.state.brandName - : "Hyperspace"}{" "} - from your list of authorized apps and log in again - if you want to use{" "} - {this.state.brandName - ? this.state.brandName - : "Hyperspace"} - . - - - - - - - + {this.logoutDialog()} { ); } + + logoutDialog() { + return ( + this.toggleLogOutDialog()} + > + + Log out of{" "} + {this.state.brandName ? this.state.brandName : "Hyperspace"} + + + + + You'll need to remove{" "} + {this.state.brandName + ? this.state.brandName + : "Hyperspace"}{" "} + from your list of authorized apps and log in again + if you want to use{" "} + {this.state.brandName + ? this.state.brandName + : "Hyperspace"} + . + + + Logging out will also remove this account from the + account list. + + + + + + + + + ); + } } export default withStyles(styles)(withSnackbar(AppLayout)); diff --git a/src/components/Post/Post.tsx b/src/components/Post/Post.tsx index f413294..29ed420 100644 --- a/src/components/Post/Post.tsx +++ b/src/components/Post/Post.tsx @@ -1,33 +1,32 @@ import React from "react"; import { - Typography, - IconButton, - Card, - CardHeader, Avatar, - CardContent, - CardActions, - withStyles, - Menu, - MenuItem, - Chip, - Divider, - CardMedia, - CardActionArea, - ExpansionPanel, - ExpansionPanelSummary, - ExpansionPanelDetails, - Zoom, - Tooltip, - RadioGroup, - Radio, - FormControlLabel, Button, + Card, + CardActionArea, + CardActions, + CardContent, + CardHeader, + CardMedia, Dialog, - DialogTitle, + DialogActions, DialogContent, DialogContentText, - DialogActions + DialogTitle, + Divider, + ExpansionPanel, + ExpansionPanelDetails, + ExpansionPanelSummary, + FormControlLabel, + IconButton, + Menu, + MenuItem, + Radio, + RadioGroup, + Tooltip, + Typography, + withStyles, + Zoom } from "@material-ui/core"; import MoreVertIcon from "@material-ui/icons/MoreVert"; import ReplyIcon from "@material-ui/icons/Reply"; @@ -51,10 +50,10 @@ import moment from "moment"; import AttachmentComponent from "../Attachment"; import Mastodon from "megalodon"; import { + LinkableAvatar, LinkableChip, - LinkableMenuItem, LinkableIconButton, - LinkableAvatar + LinkableMenuItem } from "../../interfaces/overrides"; import { withSnackbar } from "notistack"; import ShareMenu from "./PostShareMenu"; @@ -73,6 +72,7 @@ interface IPostState { menuIsOpen: boolean; myVote?: [number]; deletePostDialog: boolean; + myAccount?: string; } export class Post extends React.Component { @@ -94,6 +94,12 @@ export class Post extends React.Component { this.client = this.props.client; } + componentWillMount() { + this.setState({ + myAccount: sessionStorage.getItem("id") as string + }); + } + togglePostMenu() { this.setState({ menuIsOpen: !this.state.menuIsOpen }); } @@ -105,7 +111,7 @@ export class Post extends React.Component { deletePost() { this.client .del("/statuses/" + this.state.post.id) - .then((resp: any) => { + .then(() => { this.props.enqueueSnackbar( "Post deleted. Refresh to see changes." ); @@ -261,7 +267,7 @@ export class Post extends React.Component { {status.poll.options.map( (pollOption: PollOption) => { - let x = ( + return ( { } /> ); - return x; } )} @@ -302,7 +307,7 @@ export class Post extends React.Component { > {status.poll.options.map( (pollOption: PollOption) => { - let x = ( + return ( } @@ -313,13 +318,12 @@ export class Post extends React.Component { } /> ); - return x; } )} @@ -380,7 +384,6 @@ export class Post extends React.Component { } getReblogOfPost(of: Status | null) { - const { classes } = this.props; if (of !== null) { return of.sensitive ? this.getSensitiveContent(of.spoiler_text, of) @@ -653,7 +656,7 @@ export class Post extends React.Component { dangerouslySetInnerHTML={{ __html: this.getReblogAuthors(post) }} - > + /> } subheader={moment(post.created_at).format( "MMMM Do YYYY [at] h:mm A" @@ -827,9 +830,8 @@ export class Post extends React.Component { Open in Web - {post.account.id == - JSON.parse(localStorage.getItem("account") as string) - .id ? ( + {this.state.myAccount && + post.account.id === this.state.myAccount ? (
{ + if (event.key == "account") { + window.location.reload(); + } +}; + ReactDOM.render( { componentDidMount() { 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 }); + }); getConfig().then((config: any) => { this.setState({ federated: config.federation.allowPublicPosts, @@ -439,7 +443,7 @@ class Composer extends Component { event.target.value ) } - > + /> ) : null} {this.state.visibility === "direct" ? ( diff --git a/src/pages/Welcome.tsx b/src/pages/Welcome.tsx index 3be6821..2dd15aa 100644 --- a/src/pages/Welcome.tsx +++ b/src/pages/Welcome.tsx @@ -12,7 +12,13 @@ import { Dialog, DialogTitle, DialogActions, - DialogContent + DialogContent, + List, + ListItem, + ListItemText, + ListItemAvatar, + ListItemSecondaryAction, + IconButton } from "@material-ui/core"; import { styles } from "./WelcomePage.styles"; import Mastodon from "megalodon"; @@ -28,6 +34,16 @@ import { isDarwinApp } from "../utilities/desktop"; import axios from "axios"; import { withSnackbar, withSnackbarProps } from "notistack"; import { Config } from "../types/Config"; +import { + addAccountToRegistry, + getAccountRegistry, + loginWithAccount, + removeAccountFromRegistry +} from "../utilities/accounts"; +import { Account, MultiAccount } from "../types/Account"; + +import AccountCircleIcon from "@material-ui/icons/AccountCircle"; +import CloseIcon from "@material-ui/icons/Close"; interface IWelcomeProps extends withSnackbarProps { classes: any; @@ -39,7 +55,7 @@ interface IWelcomeState { brandName?: string; registerBase?: string; federates?: boolean; - wantsToLogin: boolean; + proceedToGetCode: boolean; user: string; userInputError: boolean; userInputErrorMessage: string; @@ -47,7 +63,7 @@ interface IWelcomeState { clientSecret?: string; authUrl?: string; foundSavedLogin: boolean; - authority: boolean; + authorizing: boolean; license?: string; repo?: string; defaultRedirectAddress: string; @@ -55,6 +71,7 @@ interface IWelcomeState { authCode: string; emergencyMode: boolean; version: string; + willAddAccount: boolean; } class WelcomePage extends Component { @@ -64,17 +81,18 @@ class WelcomePage extends Component { super(props); this.state = { - wantsToLogin: false, + proceedToGetCode: false, user: "", userInputError: false, foundSavedLogin: false, - authority: false, + authorizing: false, userInputErrorMessage: "", defaultRedirectAddress: "", openAuthDialog: false, authCode: "", emergencyMode: false, - version: "" + version: "", + willAddAccount: false }; getConfig() @@ -155,6 +173,11 @@ class WelcomePage extends Component { } } + clear() { + localStorage.removeItem("access_token"); + localStorage.removeItem("baseurl"); + } + getSavedSession() { let loginData = localStorage.getItem("login"); if (loginData) { @@ -253,7 +276,7 @@ class WelcomePage extends Component { clientId: resp.clientId, clientSecret: resp.clientSecret, authUrl: resp.url, - wantsToLogin: true + proceedToGetCode: true }); }); } else { @@ -304,7 +327,7 @@ class WelcomePage extends Component { clientSecret: session.clientSecret, authUrl: session.authUrl, emergencyMode: session.emergency, - wantsToLogin: true + proceedToGetCode: true }); } } @@ -332,8 +355,8 @@ class WelcomePage extends Component { axios .get( "https://" + - baseUrl + - "/api/v1/timelines/public" + baseUrl + + "/api/v1/timelines/public" ) .catch((err: Error) => { let userInputError = true; @@ -375,7 +398,7 @@ class WelcomePage extends Component { let location = window.location.href; if (location.includes("?code=")) { let code = parseUrl(location).query.code as string; - this.setState({ authority: true }); + this.setState({ authorizing: true }); let loginData = localStorage.getItem("login"); if (loginData) { let clientLoginSession: SaveClientSession = JSON.parse( @@ -389,12 +412,12 @@ class WelcomePage extends Component { this.state.emergencyMode ? undefined : clientLoginSession.authUrl.includes( - "urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob" - ) + "urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob" + ) ? undefined : window.location.protocol === "hyperspace:" - ? "hyperspace://hyperspace/app/" - : `https://${window.location.host}` + ? "hyperspace://hyperspace/app/" + : `https://${window.location.host}` ) .then((tokenData: any) => { localStorage.setItem( @@ -436,6 +459,65 @@ class WelcomePage extends Component { } } + showMultiAccount() { + const { classes } = this.props; + return ( +
+ Select an account + from the list below or add a new one + + + {getAccountRegistry().map( + (account: MultiAccount, index: number) => ( + { + loginWithAccount(account); + window.location.href = + window.location.protocol === + "hyperspace:" + ? "hyperspace://hyperspace/app/" + : `https://${window.location.host}/#/`; + }} + button={true} + > + + + + + + { + e.preventDefault(); + removeAccountFromRegistry(index); + window.location.reload(); + }} + > + + + + + ) + )} + +
+ + +
+ ); + } + showLanding() { const { classes } = this.props; return ( @@ -452,7 +534,7 @@ class WelcomePage extends Component { onKeyDown={event => this.watchUsernameField(event)} error={this.state.userInputError} onBlur={() => this.checkForErrors()} - > + /> {this.state.userInputError ? ( {this.state.userInputErrorMessage} @@ -597,7 +679,7 @@ class WelcomePage extends Component { this.updateAuthCode(event.target.value) } onKeyDown={event => this.watchAuthField(event)} - > + /> - + + Edit your profile + + + Change information such as your display + name, bio, and images used here. + +
+ + +
+
+
+ + + Display Name + +
+ + this.updateDisplayName( + event.target.value + ) + } + /> +
+ +
+
+
+ + + About you + +
+ + this.updateBio(event.target.value) + } + /> +
+ +
+
+
- -
- - - Display Name - -
- - this.updateDisplayName(event.target.value) - } + ) : ( + "AAA" + )} + {this.state.viewIsLoading ? ( +
+ -
- -
- -
- - - About you - -
- - this.updateBio(event.target.value) - } - /> -
- -
-
-
+
+ ) : ( + + )} ); } diff --git a/src/types/Account.tsx b/src/types/Account.tsx index 09622f4..a6454a9 100644 --- a/src/types/Account.tsx +++ b/src/types/Account.tsx @@ -26,9 +26,32 @@ export type Account = { bot: boolean | null; }; +/** + * Watered-down type for Mastodon accounts + */ export type UAccount = { id: string; acct: string; display_name: string; avatar_static: string; }; + +/** + * Account type for use with multi-account support + */ +export type MultiAccount = { + /** + * The host name of the account (ex.: mastodon.social) + */ + host: string; + + /** + * The username of the account (@test) + */ + username: string; + + /** + * The access token generated from the login + */ + access_token: string; +}; diff --git a/src/utilities/accounts.tsx b/src/utilities/accounts.tsx index cae9078..3a6bcd0 100644 --- a/src/utilities/accounts.tsx +++ b/src/utilities/accounts.tsx @@ -1,25 +1,26 @@ import Mastodon from "megalodon"; +import { MultiAccount, Account } from "../types/Account"; export function userLoggedIn(): boolean { - if ( - localStorage.getItem("baseurl") && - localStorage.getItem("access_token") - ) { - return true; - } else { - return false; - } + return !!( + localStorage.getItem("baseurl") && localStorage.getItem("access_token") + ); } export function refreshUserAccountData() { - let client = new Mastodon( - localStorage.getItem("access_token") as string, - (localStorage.getItem("baseurl") as string) + "/api/v1" - ); + let host = localStorage.getItem("baseurl") as string; + let token = localStorage.getItem("access_token") as string; + + let client = new Mastodon(token, host + "/api/v1"); + client .get("/accounts/verify_credentials") .then((resp: any) => { - localStorage.setItem("account", JSON.stringify(resp.data)); + let account: Account = resp.data; + localStorage.setItem("account", JSON.stringify(account)); + sessionStorage.setItem("id", account.id); + + addAccountToRegistry(host, token, account.acct); }) .catch((err: Error) => { console.error(err.message); @@ -31,3 +32,111 @@ export function refreshUserAccountData() { ); }); } + +/** + * Set the access token and base URL to a given multi-account user. + * @param account The multi-account from localStorage to use + */ +export function loginWithAccount(account: MultiAccount) { + if (localStorage.getItem("access_token") !== null) { + console.info( + "Existing login detected. Removing and using assigned token..." + ); + } + localStorage.setItem("access_token", account.access_token); + localStorage.setItem("baseurl", account.host); +} + +/** + * Gets the account registry. + * @returns A list of accounts + */ +export function getAccountRegistry(): MultiAccount[] { + let accountRegistry: MultiAccount[] = []; + + let accountRegistryString = localStorage.getItem("accountRegistry"); + if (accountRegistryString !== null) { + accountRegistry = JSON.parse(accountRegistryString); + } + return accountRegistry; +} + +/** + * Add an account to the multi-account registry if it doesn't exist already. + * @param base_url The base URL of the user (eg., the instance) + * @param access_token The access token for the user + * @param username The username of the user + */ +export function addAccountToRegistry( + base_url: string, + access_token: string, + username: string +) { + const newAccount: MultiAccount = { + host: base_url, + username, + access_token + }; + + let accountRegistry = getAccountRegistry(); + const stringifiedRegistry = accountRegistry.map(account => + JSON.stringify(account) + ); + + if (stringifiedRegistry.indexOf(JSON.stringify(newAccount)) === -1) { + accountRegistry.push(newAccount); + } + + localStorage.setItem("accountRegistry", JSON.stringify(accountRegistry)); +} + +/** + * Remove an account from the multi-account registry, if possible + * @param accountIdentifier The index of the account from the registry or the MultiAccount object itself + */ +export function removeAccountFromRegistry( + accountIdentifier: number | MultiAccount +) { + let accountRegistry = getAccountRegistry(); + + if (typeof accountIdentifier === "number") { + if (accountRegistry.length > accountIdentifier) { + if ( + localStorage.getItem("access_token") === + accountRegistry[accountIdentifier].access_token + ) { + localStorage.removeItem("baseurl"); + localStorage.removeItem("access_token"); + } + accountRegistry.splice(accountIdentifier); + } else { + console.log("Multi account index may be out of range"); + } + } else { + const stringifiedRegistry = accountRegistry.map(account => + JSON.stringify(account) + ); + + const stringifiedAccountId = JSON.stringify(accountIdentifier); + + if ( + stringifiedRegistry.indexOf( + JSON.stringify(stringifiedAccountId) + ) !== -1 + ) { + if ( + localStorage.getItem("access_token") === + accountIdentifier.access_token + ) { + localStorage.removeItem("baseurl"); + localStorage.removeItem("access_token"); + } + + accountRegistry.splice( + stringifiedRegistry.indexOf(stringifiedAccountId) + ); + } + } + + localStorage.setItem("accountRegistry", JSON.stringify(accountRegistry)); +}