diff --git a/package.json b/package.json index c99fe79..5d63453 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,11 @@ "file-dialog": "^0.0.7", "emoji-mart": "^2.8.2", "material-ui-pickers": "^2.2.4", - "@date-io/moment": "^1.1.0" + "@date-io/moment": "^1.1.0", + "axios": "^0.18.0" }, "scripts": { - "start": "BROWSER='Safari Technology Preview' react-scripts start", + "start": "HTTPS=true BROWSER='Safari Technology Preview' react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" diff --git a/public/background.png b/public/background.png new file mode 100644 index 0000000..c2191b4 Binary files /dev/null and b/public/background.png differ diff --git a/public/config.json b/public/config.json new file mode 100644 index 0000000..2dcbdc9 --- /dev/null +++ b/public/config.json @@ -0,0 +1,15 @@ +{ + "branding": { + "name": "Hyperspace", + "logo": "logo.svg", + "background": "background.png" + }, + "federated": "true", + "registration": { + "defaultInstance": "mastodon.social" + }, + "admin": { + "name": "Marquis Kurt", + "account": "alicerunsonfedora@mastodon.social" + } +} \ No newline at end of file diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..9e2ddbf --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.tsx b/src/App.tsx index b3fc6e1..72467bf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,8 +15,10 @@ import Conversation from './pages/Conversation'; import NotificationsPage from './pages/Notifications'; import SearchPage from './pages/Search'; import Composer from './pages/Compose'; - +import WelcomePage from './pages/Welcome'; import {withSnackbar} from 'notistack'; +import {PrivateRoute} from './interfaces/overrides'; +import { userLoggedIn, refreshUserAccountData } from './utilities/accounts'; let theme = setHyperspaceTheme(getUserDefaultTheme()); class App extends Component { @@ -42,19 +44,23 @@ class App extends Component { return ( - - - - - - - - }/> - - - - - + +
+ { userLoggedIn()? : null} + + + + + + + + + + + + +
+
); } diff --git a/src/components/AppLayout/AppLayout.tsx b/src/components/AppLayout/AppLayout.tsx index 835e1d8..60bba05 100644 --- a/src/components/AppLayout/AppLayout.tsx +++ b/src/components/AppLayout/AppLayout.tsx @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { Typography, AppBar, Toolbar, IconButton, InputBase, Avatar, ListItemText, Divider, List, ListItemIcon, Hidden, Drawer, ListSubheader, ListItemAvatar, withStyles, Menu, MenuItem, ClickAwayListener, Badge } from '@material-ui/core'; +import { Typography, AppBar, Toolbar, IconButton, InputBase, Avatar, ListItemText, Divider, List, ListItemIcon, Hidden, Drawer, ListSubheader, ListItemAvatar, withStyles, Menu, MenuItem, ClickAwayListener, Badge, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button } from '@material-ui/core'; import MenuIcon from '@material-ui/icons/Menu'; import SearchIcon from '@material-ui/icons/Search'; import NotificationsIcon from '@material-ui/icons/Notifications'; @@ -19,12 +19,14 @@ import {LinkableListItem, LinkableIconButton, LinkableFab} from '../../interface import Mastodon from 'megalodon'; import { Notification } from '../../types/Notification'; import {sendNotificationRequest} from '../../utilities/notifications'; +import {withSnackbar} from 'notistack'; interface IAppLayoutState { acctMenuOpen: boolean; drawerOpenOnMobile: boolean; - currentUser: UAccount; + currentUser?: UAccount; notificationCount: number; + logOutOpen: boolean; } export class AppLayout extends Component { @@ -35,15 +37,13 @@ export class AppLayout extends Component { constructor(props: any) { super(props); - let accountData = JSON.parse(localStorage.getItem('account') as string); - this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1"); this.state = { drawerOpenOnMobile: false, acctMenuOpen: false, - currentUser: accountData, - notificationCount: 0 + notificationCount: 0, + logOutOpen: false } this.toggleDrawerOnMobile = this.toggleDrawerOnMobile.bind(this); @@ -51,6 +51,20 @@ 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.streamListener = this.client.stream('/streaming/user'); this.streamListener.on('notification', (notif: Notification) => { @@ -105,11 +119,23 @@ export class AppLayout extends Component { }) } + toggleLogOutDialog() { + this.setState({ logOutOpen: !this.state.logOutOpen }); + } + searchForQuery(what: string) { window.location.href = "/#/search?query=" + what; window.location.reload; } + logOutAndRestart() { + let loginData = localStorage.getItem("login"); + if (loginData) { + localStorage.clear(); + window.location.reload(); + } + } + titlebar() { const { classes } = this.props; if (process.env.NODE_ENV === "development") { @@ -133,11 +159,11 @@ export class AppLayout extends Component {
- + - + - + @@ -237,7 +263,7 @@ export class AppLayout extends Component { - + { >
- + - + - + {/* Switch account */} - Log out + this.toggleLogOutDialog()}>Log out
@@ -288,6 +317,27 @@ export class AppLayout extends Component {
+ this.toggleLogOutDialog()} + > + Log out of Hyperspace? + + + You'll need to remove Hyperspace from your list of authorized apps and log in again if you want to use Hyperspace. + + + + + + + @@ -296,4 +346,4 @@ export class AppLayout extends Component { } } -export default withStyles(styles)(AppLayout); \ No newline at end of file +export default withStyles(styles)(withSnackbar(AppLayout)); \ No newline at end of file diff --git a/src/components/AppLayout/index.tsx b/src/components/AppLayout/index.tsx index a64cac0..eea680e 100644 --- a/src/components/AppLayout/index.tsx +++ b/src/components/AppLayout/index.tsx @@ -1,5 +1,6 @@ import { AppLayout } from './AppLayout'; import { withStyles } from '@material-ui/core'; import { styles } from './AppLayout.styles'; +import {withSnackbar} from 'notistack'; -export default withStyles(styles)(AppLayout); \ No newline at end of file +export default withStyles(styles)(withSnackbar(AppLayout)); \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 56c8897..ed31b66 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,14 +3,23 @@ import ReactDOM from 'react-dom'; import App from './App'; import { HashRouter } from 'react-router-dom'; import * as serviceWorker from './serviceWorker'; -import {createUserDefaults, getUserDefaultBool} from './utilities/settings'; -import {refreshUserAccountData} from './utilities/accounts'; +import {createUserDefaults} from './utilities/settings'; import {collectEmojisFromServer} from './utilities/emojis'; import {SnackbarProvider} from 'notistack'; +import axios from 'axios'; +import { userLoggedIn, refreshUserAccountData } from './utilities/accounts'; + +axios.get('config.json').then((resp: any) => { + document.title = resp.data.branding.name || "Hyperspace"; +}).catch((err: Error) => { + console.error(err); +}) createUserDefaults(); -refreshUserAccountData(); -collectEmojisFromServer(); +if (userLoggedIn()) { + collectEmojisFromServer(); + refreshUserAccountData(); +} ReactDOM.render( diff --git a/src/interfaces/overrides.tsx b/src/interfaces/overrides.tsx index f1b136d..0871fe5 100644 --- a/src/interfaces/overrides.tsx +++ b/src/interfaces/overrides.tsx @@ -1,12 +1,13 @@ import React, { Component } from 'react'; import ListItem, { ListItemProps } from "@material-ui/core/ListItem"; import IconButton, { IconButtonProps } from "@material-ui/core/IconButton"; -import { Link, Route } from "react-router-dom"; +import { Link, Route, Redirect, RouteProps } from "react-router-dom"; import Chip, { ChipProps } from '@material-ui/core/Chip'; import { MenuItemProps } from '@material-ui/core/MenuItem'; import { MenuItem } from '@material-ui/core'; import Button, { ButtonProps } from '@material-ui/core/Button'; import Fab, { FabProps } from '@material-ui/core/Fab'; +import { userLoggedIn } from '../utilities/accounts'; export interface ILinkableListItemProps extends ListItemProps { to: string; @@ -67,4 +68,19 @@ export const ProfileRoute = (rest: any, component: Component) => ( )}/> ) + +export const PrivateRoute = (props: IPrivateRouteProps) => { + const { component, render, ...rest } = props; + return ( ( + userLoggedIn()? + React.createElement(component, compProps): + + )} + /> + )} + +interface IPrivateRouteProps extends RouteProps { + component: any +} \ No newline at end of file diff --git a/src/pages/Welcome.tsx b/src/pages/Welcome.tsx new file mode 100644 index 0000000..d01e0b5 --- /dev/null +++ b/src/pages/Welcome.tsx @@ -0,0 +1,290 @@ +import React, { Component } from 'react'; +import {withStyles, Paper, Typography, Button, TextField, Fade, Checkbox, FormControlLabel, Link, CircularProgress} from '@material-ui/core'; +import {styles} from './WelcomePage.styles'; +import axios from 'axios'; +import Mastodon from 'megalodon'; +import {Config} from '../types/Config'; +import {SaveClientSession} from '../types/SessionData'; +import { createHyperspaceApp } from '../utilities/login'; +import {parseUrl} from 'query-string'; + +interface IWelcomeState { + logoUrl?: string; + backgroundUrl?: string; + brandName?: string; + registerBase?: string; + federates?: boolean; + wantsToLogin: boolean; + user: string; + userInputError: boolean; + clientId?: string; + clientSecret?: string; + authUrl?: string; + foundSavedLogin: boolean; + authority: boolean; +} + +class WelcomePage extends Component { + + client: any; + + constructor(props: any) { + super(props); + + this.state = { + wantsToLogin: false, + user: "", + userInputError: false, + foundSavedLogin: false, + authority: false + } + + axios.get('config.json').then((resp: any) => { + let result: Config = resp.data; + + this.setState({ + logoUrl: result.branding? result.branding.logo: "logo.png", + backgroundUrl: result.branding? result.branding.background: "background.png", + brandName: result.branding? result.branding.name: "Hyperspace", + registerBase: result.registration? result.registration.defaultInstance: "", + federates: result.federated? result.federated === "true": true + }); + }).catch(() => { + console.warn('config.json is missing. If you want to customize Hyperspace, please include config.json'); + }) + } + + componentDidMount() { + if (localStorage.getItem("login")) { + this.setState({ + foundSavedLogin: true + }) + this.getSavedSession(); + this.checkForToken(); + } + } + + checkForToken() { + let location = window.location.href; + if (location.includes("?code=")) { + let code = parseUrl(location).query.code as string; + this.setState({ authority: true }); + let loginData = localStorage.getItem("login"); + + if (loginData) { + let clientLoginSession: SaveClientSession = JSON.parse(loginData); + console.log(clientLoginSession); + Mastodon.fetchAccessToken( + clientLoginSession.clientId, + clientLoginSession.clientSecret, + code, + (localStorage.getItem("baseurl") as string), + `https://${window.location.host}`, + ).then((tokenData: any) => { + localStorage.setItem("access_token", tokenData.access_token); + window.location.href=`https://${window.location.host}/#/`; + }).catch((err: Error) => { + console.log(err.message); + }) + } + } + } + + updateUserInfo(user: string) { + this.setState({ user }); + } + + getLoginUser(user: string) { + if (user.includes("@")) { + let newUser = user; + this.setState({ user: newUser }) + return "https://" + newUser.split("@")[1]; + } else { + let newUser = `${user}@${this.state.registerBase? this.state.registerBase: "mastodon.social"}`; + this.setState({ user: newUser }); + return "https://" + (this.state.registerBase? this.state.registerBase: "mastodon.social"); + } + } + + startRegistration() { + if (this.state.registerBase) { + return "https://" + this.state.registerBase + "/auth/sign_up"; + } else { + return "https://joinmastodon.org/#getting-started"; + } + } + + startLogin() { + if (this.state.user != "") { + const scopes = 'read write follow'; + const baseurl = this.getLoginUser(this.state.user); + localStorage.setItem("baseurl", baseurl); + createHyperspaceApp(scopes, baseurl, `https://${window.location.host}`).then((resp: any) => { + let saveSessionForCrashing: SaveClientSession = { + clientId: resp.clientId, + clientSecret: resp.clientSecret, + authUrl: resp.url + } + localStorage.setItem("login", JSON.stringify(saveSessionForCrashing)); + this.setState({ + clientId: resp.clientId, + clientSecret: resp.clientSecret, + authUrl: resp.url, + wantsToLogin: true + }) + }) + } else { + this.setState({ userInputError: true }); + } + } + + resumeLogin() { + let loginData = localStorage.getItem("login"); + if (loginData) { + let session: SaveClientSession = JSON.parse(loginData); + this.setState({ + clientId: session.clientId, + clientSecret: session.clientSecret, + authUrl: session.authUrl, + wantsToLogin: true + }) + } + } + + getSavedSession() { + let loginData = localStorage.getItem("login"); + if (loginData) { + let session: SaveClientSession = JSON.parse(loginData); + this.setState({ + clientId: session.clientId, + clientSecret: session.clientSecret, + authUrl: session.authUrl + }) + } + } + + checkForErrors() { + this.setState({ userInputError: this.state.user === "" }) + } + + readyForAuth() { + if (localStorage.getItem('baseurl')) { + return true; + } else { + return false; + } + } + + showLanding() { + const { classes } = this.props; + return ( +
+ Sign in + with your Mastodon account +
+ this.updateUserInfo(event.target.value)} + error={this.state.userInputError} + onBlur={() => this.checkForErrors()} + > + { + this.state.registerBase? If you are from {this.state.registerBase? this.state.registerBase: "noinstance"}, sign in with your username.: null + } +
+ { + this.state.foundSavedLogin? + + Signing in from a previous session? this.resumeLogin()}>Continue login. + : null + } + +
+
+ +
+ +
+
+ ); + } + + showLoginAuth() { + const { classes } = this.props; + return ( +
+ Howdy, {this.state.user? this.state.user.split("@")[0]: "user"} + To continue, finish signing in on your instance's website and authorize Hyperspace. +
+
+
+ +
+
+
+
+ ); + } + + showAuthority() { + const { classes } = this.props; + return ( +
+ Authorizing + Please wait while Hyperspace authorizes with Mastodon. This shouldn't take long... +
+
+
+ +
+
+
+
+ ); + } + + render() { + const { classes } = this.props; + return ( +
+ + +
+ + { + this.state.authority? + this.showAuthority(): + this.state.wantsToLogin? + this.showLoginAuth(): + this.showLanding() + } + +
+ + © 2019 Hyperspace developers. All rights reserved. + + + GitHub | License | File an Issue + +
+
+ ); + } +} + +export default withStyles(styles)(WelcomePage); \ No newline at end of file diff --git a/src/pages/WelcomePage.styles.tsx b/src/pages/WelcomePage.styles.tsx new file mode 100644 index 0000000..b6bff9b --- /dev/null +++ b/src/pages/WelcomePage.styles.tsx @@ -0,0 +1,50 @@ +import { Theme, createStyles } from '@material-ui/core'; + +export const styles = (theme: Theme) => createStyles({ + root: { + width: '100%', + height: '100%', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + backgroundSize: 'cover', + top: 0, + left: 0, + position: "absolute", + [theme.breakpoints.up('sm')]: { + paddingTop: theme.spacing.unit * 4, + paddingLeft: '25%', + paddingRight: '25%', + }, + [theme.breakpoints.up('lg')]: { + paddingTop: theme.spacing.unit * 12, + paddingLeft: '35%', + paddingRight: '35%', + } + }, + paper: { + height: '100%', + [theme.breakpoints.up('sm')]: { + height: 'auto', + paddingLeft: theme.spacing.unit * 8, + paddingRight: theme.spacing.unit * 8, + paddingTop: theme.spacing.unit * 6, + }, + paddingTop: theme.spacing.unit * 12, + paddingLeft: theme.spacing.unit * 4, + paddingRight: theme.spacing.unit * 4, + paddingBottom: theme.spacing.unit * 6, + textAlign: 'center' + }, + flexGrow: { + flexGrow: 1 + }, + middlePadding: { + height: theme.spacing.unit * 6 + }, + logo: { + [theme.breakpoints.up('sm')]: { + height: 64, + width: "auto" + }, + } +}); \ No newline at end of file diff --git a/src/types/Config.tsx b/src/types/Config.tsx new file mode 100644 index 0000000..5b8b341 --- /dev/null +++ b/src/types/Config.tsx @@ -0,0 +1,15 @@ +export type Config = { + branding?: { + name?: string; + logo?: string; + background?: string; + }; + federated?: string; + registration?: { + defaultInstance?: string; + }; + admin?: { + name?: string; + account?: string; + }; +} \ No newline at end of file diff --git a/src/types/SessionData.tsx b/src/types/SessionData.tsx new file mode 100644 index 0000000..d096664 --- /dev/null +++ b/src/types/SessionData.tsx @@ -0,0 +1,5 @@ +export type SaveClientSession = { + clientId: string; + clientSecret: string; + authUrl: string; +} \ No newline at end of file diff --git a/src/utilities/accounts.tsx b/src/utilities/accounts.tsx index 91aaa73..f51bfcc 100644 --- a/src/utilities/accounts.tsx +++ b/src/utilities/accounts.tsx @@ -1,9 +1,18 @@ import Mastodon from "megalodon"; +export function userLoggedIn(): boolean { + if (localStorage.getItem('baseurl') && localStorage.getItem('access_token')) { + return true; + } else { + return false; + } +} + export function refreshUserAccountData() { - let client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1/"); + let client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1"); client.get('/accounts/verify_credentials').then((resp: any) => { - if (JSON.stringify(resp.data) !== localStorage.getItem('account')) - localStorage.setItem('account', JSON.stringify(resp.data)); + localStorage.setItem('account', JSON.stringify(resp.data)); + }).catch((err: Error) => { + console.error(err.message); }); } \ No newline at end of file diff --git a/src/utilities/login.tsx b/src/utilities/login.tsx new file mode 100644 index 0000000..18346be --- /dev/null +++ b/src/utilities/login.tsx @@ -0,0 +1,23 @@ +import Mastodon from 'megalodon'; + +/** + * Creates the Hyperspace app with the appropriate Redirect URI + * @param scopes The scopes that the app needs + * @param baseurl The base URL of the instance + * @param redirect_uri The URL to redirect to when authorizing + */ +export function createHyperspaceApp(scopes: string, baseurl: string, redirect_uri: string) { + return Mastodon.createApp("Hyperspace", { + scopes: scopes, + redirect_uris: redirect_uri, + website: 'https://hyperspace.marquiskurt.net' + }).then(appData => { + return Mastodon.generateAuthUrl(appData.clientId, appData.clientSecret, { + redirect_uri: redirect_uri, + scope: scopes + }, baseurl).then(url => { + appData.url = url; + return appData; + }) + }) +} \ No newline at end of file