Set up basic UI for server blocking

Signed-off-by: Marquis Kurt <software@marquiskurt.net>
This commit is contained in:
Marquis Kurt 2019-09-21 22:13:25 -04:00
parent ee2843eff4
commit 32f9940ea2
No known key found for this signature in database
GPG Key ID: 725636D259F5402D
68 changed files with 7334 additions and 5434 deletions

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"bracketSpacing": true,
"tabWidth": 4
}

8
package-lock.json generated
View File

@ -1,6 +1,6 @@
{ {
"name": "hyperspace", "name": "hyperspace",
"version": "1.0.0-beta6", "version": "1.0.0-beta7",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -17778,6 +17778,12 @@
"integrity": "sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=", "integrity": "sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=",
"dev": true "dev": true
}, },
"prettier": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.18.2.tgz",
"integrity": "sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw==",
"dev": true
},
"pretty-bytes": { "pretty-bytes": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-4.0.2.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-4.0.2.tgz",

View File

@ -28,6 +28,7 @@
"megalodon": "^0.6.4", "megalodon": "^0.6.4",
"moment": "^2.24.0", "moment": "^2.24.0",
"notistack": "^0.5.1", "notistack": "^0.5.1",
"prettier": "^1.18.2",
"query-string": "^6.8.2", "query-string": "^6.8.2",
"react": "^16.8.6", "react": "^16.8.6",
"react-dom": "^16.8.6", "react-dom": "^16.8.6",

View File

@ -1,21 +1,24 @@
import { Theme, createStyles } from "@material-ui/core"; import { Theme, createStyles } from "@material-ui/core";
import { isDarwinApp } from './utilities/desktop'; import { isDarwinApp } from "./utilities/desktop";
export const styles = (theme: Theme) => createStyles({ export const styles = (theme: Theme) =>
createStyles({
root: { root: {
width: '100%', width: "100%",
display: 'flex', display: "flex",
height: '100%', height: "100%",
minHeight: '100vh', minHeight: "100vh",
backgroundColor: isDarwinApp()? "transparent": theme.palette.background.default, backgroundColor: isDarwinApp()
? "transparent"
: theme.palette.background.default
}, },
content: { content: {
marginTop: 72, marginTop: 72,
flexGrow: 1, flexGrow: 1,
padding: theme.spacing.unit * 3, padding: theme.spacing.unit * 3,
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
marginLeft: 250, marginLeft: 250,
marginTop: 88, marginTop: 88
}, }
}, }
}); });

View File

@ -1,33 +1,33 @@
import React, { Component } from 'react'; import React, { Component } from "react";
import {MuiThemeProvider, CssBaseline, withStyles } from '@material-ui/core'; import { MuiThemeProvider, CssBaseline, withStyles } from "@material-ui/core";
import { setHyperspaceTheme, darkMode } from './utilities/themes'; import { setHyperspaceTheme, darkMode } from "./utilities/themes";
import AppLayout from './components/AppLayout'; import AppLayout from "./components/AppLayout";
import {styles} from './App.styles'; import { styles } from "./App.styles";
import {Route} from 'react-router-dom'; import { Route } from "react-router-dom";
import AboutPage from './pages/About'; import AboutPage from "./pages/About";
import Settings from './pages/Settings'; import Settings from "./pages/Settings";
import { getUserDefaultBool, getUserDefaultTheme } from './utilities/settings'; import { getUserDefaultBool, getUserDefaultTheme } from "./utilities/settings";
import ProfilePage from './pages/ProfilePage'; import ProfilePage from "./pages/ProfilePage";
import HomePage from './pages/Home'; import HomePage from "./pages/Home";
import LocalPage from './pages/Local'; import LocalPage from "./pages/Local";
import PublicPage from './pages/Public'; import PublicPage from "./pages/Public";
import Conversation from './pages/Conversation'; import Conversation from "./pages/Conversation";
import NotificationsPage from './pages/Notifications'; import NotificationsPage from "./pages/Notifications";
import SearchPage from './pages/Search'; import SearchPage from "./pages/Search";
import Composer from './pages/Compose'; import Composer from "./pages/Compose";
import WelcomePage from './pages/Welcome'; import WelcomePage from "./pages/Welcome";
import MessagesPage from './pages/Messages'; import MessagesPage from "./pages/Messages";
import RecommendationsPage from './pages/Recommendations'; import RecommendationsPage from "./pages/Recommendations";
import Missingno from './pages/Missingno'; import Missingno from "./pages/Missingno";
import You from './pages/You'; import You from "./pages/You";
import {withSnackbar} from 'notistack'; import Blocked from "./pages/Blocked";
import {PrivateRoute} from './interfaces/overrides'; import { withSnackbar } from "notistack";
import { userLoggedIn } from './utilities/accounts'; import { PrivateRoute } from "./interfaces/overrides";
import { isDarwinApp } from './utilities/desktop'; import { userLoggedIn } from "./utilities/accounts";
import { isDarwinApp } from "./utilities/desktop";
let theme = setHyperspaceTheme(getUserDefaultTheme()); let theme = setHyperspaceTheme(getUserDefaultTheme());
class App extends Component<any, any> { class App extends Component<any, any> {
offline: any; offline: any;
constructor(props: any) { constructor(props: any) {
@ -35,57 +35,73 @@ class App extends Component<any, any> {
this.state = { this.state = {
theme: theme theme: theme
} };
} }
componentWillMount() { componentWillMount() {
let newTheme = darkMode(this.state.theme, getUserDefaultBool('darkModeEnabled')); let newTheme = darkMode(
this.state.theme,
getUserDefaultBool("darkModeEnabled")
);
this.setState({ theme: newTheme }); this.setState({ theme: newTheme });
} }
componentDidMount() { componentDidMount() {
this.removeBodyBackground() this.removeBodyBackground();
} }
componentDidUpdate() { componentDidUpdate() {
this.removeBodyBackground() this.removeBodyBackground();
} }
removeBodyBackground() { removeBodyBackground() {
if (isDarwinApp()) { if (isDarwinApp()) {
document.body.style.backgroundColor = "transparent"; document.body.style.backgroundColor = "transparent";
console.log("Changed!") console.log("Changed!");
console.log(`New color: ${document.body.style.backgroundColor}`) console.log(`New color: ${document.body.style.backgroundColor}`);
} }
} }
render() { render() {
const { classes } = this.props; const { classes } = this.props;
this.removeBodyBackground() this.removeBodyBackground();
return ( return (
<MuiThemeProvider theme={this.state.theme}> <MuiThemeProvider theme={this.state.theme}>
<CssBaseline/> <CssBaseline />
<Route path="/welcome" component={WelcomePage}/> <Route path="/welcome" component={WelcomePage} />
<div> <div>
{ userLoggedIn()? <AppLayout/>: null} {userLoggedIn() ? <AppLayout /> : null}
<PrivateRoute exact path="/" component={HomePage}/> <PrivateRoute exact path="/" component={HomePage} />
<PrivateRoute path="/home" component={HomePage}/> <PrivateRoute path="/home" component={HomePage} />
<PrivateRoute path="/local" component={LocalPage}/> <PrivateRoute path="/local" component={LocalPage} />
<PrivateRoute path="/public" component={PublicPage}/> <PrivateRoute path="/public" component={PublicPage} />
<PrivateRoute path="/messages" component={MessagesPage}/> <PrivateRoute path="/messages" component={MessagesPage} />
<PrivateRoute path="/notifications" component={NotificationsPage}/> <PrivateRoute
<PrivateRoute path="/profile/:profileId" component={ProfilePage}/> path="/notifications"
<PrivateRoute path="/conversation/:conversationId" component={Conversation}/> component={NotificationsPage}
<PrivateRoute path="/search" component={SearchPage}/> />
<PrivateRoute path="/settings" component={Settings}/> <PrivateRoute
<PrivateRoute path="/you" component={You}/> path="/profile/:profileId"
<PrivateRoute path="/about" component={AboutPage}/> component={ProfilePage}
<PrivateRoute path="/compose" component={Composer}/> />
<PrivateRoute path="/recommended" component={RecommendationsPage}/> <PrivateRoute
</div> path="/conversation/:conversationId"
component={Conversation}
/>
<PrivateRoute path="/search" component={SearchPage} />
<PrivateRoute path="/blocked" component={Blocked} />
<PrivateRoute path="/settings" component={Settings} />
<PrivateRoute path="/you" component={You} />
<PrivateRoute path="/about" component={AboutPage} />
<PrivateRoute path="/compose" component={Composer} />
<PrivateRoute
path="/recommended"
component={RecommendationsPage}
/>
</div>
</MuiThemeProvider> </MuiThemeProvider>
); );
} }

View File

@ -1,32 +1,35 @@
import { Theme, createStyles } from "@material-ui/core"; import { Theme, createStyles } from "@material-ui/core";
import { darken } from "@material-ui/core/styles/colorManipulator"; import { darken } from "@material-ui/core/styles/colorManipulator";
import { isDarwinApp } from '../../utilities/desktop'; import { isDarwinApp } from "../../utilities/desktop";
import { fade } from "@material-ui/core/styles/colorManipulator"; import { fade } from "@material-ui/core/styles/colorManipulator";
export const styles = (theme: Theme) => createStyles({ export const styles = (theme: Theme) =>
createStyles({
root: { root: {
width: '100%', width: "100%",
display: 'flex', display: "flex"
}, },
stickyArea: { stickyArea: {
position: 'fixed', position: "fixed",
width: '100%', width: "100%",
top: 0, top: 0,
left: 0, left: 0,
zIndex: 1000, zIndex: 1000
}, },
titleBarRoot: { titleBarRoot: {
top: 0, top: 0,
left: 0, left: 0,
height: 24, height: 24,
width: '100%', width: "100%",
backgroundColor: isDarwinApp()? theme.palette.primary.main: theme.palette.primary.dark, backgroundColor: isDarwinApp()
textAlign: 'center', ? theme.palette.primary.main
: theme.palette.primary.dark,
textAlign: "center",
zIndex: 1000, zIndex: 1000,
verticalAlign: 'middle', verticalAlign: "middle",
WebkitUserSelect: 'none', WebkitUserSelect: "none",
WebkitAppRegion: "drag", WebkitAppRegion: "drag"
}, },
titleBarText: { titleBarText: {
color: theme.palette.common.white, color: theme.palette.common.white,
@ -36,83 +39,83 @@ export const styles = (theme: Theme) => createStyles({
}, },
appBar: { appBar: {
zIndex: 1000, zIndex: 1000,
backgroundImage: isDarwinApp()? `linear-gradient(${theme.palette.primary.main}, ${theme.palette.primary.dark})`: undefined, backgroundImage: isDarwinApp()
? `linear-gradient(${theme.palette.primary.main}, ${theme.palette.primary.dark})`
: undefined,
backgroundColor: theme.palette.primary.main, backgroundColor: theme.palette.primary.main,
borderBottomColor: darken(theme.palette.primary.dark, 0.2), borderBottomColor: darken(theme.palette.primary.dark, 0.2),
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomStyle: isDarwinApp()? "solid": "none", borderBottomStyle: isDarwinApp() ? "solid" : "none",
boxShadow: isDarwinApp()? "none": "inherit" boxShadow: isDarwinApp() ? "none" : "inherit"
}, },
appBarMenuButton: { appBarMenuButton: {
marginLeft: -12, marginLeft: -12,
marginRight: 20, marginRight: 20,
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
display: 'none' display: "none"
} }
}, },
appBarTitle: { appBarTitle: {
display: 'none', display: "none",
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
display: 'block', display: "block"
} }
}, },
appBarSearch: { appBarSearch: {
position: 'relative', position: "relative",
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
backgroundColor: fade(theme.palette.common.white, 0.15), backgroundColor: fade(theme.palette.common.white, 0.15),
'&:hover': { "&:hover": {
backgroundColor: fade(theme.palette.common.white, 0.25) backgroundColor: fade(theme.palette.common.white, 0.25)
}, },
width: '100%', width: "100%",
marginLeft: 0, marginLeft: 0,
marginRight: theme.spacing.unit, marginRight: theme.spacing.unit,
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
marginLeft: theme.spacing.unit * 6, marginLeft: theme.spacing.unit * 6,
width: '50%' width: "50%"
} }
}, },
appBarSearchIcon: { appBarSearchIcon: {
width: theme.spacing.unit * 9, width: theme.spacing.unit * 9,
height: '100%', height: "100%",
position: 'absolute', position: "absolute",
pointerEvents: 'none', pointerEvents: "none",
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center' justifyContent: "center"
}, },
appBarSearchInputRoot: { appBarSearchInputRoot: {
color: 'inherit', color: "inherit",
width: '100%' width: "100%"
}, },
appBarSearchInputInput: { appBarSearchInputInput: {
paddingTop: theme.spacing.unit, paddingTop: theme.spacing.unit,
paddingBottom: theme.spacing.unit, paddingBottom: theme.spacing.unit,
paddingLeft: theme.spacing.unit * 10, paddingLeft: theme.spacing.unit * 10,
paddingRight: theme.spacing.unit, paddingRight: theme.spacing.unit,
transition: theme.transitions.create('width'), transition: theme.transitions.create("width"),
width: '100%', width: "100%"
}, },
appBarFlexGrow: { appBarFlexGrow: {
flexGrow: 1 flexGrow: 1
}, },
appBarActionButtons: { appBarActionButtons: {
display: 'none', display: "none",
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up("sm")]: {
display: 'flex', display: "flex"
}, }
}, },
appBarAcctMenuIcon: { appBarAcctMenuIcon: {
backgroundColor: theme.palette.primary.dark backgroundColor: theme.palette.primary.dark
}, },
acctMenu: { acctMenu: {},
},
drawer: { drawer: {
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up("sm")]: {
width: 250, width: 250,
flexShrink: 0 flexShrink: 0
}, },
zIndex: 1, zIndex: 1
}, },
drawerPaper: { drawerPaper: {
width: 250, width: 250,
@ -122,43 +125,47 @@ export const styles = (theme: Theme) => createStyles({
width: 250, width: 250,
zIndex: -1, zIndex: -1,
marginTop: 64, marginTop: 64,
backgroundColor: isDarwinApp()? "transparent": theme.palette.background.paper backgroundColor: isDarwinApp()
? "transparent"
: theme.palette.background.paper
}, },
drawerPaperWithTitleAndAppBar: { drawerPaperWithTitleAndAppBar: {
width: 250, width: 250,
zIndex: -1, zIndex: -1,
marginTop: 88, marginTop: 88,
backgroundColor: isDarwinApp()? "transparent": theme.palette.background.paper backgroundColor: isDarwinApp()
? "transparent"
: theme.palette.background.paper
}, },
drawerDisplayMobile: { drawerDisplayMobile: {
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
display: 'none' display: "none"
} }
}, },
toolbar: theme.mixins.toolbar, toolbar: theme.mixins.toolbar,
sectionDesktop: { sectionDesktop: {
display: 'none', display: "none",
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
display: 'flex', display: "flex"
}, }
}, },
sectionMobile: { sectionMobile: {
display: 'flex', display: "flex",
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
display: 'none', display: "none"
}, }
}, },
content: { content: {
padding: theme.spacing.unit * 3, padding: theme.spacing.unit * 3,
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
marginLeft: 250 marginLeft: 250
}, },
overflowY: 'auto', overflowY: "auto"
}, },
composeButton: { composeButton: {
position: "fixed", position: "fixed",
bottom: theme.spacing.unit * 2, bottom: theme.spacing.unit * 2,
right: theme.spacing.unit * 2, right: theme.spacing.unit * 2,
zIndex: 50 zIndex: 50
}, }
}); });

View File

@ -1,28 +1,60 @@
import React, { Component } from 'react'; 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, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, ListItem, Tooltip } from '@material-ui/core'; import {
import MenuIcon from '@material-ui/icons/Menu'; Typography,
import SearchIcon from '@material-ui/icons/Search'; AppBar,
import NotificationsIcon from '@material-ui/icons/Notifications'; Toolbar,
import MailIcon from '@material-ui/icons/Mail'; IconButton,
import HomeIcon from '@material-ui/icons/Home'; InputBase,
import DomainIcon from '@material-ui/icons/Domain'; Avatar,
import PublicIcon from '@material-ui/icons/Public'; ListItemText,
import GroupIcon from '@material-ui/icons/Group'; Divider,
import SettingsIcon from '@material-ui/icons/Settings'; List,
import InfoIcon from '@material-ui/icons/Info'; ListItemIcon,
import CreateIcon from '@material-ui/icons/Create'; Hidden,
Drawer,
ListSubheader,
ListItemAvatar,
withStyles,
Menu,
MenuItem,
ClickAwayListener,
Badge,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Button,
ListItem,
Tooltip
} 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";
import MailIcon from "@material-ui/icons/Mail";
import HomeIcon from "@material-ui/icons/Home";
import DomainIcon from "@material-ui/icons/Domain";
import PublicIcon from "@material-ui/icons/Public";
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 ExitToAppIcon from "@material-ui/icons/ExitToApp";
import {styles} from './AppLayout.styles'; import { styles } from "./AppLayout.styles";
import { UAccount } from '../../types/Account'; import { UAccount } from "../../types/Account";
import {LinkableListItem, LinkableIconButton, LinkableFab} from '../../interfaces/overrides'; import {
import Mastodon from 'megalodon'; LinkableListItem,
import { Notification } from '../../types/Notification'; LinkableIconButton,
import {sendNotificationRequest} from '../../utilities/notifications'; LinkableFab
import {withSnackbar} from 'notistack'; } from "../../interfaces/overrides";
import { getConfig, getUserDefaultBool } from '../../utilities/settings'; import Mastodon from "megalodon";
import { isDesktopApp, isDarwinApp } from '../../utilities/desktop'; import { Notification } from "../../types/Notification";
import { Config } from '../../types/Config'; import { sendNotificationRequest } from "../../utilities/notifications";
import { withSnackbar } from "notistack";
import { getConfig, getUserDefaultBool } from "../../utilities/settings";
import { isDesktopApp, isDarwinApp } from "../../utilities/desktop";
import { Config } from "../../types/Config";
interface IAppLayoutState { interface IAppLayoutState {
acctMenuOpen: boolean; acctMenuOpen: boolean;
@ -36,21 +68,23 @@ interface IAppLayoutState {
} }
export class AppLayout extends Component<any, IAppLayoutState> { export class AppLayout extends Component<any, IAppLayoutState> {
client: Mastodon; client: Mastodon;
streamListener: any; streamListener: any;
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1"); this.client = new Mastodon(
localStorage.getItem("access_token") as string,
(localStorage.getItem("baseurl") as string) + "/api/v1"
);
this.state = { this.state = {
drawerOpenOnMobile: false, drawerOpenOnMobile: false,
acctMenuOpen: false, acctMenuOpen: false,
notificationCount: 0, notificationCount: 0,
logOutOpen: false logOutOpen: false
} };
this.toggleDrawerOnMobile = this.toggleDrawerOnMobile.bind(this); this.toggleDrawerOnMobile = this.toggleDrawerOnMobile.bind(this);
this.toggleAcctMenu = this.toggleAcctMenu.bind(this); this.toggleAcctMenu = this.toggleAcctMenu.bind(this);
@ -58,18 +92,20 @@ export class AppLayout extends Component<any, IAppLayoutState> {
} }
componentDidMount() { componentDidMount() {
let acct = localStorage.getItem("account");
let acct = localStorage.getItem('account');
if (acct) { if (acct) {
this.setState({ currentUser: JSON.parse(acct) }); this.setState({ currentUser: JSON.parse(acct) });
} else { } else {
this.client.get('/accounts/verify_credentials').then((resp: any) => { this.client
.get("/accounts/verify_credentials")
.then((resp: any) => {
let data: UAccount = resp.data; let data: UAccount = resp.data;
this.setState({ currentUser: data }); this.setState({ currentUser: data });
}).catch((err: Error) => { })
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't find profile info: " + err.name); this.props.enqueueSnackbar("Couldn't find profile info: " + err.name);
console.error(err.message); console.error(err.message);
}) });
} }
getConfig().then((result: any) => { getConfig().then((result: any) => {
@ -77,59 +113,69 @@ export class AppLayout extends Component<any, IAppLayoutState> {
let config: Config = result; let config: Config = result;
this.setState({ this.setState({
enableFederation: config.federation.enablePublicTimeline, enableFederation: config.federation.enablePublicTimeline,
brandName: config.branding? config.branding.name: "Hyperspace", brandName: config.branding ? config.branding.name : "Hyperspace",
developerMode: config.developer developerMode: config.developer
}); });
} }
}) });
this.streamNotifications()
this.streamNotifications();
} }
streamNotifications() { streamNotifications() {
this.streamListener = this.client.stream('/streaming/user'); this.streamListener = this.client.stream("/streaming/user");
if (getUserDefaultBool('displayAllOnNotificationBadge')) { if (getUserDefaultBool("displayAllOnNotificationBadge")) {
this.client.get('/notifications').then((resp: any) => { this.client.get("/notifications").then((resp: any) => {
let notifArray = resp.data; let notifArray = resp.data;
this.setState({ notificationCount: notifArray.length }); this.setState({ notificationCount: notifArray.length });
}) });
} }
this.streamListener.on('notification', (notif: Notification) => { this.streamListener.on("notification", (notif: Notification) => {
const notificationCount = this.state.notificationCount + 1; const notificationCount = this.state.notificationCount + 1;
this.setState({ notificationCount }); this.setState({ notificationCount });
if (!document.hasFocus()) { if (!document.hasFocus()) {
let primaryMessage = ""; let primaryMessage = "";
let secondaryMessage = ""; let secondaryMessage = "";
switch(notif.type) { switch (notif.type) {
case "favourite": case "favourite":
primaryMessage = (notif.account.display_name || "@" + notif.account.username) + " favorited your post."; primaryMessage =
(notif.account.display_name || "@" + notif.account.username) +
" favorited your post.";
if (notif.status) { if (notif.status) {
const div = document.createElement('div'); const div = document.createElement("div");
div.innerHTML = notif.status.content; div.innerHTML = notif.status.content;
secondaryMessage = (div.textContent || div.innerText || "").slice(0, 100) + "..." secondaryMessage =
(div.textContent || div.innerText || "").slice(0, 100) + "...";
} }
break; break;
case "follow": case "follow":
primaryMessage = (notif.account.display_name || "@" + notif.account.username) + " is now following you."; primaryMessage =
(notif.account.display_name || "@" + notif.account.username) +
" is now following you.";
break; break;
case "mention": case "mention":
primaryMessage = (notif.account.display_name || "@" + notif.account.username) + " mentioned you in a post."; primaryMessage =
(notif.account.display_name || "@" + notif.account.username) +
" mentioned you in a post.";
if (notif.status) { if (notif.status) {
const div = document.createElement('div'); const div = document.createElement("div");
div.innerHTML = notif.status.content; div.innerHTML = notif.status.content;
secondaryMessage = (div.textContent || div.innerText || "").slice(0, 100) + "..." secondaryMessage =
(div.textContent || div.innerText || "").slice(0, 100) + "...";
} }
break; break;
case "reblog": case "reblog":
primaryMessage = (notif.account.display_name || "@" + notif.account.username) + " reblogged your post."; primaryMessage =
(notif.account.display_name || "@" + notif.account.username) +
" reblogged your post.";
if (notif.status) { if (notif.status) {
const div = document.createElement('div'); const div = document.createElement("div");
div.innerHTML = notif.status.content; div.innerHTML = notif.status.content;
secondaryMessage = (div.textContent || div.innerText || "").slice(0, 100) + "..." secondaryMessage =
(div.textContent || div.innerText || "").slice(0, 100) + "...";
} }
break; break;
} }
@ -146,7 +192,7 @@ export class AppLayout extends Component<any, IAppLayoutState> {
toggleDrawerOnMobile() { toggleDrawerOnMobile() {
this.setState({ this.setState({
drawerOpenOnMobile: !this.state.drawerOpenOnMobile drawerOpenOnMobile: !this.state.drawerOpenOnMobile
}) });
} }
toggleLogOutDialog() { toggleLogOutDialog() {
@ -154,7 +200,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
} }
searchForQuery(what: string) { searchForQuery(what: string) {
window.location.href = isDesktopApp()? "hyperspace://hyperspace/app/index.html#/search?query=" + what: "/#/search?query=" + what; window.location.href = isDesktopApp()
? "hyperspace://hyperspace/app/index.html#/search?query=" + what
: "/#/search?query=" + what;
window.location.reload; window.location.reload;
} }
@ -162,15 +210,15 @@ export class AppLayout extends Component<any, IAppLayoutState> {
let loginData = localStorage.getItem("login"); let loginData = localStorage.getItem("login");
if (loginData) { if (loginData) {
let items = ["login", "account", "baseurl", "access_token"]; let items = ["login", "account", "baseurl", "access_token"];
items.forEach((entry) => { items.forEach(entry => {
localStorage.removeItem(entry); localStorage.removeItem(entry);
}) });
window.location.reload(); window.location.reload();
} }
} }
clearBadge() { clearBadge() {
if (!getUserDefaultBool('displayAllOnNotificationBadge')) { if (!getUserDefaultBool("displayAllOnNotificationBadge")) {
this.setState({ notificationCount: 0 }); this.setState({ notificationCount: 0 });
} }
} }
@ -180,13 +228,21 @@ export class AppLayout extends Component<any, IAppLayoutState> {
if (isDarwinApp()) { if (isDarwinApp()) {
return ( return (
<div className={classes.titleBarRoot}> <div className={classes.titleBarRoot}>
<Typography className={classes.titleBarText}>{this.state.brandName? this.state.brandName: "Hyperspace"} {this.state.developerMode? "(beta)": null}</Typography> <Typography className={classes.titleBarText}>
{this.state.brandName ? this.state.brandName : "Hyperspace"}{" "}
{this.state.developerMode ? "(beta)" : null}
</Typography>
</div> </div>
); );
} else if (this.state.developerMode || process.env.NODE_ENV === "development") { } else if (
this.state.developerMode ||
process.env.NODE_ENV === "development"
) {
return ( return (
<div className={classes.titleBarRoot}> <div className={classes.titleBarRoot}>
<Typography className={classes.titleBarText}>Careful: you're running in developer mode.</Typography> <Typography className={classes.titleBarText}>
Careful: you're running in developer mode.
</Typography>
</div> </div>
); );
} }
@ -198,71 +254,129 @@ export class AppLayout extends Component<any, IAppLayoutState> {
<div> <div>
<List> <List>
<div className={classes.drawerDisplayMobile}> <div className={classes.drawerDisplayMobile}>
<LinkableListItem button key="profile-mobile" to={`/profile/${this.state.currentUser? this.state.currentUser.id: "1"}`}> <LinkableListItem
button
key="profile-mobile"
to={`/profile/${
this.state.currentUser ? this.state.currentUser.id : "1"
}`}
>
<ListItemAvatar> <ListItemAvatar>
<Avatar alt="You" src={this.state.currentUser? this.state.currentUser.avatar_static: ""}/> <Avatar
alt="You"
src={
this.state.currentUser
? this.state.currentUser.avatar_static
: ""
}
/>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={this.state.currentUser? (this.state.currentUser.display_name || this.state.currentUser.acct): "Loading..."} secondary={this.state.currentUser? this.state.currentUser.acct: "Loading..."}/> <ListItemText
primary={
this.state.currentUser
? this.state.currentUser.display_name ||
this.state.currentUser.acct
: "Loading..."
}
secondary={
this.state.currentUser
? this.state.currentUser.acct
: "Loading..."
}
/>
</LinkableListItem> </LinkableListItem>
{/* <LinkableListItem button key="acctSwitch-module" to="/switchacct"> {/* <LinkableListItem button key="acctSwitch-module" to="/switchacct">
<ListItemIcon><SupervisedUserCircleIcon/></ListItemIcon> <ListItemIcon><SupervisedUserCircleIcon/></ListItemIcon>
<ListItemText primary="Switch account"/> <ListItemText primary="Switch account"/>
</LinkableListItem> */} </LinkableListItem> */}
<ListItem button key="acctLogout-mobile" onClick={() => this.toggleLogOutDialog()}> <ListItem
<ListItemIcon><ExitToAppIcon/></ListItemIcon> button
<ListItemText primary="Log out"/> key="acctLogout-mobile"
onClick={() => this.toggleLogOutDialog()}
>
<ListItemIcon>
<ExitToAppIcon />
</ListItemIcon>
<ListItemText primary="Log out" />
</ListItem> </ListItem>
<Divider/> <Divider />
</div> </div>
<ListSubheader>Timelines</ListSubheader> <ListSubheader>Timelines</ListSubheader>
<LinkableListItem button key="home" to="/home"> <LinkableListItem button key="home" to="/home">
<ListItemIcon><HomeIcon/></ListItemIcon> <ListItemIcon>
<ListItemText primary="Home"/> <HomeIcon />
</ListItemIcon>
<ListItemText primary="Home" />
</LinkableListItem> </LinkableListItem>
<LinkableListItem button key="local" to="/local"> <LinkableListItem button key="local" to="/local">
<ListItemIcon><DomainIcon/></ListItemIcon> <ListItemIcon>
<ListItemText primary="Local"/> <DomainIcon />
</ListItemIcon>
<ListItemText primary="Local" />
</LinkableListItem> </LinkableListItem>
{ {this.state.enableFederation ? (
this.state.enableFederation?
<LinkableListItem button key="public" to="/public"> <LinkableListItem button key="public" to="/public">
<ListItemIcon><PublicIcon/></ListItemIcon> <ListItemIcon>
<ListItemText primary="Public"/> <PublicIcon />
</LinkableListItem>: </ListItemIcon>
<ListItemText primary="Public" />
</LinkableListItem>
) : (
<ListItem disabled> <ListItem disabled>
<ListItemIcon><PublicIcon/></ListItemIcon> <ListItemIcon>
<ListItemText primary="Public" secondary="Disabled by admin"/> <PublicIcon />
</ListItemIcon>
<ListItemText primary="Public" secondary="Disabled by admin" />
</ListItem> </ListItem>
} )}
<Divider/> <Divider />
<div className={classes.drawerDisplayMobile}> <div className={classes.drawerDisplayMobile}>
<ListSubheader>Account</ListSubheader> <ListSubheader>Account</ListSubheader>
<LinkableListItem button key="notifications-mobile" to="/notifications"> <LinkableListItem
button
key="notifications-mobile"
to="/notifications"
>
<ListItemIcon> <ListItemIcon>
<Badge badgeContent={this.state.notificationCount > 0? this.state.notificationCount: ""} color="secondary"> <Badge
badgeContent={
this.state.notificationCount > 0
? this.state.notificationCount
: ""
}
color="secondary"
>
<NotificationsIcon /> <NotificationsIcon />
</Badge> </Badge>
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Notifications"/> <ListItemText primary="Notifications" />
</LinkableListItem> </LinkableListItem>
<LinkableListItem button key="messages-mobile" to="/messages"> <LinkableListItem button key="messages-mobile" to="/messages">
<ListItemIcon><MailIcon/></ListItemIcon> <ListItemIcon>
<ListItemText primary="Messages"/> <MailIcon />
</ListItemIcon>
<ListItemText primary="Messages" />
</LinkableListItem> </LinkableListItem>
<Divider/> <Divider />
</div> </div>
<ListSubheader>More</ListSubheader> <ListSubheader>More</ListSubheader>
<LinkableListItem button key="recommended" to="/recommended"> <LinkableListItem button key="recommended" to="/recommended">
<ListItemIcon><GroupIcon/></ListItemIcon> <ListItemIcon>
<ListItemText primary="Who to follow"/> <GroupIcon />
</ListItemIcon>
<ListItemText primary="Who to follow" />
</LinkableListItem> </LinkableListItem>
<LinkableListItem button key="settings" to="/settings"> <LinkableListItem button key="settings" to="/settings">
<ListItemIcon><SettingsIcon/></ListItemIcon> <ListItemIcon>
<ListItemText primary="Settings"/> <SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</LinkableListItem> </LinkableListItem>
<LinkableListItem button key="info" to="/about"> <LinkableListItem button key="info" to="/about">
<ListItemIcon><InfoIcon/></ListItemIcon> <ListItemIcon>
<ListItemText primary="About"/> <InfoIcon />
</ListItemIcon>
<ListItemText primary="About" />
</LinkableListItem> </LinkableListItem>
</List> </List>
</div> </div>
@ -283,15 +397,20 @@ export class AppLayout extends Component<any, IAppLayoutState> {
aria-label="Open drawer" aria-label="Open drawer"
onClick={this.toggleDrawerOnMobile} onClick={this.toggleDrawerOnMobile}
> >
<MenuIcon/> <MenuIcon />
</IconButton> </IconButton>
<Typography className={classes.appBarTitle} variant="h6" color="inherit" noWrap> <Typography
{this.state.brandName? this.state.brandName: "Hyperspace"} className={classes.appBarTitle}
variant="h6"
color="inherit"
noWrap
>
{this.state.brandName ? this.state.brandName : "Hyperspace"}
</Typography> </Typography>
<div className={classes.appBarFlexGrow}/> <div className={classes.appBarFlexGrow} />
<div className={classes.appBarSearch}> <div className={classes.appBarSearch}>
<div className={classes.appBarSearchIcon}> <div className={classes.appBarSearchIcon}>
<SearchIcon/> <SearchIcon />
</div> </div>
<InputBase <InputBase
placeholder="Search..." placeholder="Search..."
@ -299,30 +418,49 @@ export class AppLayout extends Component<any, IAppLayoutState> {
root: classes.appBarSearchInputRoot, root: classes.appBarSearchInputRoot,
input: classes.appBarSearchInputInput input: classes.appBarSearchInputInput
}} }}
onKeyUp={(event) => { onKeyUp={event => {
if (event.keyCode === 13) { if (event.keyCode === 13) {
this.searchForQuery(event.currentTarget.value); this.searchForQuery(event.currentTarget.value);
} }
}} }}
/> />
</div> </div>
<div className={classes.appBarFlexGrow}/> <div className={classes.appBarFlexGrow} />
<div className={classes.appBarActionButtons}> <div className={classes.appBarActionButtons}>
<Tooltip title="Notifications"> <Tooltip title="Notifications">
<LinkableIconButton color="inherit" to="/notifications" onClick={this.clearBadge}> <LinkableIconButton
<Badge badgeContent={this.state.notificationCount > 0? this.state.notificationCount: ""} color="secondary"> color="inherit"
to="/notifications"
onClick={this.clearBadge}
>
<Badge
badgeContent={
this.state.notificationCount > 0
? this.state.notificationCount
: ""
}
color="secondary"
>
<NotificationsIcon /> <NotificationsIcon />
</Badge> </Badge>
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
<Tooltip title="Direct messages"> <Tooltip title="Direct messages">
<LinkableIconButton color="inherit" to="/messages"> <LinkableIconButton color="inherit" to="/messages">
<MailIcon/> <MailIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
<Tooltip title="Your account"> <Tooltip title="Your account">
<IconButton id="acctMenuBtn" onClick={this.toggleAcctMenu}> <IconButton id="acctMenuBtn" onClick={this.toggleAcctMenu}>
<Avatar className={classes.appBarAcctMenuIcon} alt="You" src={this.state.currentUser? this.state.currentUser.avatar_static: ""}/> <Avatar
className={classes.appBarAcctMenuIcon}
alt="You"
src={
this.state.currentUser
? this.state.currentUser.avatar_static
: ""
}
/>
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@ -334,18 +472,43 @@ export class AppLayout extends Component<any, IAppLayoutState> {
> >
<ClickAwayListener onClickAway={this.toggleAcctMenu}> <ClickAwayListener onClickAway={this.toggleAcctMenu}>
<div> <div>
<LinkableListItem to={`/profile/${this.state.currentUser? this.state.currentUser.id: "1"}`}> <LinkableListItem
to={`/profile/${
this.state.currentUser
? this.state.currentUser.id
: "1"
}`}
>
<ListItemAvatar> <ListItemAvatar>
<Avatar alt="You" src={this.state.currentUser? this.state.currentUser.avatar_static: ""}/> <Avatar
alt="You"
src={
this.state.currentUser
? this.state.currentUser.avatar_static
: ""
}
/>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={this.state.currentUser? (this.state.currentUser.display_name || this.state.currentUser.acct): "Loading..."} primary={
secondary={'@' + (this.state.currentUser? this.state.currentUser.acct: "Loading...")} this.state.currentUser
? this.state.currentUser.display_name ||
this.state.currentUser.acct
: "Loading..."
}
secondary={
"@" +
(this.state.currentUser
? this.state.currentUser.acct
: "Loading...")
}
/> />
</LinkableListItem> </LinkableListItem>
<Divider/> <Divider />
{/* <MenuItem>Switch account</MenuItem> */} {/* <MenuItem>Switch account</MenuItem> */}
<MenuItem onClick={() => this.toggleLogOutDialog()}>Log out</MenuItem> <MenuItem onClick={() => this.toggleLogOutDialog()}>
Log out
</MenuItem>
</div> </div>
</ClickAwayListener> </ClickAwayListener>
</Menu> </Menu>
@ -357,7 +520,7 @@ export class AppLayout extends Component<any, IAppLayoutState> {
<Drawer <Drawer
container={this.props.container} container={this.props.container}
variant="temporary" variant="temporary"
anchor={'left'} anchor={"left"}
open={this.state.drawerOpenOnMobile} open={this.state.drawerOpenOnMobile}
onClose={this.toggleDrawerOnMobile} onClose={this.toggleDrawerOnMobile}
classes={{ paper: classes.drawerPaper }} classes={{ paper: classes.drawerPaper }}
@ -368,7 +531,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
<Hidden smDown implementation="css"> <Hidden smDown implementation="css">
<Drawer <Drawer
classes={{ classes={{
paper: this.titlebar()? classes.drawerPaperWithTitleAndAppBar: classes.drawerPaperWithAppBar paper: this.titlebar()
? classes.drawerPaperWithTitleAndAppBar
: classes.drawerPaperWithAppBar
}} }}
variant="permanent" variant="permanent"
open open
@ -382,26 +547,44 @@ export class AppLayout extends Component<any, IAppLayoutState> {
open={this.state.logOutOpen} open={this.state.logOutOpen}
onClose={() => this.toggleLogOutDialog()} onClose={() => this.toggleLogOutDialog()}
> >
<DialogTitle id="alert-dialog-title">Log out of {this.state.brandName? this.state.brandName: "Hyperspace"}</DialogTitle> <DialogTitle id="alert-dialog-title">
Log out of{" "}
{this.state.brandName ? this.state.brandName : "Hyperspace"}
</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText id="alert-dialog-description"> <DialogContentText id="alert-dialog-description">
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"}. 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"}.
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => this.toggleLogOutDialog()} color="primary" autoFocus> <Button
onClick={() => this.toggleLogOutDialog()}
color="primary"
autoFocus
>
Cancel Cancel
</Button> </Button>
<Button onClick={() => { <Button
onClick={() => {
this.logOutAndRestart(); this.logOutAndRestart();
}} color="primary"> }}
color="primary"
>
Log out Log out
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<Tooltip title="Create a new post"> <Tooltip title="Create a new post">
<LinkableFab to="/compose" className={classes.composeButton} color="secondary" aria-label="Compose"> <LinkableFab
<CreateIcon/> to="/compose"
className={classes.composeButton}
color="secondary"
aria-label="Compose"
>
<CreateIcon />
</LinkableFab> </LinkableFab>
</Tooltip> </Tooltip>
</div> </div>

View File

@ -1,6 +1,6 @@
import { AppLayout } from './AppLayout'; import { AppLayout } from "./AppLayout";
import { withStyles } from '@material-ui/core'; import { withStyles } from "@material-ui/core";
import { styles } from './AppLayout.styles'; import { styles } from "./AppLayout.styles";
import {withSnackbar} from 'notistack'; import { withSnackbar } from "notistack";
export default withStyles(styles)(withSnackbar(AppLayout)); export default withStyles(styles)(withSnackbar(AppLayout));

View File

@ -1,16 +1,17 @@
import { Theme, createStyles } from '@material-ui/core'; import { Theme, createStyles } from "@material-ui/core";
export const styles = (theme: Theme) => createStyles({ export const styles = (theme: Theme) =>
createStyles({
mediaContainer: { mediaContainer: {
padding: theme.spacing.unit * 2, padding: theme.spacing.unit * 2
}, },
mediaObject: { mediaObject: {
width: '100%', width: "100%",
height: '100%' height: "100%"
}, },
mediaSlide: { mediaSlide: {
backgroundColor: theme.palette.primary.light, backgroundColor: theme.palette.primary.light,
width: '100%', width: "100%",
height: 'auto' height: "auto"
} }
}); });

View File

@ -1,8 +1,13 @@
import React, { Component } from 'react'; import React, { Component } from "react";
import {withStyles, Typography, MobileStepper, Button} from '@material-ui/core'; import {
import { styles } from './Attachment.styles'; withStyles,
import { Attachment } from '../../types/Attachment'; Typography,
import SwipeableViews from 'react-swipeable-views'; MobileStepper,
Button
} from "@material-ui/core";
import { styles } from "./Attachment.styles";
import { Attachment } from "../../types/Attachment";
import SwipeableViews from "react-swipeable-views";
interface IAttachmentProps { interface IAttachmentProps {
media: [Attachment]; media: [Attachment];
@ -15,7 +20,10 @@ interface IAttachmentState {
attachments: [Attachment]; attachments: [Attachment];
} }
class AttachmentComponent extends Component<IAttachmentProps, IAttachmentState> { class AttachmentComponent extends Component<
IAttachmentProps,
IAttachmentState
> {
constructor(props: IAttachmentProps) { constructor(props: IAttachmentProps) {
super(props); super(props);
@ -23,7 +31,7 @@ class AttachmentComponent extends Component<IAttachmentProps, IAttachmentState>
attachments: this.props.media, attachments: this.props.media,
totalSteps: this.props.media.length, totalSteps: this.props.media.length,
currentStep: 0 currentStep: 0
} };
} }
moveBack() { moveBack() {
@ -40,45 +48,61 @@ class AttachmentComponent extends Component<IAttachmentProps, IAttachmentState>
nextStep = this.state.totalSteps; nextStep = this.state.totalSteps;
} }
this.setState({ currentStep: nextStep }); this.setState({ currentStep: nextStep });
} }
handleStepChange(currentStep: number) { handleStepChange(currentStep: number) {
this.setState({ this.setState({
currentStep currentStep
}) });
} }
getSlide(slide: Attachment) { getSlide(slide: Attachment) {
const {classes} = this.props; const { classes } = this.props;
switch (slide.type) { switch (slide.type) {
case 'image': case "image":
return <img src={slide.url} alt={slide.description? slide.description: ''} className={classes.mediaObject}/> return (
case 'video': <img
return <video controls autoPlay={false} src={slide.url} className={classes.mediaObject}/> src={slide.url}
case 'gifv': alt={slide.description ? slide.description : ""}
return <img src={slide.url} alt={slide.description? slide.description: ''} className={classes.mediaObject}/> className={classes.mediaObject}
case 'unknown': />
return <object data={slide.url} className={classes.mediaObject}/> );
case "video":
return (
<video
controls
autoPlay={false}
src={slide.url}
className={classes.mediaObject}
/>
);
case "gifv":
return (
<img
src={slide.url}
alt={slide.description ? slide.description : ""}
className={classes.mediaObject}
/>
);
case "unknown":
return <object data={slide.url} className={classes.mediaObject} />;
} }
} }
render() { render() {
const {classes} = this.props; const { classes } = this.props;
const step = this.state.currentStep; const step = this.state.currentStep;
const mediaItem = this.state.attachments[step]; const mediaItem = this.state.attachments[step];
return ( return (
<div className={classes.mediaContainer}> <div className={classes.mediaContainer}>
<SwipeableViews <SwipeableViews index={this.state.currentStep}>
index={this.state.currentStep} {this.state.attachments.map((slide: Attachment) => {
> return (
{ <div key={slide.id} className={classes.mediaSlide}>
this.state.attachments.map((slide: Attachment) => {
return (<div key={slide.id} className={classes.mediaSlide}>
{this.getSlide(slide)} {this.getSlide(slide)}
</div>); </div>
}) );
} })}
</SwipeableViews> </SwipeableViews>
<MobileStepper <MobileStepper
steps={this.state.totalSteps} steps={this.state.totalSteps}
@ -86,19 +110,31 @@ class AttachmentComponent extends Component<IAttachmentProps, IAttachmentState>
activeStep={this.state.currentStep} activeStep={this.state.currentStep}
className={classes.mobileStepper} className={classes.mobileStepper}
nextButton={ nextButton={
<Button size="small" onClick={() => this.moveForward()} disabled={this.state.currentStep === this.state.totalSteps - 1}> <Button
size="small"
onClick={() => this.moveForward()}
disabled={this.state.currentStep === this.state.totalSteps - 1}
>
Next Next
</Button> </Button>
} }
backButton={ backButton={
<Button size="small" onClick={() => this.moveBack()} disabled={this.state.currentStep === 0}> <Button
size="small"
onClick={() => this.moveBack()}
disabled={this.state.currentStep === 0}
>
Back Back
</Button> </Button>
} }
/> />
<Typography variant="caption">{mediaItem.description? mediaItem.description: "No description provided."}</Typography> <Typography variant="caption">
{mediaItem.description
? mediaItem.description
: "No description provided."}
</Typography>
</div> </div>
) );
} }
} }

View File

@ -1,5 +1,5 @@
import {withStyles} from '@material-ui/core'; import { withStyles } from "@material-ui/core";
import AttachmentComponent from './Attachment'; import AttachmentComponent from "./Attachment";
import {styles} from './Attachment.styles'; import { styles } from "./Attachment.styles";
export default withStyles(styles)(AttachmentComponent); export default withStyles(styles)(AttachmentComponent);

View File

@ -1,6 +1,7 @@
import {Theme, createStyles} from '@material-ui/core'; import { Theme, createStyles } from "@material-ui/core";
export const styles = (theme: Theme) => createStyles({ export const styles = (theme: Theme) =>
createStyles({
attachmentArea: { attachmentArea: {
height: 175, height: 175,
width: 268, width: 268,
@ -14,4 +15,4 @@ export const styles = (theme: Theme) => createStyles({
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
opacity: 0.5 opacity: 0.5
} }
}); });

View File

@ -1,10 +1,16 @@
import React, { Component } from 'react'; import React, { Component } from "react";
import {GridListTile, GridListTileBar, TextField, withStyles, IconButton} from '@material-ui/core'; import {
import {styles} from './ComposeMediaAttachment.styles'; GridListTile,
import {withSnackbar, withSnackbarProps} from 'notistack'; GridListTileBar,
import Mastodon from 'megalodon'; TextField,
import { Attachment } from '../../types/Attachment'; withStyles,
import DeleteIcon from '@material-ui/icons/Delete'; IconButton
} from "@material-ui/core";
import { styles } from "./ComposeMediaAttachment.styles";
import { withSnackbar, withSnackbarProps } from "notistack";
import Mastodon from "megalodon";
import { Attachment } from "../../types/Attachment";
import DeleteIcon from "@material-ui/icons/Delete";
interface IComposeMediaAttachmentProps extends withSnackbarProps { interface IComposeMediaAttachmentProps extends withSnackbarProps {
classes: any; classes: any;
@ -18,8 +24,10 @@ interface IComposeMediaAttachmentState {
attachment: Attachment; attachment: Attachment;
} }
class ComposeMediaAttachment extends Component<IComposeMediaAttachmentProps, IComposeMediaAttachmentState> { class ComposeMediaAttachment extends Component<
IComposeMediaAttachmentProps,
IComposeMediaAttachmentState
> {
client: Mastodon; client: Mastodon;
constructor(props: IComposeMediaAttachmentProps) { constructor(props: IComposeMediaAttachmentProps) {
@ -29,29 +37,35 @@ class ComposeMediaAttachment extends Component<IComposeMediaAttachmentProps, ICo
this.state = { this.state = {
attachment: this.props.attachment attachment: this.props.attachment
} };
} }
updateAttachmentText(text: string) { updateAttachmentText(text: string) {
this.client.put(`/media/${this.state.attachment.id}`, { description: text }).then((resp: any) => { this.client
.put(`/media/${this.state.attachment.id}`, { description: text })
.then((resp: any) => {
this.props.onAttachmentUpdate(resp.data); this.props.onAttachmentUpdate(resp.data);
this.props.enqueueSnackbar("Description updated.") this.props.enqueueSnackbar("Description updated.");
}).catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't update description: " + err.name);
}) })
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't update description: " + err.name);
});
} }
render() { render() {
const { classes, attachment } = this.props; const { classes, attachment } = this.props;
return ( return (
<GridListTile className={classes.attachmentArea}> <GridListTile className={classes.attachmentArea}>
{ {attachment.type === "image" || attachment.type === "gifv" ? (
attachment.type === "image" || attachment.type === "gifv"? <img
<img src={attachment.url} alt={attachment.description? attachment.description: ""}/>: src={attachment.url}
attachment.type === "video"? alt={attachment.description ? attachment.description : ""}
<video autoPlay={false} src={attachment.url}/>: />
<object data={attachment.url}/> ) : attachment.type === "video" ? (
} <video autoPlay={false} src={attachment.url} />
) : (
<object data={attachment.url} />
)}
<GridListTileBar <GridListTileBar
classes={{ title: classes.attachmentBar }} classes={{ title: classes.attachmentBar }}
title={ title={
@ -60,21 +74,20 @@ class ComposeMediaAttachment extends Component<IComposeMediaAttachmentProps, ICo
label="Description" label="Description"
margin="dense" margin="dense"
className={classes.attachmentText} className={classes.attachmentText}
onBlur={(event) => this.updateAttachmentText(event.target.value)} onBlur={event => this.updateAttachmentText(event.target.value)}
></TextField> ></TextField>
} }
actionIcon={ actionIcon={
<IconButton color="inherit" <IconButton
onClick={ color="inherit"
() => this.props.onDeleteCallback(this.state.attachment) onClick={() => this.props.onDeleteCallback(this.state.attachment)}
}
> >
<DeleteIcon/> <DeleteIcon />
</IconButton> </IconButton>
} }
/> />
</GridListTile> </GridListTile>
) );
} }
} }

View File

@ -1,5 +1,5 @@
import {styles} from './ComposeMediaAttachment.styles'; import { styles } from "./ComposeMediaAttachment.styles";
import ComposeMediaAttachment from './ComposeMediaAttachment'; import ComposeMediaAttachment from "./ComposeMediaAttachment";
import {withStyles} from '@material-ui/core'; import { withStyles } from "@material-ui/core";
export default withStyles(styles)(ComposeMediaAttachment); export default withStyles(styles)(ComposeMediaAttachment);

View File

@ -1,13 +1,12 @@
import React, { Component } from 'react'; import React, { Component } from "react";
import {Picker, PickerProps, CustomEmoji} from 'emoji-mart'; import { Picker, PickerProps, CustomEmoji } from "emoji-mart";
import 'emoji-mart/css/emoji-mart.css'; import "emoji-mart/css/emoji-mart.css";
interface IEmojiPickerProps extends PickerProps { interface IEmojiPickerProps extends PickerProps {
onGetEmoji: any; onGetEmoji: any;
} }
export class EmojiPicker extends Component<IEmojiPickerProps, any> { export class EmojiPicker extends Component<IEmojiPickerProps, any> {
retrieveFromLocal() { retrieveFromLocal() {
return JSON.parse(localStorage.getItem("emojis") as string); return JSON.parse(localStorage.getItem("emojis") as string);
} }
@ -20,15 +19,14 @@ export class EmojiPicker extends Component<IEmojiPickerProps, any> {
title="" title=""
onClick={this.props.onGetEmoji} onClick={this.props.onGetEmoji}
style={{ style={{
borderColor: 'transparent' borderColor: "transparent"
}} }}
perLine={10} perLine={10}
emojiSize={20} emojiSize={20}
set={"google"} set={"google"}
/> />
) );
} }
} }
export default EmojiPicker; export default EmojiPicker;

View File

@ -1,13 +1,14 @@
import { Theme, createStyles } from "@material-ui/core"; import { Theme, createStyles } from "@material-ui/core";
export const styles = (theme: Theme) => createStyles({ export const styles = (theme: Theme) =>
createStyles({
post: { post: {
marginTop: theme.spacing.unit, marginTop: theme.spacing.unit,
marginBottom: theme.spacing.unit marginBottom: theme.spacing.unit
}, },
postReblogChip: { postReblogChip: {
color: theme.palette.common.white, color: theme.palette.common.white,
'&:hover': { "&:hover": {
backgroundColor: theme.palette.secondary.light backgroundColor: theme.palette.secondary.light
}, },
backgroundColor: theme.palette.secondary.main, backgroundColor: theme.palette.secondary.main,
@ -16,27 +17,27 @@ export const styles = (theme: Theme) => createStyles({
postContent: { postContent: {
paddingTop: 0, paddingTop: 0,
paddingBottom: 0, paddingBottom: 0,
'& a': { "& a": {
textDecoration: 'none', textDecoration: "none",
color: theme.palette.secondary.light, color: theme.palette.secondary.light,
'&:hover': { "&:hover": {
textDecoration: 'underline' textDecoration: "underline"
}, },
'&.u-url.mention': { "&.u-url.mention": {
textDecoration: 'none', textDecoration: "none",
color: 'inherit', color: "inherit",
fontWeight: 'bold' fontWeight: "bold"
}, },
'&.mention.hashtag': { "&.mention.hashtag": {
textDecoration: 'none', textDecoration: "none",
color: 'inherit', color: "inherit",
fontWeight: 'bold' fontWeight: "bold"
} }
} }
}, },
postCard: { postCard: {
'& a:hover': { "& a:hover": {
textDecoration: 'none' textDecoration: "none"
} }
}, },
postEmoji: { postEmoji: {
@ -44,7 +45,7 @@ export const styles = (theme: Theme) => createStyles({
}, },
postMedia: { postMedia: {
height: 0, height: 0,
paddingTop: '56.25%', // 16:9 paddingTop: "56.25%" // 16:9
}, },
postActionsReply: { postActionsReply: {
marginLeft: theme.spacing.unit, marginLeft: theme.spacing.unit,
@ -85,14 +86,14 @@ export const styles = (theme: Theme) => createStyles({
color: "inherit" color: "inherit"
}, },
mobileOnly: { mobileOnly: {
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up("sm")]: {
display: 'none' display: "none"
} }
}, },
desktopOnly: { desktopOnly: {
display: 'none', display: "none",
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up("sm")]: {
display: 'block' display: "block"
} }
} }
}); });

View File

@ -1,31 +1,65 @@
import React from 'react'; 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, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions } from '@material-ui/core'; import {
import MoreVertIcon from '@material-ui/icons/MoreVert'; Typography,
import ReplyIcon from '@material-ui/icons/Reply'; IconButton,
import FavoriteIcon from '@material-ui/icons/Favorite'; Card,
import AutorenewIcon from '@material-ui/icons/Autorenew'; CardHeader,
import OpenInNewIcon from '@material-ui/icons/OpenInNew'; Avatar,
import PublicIcon from '@material-ui/icons/Public'; CardContent,
import VisibilityOffIcon from '@material-ui/icons/VisibilityOff'; CardActions,
import WarningIcon from '@material-ui/icons/Warning'; withStyles,
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; Menu,
import GroupIcon from '@material-ui/icons/Group'; MenuItem,
import ForumIcon from '@material-ui/icons/Forum'; Chip,
import AlternateEmailIcon from '@material-ui/icons/AlternateEmail'; Divider,
import LocalOfferIcon from '@material-ui/icons/LocalOffer'; CardMedia,
import {styles} from './Post.styles'; CardActionArea,
import { Status } from '../../types/Status'; ExpansionPanel,
import { Tag } from '../../types/Tag'; ExpansionPanelSummary,
import { Mention } from '../../types/Mention'; ExpansionPanelDetails,
import { Visibility } from '../../types/Visibility'; Zoom,
import moment from 'moment'; Tooltip,
import AttachmentComponent from '../Attachment'; RadioGroup,
import Mastodon from 'megalodon'; Radio,
import { LinkableChip, LinkableMenuItem, LinkableIconButton, LinkableAvatar } from '../../interfaces/overrides'; FormControlLabel,
import {withSnackbar} from 'notistack'; Button,
import ShareMenu from './PostShareMenu'; Dialog,
import {emojifyString} from '../../utilities/emojis'; DialogTitle,
import { PollOption } from '../../types/Poll'; DialogContent,
DialogContentText,
DialogActions
} from "@material-ui/core";
import MoreVertIcon from "@material-ui/icons/MoreVert";
import ReplyIcon from "@material-ui/icons/Reply";
import FavoriteIcon from "@material-ui/icons/Favorite";
import AutorenewIcon from "@material-ui/icons/Autorenew";
import OpenInNewIcon from "@material-ui/icons/OpenInNew";
import PublicIcon from "@material-ui/icons/Public";
import VisibilityOffIcon from "@material-ui/icons/VisibilityOff";
import WarningIcon from "@material-ui/icons/Warning";
import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
import GroupIcon from "@material-ui/icons/Group";
import ForumIcon from "@material-ui/icons/Forum";
import AlternateEmailIcon from "@material-ui/icons/AlternateEmail";
import LocalOfferIcon from "@material-ui/icons/LocalOffer";
import { styles } from "./Post.styles";
import { Status } from "../../types/Status";
import { Tag } from "../../types/Tag";
import { Mention } from "../../types/Mention";
import { Visibility } from "../../types/Visibility";
import moment from "moment";
import AttachmentComponent from "../Attachment";
import Mastodon from "megalodon";
import {
LinkableChip,
LinkableMenuItem,
LinkableIconButton,
LinkableAvatar
} from "../../interfaces/overrides";
import { withSnackbar } from "notistack";
import ShareMenu from "./PostShareMenu";
import { emojifyString } from "../../utilities/emojis";
import { PollOption } from "../../types/Poll";
interface IPostProps { interface IPostProps {
post: Status; post: Status;
@ -42,7 +76,6 @@ interface IPostState {
} }
export class Post extends React.Component<any, IPostState> { export class Post extends React.Component<any, IPostState> {
client: Mastodon; client: Mastodon;
constructor(props: any) { constructor(props: any) {
@ -50,17 +83,19 @@ export class Post extends React.Component<any, IPostState> {
this.state = { this.state = {
post: this.props.post, post: this.props.post,
media_slides: this.props.post.media_attachments.length > 0? this.props.post.media_attachments.length: 0, media_slides:
this.props.post.media_attachments.length > 0
? this.props.post.media_attachments.length
: 0,
menuIsOpen: false, menuIsOpen: false,
deletePostDialog: false deletePostDialog: false
} };
this.client = this.props.client; this.client = this.props.client;
} }
togglePostMenu() { togglePostMenu() {
this.setState({ menuIsOpen: !this.state.menuIsOpen }) this.setState({ menuIsOpen: !this.state.menuIsOpen });
} }
togglePostDeleteDialog() { togglePostDeleteDialog() {
@ -68,12 +103,15 @@ export class Post extends React.Component<any, IPostState> {
} }
deletePost() { deletePost() {
this.client.del('/statuses/' + this.state.post.id).then((resp: any) => { this.client
this.props.enqueueSnackbar('Post deleted. Refresh to see changes.'); .del("/statuses/" + this.state.post.id)
}).catch((err: Error) => { .then((resp: any) => {
this.props.enqueueSnackbar("Post deleted. Refresh to see changes.");
})
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't delete post: " + err.name); this.props.enqueueSnackbar("Couldn't delete post: " + err.name);
console.log(err.message); console.log(err.message);
}) });
} }
findBiggestVote() { findBiggestVote() {
@ -82,13 +120,13 @@ export class Post extends React.Component<any, IPostState> {
let bv = ""; let bv = "";
if (poll) { if (poll) {
poll.options.forEach((option: PollOption) => { poll.options.forEach((option: PollOption) => {
votes.push(option.votes_count? option.votes_count: 0); votes.push(option.votes_count ? option.votes_count : 0);
}); });
let biggestVote = Math.max.apply(null, votes); let biggestVote = Math.max.apply(null, votes);
poll.options.forEach((option: PollOption) => { poll.options.forEach((option: PollOption) => {
if (option.votes_count === biggestVote) { if (option.votes_count === biggestVote) {
bv = option.title; bv = option.title;
}; }
}); });
return bv; return bv;
} else { } else {
@ -104,7 +142,7 @@ export class Post extends React.Component<any, IPostState> {
if (pollOption.title === option) { if (pollOption.title === option) {
pollIndex = index; pollIndex = index;
} }
}) });
} }
this.setState({ myVote: [pollIndex] }); this.setState({ myVote: [pollIndex] });
} }
@ -112,117 +150,158 @@ export class Post extends React.Component<any, IPostState> {
submitVote() { submitVote() {
let poll = this.state.post.poll; let poll = this.state.post.poll;
if (poll) { if (poll) {
this.client.post(`/polls/${poll.id}/votes`, {choices: this.state.myVote}).then((resp: any) => { this.client
.post(`/polls/${poll.id}/votes`, { choices: this.state.myVote })
.then((resp: any) => {
let post = this.state.post; let post = this.state.post;
post.poll = resp.data; post.poll = resp.data;
this.setState({ post }); this.setState({ post });
this.props.enqueueSnackbar("Vote submitted."); this.props.enqueueSnackbar("Vote submitted.");
}).catch((err: Error) => { })
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't vote: " + err.name); this.props.enqueueSnackbar("Couldn't vote: " + err.name);
console.error(err.message); console.error(err.message);
}) });
} }
} }
materializeContent(status: Status) { materializeContent(status: Status) {
const { classes } = this.props; const { classes } = this.props;
const oldContent = document.createElement('div'); const oldContent = document.createElement("div");
oldContent.innerHTML = status.content; oldContent.innerHTML = status.content;
let anchors = oldContent.getElementsByTagName("a"); let anchors = oldContent.getElementsByTagName("a");
Array.prototype.forEach.call(anchors, (link: HTMLAnchorElement) => { Array.prototype.forEach.call(anchors, (link: HTMLAnchorElement) => {
if (link.className.includes("mention") || link.className.includes("hashtag")) { if (
link.className.includes("mention") ||
link.className.includes("hashtag")
) {
link.removeAttribute("href"); link.removeAttribute("href");
} }
}); });
oldContent.innerHTML = emojifyString(oldContent.innerHTML, status.emojis, classes.postEmoji); oldContent.innerHTML = emojifyString(
oldContent.innerHTML,
status.emojis,
classes.postEmoji
);
return ( return (
<CardContent className={classes.postContent}> <CardContent className={classes.postContent}>
<div className={classes.mediaContainer}> <div className={classes.mediaContainer}>
<Typography paragraph dangerouslySetInnerHTML={{__html: oldContent.innerHTML}}/> <Typography
{ paragraph
status.card? dangerouslySetInnerHTML={{ __html: oldContent.innerHTML }}
/>
{status.card ? (
<div className={classes.postCard}> <div className={classes.postCard}>
<Divider/> <Divider />
<CardActionArea href={status.card.url} target="_blank" rel="noreferrer"> <CardActionArea
href={status.card.url}
target="_blank"
rel="noreferrer"
>
<CardContent> <CardContent>
<Typography gutterBottom variant="h6" component="h2">{status.card.title}</Typography> <Typography gutterBottom variant="h6" component="h2">
{status.card.title}
</Typography>
<Typography> <Typography>
{ {status.card.description.slice(0, 500) +
status.card.description.slice(0, 500) + (status.card.description.length > 500? "...": "") (status.card.description.length > 500 ? "..." : "") ||
|| "No description provided. Click with caution." "No description provided. Click with caution."}
}
</Typography> </Typography>
</CardContent> </CardContent>
{ {status.card.image && status.media_attachments.length <= 0 ? (
status.card.image && status.media_attachments.length <= 0? <CardMedia
<CardMedia className={classes.postMedia} image={status.card.image}/>: <span/> className={classes.postMedia}
} image={status.card.image}
/>
) : (
<span />
)}
<CardContent> <CardContent>
<Typography>{status.card.provider_url|| status.card.author_url || status.card.author_url}</Typography> <Typography>
{status.card.provider_url ||
status.card.author_url ||
status.card.author_url}
</Typography>
</CardContent> </CardContent>
</CardActionArea> </CardActionArea>
<Divider/> <Divider />
</div>: </div>
<span/> ) : (
} <span />
{ )}
status.media_attachments.length > 0? {status.media_attachments.length > 0 ? (
<AttachmentComponent media={status.media_attachments}/>: <AttachmentComponent media={status.media_attachments} />
<span/> ) : (
} <span />
{ )}
status.poll? {status.poll ? (
status.poll.voted || status.poll.expired? status.poll.voted || status.poll.expired ? (
<div> <div>
<Typography variant="caption">You can't vote on this poll. Below are the results of the poll.</Typography> <Typography variant="caption">
<RadioGroup You can't vote on this poll. Below are the results of the
value={this.findBiggestVote()} poll.
> </Typography>
{ <RadioGroup value={this.findBiggestVote()}>
status.poll.options.map((pollOption: PollOption) => { {status.poll.options.map((pollOption: PollOption) => {
let x = <FormControlLabel let x = (
<FormControlLabel
disabled disabled
value={pollOption.title} value={pollOption.title}
control={<Radio />} control={<Radio />}
label={`${pollOption.title} (${pollOption.votes_count} votes)`} label={`${pollOption.title} (${pollOption.votes_count} votes)`}
key={pollOption.title+pollOption.votes_count} key={pollOption.title + pollOption.votes_count}
/>; />
return (x); );
return x;
}) })}
}
</RadioGroup> </RadioGroup>
{ {status.poll && status.poll.expired ? (
status.poll && status.poll.expired? <Typography variant="caption">
<Typography variant="caption">This poll has expired.</Typography>: This poll has expired.
<Typography variant="caption">This poll will expire on {moment(status.poll.expires_at? status.poll.expires_at: "").format('MMMM Do YYYY, [at] h:mm A')}.</Typography> </Typography>
} ) : (
</div>: <Typography variant="caption">
This poll will expire on{" "}
{moment(
status.poll.expires_at ? status.poll.expires_at : ""
).format("MMMM Do YYYY, [at] h:mm A")}
.
</Typography>
)}
</div>
) : (
<div> <div>
<RadioGroup <RadioGroup
onChange={(event: any, option: any) => this.captureVote(option)} onChange={(event: any, option: any) =>
this.captureVote(option)
}
> >
{ {status.poll.options.map((pollOption: PollOption) => {
status.poll.options.map((pollOption: PollOption) => { let x = (
let x = <FormControlLabel <FormControlLabel
value={pollOption.title} value={pollOption.title}
control={<Radio />} control={<Radio />}
label={pollOption.title} label={pollOption.title}
key={pollOption.title+pollOption.votes_count} key={pollOption.title + pollOption.votes_count}
/>; />
return (x); );
return x;
}) })}
}
</RadioGroup> </RadioGroup>
<Button color="primary" onClick={(event: any) => this.submitVote()}>Vote</Button> <Button
</div>: null color="primary"
} onClick={(event: any) => this.submitVote()}
>
Vote
</Button>
</div>
)
) : null}
</div> </div>
</CardContent> </CardContent>
); );
@ -235,7 +314,7 @@ export class Post extends React.Component<any, IPostState> {
if (text.includes(flag)) { if (text.includes(flag)) {
result = true; result = true;
} }
}) });
return result; return result;
} }
@ -243,27 +322,39 @@ export class Post extends React.Component<any, IPostState> {
const { classes } = this.props; const { classes } = this.props;
const warningText = spoiler_text || "Unmarked content"; const warningText = spoiler_text || "Unmarked content";
let icon; let icon;
if (this.spoilerContainsFlags(spoiler_text) || spoiler_text.includes("Spoiler") || warningText === "Unmarked content") { if (
icon = <WarningIcon className={classes.postWarningIcon}/>; this.spoilerContainsFlags(spoiler_text) ||
spoiler_text.includes("Spoiler") ||
warningText === "Unmarked content"
) {
icon = <WarningIcon className={classes.postWarningIcon} />;
} }
return ( return (
<ExpansionPanel className={this.spoilerContainsFlags(spoiler_text)? classes.nsfwCard: classes.spoilerCard} color="inherit"> <ExpansionPanel
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon/>} color="inherit"> className={
{icon}<Typography>{warningText}</Typography> this.spoilerContainsFlags(spoiler_text)
? classes.nsfwCard
: classes.spoilerCard
}
color="inherit"
>
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon />} color="inherit">
{icon}
<Typography>{warningText}</Typography>
</ExpansionPanelSummary> </ExpansionPanelSummary>
<ExpansionPanelDetails className={classes.postContent} color="inherit"> <ExpansionPanelDetails className={classes.postContent} color="inherit">
{this.materializeContent(content)} {this.materializeContent(content)}
</ExpansionPanelDetails> </ExpansionPanelDetails>
</ExpansionPanel> </ExpansionPanel>
) );
} }
getReblogOfPost(of: Status | null) { getReblogOfPost(of: Status | null) {
const { classes } = this.props; const { classes } = this.props;
if (of !== null) { if (of !== null) {
return ( return of.sensitive
of.sensitive? this.getSensitiveContent(of.spoiler_text, of): this.materializeContent(of) ? this.getSensitiveContent(of.spoiler_text, of)
); : this.materializeContent(of);
} else { } else {
return null; return null;
} }
@ -273,16 +364,19 @@ export class Post extends React.Component<any, IPostState> {
const { classes } = this.props; const { classes } = this.props;
if (post.reblog) { if (post.reblog) {
let author = post.reblog.account; let author = post.reblog.account;
let origString = `<span>${author.display_name || author.username} (@${author.acct}) 🔄 ${post.account.display_name || post.account.username}</span>`; let origString = `<span>${author.display_name || author.username} (@${
author.acct
}) 🔄 ${post.account.display_name || post.account.username}</span>`;
let emojis = author.emojis; let emojis = author.emojis;
emojis.concat(post.account.emojis); emojis.concat(post.account.emojis);
return emojifyString(origString, emojis, classes.postAuthorEmoji); return emojifyString(origString, emojis, classes.postAuthorEmoji);
} else { } else {
let author = post.account; let author = post.account;
let origString = `<span>${author.display_name || author.username} (@${author.acct})</span>`; let origString = `<span>${author.display_name || author.username} (@${
author.acct
})</span>`;
return emojifyString(origString, author.emojis, classes.postAuthorEmoji); return emojifyString(origString, author.emojis, classes.postAuthorEmoji);
} }
} }
getMentions(mention: [Mention]) { getMentions(mention: [Mention]) {
@ -291,12 +385,12 @@ export class Post extends React.Component<any, IPostState> {
return ( return (
<CardContent className={classes.postTags}> <CardContent className={classes.postTags}>
<Typography variant="caption">Mentions</Typography> <Typography variant="caption">Mentions</Typography>
{ {mention.map((person: Mention) => {
mention.map((person: Mention) => { return (
return <LinkableChip <LinkableChip
avatar={ avatar={
<Avatar> <Avatar>
<AlternateEmailIcon/> <AlternateEmailIcon />
</Avatar> </Avatar>
} }
label={person.username} label={person.username}
@ -305,10 +399,10 @@ export class Post extends React.Component<any, IPostState> {
className={classes.postMention} className={classes.postMention}
clickable clickable
/> />
}) );
} })}
</CardContent> </CardContent>
) );
} else { } else {
return null; return null;
} }
@ -320,12 +414,12 @@ export class Post extends React.Component<any, IPostState> {
return ( return (
<CardContent className={classes.postTags}> <CardContent className={classes.postTags}>
<Typography variant="caption">Tags</Typography> <Typography variant="caption">Tags</Typography>
{ {tags.map((tag: Tag) => {
tags.map((tag: Tag) => { return (
return <LinkableChip <LinkableChip
avatar={ avatar={
<Avatar> <Avatar>
<LocalOfferIcon/> <LocalOfferIcon />
</Avatar> </Avatar>
} }
label={tag.name} label={tag.name}
@ -334,10 +428,10 @@ export class Post extends React.Component<any, IPostState> {
className={classes.postMention} className={classes.postMention}
clickable clickable
/> />
}) );
} })}
</CardContent> </CardContent>
) );
} else { } else {
return null; return null;
} }
@ -345,22 +439,34 @@ export class Post extends React.Component<any, IPostState> {
showVisibilityIcon(visibility: Visibility) { showVisibilityIcon(visibility: Visibility) {
const { classes } = this.props; const { classes } = this.props;
switch(visibility) { switch (visibility) {
case "public": case "public":
return <Tooltip title="Public"><PublicIcon className={classes.postTypeIcon}/></Tooltip>; return (
<Tooltip title="Public">
<PublicIcon className={classes.postTypeIcon} />
</Tooltip>
);
case "private": case "private":
return <Tooltip title="Followers only"><GroupIcon className={classes.postTypeIcon}/></Tooltip>; return (
<Tooltip title="Followers only">
<GroupIcon className={classes.postTypeIcon} />
</Tooltip>
);
case "unlisted": case "unlisted":
return <Tooltip title="Unlisted (invisible from public timeline)"><VisibilityOffIcon className={classes.postTypeIcon}/></Tooltip>; return (
<Tooltip title="Unlisted (invisible from public timeline)">
<VisibilityOffIcon className={classes.postTypeIcon} />
</Tooltip>
);
} }
} }
getMastodonUrl(post: Status) { getMastodonUrl(post: Status) {
let url = ""; let url = "";
if (post.reblog) { if (post.reblog) {
url = post.reblog.uri url = post.reblog.uri;
} else { } else {
url = post.uri url = post.uri;
} }
return url; return url;
} }
@ -368,49 +474,61 @@ export class Post extends React.Component<any, IPostState> {
toggleFavorited(post: Status) { toggleFavorited(post: Status) {
let _this = this; let _this = this;
if (post.favourited) { if (post.favourited) {
this.client.post(`/statuses/${post.id}/unfavourite`).then((resp: any) => { this.client
.post(`/statuses/${post.id}/unfavourite`)
.then((resp: any) => {
let post: Status = resp.data; let post: Status = resp.data;
this.setState({ post }); this.setState({ post });
}).catch((err: Error) => { })
.catch((err: Error) => {
_this.props.enqueueSnackbar(`Couldn't unfavorite post: ${err.name}`, { _this.props.enqueueSnackbar(`Couldn't unfavorite post: ${err.name}`, {
variant: 'error' variant: "error"
}) });
console.log(err.message); console.log(err.message);
}) });
} else { } else {
this.client.post(`/statuses/${post.id}/favourite`).then((resp: any) => { this.client
.post(`/statuses/${post.id}/favourite`)
.then((resp: any) => {
let post: Status = resp.data; let post: Status = resp.data;
this.setState({ post }); this.setState({ post });
}).catch((err: Error) => { })
.catch((err: Error) => {
_this.props.enqueueSnackbar(`Couldn't favorite post: ${err.name}`, { _this.props.enqueueSnackbar(`Couldn't favorite post: ${err.name}`, {
variant: 'error' variant: "error"
}) });
console.log(err.message); console.log(err.message);
}) });
} }
} }
toggleReblogged(post: Status) { toggleReblogged(post: Status) {
if (post.reblogged) { if (post.reblogged) {
this.client.post(`/statuses/${post.id}/unreblog`).then((resp: any) => { this.client
.post(`/statuses/${post.id}/unreblog`)
.then((resp: any) => {
let post: Status = resp.data; let post: Status = resp.data;
this.setState({ post }); this.setState({ post });
}).catch((err: Error) => { })
.catch((err: Error) => {
this.props.enqueueSnackbar(`Couldn't unboost post: ${err.name}`, { this.props.enqueueSnackbar(`Couldn't unboost post: ${err.name}`, {
variant: 'error' variant: "error"
}) });
console.log(err.message); console.log(err.message);
}) });
} else { } else {
this.client.post(`/statuses/${post.id}/reblog`).then((resp: any) => { this.client
.post(`/statuses/${post.id}/reblog`)
.then((resp: any) => {
let post: Status = resp.data; let post: Status = resp.data;
this.setState({ post }); this.setState({ post });
}).catch((err: Error) => { })
.catch((err: Error) => {
this.props.enqueueSnackbar(`Couldn't boost post: ${err.name}`, { this.props.enqueueSnackbar(`Couldn't boost post: ${err.name}`, {
variant: 'error' variant: "error"
}) });
console.log(err.message); console.log(err.message);
}) });
} }
} }
@ -423,22 +541,30 @@ export class Post extends React.Component<any, IPostState> {
<DialogTitle id="alert-dialog-title">Delete this post?</DialogTitle> <DialogTitle id="alert-dialog-title">Delete this post?</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText id="alert-dialog-description"> <DialogContentText id="alert-dialog-description">
Are you sure you want to delete this post? This action cannot be undone. Are you sure you want to delete this post? This action cannot be
undone.
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => this.togglePostDeleteDialog()} color="primary" autoFocus> <Button
onClick={() => this.togglePostDeleteDialog()}
color="primary"
autoFocus
>
Cancel Cancel
</Button> </Button>
<Button onClick={() => { <Button
onClick={() => {
this.deletePost(); this.deletePost();
this.togglePostDeleteDialog(); this.togglePostDeleteDialog();
}} color="primary"> }}
color="primary"
>
Delete Delete
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
) );
} }
render() { render() {
@ -447,76 +573,119 @@ export class Post extends React.Component<any, IPostState> {
return ( return (
<Zoom in={true}> <Zoom in={true}>
<Card className={classes.post} id={`post_${post.id}`}> <Card className={classes.post} id={`post_${post.id}`}>
<CardHeader avatar={ <CardHeader
<LinkableAvatar to={`/profile/${post.reblog? post.reblog.account.id: post.account.id}`} src={ avatar={
post.reblog? post.reblog.account.avatar_static: post.account.avatar_static <LinkableAvatar
} /> to={`/profile/${
} action={ post.reblog ? post.reblog.account.id : post.account.id
}`}
src={
post.reblog
? post.reblog.account.avatar_static
: post.account.avatar_static
}
/>
}
action={
<Tooltip title="More"> <Tooltip title="More">
<IconButton key={`${post.id}_submenu`} id={`${post.id}_submenu`} onClick={() => this.togglePostMenu()}> <IconButton
key={`${post.id}_submenu`}
id={`${post.id}_submenu`}
onClick={() => this.togglePostMenu()}
>
<MoreVertIcon /> <MoreVertIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
} }
title={ title={
<Typography dangerouslySetInnerHTML={{__html: this.getReblogAuthors(post)}}></Typography> <Typography
} dangerouslySetInnerHTML={{
subheader={moment(post.created_at).format("MMMM Do YYYY [at] h:mm A")} /> __html: this.getReblogAuthors(post)
{ }}
post.reblog? this.getReblogOfPost(post.reblog): null ></Typography>
}
{
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)
} }
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)}
<CardActions> <CardActions>
<Tooltip title="Reply"> <Tooltip title="Reply">
<LinkableIconButton to={`/compose?reply=${post.reblog? post.reblog.id: post.id}&visibility=${post.visibility}&acct=${post.reblog? post.reblog.account.acct: post.account.acct}`}> <LinkableIconButton
<ReplyIcon/> to={`/compose?reply=${
post.reblog ? post.reblog.id : post.id
}&visibility=${post.visibility}&acct=${
post.reblog ? post.reblog.account.acct : post.account.acct
}`}
>
<ReplyIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
<Typography>{post.reblog? post.reblog.replies_count: post.replies_count}</Typography> <Typography>
{post.reblog ? post.reblog.replies_count : post.replies_count}
</Typography>
<Tooltip title="Favorite"> <Tooltip title="Favorite">
<IconButton onClick={() => this.toggleFavorited(post)}> <IconButton onClick={() => this.toggleFavorited(post)}>
<FavoriteIcon className={ <FavoriteIcon
post.reblog? className={
post.reblog.favourited? post.reblog
classes.postDidAction: ? post.reblog.favourited
'': ? classes.postDidAction
post.favourited? : ""
classes.postDidAction: : post.favourited
'' ? classes.postDidAction
}/> : ""
}
/>
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Typography>{post.reblog? post.reblog.favourites_count: post.favourites_count}</Typography> <Typography>
{post.reblog
? post.reblog.favourites_count
: post.favourites_count}
</Typography>
<Tooltip title="Boost"> <Tooltip title="Boost">
<IconButton onClick={() => this.toggleReblogged(post)}> <IconButton onClick={() => this.toggleReblogged(post)}>
<AutorenewIcon className={ <AutorenewIcon
post.reblog? className={
post.reblog.reblogged? post.reblog
classes.postDidAction: ? post.reblog.reblogged
'': ? classes.postDidAction
post.reblogged? : ""
classes.postDidAction: : post.reblogged
'' ? classes.postDidAction
}/> : ""
}
/>
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Typography>{post.reblog? post.reblog.reblogs_count: post.reblogs_count}</Typography> <Typography>
{post.reblog ? post.reblog.reblogs_count : post.reblogs_count}
</Typography>
<Tooltip className={classes.desktopOnly} title="View thread"> <Tooltip className={classes.desktopOnly} title="View thread">
<LinkableIconButton to={`/conversation/${post.reblog? post.reblog.id: post.id}`}> <LinkableIconButton
to={`/conversation/${post.reblog ? post.reblog.id : post.id}`}
>
<ForumIcon /> <ForumIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
<Tooltip className={classes.desktopOnly} title="Open in Web"> <Tooltip className={classes.desktopOnly} title="Open in Web">
<IconButton href={this.getMastodonUrl(post)} rel="noreferrer" target="_blank"> <IconButton
href={this.getMastodonUrl(post)}
rel="noreferrer"
target="_blank"
>
<OpenInNewIcon /> <OpenInNewIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@ -531,39 +700,65 @@ export class Post extends React.Component<any, IPostState> {
open={this.state.menuIsOpen} open={this.state.menuIsOpen}
onClose={() => this.togglePostMenu()} onClose={() => this.togglePostMenu()}
> >
<ShareMenu config={{ <ShareMenu
config={{
params: { params: {
title: `@${post.account.username} posted on Mastodon: `, title: `@${post.account.username} posted on Mastodon: `,
text: post.content, text: post.content,
url: this.getMastodonUrl(post), url: this.getMastodonUrl(post)
}, },
onShareSuccess: () => this.props.enqueueSnackbar("Post shared!", {variant: 'success'}), onShareSuccess: () =>
this.props.enqueueSnackbar("Post shared!", {
variant: "success"
}),
onShareError: (error: Error) => { onShareError: (error: Error) => {
if (error.name != "AbortError") if (error.name != "AbortError")
this.props.enqueueSnackbar(`Couldn't share post: ${error.name}`, {variant: 'error'}) this.props.enqueueSnackbar(
}, `Couldn't share post: ${error.name}`,
}}/> { variant: "error" }
{ );
post.reblog?
<div>
<LinkableMenuItem to={`/profile/${post.reblog.account.id}`}>View author profile</LinkableMenuItem>
<LinkableMenuItem to={`/profile/${post.account.id}`}>View reblogger profile</LinkableMenuItem>
</div>: <LinkableMenuItem to={`/profile/${post.account.id}`}>View profile</LinkableMenuItem>
} }
<div className={classes.mobileOnly}> }}
<Divider/> />
<LinkableMenuItem to={`/conversation/${post.reblog? post.reblog.id: post.id}`}>View thread</LinkableMenuItem> {post.reblog ? (
<MenuItem component="a" href={this.getMastodonUrl(post)} rel="noreferrer" target="_blank">Open in Web</MenuItem> <div>
<LinkableMenuItem to={`/profile/${post.reblog.account.id}`}>
View author profile
</LinkableMenuItem>
<LinkableMenuItem to={`/profile/${post.account.id}`}>
View reblogger profile
</LinkableMenuItem>
</div> </div>
{ ) : (
post.account.id == JSON.parse(localStorage.getItem('account') as string).id? <LinkableMenuItem to={`/profile/${post.account.id}`}>
View profile
</LinkableMenuItem>
)}
<div className={classes.mobileOnly}>
<Divider />
<LinkableMenuItem
to={`/conversation/${post.reblog ? post.reblog.id : post.id}`}
>
View thread
</LinkableMenuItem>
<MenuItem
component="a"
href={this.getMastodonUrl(post)}
rel="noreferrer"
target="_blank"
>
Open in Web
</MenuItem>
</div>
{post.account.id ==
JSON.parse(localStorage.getItem("account") as string).id ? (
<div> <div>
<Divider/> <Divider />
<MenuItem onClick={() => this.togglePostDeleteDialog()}>Delete</MenuItem> <MenuItem onClick={() => this.togglePostDeleteDialog()}>
</div>: Delete
null </MenuItem>
} </div>
) : null}
{this.showDeleteDialog()} {this.showDeleteDialog()}
</Menu> </Menu>
</Card> </Card>

View File

@ -1,15 +1,20 @@
import * as React from 'react'; import * as React from "react";
import webShare, { WebShareInterface } from 'react-web-share-api'; import webShare, { WebShareInterface } from "react-web-share-api";
import {MenuItem} from '@material-ui/core'; import { MenuItem } from "@material-ui/core";
export interface OwnProps { export interface OwnProps {
style: object; style: object;
} }
const ShareMenu: React.FunctionComponent<WebShareInterface & OwnProps> = ({ const ShareMenu: React.FunctionComponent<WebShareInterface & OwnProps> = ({
share, isSupported, style, share,
}) => isSupported isSupported,
? <MenuItem onClick={share} style={style}>Share</MenuItem> style
: null }) =>
isSupported ? (
<MenuItem onClick={share} style={style}>
Share
</MenuItem>
) : null;
export default webShare<OwnProps>()(ShareMenu); export default webShare<OwnProps>()(ShareMenu);

View File

@ -1,6 +1,6 @@
import { Post } from './Post'; import { Post } from "./Post";
import { withStyles } from '@material-ui/core'; import { withStyles } from "@material-ui/core";
import { styles } from './Post.styles'; import { styles } from "./Post.styles";
import { withSnackbar } from 'notistack' import { withSnackbar } from "notistack";
export default withStyles(styles)(withSnackbar(Post)); export default withStyles(styles)(withSnackbar(Post));

View File

@ -1,7 +1,16 @@
import React, {Component} from 'react'; import React, { Component } from "react";
import {MuiThemeProvider, Theme, AppBar, Typography, CssBaseline, Toolbar, Fab, Paper} from '@material-ui/core'; import {
import EditIcon from '@material-ui/icons/Edit'; MuiThemeProvider,
import MenuIcon from '@material-ui/icons/Menu'; Theme,
AppBar,
Typography,
CssBaseline,
Toolbar,
Fab,
Paper
} from "@material-ui/core";
import EditIcon from "@material-ui/icons/Edit";
import MenuIcon from "@material-ui/icons/Menu";
interface IThemePreviewProps { interface IThemePreviewProps {
theme: Theme; theme: Theme;
@ -17,40 +26,62 @@ class ThemePreview extends Component<IThemePreviewProps, IThemePreviewState> {
this.state = { this.state = {
theme: this.props.theme theme: this.props.theme
} };
} }
render() { render() {
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: "relative" }}>
<MuiThemeProvider theme={this.props.theme}> <MuiThemeProvider theme={this.props.theme}>
<CssBaseline/> <CssBaseline />
<Paper> <Paper>
<AppBar color="primary" position="static"> <AppBar color="primary" position="static">
<Toolbar> <Toolbar>
<MenuIcon style={{ marginRight: 20, marginLeft: -4 }}/> <MenuIcon style={{ marginRight: 20, marginLeft: -4 }} />
<Typography variant="h6" color="inherit">Hyperspace</Typography> <Typography variant="h6" color="inherit">
Hyperspace
</Typography>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<div style={{ paddingLeft: 16, paddingTop: 16, paddingRight: 16, paddingBottom: 16, flexGrow: 1 }}> <div
style={{
paddingLeft: 16,
paddingTop: 16,
paddingRight: 16,
paddingBottom: 16,
flexGrow: 1
}}
>
<Typography variant="h4" component="p"> <Typography variant="h4" component="p">
This is your theme. This is your theme.
</Typography> </Typography>
<br/> <br />
<Typography paragraph> <Typography paragraph>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc vestibulum congue sem ac ornare. In nec imperdiet neque. In eleifend laoreet efficitur. Vestibulum vel odio mattis, scelerisque nibh a, ornare lectus. Phasellus sollicitudin erat et turpis pellentesque consequat. In maximus luctus purus, eu molestie elit euismod eu. Pellentesque quam lectus, sagittis eget accumsan in, consequat ut sapien. Morbi aliquet ligula erat, id dapibus nunc laoreet at. Integer sodales lacinia finibus. Aliquam augue nibh, eleifend quis consectetur et, rhoncus ut odio. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc
vestibulum congue sem ac ornare. In nec imperdiet neque. In
eleifend laoreet efficitur. Vestibulum vel odio mattis,
scelerisque nibh a, ornare lectus. Phasellus sollicitudin erat
et turpis pellentesque consequat. In maximus luctus purus, eu
molestie elit euismod eu. Pellentesque quam lectus, sagittis
eget accumsan in, consequat ut sapien. Morbi aliquet ligula
erat, id dapibus nunc laoreet at. Integer sodales lacinia
finibus. Aliquam augue nibh, eleifend quis consectetur et,
rhoncus ut odio. Lorem ipsum dolor sit amet, consectetur
adipiscing elit.
</Typography> </Typography>
</div> </div>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: "right" }}>
<Fab color="secondary" style={{ marginRight: 8, marginBottom: 8 }}> <Fab
<EditIcon/> color="secondary"
style={{ marginRight: 8, marginBottom: 8 }}
>
<EditIcon />
</Fab> </Fab>
</div> </div>
</Paper> </Paper>
</MuiThemeProvider> </MuiThemeProvider>
</div> </div>
) );
} }
} }

View File

@ -1,3 +1,3 @@
import ThemePreview from './ThemePreview'; import ThemePreview from "./ThemePreview";
export default ThemePreview; export default ThemePreview;

View File

@ -1,18 +1,20 @@
import React from 'react'; import React from "react";
import ReactDOM from 'react-dom'; import ReactDOM from "react-dom";
import App from './App'; import App from "./App";
import { HashRouter } from 'react-router-dom'; import { HashRouter } from "react-router-dom";
import * as serviceWorker from './serviceWorker'; import * as serviceWorker from "./serviceWorker";
import {createUserDefaults, getConfig} from './utilities/settings'; import { createUserDefaults, getConfig } from "./utilities/settings";
import {collectEmojisFromServer} from './utilities/emojis'; import { collectEmojisFromServer } from "./utilities/emojis";
import {SnackbarProvider} from 'notistack'; import { SnackbarProvider } from "notistack";
import { userLoggedIn, refreshUserAccountData } from './utilities/accounts'; import { userLoggedIn, refreshUserAccountData } from "./utilities/accounts";
getConfig().then((config: any) => { getConfig()
.then((config: any) => {
document.title = config.branding.name || "Hyperspace"; document.title = config.branding.name || "Hyperspace";
}).catch((err: Error) => { })
.catch((err: Error) => {
console.error(err); console.error(err);
}) });
createUserDefaults(); createUserDefaults();
if (userLoggedIn()) { if (userLoggedIn()) {
@ -24,15 +26,15 @@ ReactDOM.render(
<HashRouter> <HashRouter>
<SnackbarProvider <SnackbarProvider
anchorOrigin={{ anchorOrigin={{
vertical: 'bottom', vertical: "bottom",
horizontal: 'left', horizontal: "left"
}} }}
> >
<App /> <App />
</SnackbarProvider> </SnackbarProvider>
</HashRouter>, </HashRouter>,
document.getElementById('root')); document.getElementById("root")
);
// If you want your app to work offline and load faster, you can change // If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls. // unregister() to register() below. Note this comes with some pitfalls.

View File

@ -1,14 +1,14 @@
import React, { Component } from 'react'; import React, { Component } from "react";
import ListItem, { ListItemProps } from "@material-ui/core/ListItem"; import ListItem, { ListItemProps } from "@material-ui/core/ListItem";
import IconButton, { IconButtonProps } from "@material-ui/core/IconButton"; import IconButton, { IconButtonProps } from "@material-ui/core/IconButton";
import { Link, Route, Redirect, RouteProps } from "react-router-dom"; import { Link, Route, Redirect, RouteProps } from "react-router-dom";
import Chip, { ChipProps } from '@material-ui/core/Chip'; import Chip, { ChipProps } from "@material-ui/core/Chip";
import { MenuItemProps } from '@material-ui/core/MenuItem'; import { MenuItemProps } from "@material-ui/core/MenuItem";
import { MenuItem } from '@material-ui/core'; import { MenuItem } from "@material-ui/core";
import Button, { ButtonProps } from '@material-ui/core/Button'; import Button, { ButtonProps } from "@material-ui/core/Button";
import Fab, { FabProps } from '@material-ui/core/Fab'; import Fab, { FabProps } from "@material-ui/core/Fab";
import Avatar, { AvatarProps } from '@material-ui/core/Avatar'; import Avatar, { AvatarProps } from "@material-ui/core/Avatar";
import { userLoggedIn } from '../utilities/accounts'; import { userLoggedIn } from "../utilities/accounts";
export interface ILinkableListItemProps extends ListItemProps { export interface ILinkableListItemProps extends ListItemProps {
to: string; to: string;
@ -46,50 +46,52 @@ export interface ILinkableAvatarProps extends AvatarProps {
} }
export const LinkableListItem = (props: ILinkableListItemProps) => ( export const LinkableListItem = (props: ILinkableListItemProps) => (
<ListItem {...props} component={Link as any}/> <ListItem {...props} component={Link as any} />
) );
export const LinkableIconButton = (props: ILinkableIconButtonProps) => ( export const LinkableIconButton = (props: ILinkableIconButtonProps) => (
<IconButton {...props} component={Link as any}/> <IconButton {...props} component={Link as any} />
) );
export const LinkableChip = (props: ILinkableChipProps) => ( export const LinkableChip = (props: ILinkableChipProps) => (
<Chip {...props} component={Link as any}/> <Chip {...props} component={Link as any} />
) );
export const LinkableMenuItem = (props: ILinkableMenuItemProps) => ( export const LinkableMenuItem = (props: ILinkableMenuItemProps) => (
<MenuItem {...props} component={Link as any}/> <MenuItem {...props} component={Link as any} />
) );
export const LinkableButton = (props: ILinkableButtonProps) => ( export const LinkableButton = (props: ILinkableButtonProps) => (
<Button {...props} component={Link as any}/> <Button {...props} component={Link as any} />
) );
export const LinkableFab = (props: ILinkableFabProps) => ( export const LinkableFab = (props: ILinkableFabProps) => (
<Fab {...props} component={Link as any}/> <Fab {...props} component={Link as any} />
) );
export const LinkableAvatar = (props: ILinkableAvatarProps) => ( export const LinkableAvatar = (props: ILinkableAvatarProps) => (
<Avatar {...props} component={Link as any}/> <Avatar {...props} component={Link as any} />
) );
export const ProfileRoute = (rest: any, component: Component) => ( export const ProfileRoute = (rest: any, component: Component) => (
<Route {...rest} render={props => ( <Route {...rest} render={props => <Component {...props} />} />
<Component {...props}/> );
)}/>
)
export const PrivateRoute = (props: IPrivateRouteProps) => { export const PrivateRoute = (props: IPrivateRouteProps) => {
const { component, render, ...rest } = props; const { component, render, ...rest } = props;
return (<Route {...rest} return (
render={(compProps: any) => ( <Route
userLoggedIn()? {...rest}
React.createElement(component, compProps): render={(compProps: any) =>
<Redirect to="/welcome"/> userLoggedIn() ? (
)} React.createElement(component, compProps)
) : (
<Redirect to="/welcome" />
)
}
/> />
)} );
};
interface IPrivateRouteProps extends RouteProps { interface IPrivateRouteProps extends RouteProps {
component: any component: any;
} }

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react'; import React, { Component } from "react";
import { import {
List, List,
ListItem, ListItem,
@ -14,30 +14,30 @@ import {
Link, Link,
Tooltip, Tooltip,
Button Button
} from '@material-ui/core'; } from "@material-ui/core";
import OpenInNewIcon from '@material-ui/icons/OpenInNew'; import OpenInNewIcon from "@material-ui/icons/OpenInNew";
import ChatIcon from '@material-ui/icons/Chat'; import ChatIcon from "@material-ui/icons/Chat";
import PersonIcon from '@material-ui/icons/Person'; import PersonIcon from "@material-ui/icons/Person";
import AssignmentIcon from '@material-ui/icons/Assignment'; import AssignmentIcon from "@material-ui/icons/Assignment";
import AssignmentIndIcon from '@material-ui/icons/AssignmentInd'; import AssignmentIndIcon from "@material-ui/icons/AssignmentInd";
import NetworkCheckIcon from '@material-ui/icons/NetworkCheck'; import NetworkCheckIcon from "@material-ui/icons/NetworkCheck";
import UpdateIcon from '@material-ui/icons/Update'; import UpdateIcon from "@material-ui/icons/Update";
import InfoIcon from '@material-ui/icons/Info'; import InfoIcon from "@material-ui/icons/Info";
import NotesIcon from '@material-ui/icons/Notes'; import NotesIcon from "@material-ui/icons/Notes";
import CodeIcon from '@material-ui/icons/Code'; import CodeIcon from "@material-ui/icons/Code";
import TicketAccountIcon from 'mdi-material-ui/TicketAccount'; import TicketAccountIcon from "mdi-material-ui/TicketAccount";
import MastodonIcon from 'mdi-material-ui/Mastodon'; import MastodonIcon from "mdi-material-ui/Mastodon";
import EditIcon from '@material-ui/icons/Edit'; import EditIcon from "@material-ui/icons/Edit";
import VpnKeyIcon from '@material-ui/icons/VpnKey'; import VpnKeyIcon from "@material-ui/icons/VpnKey";
import {styles} from './PageLayout.styles'; import { styles } from "./PageLayout.styles";
import {Instance} from '../types/Instance'; import { Instance } from "../types/Instance";
import {LinkableIconButton, LinkableAvatar} from '../interfaces/overrides'; import { LinkableIconButton, LinkableAvatar } from "../interfaces/overrides";
import Mastodon from 'megalodon'; import Mastodon from "megalodon";
import { UAccount } from '../types/Account'; import { UAccount } from "../types/Account";
import { getConfig } from '../utilities/settings'; import { getConfig } from "../utilities/settings";
import { License, Federation } from '../types/Config'; import { License, Federation } from "../types/Config";
interface IAboutPageState { interface IAboutPageState {
instance?: Instance; instance?: Instance;
@ -54,50 +54,55 @@ interface IAboutPageState {
} }
class AboutPage extends Component<any, IAboutPageState> { class AboutPage extends Component<any, IAboutPageState> {
client: Mastodon; client: Mastodon;
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') + "/api/v1"); this.client = new Mastodon(
localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1"
);
this.state = { this.state = {
license: { license: {
name: "Apache 2.0 License (inherited)", name: "Apache 2.0 License (inherited)",
url: "https://www.apache.org/licenses/LICENSE-2.0" url: "https://www.apache.org/licenses/LICENSE-2.0"
} }
} };
} }
componentWillMount() { componentWillMount() {
this.client.get('/instance').then((resp: any) => { this.client.get("/instance").then((resp: any) => {
this.setState({ this.setState({
instance: resp.data as Instance instance: resp.data as Instance
}) });
}) });
getConfig().then((config: any) => { getConfig().then((config: any) => {
this.client.get('/accounts/' + (config.admin? config.admin.account: "0")).then((resp: any) => { this.client
.get("/accounts/" + (config.admin ? config.admin.account : "0"))
.then((resp: any) => {
let account = resp.data; let account = resp.data;
this.setState({ this.setState({
hyperspaceAdmin: account, hyperspaceAdmin: account,
hyperspaceAdminName: config.admin.name, hyperspaceAdminName: config.admin.name,
federation: config.federation, federation: config.federation,
developer: config.developer? config.developer === "true": false, developer: config.developer ? config.developer === "true" : false,
versionNumber: config.version, versionNumber: config.version,
brandName: config.branding? config.branding.name: "Hyperspace", brandName: config.branding ? config.branding.name : "Hyperspace",
brandBg: config.branding.background, brandBg: config.branding.background,
license: { license: {
name: config.license.name, name: config.license.name,
url: config.license.url url: config.license.url
}, },
repository: config.repository repository: config.repository
});
}) })
}).catch((err: Error) => { .catch((err: Error) => {
console.error(err.message); console.error(err.message);
}) });
}) });
} }
render() { render() {
@ -109,40 +114,86 @@ class AboutPage extends Component<any, IAboutPageState> {
<div <div
className={classes.instanceHeaderPaper} className={classes.instanceHeaderPaper}
style={{ style={{
backgroundImage: `url("${this.state.instance && this.state.instance.thumbnail? this.state.instance.thumbnail: ""}")` backgroundImage: `url("${
this.state.instance && this.state.instance.thumbnail
? this.state.instance.thumbnail
: ""
}")`
}} }}
> >
<IconButton className={classes.instanceToolbar} href={localStorage.getItem("baseurl") as string} target="_blank" rel="noreferrer" color="inherit"> <IconButton
<OpenInNewIcon/> className={classes.instanceToolbar}
href={localStorage.getItem("baseurl") as string}
target="_blank"
rel="noreferrer"
color="inherit"
>
<OpenInNewIcon />
</IconButton> </IconButton>
<Typography className={classes.instanceHeaderText} variant="h4" component="p">{this.state.instance ? this.state.instance.uri: "Loading..."}</Typography> <Typography
className={classes.instanceHeaderText}
variant="h4"
component="p"
>
{this.state.instance ? this.state.instance.uri : "Loading..."}
</Typography>
</div> </div>
<List className={classes.pageListConstraints}> <List className={classes.pageListConstraints}>
{(localStorage['isPleroma'] == "false") && <ListItem> {localStorage["isPleroma"] == "false" && (
<ListItem>
<ListItemAvatar> <ListItemAvatar>
<LinkableAvatar to={`/profile/${this.state.instance? this.state.instance.contact_account.id: 0}`} alt="Instance admin" src={this.state.instance? this.state.instance.contact_account.avatar_static: ""}/> <LinkableAvatar
to={`/profile/${
this.state.instance
? this.state.instance.contact_account.id
: 0
}`}
alt="Instance admin"
src={
this.state.instance
? this.state.instance.contact_account.avatar_static
: ""
}
/>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Instance admin" secondary={ <ListItemText
this.state.instance ? `${this.state.instance.contact_account.display_name} (@${this.state.instance.contact_account.acct})`: primary="Instance admin"
"Loading..." secondary={
}/> this.state.instance
? `${this.state.instance.contact_account.display_name} (@${this.state.instance.contact_account.acct})`
: "Loading..."
}
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Tooltip title="Send a post or message"> <Tooltip title="Send a post or message">
<LinkableIconButton to={`/compose?visibility=public&acct=${this.state.instance? this.state.instance.contact_account.acct: ""}`}> <LinkableIconButton
<ChatIcon/> to={`/compose?visibility=public&acct=${
this.state.instance
? this.state.instance.contact_account.acct
: ""
}`}
>
<ChatIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
<Tooltip title="View profile"> <Tooltip title="View profile">
<LinkableIconButton to={`/profile/${this.state.instance? this.state.instance.contact_account.id: 0}`}> <LinkableIconButton
<AssignmentIndIcon/> to={`/profile/${
this.state.instance
? this.state.instance.contact_account.id
: 0
}`}
>
<AssignmentIndIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem>} </ListItem>
)}
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar> <Avatar>
<AssignmentIcon/> <AssignmentIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
@ -151,8 +202,14 @@ class AboutPage extends Component<any, IAboutPageState> {
/> />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Tooltip title="Open in browser"> <Tooltip title="Open in browser">
<IconButton href={localStorage.getItem("baseurl") as string + "/terms"} target="_blank" rel="noreferrer"> <IconButton
<OpenInNewIcon/> href={
(localStorage.getItem("baseurl") as string) + "/terms"
}
target="_blank"
rel="noreferrer"
>
<OpenInNewIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</ListItemSecondaryAction> </ListItemSecondaryAction>
@ -160,7 +217,7 @@ class AboutPage extends Component<any, IAboutPageState> {
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar> <Avatar>
<TicketAccountIcon/> <TicketAccountIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
@ -169,7 +226,13 @@ class AboutPage extends Component<any, IAboutPageState> {
/> />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Tooltip title="Go to invite settings"> <Tooltip title="Go to invite settings">
<Button href={localStorage.getItem("baseurl") as string + "/invites"} target="_blank" rel="noreferrer"> <Button
href={
(localStorage.getItem("baseurl") as string) + "/invites"
}
target="_blank"
rel="noreferrer"
>
Invite Invite
</Button> </Button>
</Tooltip> </Tooltip>
@ -178,57 +241,103 @@ class AboutPage extends Component<any, IAboutPageState> {
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar> <Avatar>
<MastodonIcon/> <MastodonIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary="Mastodon version" primary="Mastodon version"
secondary={this.state.instance? this.state.instance.version: "x.x.x"} secondary={
this.state.instance ? this.state.instance.version : "x.x.x"
}
/> />
</ListItem> </ListItem>
</List> </List>
</Paper> </Paper>
<br/> <br />
<Paper> <Paper>
<div <div
className={classes.instanceHeaderPaper} className={classes.instanceHeaderPaper}
style={{ style={{
backgroundImage: `url("${this.state.brandBg? this.state.brandBg: ""}")` backgroundImage: `url("${
this.state.brandBg ? this.state.brandBg : ""
}")`
}} }}
> >
<div className={classes.instanceToolbar}> <div className={classes.instanceToolbar}>
{ {this.state.repository ? (
this.state.repository?
<Tooltip title="View source code"> <Tooltip title="View source code">
<IconButton href={this.state.repository} target="_blank" rel="noreferrer" color="inherit"> <IconButton
<CodeIcon/> href={this.state.repository}
target="_blank"
rel="noreferrer"
color="inherit"
>
<CodeIcon />
</IconButton> </IconButton>
</Tooltip>: null </Tooltip>
} ) : null}
</div> </div>
<Typography className={classes.instanceHeaderText} variant="h4" component="p"> <Typography
{this.state.brandName? this.state.brandName: "Hyperspace"} className={classes.instanceHeaderText}
variant="h4"
component="p"
>
{this.state.brandName ? this.state.brandName : "Hyperspace"}
</Typography> </Typography>
</div> </div>
<List className={classes.pageListConstraints}> <List className={classes.pageListConstraints}>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<LinkableAvatar to={`/profile/${this.state.hyperspaceAdmin? this.state.hyperspaceAdmin.id: 0}`} src={this.state.hyperspaceAdmin? this.state.hyperspaceAdmin.avatar_static: ""}> <LinkableAvatar
<PersonIcon/> to={`/profile/${
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin.id
: 0
}`}
src={
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin.avatar_static
: ""
}
>
<PersonIcon />
</LinkableAvatar> </LinkableAvatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="App provider" secondary={this.state.hyperspaceAdmin && this.state.hyperspaceAdminName? (this.state.hyperspaceAdminName || this.state.hyperspaceAdmin.display_name || "@" + this.state.hyperspaceAdmin.acct): "No provider set in config"}/> <ListItemText
primary="App provider"
secondary={
this.state.hyperspaceAdmin && this.state.hyperspaceAdminName
? this.state.hyperspaceAdminName ||
this.state.hyperspaceAdmin.display_name ||
"@" + this.state.hyperspaceAdmin.acct
: "No provider set in config"
}
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Tooltip title="Send a post or message"> <Tooltip title="Send a post or message">
<LinkableIconButton to={`/compose?visibility=${this.state.federated? "public": "private"}&acct=${this.state.hyperspaceAdmin? this.state.hyperspaceAdmin.acct: ""}`}> <LinkableIconButton
<ChatIcon/> to={`/compose?visibility=${
this.state.federated ? "public" : "private"
}&acct=${
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin.acct
: ""
}`}
>
<ChatIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
<Tooltip title="View profile"> <Tooltip title="View profile">
<LinkableIconButton to={`/profile/${this.state.hyperspaceAdmin? this.state.hyperspaceAdmin.id: 0}`}> <LinkableIconButton
<AssignmentIndIcon/> to={`/profile/${
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin.id
: 0
}`}
>
<AssignmentIndIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
</ListItemSecondaryAction> </ListItemSecondaryAction>
@ -236,14 +345,21 @@ class AboutPage extends Component<any, IAboutPageState> {
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar> <Avatar>
<NotesIcon/> <NotesIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="License" secondary={this.state.license.name}/> <ListItemText
primary="License"
secondary={this.state.license.name}
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Tooltip title = "View license"> <Tooltip title="View license">
<IconButton href={this.state.license.url} target="_blank" rel="noreferrer"> <IconButton
<OpenInNewIcon/> href={this.state.license.url}
target="_blank"
rel="noreferrer"
>
<OpenInNewIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</ListItemSecondaryAction> </ListItemSecondaryAction>
@ -251,66 +367,133 @@ class AboutPage extends Component<any, IAboutPageState> {
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar> <Avatar>
<UpdateIcon/> <UpdateIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Release channel" secondary={ <ListItemText
this.state? primary="Release channel"
this.state.developer? secondary={
"Developer": this.state
"Release": ? this.state.developer
"Loading..." ? "Developer"
}/> : "Release"
: "Loading..."
}
/>
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar> <Avatar>
<InfoIcon/> <InfoIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="App version" secondary={`${this.state? this.state.brandName: "Hyperspace"} v${this.state? this.state.versionNumber: "1.0.x"} ${this.state && this.state.brandName !== "Hyperspace"? "(Hyperspace-like)": ""}`}/> <ListItemText
primary="App version"
secondary={`${
this.state ? this.state.brandName : "Hyperspace"
} v${this.state ? this.state.versionNumber : "1.0.x"} ${
this.state && this.state.brandName !== "Hyperspace"
? "(Hyperspace-like)"
: ""
}`}
/>
</ListItem> </ListItem>
</List> </List>
</Paper> </Paper>
<br/> <br />
<ListSubheader>Federation status</ListSubheader> <ListSubheader>Federation status</ListSubheader>
<Paper> <Paper>
<List className={classes.pageListConstraints}> <List className={classes.pageListConstraints}>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar> <Avatar>
<NetworkCheckIcon/> <NetworkCheckIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="General federation" secondary={this.state.federation && this.state.federation.enablePublicTimeline? "This instance is federated.": "This instance is not federated."}/> <ListItemText
primary="General federation"
secondary={
this.state.federation &&
this.state.federation.enablePublicTimeline
? "This instance is federated."
: "This instance is not federated."
}
/>
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar> <Avatar>
<VpnKeyIcon/> <VpnKeyIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Universal login" secondary={this.state.federation && this.state.federation.universalLogin? "This instance supports universal login.": "This instance does not support universal login."}/> <ListItemText
primary="Universal login"
secondary={
this.state.federation && this.state.federation.universalLogin
? "This instance supports universal login."
: "This instance does not support universal login."
}
/>
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar> <Avatar>
<EditIcon/> <EditIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Public posting" secondary={this.state.federation && this.state.federation.allowPublicPosts? "This instance allows posting publicly.": "This instance does not allow posting publicly."}/> <ListItemText
primary="Public posting"
secondary={
this.state.federation &&
this.state.federation.allowPublicPosts
? "This instance allows posting publicly."
: "This instance does not allow posting publicly."
}
/>
</ListItem> </ListItem>
</List> </List>
</Paper> </Paper>
<br/> <br />
<div className={classes.pageLayoutFooter}> <div className={classes.pageLayoutFooter}>
<Typography variant="caption">(C) {new Date().getFullYear()} {this.state? this.state.brandName: "Hyperspace"} developers. All rights reserved.</Typography> <Typography variant="caption">
<Typography variant="caption" paragraph>{this.state? this.state.brandName: "Hyperspace"} is made possible by the <Link href={"https://material-ui.com"} target="_blank" rel="noreferrer">Material UI</Link> project, <Link href={"https://www.npmjs.com/package/megalodon"} target="_blank" rel="noreferrer">Megalodon</Link> library, and other <Link href={"https://github.com/hyperspacedev/hyperspace/blob/master/package.json"} target="_blank" rel="noreferrer">open source software</Link>.</Typography> (C) {new Date().getFullYear()}{" "}
{this.state ? this.state.brandName : "Hyperspace"} developers. All
rights reserved.
</Typography>
<Typography variant="caption" paragraph>
{this.state ? this.state.brandName : "Hyperspace"} is made possible
by the{" "}
<Link
href={"https://material-ui.com"}
target="_blank"
rel="noreferrer"
>
Material UI
</Link>{" "}
project,{" "}
<Link
href={"https://www.npmjs.com/package/megalodon"}
target="_blank"
rel="noreferrer"
>
Megalodon
</Link>{" "}
library, and other{" "}
<Link
href={
"https://github.com/hyperspacedev/hyperspace/blob/master/package.json"
}
target="_blank"
rel="noreferrer"
>
open source software
</Link>
.
</Typography>
</div> </div>
</div> </div>
); );
} }
} }
export default withStyles(styles)(AboutPage); export default withStyles(styles)(AboutPage);

47
src/pages/Blocked.tsx Normal file
View File

@ -0,0 +1,47 @@
import React, { Component } from "react";
import { styles } from "./PageLayout.styles";
import {
Button,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
ListSubheader,
Paper,
Switch,
withStyles
} from "@material-ui/core";
import DevicesIcon from "@material-ui/core/SvgIcon/SvgIcon";
class Blocked extends Component<any, any> {
render() {
const { classes } = this.props;
return (
<div className={classes.pageLayoutConstraints}>
<ListSubheader>Blocked servers</ListSubheader>
<Button className={classes.clearAllButton} variant="text">
{" "}
Add
</Button>
<Paper className={classes.pageListConstraints}>
<List>
<ListItem>
<ListItemAvatar>
<DevicesIcon color="action" />
</ListItemAvatar>
<ListItemText
primary="Match system appearance"
secondary="Obey light/dark theme from your system"
/>
</ListItem>
</List>
</Paper>
</div>
);
}
}
export default withStyles(styles)(Blocked);

View File

@ -1,6 +1,7 @@
import { Theme, createStyles } from "@material-ui/core"; import { Theme, createStyles } from "@material-ui/core";
export const styles = (theme: Theme) => createStyles({ export const styles = (theme: Theme) =>
createStyles({
dialog: { dialog: {
minHeight: 400 minHeight: 400
}, },
@ -18,21 +19,21 @@ export const styles = (theme: Theme) => createStyles({
verticalAlign: "text-bottom" verticalAlign: "text-bottom"
}, },
composeAttachmentArea: { composeAttachmentArea: {
display: 'flex', display: "flex",
flexWrap: 'wrap', flexWrap: "wrap",
justifyContent: 'space-around', justifyContent: "space-around",
overflow: 'hidden' overflow: "hidden"
}, },
composeAttachmentAreaGridList: { composeAttachmentAreaGridList: {
height: 250, height: 250,
width: '100%' width: "100%"
}, },
composeEmoji: { composeEmoji: {
marginTop: theme.spacing.unit * 8 marginTop: theme.spacing.unit * 8
}, },
desktopOnly: { desktopOnly: {
display: "none", display: "none",
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up("sm")]: {
display: "block" display: "block"
} }
}, },
@ -45,4 +46,4 @@ export const styles = (theme: Theme) => createStyles({
pollWizardFlexGrow: { pollWizardFlexGrow: {
flexGrow: 1 flexGrow: 1
} }
}); });

View File

@ -1,26 +1,45 @@
import React, {Component} from 'react'; import React, { Component } from "react";
import { Dialog, DialogContent, DialogActions, withStyles, Button, CardHeader, Avatar, TextField, Toolbar, IconButton, Fade, Typography, Tooltip, Menu, MenuItem, GridList, ListSubheader, GridListTile } from '@material-ui/core'; import {
import {parse as parseParams, ParsedQuery} from 'query-string'; Dialog,
import {styles} from './Compose.styles'; DialogContent,
import { UAccount } from '../types/Account'; DialogActions,
import { Visibility } from '../types/Visibility'; withStyles,
import CameraAltIcon from '@material-ui/icons/CameraAlt'; Button,
import TagFacesIcon from '@material-ui/icons/TagFaces'; CardHeader,
import HowToVoteIcon from '@material-ui/icons/HowToVote'; Avatar,
import VisibilityIcon from '@material-ui/icons/Visibility'; TextField,
import WarningIcon from '@material-ui/icons/Warning'; Toolbar,
import DeleteIcon from '@material-ui/icons/Delete'; IconButton,
import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonChecked'; Fade,
import Mastodon from 'megalodon'; Typography,
import {withSnackbar} from 'notistack'; Tooltip,
import { Attachment } from '../types/Attachment'; Menu,
import { PollWizard, PollWizardOption } from '../types/Poll'; MenuItem,
import filedialog from 'file-dialog'; GridList,
import ComposeMediaAttachment from '../components/ComposeMediaAttachment'; ListSubheader,
import EmojiPicker from '../components/EmojiPicker'; GridListTile
import { DateTimePicker, MuiPickersUtilsProvider } from 'material-ui-pickers'; } from "@material-ui/core";
import MomentUtils from '@date-io/moment'; import { parse as parseParams, ParsedQuery } from "query-string";
import { getUserDefaultVisibility, getConfig } from '../utilities/settings'; import { styles } from "./Compose.styles";
import { UAccount } from "../types/Account";
import { Visibility } from "../types/Visibility";
import CameraAltIcon from "@material-ui/icons/CameraAlt";
import TagFacesIcon from "@material-ui/icons/TagFaces";
import HowToVoteIcon from "@material-ui/icons/HowToVote";
import VisibilityIcon from "@material-ui/icons/Visibility";
import WarningIcon from "@material-ui/icons/Warning";
import DeleteIcon from "@material-ui/icons/Delete";
import RadioButtonCheckedIcon from "@material-ui/icons/RadioButtonChecked";
import Mastodon from "megalodon";
import { withSnackbar } from "notistack";
import { Attachment } from "../types/Attachment";
import { PollWizard, PollWizardOption } from "../types/Poll";
import filedialog from "file-dialog";
import ComposeMediaAttachment from "../components/ComposeMediaAttachment";
import EmojiPicker from "../components/EmojiPicker";
import { DateTimePicker, MuiPickersUtilsProvider } from "material-ui-pickers";
import MomentUtils from "@date-io/moment";
import { getUserDefaultVisibility, getConfig } from "../utilities/settings";
interface IComposerState { interface IComposerState {
account: UAccount; account: UAccount;
@ -40,29 +59,31 @@ interface IComposerState {
} }
class Composer extends Component<any, IComposerState> { class Composer extends Component<any, IComposerState> {
client: Mastodon; client: Mastodon;
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') + "/api/v1"); this.client = new Mastodon(
localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1"
);
this.state = { this.state = {
account: JSON.parse(localStorage.getItem('account') as string), account: JSON.parse(localStorage.getItem("account") as string),
visibility: getUserDefaultVisibility(), visibility: getUserDefaultVisibility(),
sensitive: false, sensitive: false,
visibilityMenu: false, visibilityMenu: false,
text: '', text: "",
remainingChars: 500, remainingChars: 500,
showEmojis: false, showEmojis: false,
federated: true federated: true
} };
} }
componentDidMount() { componentDidMount() {
let state = this.getComposerParams(this.props); let state = this.getComposerParams(this.props);
let text = state.acct? `@${state.acct}: `: ''; let text = state.acct ? `@${state.acct}: ` : "";
getConfig().then((config: any) => { getConfig().then((config: any) => {
this.setState({ this.setState({
federated: config.federation.allowPublicPosts, federated: config.federation.allowPublicPosts,
@ -72,26 +93,24 @@ class Composer extends Component<any, IComposerState> {
text, text,
remainingChars: 500 - text.length remainingChars: 500 - text.length
}); });
}) });
} }
componentWillReceiveProps(props: any) { componentWillReceiveProps(props: any) {
let state = this.getComposerParams(props); let state = this.getComposerParams(props);
let text = state.acct? `@${state.acct}: `: ''; let text = state.acct ? `@${state.acct}: ` : "";
this.setState({ this.setState({
reply: state.reply, reply: state.reply,
acct: state.acct, acct: state.acct,
visibility: state.visibility, visibility: state.visibility,
text, text,
remainingChars: 500 - text.length remainingChars: 500 - text.length
}) });
} }
checkComposerParams(location?: string): ParsedQuery { checkComposerParams(location?: string): ParsedQuery {
let params = ""; let params = "";
if (location !== undefined && typeof(location) === "string") { if (location !== undefined && typeof location === "string") {
params = location.replace("#/compose", ""); params = location.replace("#/compose", "");
} else { } else {
params = window.location.hash.replace("#/compose", ""); params = window.location.hash.replace("#/compose", "");
@ -103,7 +122,7 @@ class Composer extends Component<any, IComposerState> {
let params = this.checkComposerParams(props.location); let params = this.checkComposerParams(props.location);
let reply: string = ""; let reply: string = "";
let acct: string = ""; let acct: string = "";
let visibility= this.state.visibility; let visibility = this.state.visibility;
if (params.reply) { if (params.reply) {
reply = params.reply.toString(); reply = params.reply.toString();
@ -118,7 +137,7 @@ class Composer extends Component<any, IComposerState> {
reply, reply,
acct, acct,
visibility visibility
} };
} }
updateTextFromField(text: string) { updateTextFromField(text: string) {
@ -137,11 +156,17 @@ class Composer extends Component<any, IComposerState> {
filedialog({ filedialog({
multiple: false, multiple: false,
accept: "image/*, video/*" accept: "image/*, video/*"
}).then((media: FileList) => { })
.then((media: FileList) => {
let mediaForm = new FormData(); let mediaForm = new FormData();
mediaForm.append('file', media[0]); mediaForm.append("file", media[0]);
this.props.enqueueSnackbar("Uploading media...", { persist: true, key: "media-upload" }) this.props.enqueueSnackbar("Uploading media...", {
this.client.post('/media', mediaForm).then((resp: any) => { persist: true,
key: "media-upload"
});
this.client
.post("/media", mediaForm)
.then((resp: any) => {
let attachment: Attachment = resp.data; let attachment: Attachment = resp.data;
let attachments = this.state.attachments; let attachments = this.state.attachments;
if (attachments) { if (attachments) {
@ -151,13 +176,19 @@ class Composer extends Component<any, IComposerState> {
} }
this.setState({ attachments }); this.setState({ attachments });
this.props.closeSnackbar("media-upload"); this.props.closeSnackbar("media-upload");
this.props.enqueueSnackbar('Media uploaded.'); this.props.enqueueSnackbar("Media uploaded.");
}).catch((err: Error) => {
this.props.closeSnackbar("media-upload");
this.props.enqueueSnackbar("Couldn't upload media: " + err.name, { variant: "error" });
}) })
}).catch((err: Error) => { .catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't get media: " + err.name, { variant: "error" }); this.props.closeSnackbar("media-upload");
this.props.enqueueSnackbar("Couldn't upload media: " + err.name, {
variant: "error"
});
});
})
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't get media: " + err.name, {
variant: "error"
});
console.error(err.message); console.error(err.message);
}); });
} }
@ -179,7 +210,7 @@ class Composer extends Component<any, IComposerState> {
if (attach.id === attachment.id && attachments) { if (attach.id === attachment.id && attachments) {
attachments[attachments.indexOf(attach)] = attachment; attachments[attachments.indexOf(attach)] = attachment;
} }
}) });
this.setState({ attachments }); this.setState({ attachments });
} }
} }
@ -192,20 +223,20 @@ class Composer extends Component<any, IComposerState> {
attachments.splice(attachments.indexOf(attach), 1); attachments.splice(attachments.indexOf(attach), 1);
} }
this.setState({ attachments }); this.setState({ attachments });
}) });
this.props.enqueueSnackbar("Attachment removed."); this.props.enqueueSnackbar("Attachment removed.");
} }
} }
insertEmoji(e: any) { insertEmoji(e: any) {
if (e.custom) { if (e.custom) {
let text = this.state.text + e.colons let text = this.state.text + e.colons;
this.setState({ this.setState({
text, text,
remainingChars: 500 - text.length remainingChars: 500 - text.length
}); });
} else { } else {
let text = this.state.text + e.native let text = this.state.text + e.native;
this.setState({ this.setState({
text, text,
remainingChars: 500 - text.length remainingChars: 500 - text.length
@ -218,12 +249,12 @@ class Composer extends Component<any, IComposerState> {
let expiration = new Date(); let expiration = new Date();
let current = new Date(); let current = new Date();
expiration.setMinutes(expiration.getMinutes() + 30); expiration.setMinutes(expiration.getMinutes() + 30);
let expiryDifference = (expiration.getTime() - current.getTime() / 1000); let expiryDifference = expiration.getTime() - current.getTime() / 1000;
let temporaryPoll: PollWizard = { let temporaryPoll: PollWizard = {
expires_at: expiryDifference.toString(), expires_at: expiryDifference.toString(),
multiple: false, multiple: false,
options: [{title: 'Option 1'}, {title: 'Option 2'}] options: [{ title: "Option 1" }, { title: "Option 2" }]
} };
this.setState({ this.setState({
poll: temporaryPoll, poll: temporaryPoll,
pollExpiresDate: expiration pollExpiresDate: expiration
@ -233,7 +264,7 @@ class Composer extends Component<any, IComposerState> {
addPollItem() { addPollItem() {
if (this.state.poll !== undefined && this.state.poll.options.length < 4) { if (this.state.poll !== undefined && this.state.poll.options.length < 4) {
let newOption = {title: 'New option'} let newOption = { title: "New option" };
let options = this.state.poll.options; let options = this.state.poll.options;
let poll = this.state.poll; let poll = this.state.poll;
options.push(newOption); options.push(newOption);
@ -241,9 +272,12 @@ class Composer extends Component<any, IComposerState> {
poll.multiple = true; poll.multiple = true;
this.setState({ this.setState({
poll: poll poll: poll
}) });
} else if (this.state.poll && this.state.poll.options.length == 4) { } else if (this.state.poll && this.state.poll.options.length == 4) {
this.props.enqueueSnackbar("You've reached the options limit in your poll.", { variant: 'error' }) this.props.enqueueSnackbar(
"You've reached the options limit in your poll.",
{ variant: "error" }
);
} }
} }
@ -260,7 +294,7 @@ class Composer extends Component<any, IComposerState> {
this.setState({ this.setState({
poll: poll poll: poll
}); });
this.props.enqueueSnackbar('Option edited.'); this.props.enqueueSnackbar("Option edited.");
} }
} }
@ -279,9 +313,11 @@ class Composer extends Component<any, IComposerState> {
} }
this.setState({ this.setState({
poll: poll poll: poll
}) });
} else if (this.state.poll && this.state.poll.options.length <= 2) { } else if (this.state.poll && this.state.poll.options.length <= 2) {
this.props.enqueueSnackbar('Polls must have at least two items.', { variant: 'error'} ); this.props.enqueueSnackbar("Polls must have at least two items.", {
variant: "error"
});
} }
} }
@ -290,14 +326,17 @@ class Composer extends Component<any, IComposerState> {
let newDate = new Date(date); let newDate = new Date(date);
let poll = this.state.poll; let poll = this.state.poll;
if (poll) { if (poll) {
let expiry = ((newDate.getTime() - currentDate.getTime()) / 1000); let expiry = (newDate.getTime() - currentDate.getTime()) / 1000;
console.log(expiry); console.log(expiry);
if (expiry >= 1800) { if (expiry >= 1800) {
poll.expires_at = expiry.toString(); poll.expires_at = expiry.toString();
this.setState({ poll, pollExpiresDate: date }); this.setState({ poll, pollExpiresDate: date });
this.props.enqueueSnackbar("Expiration updated.") this.props.enqueueSnackbar("Expiration updated.");
} else { } else {
this.props.enqueueSnackbar("Expiration is too small (min. 30 minutes).", { variant: 'error' }); this.props.enqueueSnackbar(
"Expiration is too small (min. 30 minutes).",
{ variant: "error" }
);
} }
} }
} }
@ -319,27 +358,32 @@ class Composer extends Component<any, IComposerState> {
if (this.state.poll) { if (this.state.poll) {
this.state.poll.options.forEach((option: PollWizardOption) => { this.state.poll.options.forEach((option: PollWizardOption) => {
pollOptions.push(option.title); pollOptions.push(option.title);
}) });
} }
this.client.post('/statuses', { this.client
.post("/statuses", {
status: this.state.text, status: this.state.text,
media_ids: this.getOnlyMediaIds(), media_ids: this.getOnlyMediaIds(),
visibility: this.state.visibility, visibility: this.state.visibility,
sensitive: this.state.sensitive, sensitive: this.state.sensitive,
spoiler_text: this.state.sensitiveText, spoiler_text: this.state.sensitiveText,
in_reply_to_id: this.state.reply, in_reply_to_id: this.state.reply,
poll: this.state.poll? { poll: this.state.poll
? {
options: pollOptions, options: pollOptions,
expires_in: this.state.poll.expires_at, expires_in: this.state.poll.expires_at,
multiple: this.state.poll.multiple multiple: this.state.poll.multiple
}: null }
}).then(() => { : null
this.props.enqueueSnackbar('Posted!'); })
.then(() => {
this.props.enqueueSnackbar("Posted!");
window.history.back(); window.history.back();
}).catch((err: Error) => { })
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't post: " + err.name); this.props.enqueueSnackbar("Couldn't post: " + err.name);
console.log(err.message); console.log(err.message);
}) });
} }
toggleSensitive() { toggleSensitive() {
@ -355,37 +399,46 @@ class Composer extends Component<any, IComposerState> {
} }
render() { render() {
const {classes} = this.props; const { classes } = this.props;
console.log(this.state); console.log(this.state);
return ( return (
<Dialog open={true} maxWidth="sm" fullWidth={true} className={classes.dialog} onClose={() => window.history.back()}> <Dialog
open={true}
maxWidth="sm"
fullWidth={true}
className={classes.dialog}
onClose={() => window.history.back()}
>
<CardHeader <CardHeader
avatar={ avatar={<Avatar src={this.state.account.avatar_static} />}
<Avatar src={this.state.account.avatar_static} />
}
title={`${this.state.account.display_name} (@${this.state.account.acct})`} title={`${this.state.account.display_name} (@${this.state.account.acct})`}
subheader={this.state.visibility.charAt(0).toUpperCase() + this.state.visibility.substr(1)} subheader={
this.state.visibility.charAt(0).toUpperCase() +
this.state.visibility.substr(1)
}
/> />
<DialogContent className={classes.dialogContent}> <DialogContent className={classes.dialogContent}>
{ {this.state.sensitive ? (
this.state.sensitive?
<Fade in={this.state.sensitive}> <Fade in={this.state.sensitive}>
<TextField <TextField
variant="outlined" variant="outlined"
fullWidth fullWidth
label="Content warning" label="Content warning"
margin="dense" margin="dense"
onChange={(event) => this.updateWarningFromField(event.target.value)} onChange={event =>
this.updateWarningFromField(event.target.value)
}
></TextField> ></TextField>
</Fade>: null </Fade>
} ) : null}
{ {this.state.visibility === "direct" ? (
this.state.visibility === "direct"? <Typography variant="caption">
<Typography variant="caption" > <WarningIcon className={classes.warningCaption} /> Don't forget to
<WarningIcon className={classes.warningCaption}/> Don't forget to add the usernames of the accounts you want to message in your post. add the usernames of the accounts you want to message in your
</Typography>: null post.
} </Typography>
) : null}
<TextField <TextField
variant="outlined" variant="outlined"
@ -393,65 +446,97 @@ class Composer extends Component<any, IComposerState> {
fullWidth fullWidth
placeholder="What's on your mind?" placeholder="What's on your mind?"
margin="normal" margin="normal"
onChange={(event) => this.updateTextFromField(event.target.value)} onChange={event => this.updateTextFromField(event.target.value)}
onKeyDown={(event) => this.postViaKeyboard(event)} onKeyDown={event => this.postViaKeyboard(event)}
inputProps = { inputProps={{
{
maxLength: 500 maxLength: 500
} }}
}
value={this.state.text} value={this.state.text}
/> />
<Typography variant="caption" className={this.state.remainingChars <= 100? classes.charsReachingLimit: null}> <Typography
{`${this.state.remainingChars} character${this.state.remainingChars === 1? '': 's'} remaining`} variant="caption"
className={
this.state.remainingChars <= 100
? classes.charsReachingLimit
: null
}
>
{`${this.state.remainingChars} character${
this.state.remainingChars === 1 ? "" : "s"
} remaining`}
</Typography> </Typography>
{ {this.state.attachments && this.state.attachments.length > 0 ? (
this.state.attachments && this.state.attachments.length > 0?
<div className={classes.composeAttachmentArea}> <div className={classes.composeAttachmentArea}>
<GridList cellHeight={48} className={classes.composeAttachmentAreaGridList}> <GridList
<GridListTile key="Subheader-composer" cols={2} style={{ height: 'auto' }}> cellHeight={48}
className={classes.composeAttachmentAreaGridList}
>
<GridListTile
key="Subheader-composer"
cols={2}
style={{ height: "auto" }}
>
<ListSubheader>Attachments</ListSubheader> <ListSubheader>Attachments</ListSubheader>
</GridListTile> </GridListTile>
{ {this.state.attachments.map((attachment: Attachment) => {
this.state.attachments.map((attachment: Attachment) => { let c = (
let c = <ComposeMediaAttachment <ComposeMediaAttachment
client={this.client} client={this.client}
attachment={attachment} attachment={attachment}
onAttachmentUpdate={(attachment: Attachment) => this.fetchAttachmentAfterUpdate(attachment)} onAttachmentUpdate={(attachment: Attachment) =>
onDeleteCallback={(attachment: Attachment) => this.deleteMediaAttachment(attachment)} this.fetchAttachmentAfterUpdate(attachment)
/>;
return (c);
})
} }
onDeleteCallback={(attachment: Attachment) =>
this.deleteMediaAttachment(attachment)
}
/>
);
return c;
})}
</GridList> </GridList>
</div>: null </div>
} ) : null}
{ {this.state.poll ? (
this.state.poll? <div style={{ marginTop: 4 }}>
<div style={{ marginTop: 4}}> {this.state.poll
? this.state.poll.options.map(
{ (option: PollWizardOption, index: number) => {
this.state.poll? let c = (
this.state.poll.options.map((option: PollWizardOption, index: number) => { <div
let c = <div style={{ display: "flex" }} key={"compose_option_" + index.toString()}> style={{ display: "flex" }}
<RadioButtonCheckedIcon className={classes.pollWizardOptionIcon}/> key={"compose_option_" + index.toString()}
>
<RadioButtonCheckedIcon
className={classes.pollWizardOptionIcon}
/>
<TextField <TextField
onBlur={(event: any) => this.editPollItem(index, event)} onBlur={(event: any) =>
defaultValue={option.title}/> this.editPollItem(index, event)
<div className={classes.pollWizardFlexGrow}/> }
defaultValue={option.title}
/>
<div className={classes.pollWizardFlexGrow} />
<Tooltip title="Remove poll option"> <Tooltip title="Remove poll option">
<IconButton onClick={() => this.removePollItem(option.title)}> <IconButton
<DeleteIcon/> onClick={() => this.removePollItem(option.title)}
>
<DeleteIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</div> </div>
);
return c; return c;
}): null
} }
<div style={{ display: "flex"}}> )
: null}
<div style={{ display: "flex" }}>
<MuiPickersUtilsProvider utils={MomentUtils}> <MuiPickersUtilsProvider utils={MomentUtils}>
<DateTimePicker <DateTimePicker
value={this.state.pollExpiresDate? this.state.pollExpiresDate: new Date()} value={
this.state.pollExpiresDate
? this.state.pollExpiresDate
: new Date()
}
onChange={(date: any) => { onChange={(date: any) => {
this.setPollExpires(date.toISOString()); this.setPollExpires(date.toISOString());
}} }}
@ -459,62 +544,96 @@ class Composer extends Component<any, IComposerState> {
disablePast disablePast
/> />
</MuiPickersUtilsProvider> </MuiPickersUtilsProvider>
<div className={classes.pollWizardFlexGrow}/> <div className={classes.pollWizardFlexGrow} />
<Button onClick={() => this.addPollItem()}>Add Option</Button> <Button onClick={() => this.addPollItem()}>Add Option</Button>
</div> </div>
</div>: null </div>
} ) : null}
</DialogContent> </DialogContent>
<Toolbar className={classes.dialogActions}> <Toolbar className={classes.dialogActions}>
<Tooltip title="Add photos or videos"> <Tooltip title="Add photos or videos">
<IconButton disabled={this.state.poll !== undefined} onClick={() => this.uploadMedia()} id="compose-media"> <IconButton
<CameraAltIcon/> disabled={this.state.poll !== undefined}
onClick={() => this.uploadMedia()}
id="compose-media"
>
<CameraAltIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Insert emoji"> <Tooltip title="Insert emoji">
<IconButton id="compose-emoji" onClick={() => this.toggleEmojis()} className={classes.desktopOnly}> <IconButton
<TagFacesIcon/> id="compose-emoji"
onClick={() => this.toggleEmojis()}
className={classes.desktopOnly}
>
<TagFacesIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Menu <Menu
open={this.state.showEmojis} open={this.state.showEmojis}
anchorEl={document.getElementById('compose-emoji')} anchorEl={document.getElementById("compose-emoji")}
onClose={() => this.toggleEmojis()} onClose={() => this.toggleEmojis()}
className={classes.composeEmoji} className={classes.composeEmoji}
> >
<EmojiPicker onGetEmoji={(emoji: any) => this.insertEmoji(emoji)}/> <EmojiPicker onGetEmoji={(emoji: any) => this.insertEmoji(emoji)} />
</Menu> </Menu>
<Tooltip title="Add/remove a poll"> <Tooltip title="Add/remove a poll">
<IconButton disabled={this.state.attachments && this.state.attachments.length > 0} id="compose-poll" onClick={() => { <IconButton
this.state.poll? disabled={
this.removePoll(): this.state.attachments && this.state.attachments.length > 0
this.createPoll() }
}}> id="compose-poll"
<HowToVoteIcon/> onClick={() => {
this.state.poll ? this.removePoll() : this.createPoll();
}}
>
<HowToVoteIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Change who sees your post"> <Tooltip title="Change who sees your post">
<IconButton id="compose-visibility" onClick={() => this.toggleVisibilityMenu()}> <IconButton
<VisibilityIcon/> id="compose-visibility"
onClick={() => this.toggleVisibilityMenu()}
>
<VisibilityIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Set a content warning"> <Tooltip title="Set a content warning">
<IconButton onClick={() => this.toggleSensitive()} id="compose-warning"> <IconButton
<WarningIcon/> onClick={() => this.toggleSensitive()}
id="compose-warning"
>
<WarningIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Menu open={this.state.visibilityMenu} anchorEl={document.getElementById('compose-visibility')} onClose={() => this.toggleVisibilityMenu()}> <Menu
<MenuItem onClick={() => this.changeVisibility('direct')}>Direct (direct message)</MenuItem> open={this.state.visibilityMenu}
<MenuItem onClick={() => this.changeVisibility('private')}>Private (followers only)</MenuItem> anchorEl={document.getElementById("compose-visibility")}
<MenuItem onClick={() => this.changeVisibility('unlisted')}>Unlisted</MenuItem> onClose={() => this.toggleVisibilityMenu()}
{this.state.federated? <MenuItem onClick={() => this.changeVisibility('public')}>Public</MenuItem>: null} >
<MenuItem onClick={() => this.changeVisibility("direct")}>
Direct (direct message)
</MenuItem>
<MenuItem onClick={() => this.changeVisibility("private")}>
Private (followers only)
</MenuItem>
<MenuItem onClick={() => this.changeVisibility("unlisted")}>
Unlisted
</MenuItem>
{this.state.federated ? (
<MenuItem onClick={() => this.changeVisibility("public")}>
Public
</MenuItem>
) : null}
</Menu> </Menu>
</Toolbar> </Toolbar>
<DialogActions> <DialogActions>
<Button color="secondary" onClick={() => this.post()}>Post</Button> <Button color="secondary" onClick={() => this.post()}>
Post
</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
) );
} }
} }

View File

@ -1,11 +1,16 @@
import React, { Component } from 'react'; import React, { Component } from "react";
import { withStyles, CircularProgress, Typography, Paper} from '@material-ui/core'; import {
import {styles} from './PageLayout.styles'; withStyles,
import Post from '../components/Post'; CircularProgress,
import { Status } from '../types/Status'; Typography,
import { Context } from '../types/Context'; Paper
import Mastodon from 'megalodon'; } from "@material-ui/core";
import {withSnackbar} from 'notistack'; import { styles } from "./PageLayout.styles";
import Post from "../components/Post";
import { Status } from "../types/Status";
import { Context } from "../types/Context";
import Mastodon from "megalodon";
import { withSnackbar } from "notistack";
interface IConversationPageState { interface IConversationPageState {
posts?: [Status]; posts?: [Status];
@ -16,9 +21,7 @@ interface IConversationPageState {
conversationId: string; conversationId: string;
} }
class Conversation extends Component<any, IConversationPageState> { class Conversation extends Component<any, IConversationPageState> {
client: Mastodon; client: Mastodon;
streamListener: any; streamListener: any;
@ -28,30 +31,42 @@ class Conversation extends Component<any, IConversationPageState> {
this.state = { this.state = {
viewIsLoading: true, viewIsLoading: true,
conversationId: props.match.params.conversationId conversationId: props.match.params.conversationId
} };
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1");
this.client = new Mastodon(
localStorage.getItem("access_token") as string,
(localStorage.getItem("baseurl") as string) + "/api/v1"
);
} }
getContext() { getContext() {
this.client.get(`/statuses/${this.state.conversationId}`).then((resp: any) => { this.client
.get(`/statuses/${this.state.conversationId}`)
.then((resp: any) => {
let result: Status = resp.data; let result: Status = resp.data;
this.setState({ posts: [result] }); this.setState({ posts: [result] });
}).catch((err: Error) => { })
.catch((err: Error) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.message viewDidErrorCode: err.message
}) });
this.props.enqueueSnackbar("Couldn't get conversation: " + err.name, { variant: 'error' }); this.props.enqueueSnackbar("Couldn't get conversation: " + err.name, {
}) variant: "error"
this.client.get(`/statuses/${this.state.conversationId}/context`).then((resp: any) => { });
});
this.client
.get(`/statuses/${this.state.conversationId}/context`)
.then((resp: any) => {
let context: Context = resp.data; let context: Context = resp.data;
let posts = this.state.posts; let posts = this.state.posts;
let array: any[] = []; let array: any[] = [];
if (posts) { if (posts) {
array = array.concat(context.ancestors).concat(posts).concat(context.descendants); array = array
.concat(context.ancestors)
.concat(posts)
.concat(context.descendants);
} }
this.setState({ this.setState({
posts: array as [Status], posts: array as [Status],
@ -59,59 +74,75 @@ class Conversation extends Component<any, IConversationPageState> {
viewDidLoad: true, viewDidLoad: true,
viewDidError: false viewDidError: false
}); });
}).catch((err: Error) => { })
.catch((err: Error) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.message viewDidErrorCode: err.message
}) });
this.props.enqueueSnackbar("Couldn't get conversation: " + err.name, { variant: 'error' }); this.props.enqueueSnackbar("Couldn't get conversation: " + err.name, {
variant: "error"
});
}); });
} }
componentWillReceiveProps(props: any) { componentWillReceiveProps(props: any) {
if (props.match.params.conversationId !== this.state.conversationId) { if (props.match.params.conversationId !== this.state.conversationId) {
this.getContext() this.getContext();
} }
} }
componentWillMount() { componentWillMount() {
this.getContext() this.getContext();
} }
componentDidUpdate() { componentDidUpdate() {
const where: HTMLElement | null = document.getElementById(`post_${this.state.conversationId}`); const where: HTMLElement | null = document.getElementById(
if (where && this.state.posts && this.state.posts[0].id !== this.state.conversationId) { `post_${this.state.conversationId}`
);
if (
where &&
this.state.posts &&
this.state.posts[0].id !== this.state.conversationId
) {
window.scrollTo(0, where.getBoundingClientRect().top); window.scrollTo(0, where.getBoundingClientRect().top);
} }
} }
render() { render() {
const {classes} = this.props; const { classes } = this.props;
return ( return (
<div className={classes.pageLayoutMaxConstraints}> <div className={classes.pageLayoutMaxConstraints}>
{ this.state.posts? {this.state.posts ? (
<div> <div>
{ this.state.posts.map((post: Status) => { {this.state.posts.map((post: Status) => {
return <Post key={post.id} post={post} client={this.client}/> return <Post key={post.id} post={post} client={this.client} />;
}) } })}
</div>: </div>
<span/> ) : (
} <span />
{ )}
this.state.viewDidError? {this.state.viewDidError ? (
<Paper className={classes.errorCard}> <Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography> <Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this conversation.</Typography> <Typography variant="h6">
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography> Something went wrong when loading this conversation.
</Paper>: </Typography>
<span/> <Typography>
} {this.state.viewDidErrorCode ? this.state.viewDidErrorCode : ""}
{ </Typography>
this.state.viewIsLoading? </Paper>
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>: ) : (
<span/> <span />
} )}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress className={classes.progress} color="primary" />
</div>
) : (
<span />
)}
</div> </div>
); );
} }

View File

@ -1,11 +1,20 @@
import React, { Component } from 'react'; import React, { Component } from "react";
import { withStyles, CircularProgress, Typography, Paper, Button, Chip, Avatar, Slide} from '@material-ui/core'; import {
import {styles} from './PageLayout.styles'; withStyles,
import Post from '../components/Post'; CircularProgress,
import { Status } from '../types/Status'; Typography,
import Mastodon, { StreamListener } from 'megalodon'; Paper,
import {withSnackbar} from 'notistack'; Button,
import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward'; Chip,
Avatar,
Slide
} from "@material-ui/core";
import { styles } from "./PageLayout.styles";
import Post from "../components/Post";
import { Status } from "../types/Status";
import Mastodon, { StreamListener } from "megalodon";
import { withSnackbar } from "notistack";
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
interface IHomePageState { interface IHomePageState {
posts?: [Status]; posts?: [Status];
@ -16,9 +25,7 @@ interface IHomePageState {
viewDidErrorCode?: any; viewDidErrorCode?: any;
} }
class HomePage extends Component<any, IHomePageState> { class HomePage extends Component<any, IHomePageState> {
client: Mastodon; client: Mastodon;
streamListener: StreamListener; streamListener: StreamListener;
@ -28,68 +35,74 @@ class HomePage extends Component<any, IHomePageState> {
this.state = { this.state = {
viewIsLoading: true, viewIsLoading: true,
backlogPosts: null backlogPosts: null
} };
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1");
this.streamListener = this.client.stream('/streaming/user');
this.client = new Mastodon(
localStorage.getItem("access_token") as string,
(localStorage.getItem("baseurl") as string) + "/api/v1"
);
this.streamListener = this.client.stream("/streaming/user");
} }
componentWillMount() { componentWillMount() {
this.streamListener.on("connect", () => {
this.streamListener.on('connect', () => { this.client
this.client.get('/timelines/home', {limit: 40}).then((resp: any) => { .get("/timelines/home", { limit: 40 })
.then((resp: any) => {
let statuses: [Status] = resp.data; let statuses: [Status] = resp.data;
this.setState({ this.setState({
posts: statuses, posts: statuses,
viewIsLoading: false, viewIsLoading: false,
viewDidLoad: true, viewDidLoad: true,
viewDidError: false viewDidError: false
});
}) })
}).catch((resp: any) => { .catch((resp: any) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidLoad: true, viewDidLoad: true,
viewDidError: true, viewDidError: true,
viewDidErrorCode: String(resp) viewDidErrorCode: String(resp)
}) });
this.props.enqueueSnackbar("Failed to get posts.", { this.props.enqueueSnackbar("Failed to get posts.", {
variant: 'error', variant: "error"
});
}); });
})
}); });
this.streamListener.on('update', (status: Status) => { this.streamListener.on("update", (status: Status) => {
let queue = this.state.backlogPosts; let queue = this.state.backlogPosts;
if (queue !== null && queue !== undefined) { queue.unshift(status); } else { queue = [status] } if (queue !== null && queue !== undefined) {
queue.unshift(status);
} else {
queue = [status];
}
this.setState({ backlogPosts: queue }); this.setState({ backlogPosts: queue });
}) });
this.streamListener.on('delete', (id: number) => { this.streamListener.on("delete", (id: number) => {
let posts = this.state.posts; let posts = this.state.posts;
if (posts) { if (posts) {
posts.forEach((post: Status) => { posts.forEach((post: Status) => {
if (posts && parseInt(post.id) === id) { if (posts && parseInt(post.id) === id) {
posts.splice(posts.indexOf(post), 1); posts.splice(posts.indexOf(post), 1);
} }
}) });
this.setState({ posts }); this.setState({ posts });
} }
}) });
this.streamListener.on('error', (err: Error) => { this.streamListener.on("error", (err: Error) => {
this.setState({ this.setState({
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.message viewDidErrorCode: err.message
})
this.props.enqueueSnackbar("An error occured.", {
variant: 'error',
}); });
}) this.props.enqueueSnackbar("An error occured.", {
variant: "error"
});
});
this.streamListener.on('heartbeat', () => { this.streamListener.on("heartbeat", () => {});
})
} }
componentWillUnmount() { componentWillUnmount() {
@ -102,14 +115,19 @@ class HomePage extends Component<any, IHomePageState> {
let backlog = this.state.backlogPosts; let backlog = this.state.backlogPosts;
if (posts && backlog && backlog.length > 0) { if (posts && backlog && backlog.length > 0) {
let push = backlog.concat(posts); let push = backlog.concat(posts);
this.setState({ posts: push as [Status], backlogPosts: null }) this.setState({ posts: push as [Status], backlogPosts: null });
} }
} }
loadMoreTimelinePieces() { loadMoreTimelinePieces() {
this.setState({ viewDidLoad: false, viewIsLoading: true}) this.setState({ viewDidLoad: false, viewIsLoading: true });
if (this.state.posts) { if (this.state.posts) {
this.client.get('/timelines/home', { max_id: this.state.posts[this.state.posts.length - 1].id, limit: 20 }).then((resp: any) => { this.client
.get("/timelines/home", {
max_id: this.state.posts[this.state.posts.length - 1].id,
limit: 20
})
.then((resp: any) => {
let newPosts: [Status] = resp.data; let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status]; let posts = this.state.posts as [Status];
newPosts.forEach((post: Status) => { newPosts.forEach((post: Status) => {
@ -119,37 +137,39 @@ class HomePage extends Component<any, IHomePageState> {
viewIsLoading: false, viewIsLoading: false,
viewDidLoad: true, viewDidLoad: true,
posts posts
});
}) })
}).catch((err: Error) => { .catch((err: Error) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.message viewDidErrorCode: err.message
})
this.props.enqueueSnackbar("Failed to get posts", {
variant: 'error',
}); });
}) this.props.enqueueSnackbar("Failed to get posts", {
variant: "error"
});
});
} }
} }
render() { render() {
const {classes} = this.props; const { classes } = this.props;
return ( return (
<div className={classes.pageLayoutMaxConstraints}> <div className={classes.pageLayoutMaxConstraints}>
{ {this.state.backlogPosts ? (
this.state.backlogPosts?
<div className={classes.pageTopChipContainer}> <div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}> <div className={classes.pageTopChips}>
<Slide direction="down" in={true}> <Slide direction="down" in={true}>
<Chip <Chip
avatar={ avatar={
<Avatar> <Avatar>
<ArrowUpwardIcon/> <ArrowUpwardIcon />
</Avatar> </Avatar>
} }
label={`View ${this.state.backlogPosts.length} new post${this.state.backlogPosts.length > 1? "s": ""}`} label={`View ${this.state.backlogPosts.length} new post${
this.state.backlogPosts.length > 1 ? "s" : ""
}`}
color="primary" color="primary"
className={classes.pageTopChip} className={classes.pageTopChip}
onClick={() => this.insertBacklog()} onClick={() => this.insertBacklog()}
@ -157,34 +177,46 @@ class HomePage extends Component<any, IHomePageState> {
/> />
</Slide> </Slide>
</div> </div>
</div>: null </div>
} ) : null}
{ this.state.posts? {this.state.posts ? (
<div> <div>
{ this.state.posts.map((post: Status) => { {this.state.posts.map((post: Status) => {
return <Post key={post.id} post={post} client={this.client}/> return <Post key={post.id} post={post} client={this.client} />;
}) } })}
<br/> <br />
{ {this.state.viewDidLoad && !this.state.viewDidError ? (
this.state.viewDidLoad && !this.state.viewDidError? <div style={{textAlign: "center"}} onClick={() => this.loadMoreTimelinePieces()}><Button variant="contained">Load more</Button></div>: null <div
} style={{ textAlign: "center" }}
</div>: onClick={() => this.loadMoreTimelinePieces()}
<span/> >
} <Button variant="contained">Load more</Button>
{ </div>
this.state.viewDidError? ) : null}
</div>
) : (
<span />
)}
{this.state.viewDidError ? (
<Paper className={classes.errorCard}> <Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography> <Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this timeline.</Typography> <Typography variant="h6">
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography> Something went wrong when loading this timeline.
</Paper>: </Typography>
<span/> <Typography>
} {this.state.viewDidErrorCode ? this.state.viewDidErrorCode : ""}
{ </Typography>
this.state.viewIsLoading? </Paper>
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>: ) : (
<span/> <span />
} )}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress className={classes.progress} color="primary" />
</div>
) : (
<span />
)}
</div> </div>
); );
} }

View File

@ -1,11 +1,20 @@
import React, { Component } from 'react'; import React, { Component } from "react";
import { withStyles, CircularProgress, Typography, Paper, Button, Chip, Avatar, Slide} from '@material-ui/core'; import {
import {styles} from './PageLayout.styles'; withStyles,
import Post from '../components/Post'; CircularProgress,
import { Status } from '../types/Status'; Typography,
import Mastodon, { StreamListener } from 'megalodon'; Paper,
import {withSnackbar} from 'notistack'; Button,
import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward'; Chip,
Avatar,
Slide
} from "@material-ui/core";
import { styles } from "./PageLayout.styles";
import Post from "../components/Post";
import { Status } from "../types/Status";
import Mastodon, { StreamListener } from "megalodon";
import { withSnackbar } from "notistack";
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
interface ILocalPageState { interface ILocalPageState {
posts?: [Status]; posts?: [Status];
@ -16,9 +25,7 @@ interface ILocalPageState {
viewDidErrorCode?: any; viewDidErrorCode?: any;
} }
class LocalPage extends Component<any, ILocalPageState> { class LocalPage extends Component<any, ILocalPageState> {
client: Mastodon; client: Mastodon;
streamListener: StreamListener; streamListener: StreamListener;
@ -28,68 +35,74 @@ class LocalPage extends Component<any, ILocalPageState> {
this.state = { this.state = {
viewIsLoading: true, viewIsLoading: true,
backlogPosts: null backlogPosts: null
} };
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1");
this.streamListener = this.client.stream('/streaming/public/local');
this.client = new Mastodon(
localStorage.getItem("access_token") as string,
(localStorage.getItem("baseurl") as string) + "/api/v1"
);
this.streamListener = this.client.stream("/streaming/public/local");
} }
componentWillMount() { componentWillMount() {
this.streamListener.on("connect", () => {
this.streamListener.on('connect', () => { this.client
this.client.get('/timelines/public', {limit: 40, local: true}).then((resp: any) => { .get("/timelines/public", { limit: 40, local: true })
.then((resp: any) => {
let statuses: [Status] = resp.data; let statuses: [Status] = resp.data;
this.setState({ this.setState({
posts: statuses, posts: statuses,
viewIsLoading: false, viewIsLoading: false,
viewDidLoad: true, viewDidLoad: true,
viewDidError: false viewDidError: false
});
}) })
}).catch((resp: any) => { .catch((resp: any) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidLoad: true, viewDidLoad: true,
viewDidError: true, viewDidError: true,
viewDidErrorCode: String(resp) viewDidErrorCode: String(resp)
}) });
this.props.enqueueSnackbar("Failed to get posts.", { this.props.enqueueSnackbar("Failed to get posts.", {
variant: 'error', variant: "error"
});
}); });
})
}); });
this.streamListener.on('update', (status: Status) => { this.streamListener.on("update", (status: Status) => {
let queue = this.state.backlogPosts; let queue = this.state.backlogPosts;
if (queue !== null && queue !== undefined) { queue.unshift(status); } else { queue = [status] } if (queue !== null && queue !== undefined) {
queue.unshift(status);
} else {
queue = [status];
}
this.setState({ backlogPosts: queue }); this.setState({ backlogPosts: queue });
}) });
this.streamListener.on('delete', (id: number) => { this.streamListener.on("delete", (id: number) => {
let posts = this.state.posts; let posts = this.state.posts;
if (posts) { if (posts) {
posts.forEach((post: Status) => { posts.forEach((post: Status) => {
if (posts && parseInt(post.id) === id) { if (posts && parseInt(post.id) === id) {
posts.splice(posts.indexOf(post), 1); posts.splice(posts.indexOf(post), 1);
} }
}) });
this.setState({ posts }); this.setState({ posts });
} }
}) });
this.streamListener.on('error', (err: Error) => { this.streamListener.on("error", (err: Error) => {
this.setState({ this.setState({
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.message viewDidErrorCode: err.message
})
this.props.enqueueSnackbar("An error occured.", {
variant: 'error',
}); });
}) this.props.enqueueSnackbar("An error occured.", {
variant: "error"
});
});
this.streamListener.on('heartbeat', () => { this.streamListener.on("heartbeat", () => {});
})
} }
componentWillUnmount() { componentWillUnmount() {
@ -102,14 +115,20 @@ class LocalPage extends Component<any, ILocalPageState> {
let backlog = this.state.backlogPosts; let backlog = this.state.backlogPosts;
if (posts && backlog && backlog.length > 0) { if (posts && backlog && backlog.length > 0) {
let push = backlog.concat(posts); let push = backlog.concat(posts);
this.setState({ posts: push as [Status], backlogPosts: null }) this.setState({ posts: push as [Status], backlogPosts: null });
} }
} }
loadMoreTimelinePieces() { loadMoreTimelinePieces() {
this.setState({ viewDidLoad: false, viewIsLoading: true}) this.setState({ viewDidLoad: false, viewIsLoading: true });
if (this.state.posts) { if (this.state.posts) {
this.client.get('/timelines/public', { max_id: this.state.posts[this.state.posts.length - 1].id, limit: 20, local: true }).then((resp: any) => { this.client
.get("/timelines/public", {
max_id: this.state.posts[this.state.posts.length - 1].id,
limit: 20,
local: true
})
.then((resp: any) => {
let newPosts: [Status] = resp.data; let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status]; let posts = this.state.posts as [Status];
newPosts.forEach((post: Status) => { newPosts.forEach((post: Status) => {
@ -119,37 +138,39 @@ class LocalPage extends Component<any, ILocalPageState> {
viewIsLoading: false, viewIsLoading: false,
viewDidLoad: true, viewDidLoad: true,
posts posts
});
}) })
}).catch((err: Error) => { .catch((err: Error) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.message viewDidErrorCode: err.message
})
this.props.enqueueSnackbar("Failed to get posts", {
variant: 'error',
}); });
}) this.props.enqueueSnackbar("Failed to get posts", {
variant: "error"
});
});
} }
} }
render() { render() {
const {classes} = this.props; const { classes } = this.props;
return ( return (
<div className={classes.pageLayoutMaxConstraints}> <div className={classes.pageLayoutMaxConstraints}>
{ {this.state.backlogPosts ? (
this.state.backlogPosts?
<div className={classes.pageTopChipContainer}> <div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}> <div className={classes.pageTopChips}>
<Slide direction="down" in={true}> <Slide direction="down" in={true}>
<Chip <Chip
avatar={ avatar={
<Avatar> <Avatar>
<ArrowUpwardIcon/> <ArrowUpwardIcon />
</Avatar> </Avatar>
} }
label={`View ${this.state.backlogPosts.length} new post${this.state.backlogPosts.length > 1? "s": ""}`} label={`View ${this.state.backlogPosts.length} new post${
this.state.backlogPosts.length > 1 ? "s" : ""
}`}
color="primary" color="primary"
className={classes.pageTopChip} className={classes.pageTopChip}
onClick={() => this.insertBacklog()} onClick={() => this.insertBacklog()}
@ -157,34 +178,46 @@ class LocalPage extends Component<any, ILocalPageState> {
/> />
</Slide> </Slide>
</div> </div>
</div>: null </div>
} ) : null}
{ this.state.posts? {this.state.posts ? (
<div> <div>
{this.state.posts.map((post: Status) => { {this.state.posts.map((post: Status) => {
return <Post key={post.id} post={post} client={this.client}/> return <Post key={post.id} post={post} client={this.client} />;
})} })}
<br/> <br />
{ {this.state.viewDidLoad && !this.state.viewDidError ? (
this.state.viewDidLoad && !this.state.viewDidError? <div style={{textAlign: "center"}} onClick={() => this.loadMoreTimelinePieces()}><Button variant="contained">Load more</Button></div>: null <div
} style={{ textAlign: "center" }}
</div>: onClick={() => this.loadMoreTimelinePieces()}
<span/> >
} <Button variant="contained">Load more</Button>
{ </div>
this.state.viewDidError? ) : null}
</div>
) : (
<span />
)}
{this.state.viewDidError ? (
<Paper className={classes.errorCard}> <Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography> <Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this timeline.</Typography> <Typography variant="h6">
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography> Something went wrong when loading this timeline.
</Paper>: </Typography>
<span/> <Typography>
} {this.state.viewDidErrorCode ? this.state.viewDidErrorCode : ""}
{ </Typography>
this.state.viewIsLoading? </Paper>
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>: ) : (
<span/> <span />
} )}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress className={classes.progress} color="primary" />
</div>
) : (
<span />
)}
</div> </div>
); );
} }

View File

@ -1,11 +1,23 @@
import React, { Component } from 'react'; import React, { Component } from "react";
import {withStyles, ListSubheader, Paper, List, ListItem, ListItemText, CircularProgress, ListItemAvatar, Avatar, ListItemSecondaryAction, Tooltip} from '@material-ui/core'; import {
import PersonIcon from '@material-ui/icons/Person'; withStyles,
import ForumIcon from '@material-ui/icons/Forum'; ListSubheader,
import {styles} from './PageLayout.styles'; Paper,
import Mastodon from 'megalodon'; List,
import { Status } from '../types/Status'; ListItem,
import { LinkableIconButton, LinkableAvatar } from '../interfaces/overrides'; ListItemText,
CircularProgress,
ListItemAvatar,
Avatar,
ListItemSecondaryAction,
Tooltip
} from "@material-ui/core";
import PersonIcon from "@material-ui/icons/Person";
import ForumIcon from "@material-ui/icons/Forum";
import { styles } from "./PageLayout.styles";
import Mastodon from "megalodon";
import { Status } from "../types/Status";
import { LinkableIconButton, LinkableAvatar } from "../interfaces/overrides";
interface IMessagesState { interface IMessagesState {
posts?: [Status]; posts?: [Status];
@ -16,23 +28,24 @@ interface IMessagesState {
} }
class MessagesPage extends Component<any, IMessagesState> { class MessagesPage extends Component<any, IMessagesState> {
client: Mastodon; client: Mastodon;
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1"); this.client = new Mastodon(
localStorage.getItem("access_token") as string,
(localStorage.getItem("baseurl") as string) + "/api/v1"
);
this.state = { this.state = {
viewIsLoading: true viewIsLoading: true
} };
} }
componentWillMount() { componentWillMount() {
this.client.get('/conversations') this.client.get("/conversations").then(resp => {
.then((resp) => { let data: any = resp.data;
let data:any = resp.data;
let messages: any = []; let messages: any = [];
data.forEach((message: any) => { data.forEach((message: any) => {
@ -50,10 +63,10 @@ class MessagesPage extends Component<any, IMessagesState> {
} }
removeHTMLContent(text: string) { removeHTMLContent(text: string) {
const div = document.createElement('div'); const div = document.createElement("div");
div.innerHTML = text; div.innerHTML = text;
let innerContent = div.textContent || div.innerText || ""; let innerContent = div.textContent || div.innerText || "";
innerContent = innerContent.slice(0, 100) + "..." innerContent = innerContent.slice(0, 100) + "...";
return innerContent; return innerContent;
} }
@ -61,46 +74,56 @@ class MessagesPage extends Component<any, IMessagesState> {
const { classes } = this.props; const { classes } = this.props;
return ( return (
<div className={classes.pageLayoutConstraints}> <div className={classes.pageLayoutConstraints}>
{ {this.state.viewDidLoad ? (
this.state.viewDidLoad?
<div className={classes.pageListContsraints}> <div className={classes.pageListContsraints}>
<ListSubheader>Recent messages</ListSubheader> <ListSubheader>Recent messages</ListSubheader>
<Paper className={classes.pageListConstraints}> <Paper className={classes.pageListConstraints}>
<List> <List>
{ {this.state.posts
this.state.posts? ? this.state.posts.map((message: Status) => {
this.state.posts.map((message: Status) => {
return ( return (
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<LinkableAvatar to={`/profile/${message.account.id}`} alt={message.account.username} src={message.account.avatar_static}> <LinkableAvatar
<PersonIcon/> to={`/profile/${message.account.id}`}
alt={message.account.username}
src={message.account.avatar_static}
>
<PersonIcon />
</LinkableAvatar> </LinkableAvatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={message.account.display_name || "@" + message.account.acct} secondary={this.removeHTMLContent(message.content)}/> <ListItemText
primary={
message.account.display_name ||
"@" + message.account.acct
}
secondary={this.removeHTMLContent(message.content)}
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Tooltip title="View conversation"> <Tooltip title="View conversation">
<LinkableIconButton to={`/conversation/${message.id}`}> <LinkableIconButton
<ForumIcon/> to={`/conversation/${message.id}`}
>
<ForumIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
) );
}): null })
} : null}
</List> </List>
</Paper> </Paper>
<br/> <br />
</div>: null
}
{
this.state.viewIsLoading?
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>:
null
}
</div> </div>
) ) : null}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress className={classes.progress} color="primary" />
</div>
) : null}
</div>
);
} }
} }

View File

@ -1,24 +1,27 @@
import React, {Component} from 'react'; import React, { Component } from "react";
import {withStyles, Typography} from '@material-ui/core'; import { withStyles, Typography } from "@material-ui/core";
import {styles} from './PageLayout.styles'; import { styles } from "./PageLayout.styles";
import {LinkableButton} from '../interfaces/overrides'; import { LinkableButton } from "../interfaces/overrides";
class Missingno extends Component<any, any> { class Missingno extends Component<any, any> {
render() { render() {
const {classes} = this.props; const { classes } = this.props;
return ( return (
<div className={classes.pageLayoutConstraints}> <div className={classes.pageLayoutConstraints}>
<div> <div>
<Typography variant="h4" component="h1"><b>Uh oh!</b></Typography> <Typography variant="h4" component="h1">
<Typography variant="h6" component="p">The part of Hyperspace you're looking for isn't here.</Typography> <b>Uh oh!</b>
<br/> </Typography>
<Typography variant="h6" component="p">
The part of Hyperspace you're looking for isn't here.
</Typography>
<br />
<LinkableButton to="/home" color="primary" variant="contained"> <LinkableButton to="/home" color="primary" variant="contained">
Go back to home timeline Go back to home timeline
</LinkableButton> </LinkableButton>
</div> </div>
</div> </div>
) );
} }
} }

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react'; import React, { Component } from "react";
import { import {
List, List,
ListItem, ListItem,
@ -18,19 +18,19 @@ import {
DialogContentText, DialogContentText,
DialogActions, DialogActions,
Tooltip Tooltip
} from '@material-ui/core'; } from "@material-ui/core";
import AssignmentIndIcon from '@material-ui/icons/AssignmentInd'; import AssignmentIndIcon from "@material-ui/icons/AssignmentInd";
import PersonIcon from '@material-ui/icons/Person'; import PersonIcon from "@material-ui/icons/Person";
import PersonAddIcon from '@material-ui/icons/PersonAdd'; import PersonAddIcon from "@material-ui/icons/PersonAdd";
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from "@material-ui/icons/Delete";
import {styles} from './PageLayout.styles'; import { styles } from "./PageLayout.styles";
import { LinkableIconButton, LinkableAvatar } from '../interfaces/overrides'; import { LinkableIconButton, LinkableAvatar } from "../interfaces/overrides";
import ForumIcon from '@material-ui/icons/Forum'; import ForumIcon from "@material-ui/icons/Forum";
import ReplyIcon from '@material-ui/icons/Reply'; import ReplyIcon from "@material-ui/icons/Reply";
import Mastodon from 'megalodon'; import Mastodon from "megalodon";
import { Notification } from '../types/Notification'; import { Notification } from "../types/Notification";
import { Account } from '../types/Account'; import { Account } from "../types/Account";
import { withSnackbar } from 'notistack'; import { withSnackbar } from "notistack";
interface INotificationsPageState { interface INotificationsPageState {
notifications?: [Notification]; notifications?: [Notification];
@ -42,36 +42,41 @@ interface INotificationsPageState {
} }
class NotificationsPage extends Component<any, INotificationsPageState> { class NotificationsPage extends Component<any, INotificationsPageState> {
client: Mastodon; client: Mastodon;
streamListener: any; streamListener: any;
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') + "/api/v1"); this.client = new Mastodon(
localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1"
);
this.state = { this.state = {
viewIsLoading: true, viewIsLoading: true,
deleteDialogOpen: false deleteDialogOpen: false
} };
} }
componentWillMount() { componentWillMount() {
this.client.get('/notifications').then((resp: any) => { this.client
.get("/notifications")
.then((resp: any) => {
let notifications: [Notification] = resp.data; let notifications: [Notification] = resp.data;
this.setState({ this.setState({
notifications, notifications,
viewIsLoading: false, viewIsLoading: false,
viewDidLoad: true viewDidLoad: true
});
}) })
}).catch((err: Error) => { .catch((err: Error) => {
this.setState({ this.setState({
viewDidLoad: true, viewDidLoad: true,
viewIsLoading: false, viewIsLoading: false,
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.message viewDidErrorCode: err.message
}) });
}) });
} }
componentDidMount() { componentDidMount() {
@ -79,15 +84,15 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
} }
streamNotifications() { streamNotifications() {
this.streamListener = this.client.stream('/streaming/user'); this.streamListener = this.client.stream("/streaming/user");
this.streamListener.on('notification', (notif: Notification) => { this.streamListener.on("notification", (notif: Notification) => {
let notifications = this.state.notifications; let notifications = this.state.notifications;
if (notifications) { if (notifications) {
notifications.unshift(notif); notifications.unshift(notif);
this.setState({ notifications }); this.setState({ notifications });
} }
}) });
} }
toggleDeleteDialog() { toggleDeleteDialog() {
@ -95,7 +100,7 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
} }
removeHTMLContent(text: string) { removeHTMLContent(text: string) {
const div = document.createElement('div'); const div = document.createElement("div");
div.innerHTML = text; div.innerHTML = text;
let innerContent = div.textContent || div.innerText || ""; let innerContent = div.textContent || div.innerText || "";
if (innerContent.length > 65) if (innerContent.length > 65)
@ -104,33 +109,45 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
} }
removeNotification(id: string) { removeNotification(id: string) {
this.client.post('/notifications/dismiss', {id: id}).then((resp: any) => { this.client
.post("/notifications/dismiss", { id: id })
.then((resp: any) => {
let notifications = this.state.notifications; let notifications = this.state.notifications;
if (notifications !== undefined && notifications.length > 0) { if (notifications !== undefined && notifications.length > 0) {
notifications.forEach((notification: Notification) => { notifications.forEach((notification: Notification) => {
if (notifications !== undefined && notification.id === id) { if (notifications !== undefined && notification.id === id) {
notifications.splice(notifications.indexOf(notification), 1); notifications.splice(notifications.indexOf(notification), 1);
} }
})
}
this.setState({ notifications })
this.props.enqueueSnackbar("Notification deleted.");
}).catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't delete notification: " + err.name, {
variant: 'error'
}); });
}
this.setState({ notifications });
this.props.enqueueSnackbar("Notification deleted.");
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
"Couldn't delete notification: " + err.name,
{
variant: "error"
}
);
}); });
} }
removeAllNotifications() { removeAllNotifications() {
this.client.post('/notifications/clear').then((resp: any) => { this.client
this.setState({ notifications: undefined }) .post("/notifications/clear")
this.props.enqueueSnackbar('All notifications deleted.'); .then((resp: any) => {
}).catch((err: Error) => { this.setState({ notifications: undefined });
this.props.enqueueSnackbar("Couldn't delete notifications: " + err.name, { this.props.enqueueSnackbar("All notifications deleted.");
variant: 'error'
});
}) })
.catch((err: Error) => {
this.props.enqueueSnackbar(
"Couldn't delete notifications: " + err.name,
{
variant: "error"
}
);
});
} }
createNotification(notif: Notification) { createNotification(notif: Notification) {
@ -139,24 +156,36 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
let secondary = ""; let secondary = "";
switch (notif.type) { switch (notif.type) {
case "follow": case "follow":
primary = `${notif.account.display_name || notif.account.username} is now following you!`; primary = `${notif.account.display_name ||
notif.account.username} is now following you!`;
break; break;
case "mention": case "mention":
primary = `${notif.account.display_name || notif.account.username} mentioned you in a post.`; primary = `${notif.account.display_name ||
secondary = this.removeHTMLContent(notif.status? notif.status.content: ""); notif.account.username} mentioned you in a post.`;
secondary = this.removeHTMLContent(
notif.status ? notif.status.content : ""
);
break; break;
case "reblog": case "reblog":
primary = `${notif.account.display_name || notif.account.username} reblogged your post.`; primary = `${notif.account.display_name ||
secondary = this.removeHTMLContent(notif.status? notif.status.content: ""); notif.account.username} reblogged your post.`;
secondary = this.removeHTMLContent(
notif.status ? notif.status.content : ""
);
break; break;
case "favourite": case "favourite":
primary = `${notif.account.display_name || notif.account.username} favorited your post.`; primary = `${notif.account.display_name ||
secondary = this.removeHTMLContent(notif.status? notif.status.content: ""); notif.account.username} favorited your post.`;
secondary = this.removeHTMLContent(
notif.status ? notif.status.content : ""
);
break; break;
default: default:
if (notif.status && notif.status.poll) { if (notif.status && notif.status.poll) {
primary = "A poll you voted in or created has ended."; primary = "A poll you voted in or created has ended.";
secondary = this.removeHTMLContent(notif.status? notif.status.content: ""); secondary = this.removeHTMLContent(
notif.status ? notif.status.content : ""
);
} else { } else {
primary = "A magical thing happened!"; primary = "A magical thing happened!";
} }
@ -165,11 +194,17 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
return ( return (
<ListItem key={notif.id}> <ListItem key={notif.id}>
<ListItemAvatar> <ListItemAvatar>
<LinkableAvatar alt={notif.account.username} src={notif.account.avatar_static} to={`/profile/${notif.account.id}`}> <LinkableAvatar
<PersonIcon/> alt={notif.account.username}
src={notif.account.avatar_static}
to={`/profile/${notif.account.id}`}
>
<PersonIcon />
</LinkableAvatar> </LinkableAvatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={primary} secondary={ <ListItemText
primary={primary}
secondary={
<span> <span>
<Typography color="textSecondary" className={classes.mobileOnly}> <Typography color="textSecondary" className={classes.mobileOnly}>
{secondary.slice(0, 35) + "..."} {secondary.slice(0, 35) + "..."}
@ -178,44 +213,51 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
{secondary} {secondary}
</Typography> </Typography>
</span> </span>
}/> }
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
{ {notif.type === "follow" ? (
notif.type === "follow"?
<span> <span>
<Tooltip title="View profile"> <Tooltip title="View profile">
<LinkableIconButton to={`/profile/${notif.account.id}`}> <LinkableIconButton to={`/profile/${notif.account.id}`}>
<AssignmentIndIcon/> <AssignmentIndIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
<Tooltip title="Follow account"> <Tooltip title="Follow account">
<IconButton onClick={() => this.followMember(notif.account)}> <IconButton onClick={() => this.followMember(notif.account)}>
<PersonAddIcon/> <PersonAddIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</span>: </span>
) : notif.status ? (
notif.status?
<span> <span>
<Tooltip title="View conversation"> <Tooltip title="View conversation">
<LinkableIconButton to={`/conversation/${notif.status.id}`}> <LinkableIconButton to={`/conversation/${notif.status.id}`}>
<ForumIcon/> <ForumIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
{ {notif.type === "mention" ? (
notif.type === "mention"?
<Tooltip title="Reply"> <Tooltip title="Reply">
<LinkableIconButton to={`/compose?reply=${notif.status.reblog? notif.status.reblog.id: notif.status.id}&visibility=${notif.status.visibility}&acct=${notif.status.reblog? notif.status.reblog.account.acct: notif.status.account.acct}`}> <LinkableIconButton
<ReplyIcon/> to={`/compose?reply=${
notif.status.reblog
? notif.status.reblog.id
: notif.status.id
}&visibility=${notif.status.visibility}&acct=${
notif.status.reblog
? notif.status.reblog.account.acct
: notif.status.account.acct
}`}
>
<ReplyIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip>: null </Tooltip>
} ) : null}
</span>: </span>
null ) : null}
}
<Tooltip title="Remove notification"> <Tooltip title="Remove notification">
<IconButton onClick={() => this.removeNotification(notif.id)}> <IconButton onClick={() => this.removeNotification(notif.id)}>
<DeleteIcon/> <DeleteIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</ListItemSecondaryAction> </ListItemSecondaryAction>
@ -224,73 +266,104 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
} }
followMember(acct: Account) { followMember(acct: Account) {
this.client.post(`/accounts/${acct.id}/follow`).then((resp: any) => { this.client
this.props.enqueueSnackbar('You are now following this account.'); .post(`/accounts/${acct.id}/follow`)
}).catch((err: Error) => { .then((resp: any) => {
this.props.enqueueSnackbar("Couldn't follow account: " + err.name, { variant: 'error' }); this.props.enqueueSnackbar("You are now following this account.");
console.error(err.message);
}) })
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't follow account: " + err.name, {
variant: "error"
});
console.error(err.message);
});
} }
render() { render() {
const { classes } = this.props; const { classes } = this.props;
return ( return (
<div className={classes.pageLayoutConstraints}> <div className={classes.pageLayoutConstraints}>
{ {this.state.viewDidLoad ? (
this.state.viewDidLoad? this.state.notifications && this.state.notifications.length > 0 ? (
this.state.notifications && this.state.notifications.length > 0?
<div> <div>
<ListSubheader>Recent notifications</ListSubheader> <ListSubheader>Recent notifications</ListSubheader>
<Button className={classes.clearAllButton} variant="text" onClick={() => this.toggleDeleteDialog()}> Clear All</Button> <Button
className={classes.clearAllButton}
variant="text"
onClick={() => this.toggleDeleteDialog()}
>
{" "}
Clear All
</Button>
<Paper className={classes.pageListConstraints}> <Paper className={classes.pageListConstraints}>
<List> <List>
{ {this.state.notifications.map(
this.state.notifications.map((notification: Notification) => { (notification: Notification) => {
return this.createNotification(notification) return this.createNotification(notification);
})
} }
)}
</List> </List>
</Paper> </Paper>
</div>: </div>
) : (
<div className={classes.pageLayoutEmptyTextConstraints}> <div className={classes.pageLayoutEmptyTextConstraints}>
<Typography variant="h4">All clear!</Typography> <Typography variant="h4">All clear!</Typography>
<Typography paragraph>It looks like you have no notifications. Why not get the conversation going with a new post?</Typography> <Typography paragraph>
</div>: It looks like you have no notifications. Why not get the
null conversation going with a new post?
} </Typography>
{ </div>
this.state.viewDidError? )
) : null}
{this.state.viewDidError ? (
<Paper className={classes.errorCard}> <Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography> <Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this timeline.</Typography> <Typography variant="h6">
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography> Something went wrong when loading this timeline.
</Paper>: </Typography>
<span/> <Typography>
} {this.state.viewDidErrorCode ? this.state.viewDidErrorCode : ""}
{ </Typography>
this.state.viewIsLoading? </Paper>
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>: ) : (
<span/> <span />
} )}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress className={classes.progress} color="primary" />
</div>
) : (
<span />
)}
<Dialog <Dialog
open={this.state.deleteDialogOpen} open={this.state.deleteDialogOpen}
onClose={() => this.toggleDeleteDialog()} onClose={() => this.toggleDeleteDialog()}
> >
<DialogTitle id="alert-dialog-title">Delete all notifications?</DialogTitle> <DialogTitle id="alert-dialog-title">
Delete all notifications?
</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText id="alert-dialog-description"> <DialogContentText id="alert-dialog-description">
Are you sure you want to delete all notifications? This action cannot be undone. Are you sure you want to delete all notifications? This action
cannot be undone.
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => this.toggleDeleteDialog()} color="primary" autoFocus> <Button
onClick={() => this.toggleDeleteDialog()}
color="primary"
autoFocus
>
Cancel Cancel
</Button> </Button>
<Button onClick={() => { <Button
onClick={() => {
this.removeAllNotifications(); this.removeAllNotifications();
this.toggleDeleteDialog(); this.toggleDeleteDialog();
}} color="primary"> }}
color="primary"
>
Delete Delete
</Button> </Button>
</DialogActions> </DialogActions>
@ -298,7 +371,6 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
</div> </div>
); );
} }
} }
export default withStyles(styles)(withSnackbar(NotificationsPage)); export default withStyles(styles)(withSnackbar(NotificationsPage));

View File

@ -2,11 +2,12 @@ import { Theme, createStyles } from "@material-ui/core";
import { isDarwinApp } from "../utilities/desktop"; import { isDarwinApp } from "../utilities/desktop";
import { isAppbarExpanded } from "../utilities/appbar"; import { isAppbarExpanded } from "../utilities/appbar";
export const styles = (theme: Theme) => createStyles({ export const styles = (theme: Theme) =>
createStyles({
root: { root: {
width: '100%', width: "100%",
display: 'flex', display: "flex",
height: '100%' height: "100%"
}, },
pageLayoutConstraints: { pageLayoutConstraints: {
marginTop: 72, marginTop: 72,
@ -14,87 +15,87 @@ export const styles = (theme: Theme) => createStyles({
padding: theme.spacing.unit * 3, padding: theme.spacing.unit * 3,
paddingLeft: theme.spacing.unit, paddingLeft: theme.spacing.unit,
paddingRight: theme.spacing.unit, paddingRight: theme.spacing.unit,
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
marginLeft: 250, marginLeft: 250,
marginTop: 88, marginTop: 88,
paddingLeft: theme.spacing.unit * 24, paddingLeft: theme.spacing.unit * 24,
paddingRight: theme.spacing.unit * 24 paddingRight: theme.spacing.unit * 24
}, },
backgroundColor: theme.palette.background.default, backgroundColor: theme.palette.background.default,
minHeight: isDarwinApp()? "100vh": 'auto', minHeight: isDarwinApp() ? "100vh" : "auto"
}, },
pageLayoutMaxConstraints: { pageLayoutMaxConstraints: {
marginTop: 72, marginTop: 72,
flexGrow: 1, flexGrow: 1,
paddingTop: theme.spacing.unit * 2, paddingTop: theme.spacing.unit * 2,
padding: theme.spacing.unit, padding: theme.spacing.unit,
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
marginLeft: 250, marginLeft: 250,
marginTop: 88, marginTop: 88,
padding: theme.spacing.unit * 3, padding: theme.spacing.unit * 3,
paddingLeft: theme.spacing.unit * 16, paddingLeft: theme.spacing.unit * 16,
paddingRight: theme.spacing.unit * 16, paddingRight: theme.spacing.unit * 16
}, },
[theme.breakpoints.up('lg')]: { [theme.breakpoints.up("lg")]: {
marginLeft: 250, marginLeft: 250,
marginTop: 88, marginTop: 88,
padding: theme.spacing.unit * 3, padding: theme.spacing.unit * 3,
paddingLeft: theme.spacing.unit * 32, paddingLeft: theme.spacing.unit * 32,
paddingRight: theme.spacing.unit * 32, paddingRight: theme.spacing.unit * 32
}, },
[theme.breakpoints.up('xl')]: { [theme.breakpoints.up("xl")]: {
marginLeft: 250, marginLeft: 250,
marginTop: 88, marginTop: 88,
padding: theme.spacing.unit * 3, padding: theme.spacing.unit * 3,
paddingLeft: theme.spacing.unit * 40, paddingLeft: theme.spacing.unit * 40,
paddingRight: theme.spacing.unit * 40, paddingRight: theme.spacing.unit * 40
}, },
backgroundColor: theme.palette.background.default, backgroundColor: theme.palette.background.default,
minHeight: isDarwinApp()? "100vh": 'auto', minHeight: isDarwinApp() ? "100vh" : "auto"
}, },
pageLayoutMinimalConstraints: { pageLayoutMinimalConstraints: {
flexGrow: 1, flexGrow: 1,
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
marginLeft: 250, marginLeft: 250
}, },
backgroundColor: theme.palette.background.default, backgroundColor: theme.palette.background.default,
minHeight: isDarwinApp()? "100vh": 'auto', minHeight: isDarwinApp() ? "100vh" : "auto"
}, },
pageLayoutEmptyTextConstraints: { pageLayoutEmptyTextConstraints: {
paddingLeft: theme.spacing.unit * 2, paddingLeft: theme.spacing.unit * 2,
paddingRight: theme.spacing.unit * 2 paddingRight: theme.spacing.unit * 2
}, },
pageHeroBackground: { pageHeroBackground: {
position: 'relative', position: "relative",
height: 'intrinsic', height: "intrinsic",
backgroundColor: theme.palette.primary.dark, backgroundColor: theme.palette.primary.dark,
width: '100%', width: "100%",
color: theme.palette.common.white, color: theme.palette.common.white,
zIndex: 1, zIndex: 1,
top: isAppbarExpanded()? 80: 64, top: isAppbarExpanded() ? 80 : 64
}, },
pageHeroBackgroundImage: { pageHeroBackgroundImage: {
position: 'absolute', position: "absolute",
bottom: 0, bottom: 0,
left: 0, left: 0,
backgroundPosition: 'center', backgroundPosition: "center",
backgroundRepeat: 'no-repeat', backgroundRepeat: "no-repeat",
backgroundSize: 'cover', backgroundSize: "cover",
height: '100%', height: "100%",
width: '100%', width: "100%",
opacity: 0.35, opacity: 0.35,
zIndex: -1, zIndex: -1,
filter: 'blur(2px)' filter: "blur(2px)"
}, },
pageHeroContent: { pageHeroContent: {
padding: 16, padding: 16,
paddingTop: 8, paddingTop: 8,
textAlign: 'center', textAlign: "center",
width: '100%', width: "100%",
height: '100%', height: "100%",
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
paddingLeft: '5%', paddingLeft: "5%",
paddingRight: '5%', paddingRight: "5%"
}, },
position: "relative", position: "relative",
zIndex: 1 zIndex: 1
@ -102,43 +103,43 @@ export const styles = (theme: Theme) => createStyles({
pageHeroToolbar: { pageHeroToolbar: {
position: "absolute", position: "absolute",
right: theme.spacing.unit * 2, right: theme.spacing.unit * 2,
marginTop: -16, marginTop: -16
}, },
pageListConstraints: { pageListConstraints: {
paddingLeft: theme.spacing.unit, paddingLeft: theme.spacing.unit,
paddingRight: theme.spacing.unit, paddingRight: theme.spacing.unit,
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up("sm")]: {
paddingLeft: theme.spacing.unit * 2, paddingLeft: theme.spacing.unit * 2,
paddingRight: theme.spacing.unit * 2 paddingRight: theme.spacing.unit * 2
}, }
//backgroundColor: theme.palette.background.default //backgroundColor: theme.palette.background.default
}, },
profileToolbar: { profileToolbar: {
zIndex: 2, zIndex: 2,
paddingTop: 8, paddingTop: 8
}, },
profileContent: { profileContent: {
padding: 16, padding: 16,
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
paddingLeft: '5%', paddingLeft: "5%",
paddingRight: '5%', paddingRight: "5%",
paddingBottom: 48, paddingBottom: 48,
paddingTop: 24, paddingTop: 24
}, },
width: '100%', width: "100%",
height: '100%', height: "100%",
position: 'relative', position: "relative",
zIndex: 1, zIndex: 1,
display: 'flex', display: "flex",
paddingBottom: 24, paddingBottom: 24,
paddingTop: 24, paddingTop: 24
}, },
profileAvatar: { profileAvatar: {
width: 64, width: 64,
height: 64, height: 64,
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
width: 128, width: 128,
height: 128, height: 128
}, },
backgroundColor: theme.palette.primary.main backgroundColor: theme.palette.primary.main
}, },
@ -148,19 +149,19 @@ export const styles = (theme: Theme) => createStyles({
pageProfileAvatar: { pageProfileAvatar: {
width: 128, width: 128,
height: 128, height: 128,
marginLeft: 'auto', marginLeft: "auto",
marginRight: 'auto', marginRight: "auto",
marginBottom: theme.spacing.unit, marginBottom: theme.spacing.unit,
backgroundColor: theme.palette.primary.main backgroundColor: theme.palette.primary.main
}, },
pageProfileNameEmoji: { pageProfileNameEmoji: {
height: theme.typography.h4.fontSize, height: theme.typography.h4.fontSize,
fontWeight: theme.typography.fontWeightMedium, fontWeight: theme.typography.fontWeightMedium
}, },
pageProfileStatsDiv: { pageProfileStatsDiv: {
display: 'inline-flex', display: "inline-flex",
marginTop: theme.spacing.unit * 2, marginTop: theme.spacing.unit * 2,
marginBottom: theme.spacing.unit * 2, marginBottom: theme.spacing.unit * 2
}, },
pageProfileStat: { pageProfileStat: {
marginLeft: theme.spacing.unit, marginLeft: theme.spacing.unit,
@ -177,28 +178,28 @@ export const styles = (theme: Theme) => createStyles({
paddingRight: theme.spacing.unit, paddingRight: theme.spacing.unit,
paddingTop: theme.spacing.unit * 12, paddingTop: theme.spacing.unit * 12,
paddingBottom: theme.spacing.unit * 2, paddingBottom: theme.spacing.unit * 2,
[theme.breakpoints.up('lg')]: { [theme.breakpoints.up("lg")]: {
paddingLeft: theme.spacing.unit * 32, paddingLeft: theme.spacing.unit * 32,
paddingRight: theme.spacing.unit * 32 paddingRight: theme.spacing.unit * 32
}, }
//backgroundColor: theme.palette.background.default, //backgroundColor: theme.palette.background.default,
}, },
errorCard: { errorCard: {
padding: theme.spacing.unit * 4, padding: theme.spacing.unit * 4,
backgroundColor: theme.palette.error.main, backgroundColor: theme.palette.error.main
}, },
pageTopChipContainer: { pageTopChipContainer: {
zIndex: 24, zIndex: 24,
position: "fixed", position: "fixed",
width: '100%' width: "100%"
}, },
pageTopChips: { pageTopChips: {
textAlign: 'center', textAlign: "center",
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
marginRight: '55%' marginRight: "55%"
}, },
[theme.breakpoints.up('xl')]: { [theme.breakpoints.up("xl")]: {
marginRight: '50%' marginRight: "50%"
} }
}, },
pageTopChip: { pageTopChip: {
@ -206,27 +207,27 @@ export const styles = (theme: Theme) => createStyles({
}, },
clearAllButton: { clearAllButton: {
zIndex: 3, zIndex: 3,
position: 'absolute', position: "absolute",
right: 24, right: 24,
top: 100, top: 100,
[theme.breakpoints.up('md')]: { [theme.breakpoints.up("md")]: {
top: 116, top: 116,
right: theme.spacing.unit * 24, right: theme.spacing.unit * 24
} }
}, },
mobileOnly: { mobileOnly: {
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up("sm")]: {
display: 'none' display: "none"
} }
}, },
desktopOnly: { desktopOnly: {
display: 'none', display: "none",
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up("sm")]: {
display: 'block' display: "block"
} }
}, },
pageLayoutFooter: { pageLayoutFooter: {
'& a': { "& a": {
color: theme.palette.primary.light color: theme.palette.primary.light
} }
}, },
@ -235,13 +236,13 @@ export const styles = (theme: Theme) => createStyles({
width: 88 width: 88
}, },
youPaper: { youPaper: {
padding: theme.spacing.unit * 2, padding: theme.spacing.unit * 2
}, },
youGrid: { youGrid: {
textAlign: "center", textAlign: "center",
'& *': { "& *": {
marginLeft: "auto", marginLeft: "auto",
marginRight: "auto", marginRight: "auto"
} }
}, },
youGridAvatar: { youGridAvatar: {
@ -249,7 +250,7 @@ export const styles = (theme: Theme) => createStyles({
width: 128 width: 128
}, },
youGridImage: { youGridImage: {
width: 'auto', width: "auto",
height: 128 height: 128
}, },
instanceHeaderPaper: { instanceHeaderPaper: {

View File

@ -1,4 +1,4 @@
import React, {Component} from 'react'; import React, { Component } from "react";
import { import {
withStyles, withStyles,
Typography, Typography,
@ -15,27 +15,25 @@ import {
DialogActions, DialogActions,
Toolbar, Toolbar,
IconButton IconButton
} from '@material-ui/core'; } from "@material-ui/core";
import {styles} from './PageLayout.styles'; import { styles } from "./PageLayout.styles";
import Mastodon from 'megalodon'; import Mastodon from "megalodon";
import { Account } from '../types/Account'; import { Account } from "../types/Account";
import { Status } from '../types/Status'; import { Status } from "../types/Status";
import { Relationship } from '../types/Relationship'; import { Relationship } from "../types/Relationship";
import Post from '../components/Post'; import Post from "../components/Post";
import {withSnackbar} from 'notistack'; import { withSnackbar } from "notistack";
import { LinkableIconButton } from '../interfaces/overrides'; import { LinkableIconButton } from "../interfaces/overrides";
import { emojifyString } from '../utilities/emojis'; import { emojifyString } from "../utilities/emojis";
import AccountEditIcon from 'mdi-material-ui/AccountEdit';
import PersonAddIcon from '@material-ui/icons/PersonAdd';
import PersonAddDisabledIcon from '@material-ui/icons/PersonAddDisabled';
import AccountMinusIcon from 'mdi-material-ui/AccountMinus';
import ChatIcon from '@material-ui/icons/Chat';
import AccountRemoveIcon from 'mdi-material-ui/AccountRemove';
import AccountHeartIcon from 'mdi-material-ui/AccountHeart';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import AccountEditIcon from "mdi-material-ui/AccountEdit";
import PersonAddIcon from "@material-ui/icons/PersonAdd";
import PersonAddDisabledIcon from "@material-ui/icons/PersonAddDisabled";
import AccountMinusIcon from "mdi-material-ui/AccountMinus";
import ChatIcon from "@material-ui/icons/Chat";
import AccountRemoveIcon from "mdi-material-ui/AccountRemove";
import AccountHeartIcon from "mdi-material-ui/AccountHeart";
import OpenInNewIcon from "@material-ui/icons/OpenInNew";
interface IProfilePageState { interface IProfilePageState {
account?: Account; account?: Account;
@ -49,60 +47,66 @@ interface IProfilePageState {
} }
class ProfilePage extends Component<any, IProfilePageState> { class ProfilePage extends Component<any, IProfilePageState> {
client: Mastodon; client: Mastodon;
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.client = new Mastodon(
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') + "/api/v1"); localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1"
);
this.state = { this.state = {
viewIsLoading: true, viewIsLoading: true,
blockDialogOpen: false blockDialogOpen: false
} };
} }
toggleBlockDialog() { toggleBlockDialog() {
if (this.state.relationship && !this.state.relationship.blocking) if (this.state.relationship && !this.state.relationship.blocking)
this.setState({ blockDialogOpen: !this.state.blockDialogOpen }) this.setState({ blockDialogOpen: !this.state.blockDialogOpen });
else else this.toggleBlock();
this.toggleBlock()
} }
getAccountData(id: string) { getAccountData(id: string) {
this.client.get(`/accounts/${id}`).then((resp: any) => { this.client
.get(`/accounts/${id}`)
.then((resp: any) => {
let profile: Account = resp.data; let profile: Account = resp.data;
const div = document.createElement('div'); const div = document.createElement("div");
div.innerHTML = profile.note; div.innerHTML = profile.note;
profile.note = div.textContent || div.innerText || ""; profile.note = div.textContent || div.innerText || "";
this.setState({ this.setState({
account: profile account: profile
});
}) })
}).catch((error: Error) => { .catch((error: Error) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidError: true, viewDidError: true,
viewDidErrorCode: error.message viewDidErrorCode: error.message
}) });
}); });
this.getRelationships(); this.getRelationships();
this.client.get(`/accounts/${id}/statuses`).then((resp: any) => { this.client
.get(`/accounts/${id}/statuses`)
.then((resp: any) => {
this.setState({ this.setState({
posts: resp.data, posts: resp.data,
viewIsLoading: false, viewIsLoading: false,
viewDidLoad: true, viewDidLoad: true,
viewDidError: false viewDidError: false
});
}) })
}).catch( (err: Error) => { .catch((err: Error) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.message viewDidErrorCode: err.message
}) });
}); });
} }
@ -112,41 +116,56 @@ class ProfilePage extends Component<any, IProfilePageState> {
} }
componentWillMount() { componentWillMount() {
const { match: { params }} = this.props; const {
match: { params }
} = this.props;
this.getAccountData(params.profileId); this.getAccountData(params.profileId);
} }
isItMe(): boolean { isItMe(): boolean {
if (this.state.account) { if (this.state.account) {
return this.state.account.id === JSON.parse(localStorage.getItem('account') as string).id; return (
this.state.account.id ===
JSON.parse(localStorage.getItem("account") as string).id
);
} else { } else {
return false; return false;
} }
} }
getRelationships() { getRelationships() {
this.client.get("/accounts/relationships", {id: this.props.match.params.profileId }).then((resp: any) => { this.client
.get("/accounts/relationships", { id: this.props.match.params.profileId })
.then((resp: any) => {
let relationship: Relationship = resp.data[0]; let relationship: Relationship = resp.data[0];
this.setState({ relationship }); this.setState({ relationship });
}).catch((error: Error) => { })
.catch((error: Error) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidError: true, viewDidError: true,
viewDidErrorCode: error.message viewDidErrorCode: error.message
}) });
}); });
} }
loadMoreTimelinePieces() { loadMoreTimelinePieces() {
const { match: {params}} = this.props; const {
this.setState({ viewDidLoad: false, viewIsLoading: true}) match: { params }
} = this.props;
this.setState({ viewDidLoad: false, viewIsLoading: true });
if (this.state.posts && this.state.posts.length > 0) { if (this.state.posts && this.state.posts.length > 0) {
this.client.get(`/accounts/${params.profileId}/statuses`, { max_id: this.state.posts[this.state.posts.length - 1].id, limit: 20 }).then((resp: any) => { this.client
.get(`/accounts/${params.profileId}/statuses`, {
max_id: this.state.posts[this.state.posts.length - 1].id,
limit: 20
})
.then((resp: any) => {
let newPosts: [Status] = resp.data; let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status]; let posts = this.state.posts as [Status];
if (newPosts.length <= 0) { if (newPosts.length <= 0) {
this.props.enqueueSnackbar("Reached end of posts", { this.props.enqueueSnackbar("Reached end of posts", {
variant: 'error' variant: "error"
}); });
} else { } else {
newPosts.forEach((post: Status) => { newPosts.forEach((post: Status) => {
@ -157,198 +176,321 @@ class ProfilePage extends Component<any, IProfilePageState> {
viewIsLoading: false, viewIsLoading: false,
viewDidLoad: true, viewDidLoad: true,
posts posts
});
}) })
}).catch((err: Error) => { .catch((err: Error) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.message viewDidErrorCode: err.message
})
this.props.enqueueSnackbar("Failed to get posts", {
variant: 'error',
}); });
}) this.props.enqueueSnackbar("Failed to get posts", {
variant: "error"
});
});
} else { } else {
this.props.enqueueSnackbar("Reached end of posts", { variant: 'error'} ); this.props.enqueueSnackbar("Reached end of posts", { variant: "error" });
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidLoad: true viewDidLoad: true
}) });
} }
} }
toggleFollow() { toggleFollow() {
if (this.state.relationship) { if (this.state.relationship) {
if (this.state.relationship.following) { if (this.state.relationship.following) {
this.client.post(`/accounts/${this.state.account? this.state.account.id: this.props.match.params.profileId}/unfollow`).then((resp: any) => { this.client
.post(
`/accounts/${
this.state.account
? this.state.account.id
: this.props.match.params.profileId
}/unfollow`
)
.then((resp: any) => {
let relationship: Relationship = resp.data; let relationship: Relationship = resp.data;
this.setState({ relationship }); this.setState({ relationship });
this.props.enqueueSnackbar('You are no longer following this account.'); this.props.enqueueSnackbar(
}).catch((err: Error) => { "You are no longer following this account."
this.props.enqueueSnackbar("Couldn't unfollow account: " + err.name, { variant: 'error' }); );
console.error(err.message);
}) })
.catch((err: Error) => {
this.props.enqueueSnackbar(
"Couldn't unfollow account: " + err.name,
{ variant: "error" }
);
console.error(err.message);
});
} else { } else {
this.client.post(`/accounts/${this.state.account? this.state.account.id: this.props.match.params.profileId}/follow`).then((resp: any) => { this.client
.post(
`/accounts/${
this.state.account
? this.state.account.id
: this.props.match.params.profileId
}/follow`
)
.then((resp: any) => {
let relationship: Relationship = resp.data; let relationship: Relationship = resp.data;
this.setState({ relationship }); this.setState({ relationship });
this.props.enqueueSnackbar('You are now following this account.'); 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);
}) })
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't follow account: " + err.name, {
variant: "error"
});
console.error(err.message);
});
} }
} }
} }
toggleBlock() { toggleBlock() {
if (this.state.relationship) { if (this.state.relationship) {
if (this.state.relationship.blocking) { if (this.state.relationship.blocking) {
this.client.post(`/accounts/${this.state.account? this.state.account.id: this.props.match.params.profileId}/unblock`).then((resp: any) => { this.client
.post(
`/accounts/${
this.state.account
? this.state.account.id
: this.props.match.params.profileId
}/unblock`
)
.then((resp: any) => {
let relationship: Relationship = resp.data; let relationship: Relationship = resp.data;
this.setState({ relationship }); this.setState({ relationship });
this.props.enqueueSnackbar('You are no longer blocking this account.'); this.props.enqueueSnackbar(
}).catch((err: Error) => { "You are no longer blocking this account."
this.props.enqueueSnackbar("Couldn't unblock account: " + err.name, { variant: 'error' }); );
console.error(err.message);
}) })
.catch((err: Error) => {
this.props.enqueueSnackbar(
"Couldn't unblock account: " + err.name,
{ variant: "error" }
);
console.error(err.message);
});
} else { } else {
this.client.post(`/accounts/${this.state.account? this.state.account.id: this.props.match.params.profileId}/block`).then((resp: any) => { this.client
.post(
`/accounts/${
this.state.account
? this.state.account.id
: this.props.match.params.profileId
}/block`
)
.then((resp: any) => {
let relationship: Relationship = resp.data; let relationship: Relationship = resp.data;
this.setState({ relationship }); this.setState({ relationship });
this.props.enqueueSnackbar('You are now blocking this account.'); this.props.enqueueSnackbar("You are now blocking this account.");
}).catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't block account: " + err.name, { variant: 'error' });
console.error(err.message);
}) })
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't block account: " + err.name, {
variant: "error"
});
console.error(err.message);
});
} }
} }
} }
render() { render() {
const { classes } = this.props; const { classes } = this.props;
return( return (
<div className={classes.pageLayoutMinimalConstraints}> <div className={classes.pageLayoutMinimalConstraints}>
<div className={classes.pageHeroBackground}> <div className={classes.pageHeroBackground}>
<div className={classes.pageHeroBackgroundImage} style={{ backgroundImage: this.state.account? `url("${this.state.account.header}")`: `url("")`}}/> <div
className={classes.pageHeroBackgroundImage}
style={{
backgroundImage: this.state.account
? `url("${this.state.account.header}")`
: `url("")`
}}
/>
<Toolbar className={classes.profileToolbar}> <Toolbar className={classes.profileToolbar}>
<div className={classes.pageGrow}/> <div className={classes.pageGrow} />
<Tooltip title={ <Tooltip
this.isItMe()? title={
"You can't follow yourself.": this.isItMe()
this.state.relationship && this.state.relationship.following? ? "You can't follow yourself."
"Unfollow": : this.state.relationship && this.state.relationship.following
"Follow" ? "Unfollow"
}> : "Follow"
<IconButton color={"inherit"} disabled={this.isItMe()} onClick={() => this.toggleFollow()}>
{
this.isItMe()?
<PersonAddDisabledIcon/>:
this.state.relationship && this.state.relationship.following?
<AccountMinusIcon/>:
<PersonAddIcon/>
} }
>
<IconButton
color={"inherit"}
disabled={this.isItMe()}
onClick={() => this.toggleFollow()}
>
{this.isItMe() ? (
<PersonAddDisabledIcon />
) : this.state.relationship &&
this.state.relationship.following ? (
<AccountMinusIcon />
) : (
<PersonAddIcon />
)}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title={"Send a message or post"}> <Tooltip title={"Send a message or post"}>
<LinkableIconButton to={`/compose?acct=${this.state.account? this.state.account.acct: ""}`} color={"inherit"}> <LinkableIconButton
<ChatIcon/> to={`/compose?acct=${
this.state.account ? this.state.account.acct : ""
}`}
color={"inherit"}
>
<ChatIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
<Tooltip title={this.state.relationship && this.state.relationship.blocking? "Unblock this account": "Block this account"}> <Tooltip
<IconButton color={"inherit"} disabled={this.isItMe()} onClick={() => this.toggleBlockDialog()}> title={
{ this.state.relationship && this.state.relationship.blocking
this.state.relationship && this.state.relationship.blocking? <AccountHeartIcon/>: <AccountRemoveIcon/> ? "Unblock this account"
: "Block this account"
} }
>
<IconButton
color={"inherit"}
disabled={this.isItMe()}
onClick={() => this.toggleBlockDialog()}
>
{this.state.relationship && this.state.relationship.blocking ? (
<AccountHeartIcon />
) : (
<AccountRemoveIcon />
)}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Open in web"> <Tooltip title="Open in web">
<IconButton href={this.state.account? this.state.account.url: ""} target="_blank" rel={"nofollower noreferrer noopener"} color={"inherit"}> <IconButton
<OpenInNewIcon/> href={this.state.account ? this.state.account.url : ""}
target="_blank"
rel={"nofollower noreferrer noopener"}
color={"inherit"}
>
<OpenInNewIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{ {this.isItMe() ? (
this.isItMe()?
<Tooltip title="Edit profile"> <Tooltip title="Edit profile">
<LinkableIconButton to="/you" color="inherit"> <LinkableIconButton to="/you" color="inherit">
<AccountEditIcon/> <AccountEditIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip>: null </Tooltip>
} ) : null}
</Toolbar> </Toolbar>
<div className={classes.profileContent}> <div className={classes.profileContent}>
<Avatar className={classes.profileAvatar} src={this.state.account ? this.state.account.avatar: ""}/> <Avatar
className={classes.profileAvatar}
src={this.state.account ? this.state.account.avatar : ""}
/>
<div className={classes.profileUserBox}> <div className={classes.profileUserBox}>
<Typography variant="h4" color="inherit" dangerouslySetInnerHTML={ <Typography
{__html: this.state.account? variant="h4"
this.state.account.display_name? color="inherit"
emojifyString(this.state.account.display_name, this.state.account.emojis, classes.pageProfileNameEmoji) dangerouslySetInnerHTML={{
__html: this.state.account
? this.state.account.display_name
? emojifyString(
this.state.account.display_name,
this.state.account.emojis,
classes.pageProfileNameEmoji
)
: this.state.account.username : this.state.account.username
: ""}} : ""
className={classes.pageProfileNameEmoji}/> }}
<Typography variant="caption" color="inherit">{this.state.account ? '@' + this.state.account.acct: ""}</Typography> className={classes.pageProfileNameEmoji}
<Typography paragraph color="inherit">{ />
this.state.account ? <Typography variant="caption" color="inherit">
this.state.account.note? {this.state.account ? "@" + this.state.account.acct : ""}
this.state.account.note </Typography>
<Typography paragraph color="inherit">
{this.state.account
? this.state.account.note
? this.state.account.note
: "No bio provided by user." : "No bio provided by user."
: "No bio available." : "No bio available."}
}</Typography> </Typography>
<Typography color={"inherit"}> <Typography color={"inherit"}>
{this.state.account? this.state.account.followers_count: 0} followers | {this.state.account? this.state.account.following_count: 0} following | {this.state.account? this.state.account.statuses_count: 0} posts {this.state.account ? this.state.account.followers_count : 0}{" "}
followers |{" "}
{this.state.account ? this.state.account.following_count : 0}{" "}
following |{" "}
{this.state.account ? this.state.account.statuses_count : 0}{" "}
posts
</Typography> </Typography>
</div> </div>
</div> </div>
</div> </div>
<div className={classes.pageContentLayoutConstraints}> <div className={classes.pageContentLayoutConstraints}>
{ {this.state.viewDidError ? (
this.state.viewDidError?
<Paper className={classes.errorCard}> <Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography> <Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this profile.</Typography> <Typography variant="h6">
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography> Something went wrong when loading this profile.
</Paper>: </Typography>
<span/> <Typography>
} {this.state.viewDidErrorCode ? this.state.viewDidErrorCode : ""}
{ </Typography>
this.state.posts? </Paper>
) : (
<span />
)}
{this.state.posts ? (
<div> <div>
{ {this.state.posts.map((post: Status) => {
this.state.posts.map((post: Status) => { return <Post key={post.id} post={post} client={this.client} />;
return <Post key={post.id} post={post} client={this.client}/>; })}
}) <br />
} {this.state.viewDidLoad && !this.state.viewDidError ? (
<br/> <div
{ style={{ textAlign: "center" }}
this.state.viewDidLoad && !this.state.viewDidError? <div style={{textAlign: "center"}} onClick={() => this.loadMoreTimelinePieces()}><Button variant="contained">Load more</Button></div>: null onClick={() => this.loadMoreTimelinePieces()}
} >
</div>: <span/> <Button variant="contained">Load more</Button>
} </div>
{ ) : null}
this.state.viewIsLoading? </div>
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>: ) : (
<span/> <span />
} )}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress className={classes.progress} color="primary" />
</div>
) : (
<span />
)}
<Dialog <Dialog
open={this.state.blockDialogOpen} open={this.state.blockDialogOpen}
onClose={() => this.toggleBlockDialog()} onClose={() => this.toggleBlockDialog()}
> >
<DialogTitle id="alert-dialog-title">Block this person?</DialogTitle> <DialogTitle id="alert-dialog-title">
Block this person?
</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText id="alert-dialog-description"> <DialogContentText id="alert-dialog-description">
Are you sure you want to block this person? You won't see their posts on your home feed, local timeline, or public timeline. Are you sure you want to block this person? You won't see their
posts on your home feed, local timeline, or public timeline.
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => this.toggleBlockDialog()} color="primary" autoFocus> <Button
onClick={() => this.toggleBlockDialog()}
color="primary"
autoFocus
>
Cancel Cancel
</Button> </Button>
<Button onClick={() => { <Button
onClick={() => {
this.toggleBlock(); this.toggleBlock();
this.toggleBlockDialog(); this.toggleBlockDialog();
}} color="primary"> }}
color="primary"
>
Block Block
</Button> </Button>
</DialogActions> </DialogActions>

View File

@ -1,11 +1,20 @@
import React, { Component } from 'react'; import React, { Component } from "react";
import { withStyles, CircularProgress, Typography, Paper, Button, Chip, Avatar, Slide} from '@material-ui/core'; import {
import {styles} from './PageLayout.styles'; withStyles,
import Post from '../components/Post'; CircularProgress,
import { Status } from '../types/Status'; Typography,
import Mastodon, { StreamListener } from 'megalodon'; Paper,
import {withSnackbar} from 'notistack'; Button,
import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward'; Chip,
Avatar,
Slide
} from "@material-ui/core";
import { styles } from "./PageLayout.styles";
import Post from "../components/Post";
import { Status } from "../types/Status";
import Mastodon, { StreamListener } from "megalodon";
import { withSnackbar } from "notistack";
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
interface IPublicPageState { interface IPublicPageState {
posts?: [Status]; posts?: [Status];
@ -16,9 +25,7 @@ interface IPublicPageState {
viewDidErrorCode?: any; viewDidErrorCode?: any;
} }
class PublicPage extends Component<any, IPublicPageState> { class PublicPage extends Component<any, IPublicPageState> {
client: Mastodon; client: Mastodon;
streamListener: StreamListener; streamListener: StreamListener;
@ -28,67 +35,74 @@ class PublicPage extends Component<any, IPublicPageState> {
this.state = { this.state = {
viewIsLoading: true, viewIsLoading: true,
backlogPosts: null backlogPosts: null
} };
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1");
this.streamListener = this.client.stream('/streaming/public');
this.client = new Mastodon(
localStorage.getItem("access_token") as string,
(localStorage.getItem("baseurl") as string) + "/api/v1"
);
this.streamListener = this.client.stream("/streaming/public");
} }
componentWillMount() { componentWillMount() {
this.streamListener.on('connect', () => { this.streamListener.on("connect", () => {
this.client.get('/timelines/public', {limit: 40}).then((resp: any) => { this.client
.get("/timelines/public", { limit: 40 })
.then((resp: any) => {
let statuses: [Status] = resp.data; let statuses: [Status] = resp.data;
this.setState({ this.setState({
posts: statuses, posts: statuses,
viewIsLoading: false, viewIsLoading: false,
viewDidLoad: true, viewDidLoad: true,
viewDidError: false viewDidError: false
});
}) })
}).catch((resp: any) => { .catch((resp: any) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidLoad: true, viewDidLoad: true,
viewDidError: true, viewDidError: true,
viewDidErrorCode: String(resp) viewDidErrorCode: String(resp)
}) });
this.props.enqueueSnackbar("Failed to get posts.", { this.props.enqueueSnackbar("Failed to get posts.", {
variant: 'error', variant: "error"
});
}); });
})
}); });
this.streamListener.on('update', (status: Status) => { this.streamListener.on("update", (status: Status) => {
let queue = this.state.backlogPosts; let queue = this.state.backlogPosts;
if (queue !== null && queue !== undefined) { queue.unshift(status); } else { queue = [status] } if (queue !== null && queue !== undefined) {
queue.unshift(status);
} else {
queue = [status];
}
this.setState({ backlogPosts: queue }); this.setState({ backlogPosts: queue });
}) });
this.streamListener.on('delete', (id: number) => { this.streamListener.on("delete", (id: number) => {
let posts = this.state.posts; let posts = this.state.posts;
if (posts) { if (posts) {
posts.forEach((post: Status) => { posts.forEach((post: Status) => {
if (posts && parseInt(post.id) === id) { if (posts && parseInt(post.id) === id) {
posts.splice(posts.indexOf(post), 1); posts.splice(posts.indexOf(post), 1);
} }
}) });
this.setState({ posts }); this.setState({ posts });
} }
}) });
this.streamListener.on('error', (err: Error) => { this.streamListener.on("error", (err: Error) => {
this.setState({ this.setState({
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.message viewDidErrorCode: err.message
})
this.props.enqueueSnackbar("An error occured.", {
variant: 'error',
}); });
}) this.props.enqueueSnackbar("An error occured.", {
variant: "error"
});
});
this.streamListener.on('heartbeat', () => { this.streamListener.on("heartbeat", () => {});
})
} }
componentWillUnmount() { componentWillUnmount() {
@ -101,14 +115,19 @@ class PublicPage extends Component<any, IPublicPageState> {
let backlog = this.state.backlogPosts; let backlog = this.state.backlogPosts;
if (posts && backlog && backlog.length > 0) { if (posts && backlog && backlog.length > 0) {
let push = backlog.concat(posts); let push = backlog.concat(posts);
this.setState({ posts: push as [Status], backlogPosts: null }) this.setState({ posts: push as [Status], backlogPosts: null });
} }
} }
loadMoreTimelinePieces() { loadMoreTimelinePieces() {
this.setState({ viewDidLoad: false, viewIsLoading: true}) this.setState({ viewDidLoad: false, viewIsLoading: true });
if (this.state.posts) { if (this.state.posts) {
this.client.get('/timelines/public', { max_id: this.state.posts[this.state.posts.length - 1].id, limit: 20 }).then((resp: any) => { this.client
.get("/timelines/public", {
max_id: this.state.posts[this.state.posts.length - 1].id,
limit: 20
})
.then((resp: any) => {
let newPosts: [Status] = resp.data; let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status]; let posts = this.state.posts as [Status];
newPosts.forEach((post: Status) => { newPosts.forEach((post: Status) => {
@ -118,37 +137,39 @@ class PublicPage extends Component<any, IPublicPageState> {
viewIsLoading: false, viewIsLoading: false,
viewDidLoad: true, viewDidLoad: true,
posts posts
});
}) })
}).catch((err: Error) => { .catch((err: Error) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.message viewDidErrorCode: err.message
})
this.props.enqueueSnackbar("Failed to get posts", {
variant: 'error',
}); });
}) this.props.enqueueSnackbar("Failed to get posts", {
variant: "error"
});
});
} }
} }
render() { render() {
const {classes} = this.props; const { classes } = this.props;
return ( return (
<div className={classes.pageLayoutMaxConstraints}> <div className={classes.pageLayoutMaxConstraints}>
{ {this.state.backlogPosts ? (
this.state.backlogPosts?
<div className={classes.pageTopChipContainer}> <div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}> <div className={classes.pageTopChips}>
<Slide direction="down" in={true}> <Slide direction="down" in={true}>
<Chip <Chip
avatar={ avatar={
<Avatar> <Avatar>
<ArrowUpwardIcon/> <ArrowUpwardIcon />
</Avatar> </Avatar>
} }
label={`View ${this.state.backlogPosts.length} new post${this.state.backlogPosts.length > 1? "s": ""}`} label={`View ${this.state.backlogPosts.length} new post${
this.state.backlogPosts.length > 1 ? "s" : ""
}`}
color="primary" color="primary"
className={classes.pageTopChip} className={classes.pageTopChip}
onClick={() => this.insertBacklog()} onClick={() => this.insertBacklog()}
@ -156,34 +177,46 @@ class PublicPage extends Component<any, IPublicPageState> {
/> />
</Slide> </Slide>
</div> </div>
</div>: null </div>
} ) : null}
{ this.state.posts? {this.state.posts ? (
<div> <div>
{this.state.posts.map((post: Status) => { {this.state.posts.map((post: Status) => {
return <Post key={post.id} post={post} client={this.client}/> return <Post key={post.id} post={post} client={this.client} />;
})} })}
<br/> <br />
{ {this.state.viewDidLoad && !this.state.viewDidError ? (
this.state.viewDidLoad && !this.state.viewDidError? <div style={{textAlign: "center"}} onClick={() => this.loadMoreTimelinePieces()}><Button variant="contained">Load more</Button></div>: null <div
} style={{ textAlign: "center" }}
</div>: onClick={() => this.loadMoreTimelinePieces()}
<span/> >
} <Button variant="contained">Load more</Button>
{ </div>
this.state.viewDidError? ) : null}
</div>
) : (
<span />
)}
{this.state.viewDidError ? (
<Paper className={classes.errorCard}> <Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography> <Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this timeline.</Typography> <Typography variant="h6">
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography> Something went wrong when loading this timeline.
</Paper>: </Typography>
<span/> <Typography>
} {this.state.viewDidErrorCode ? this.state.viewDidErrorCode : ""}
{ </Typography>
this.state.viewIsLoading? </Paper>
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>: ) : (
<span/> <span />
} )}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress className={classes.progress} color="primary" />
</div>
) : (
<span />
)}
</div> </div>
); );
} }

View File

@ -1,15 +1,30 @@
import React, {Component} from 'react'; import React, { Component } from "react";
import {withStyles, Typography, List, ListItem, Paper, ListItemText, Avatar, ListItemSecondaryAction, ListItemAvatar, ListSubheader, CircularProgress, IconButton, Divider, Tooltip} from '@material-ui/core'; import {
import {styles} from './PageLayout.styles'; withStyles,
import Mastodon from 'megalodon'; Typography,
import {Account} from '../types/Account'; List,
import { LinkableIconButton, LinkableAvatar } from '../interfaces/overrides'; ListItem,
import AccountCircleIcon from '@material-ui/icons/AccountCircle'; Paper,
import AssignmentIndIcon from '@material-ui/icons/AssignmentInd'; ListItemText,
import PersonAddIcon from '@material-ui/icons/PersonAdd'; Avatar,
import CheckIcon from '@material-ui/icons/Check'; ListItemSecondaryAction,
import CloseIcon from '@material-ui/icons/Close'; ListItemAvatar,
import {withSnackbar, withSnackbarProps} from 'notistack'; ListSubheader,
CircularProgress,
IconButton,
Divider,
Tooltip
} from "@material-ui/core";
import { styles } from "./PageLayout.styles";
import Mastodon from "megalodon";
import { Account } from "../types/Account";
import { LinkableIconButton, LinkableAvatar } from "../interfaces/overrides";
import AccountCircleIcon from "@material-ui/icons/AccountCircle";
import AssignmentIndIcon from "@material-ui/icons/AssignmentInd";
import PersonAddIcon from "@material-ui/icons/PersonAdd";
import CheckIcon from "@material-ui/icons/Check";
import CloseIcon from "@material-ui/icons/Close";
import { withSnackbar, withSnackbarProps } from "notistack";
interface IRecommendationsPageProps extends withSnackbarProps { interface IRecommendationsPageProps extends withSnackbarProps {
classes: any; classes: any;
@ -24,51 +39,64 @@ interface IRecommendationsPageState {
followSuggestions?: [Account]; followSuggestions?: [Account];
} }
class RecommendationsPage extends Component<IRecommendationsPageProps, IRecommendationsPageState> { class RecommendationsPage extends Component<
IRecommendationsPageProps,
IRecommendationsPageState
> {
client: Mastodon; client: Mastodon;
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') + "/api/v1"); this.client = new Mastodon(
localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1"
);
this.state = { this.state = {
viewIsLoading: true viewIsLoading: true
} };
} }
componentDidMount() { componentDidMount() {
this.client.get('/follow_requests').then((resp: any) => { this.client
.get("/follow_requests")
.then((resp: any) => {
let requestedFollows: [Account] = resp.data; let requestedFollows: [Account] = resp.data;
this.setState({ requestedFollows }) this.setState({ requestedFollows });
}).catch((err: Error) => { })
.catch((err: Error) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.name viewDidErrorCode: err.name
}); });
console.error(err.message); console.error(err.message);
}) });
this.client.get('/suggestions').then((resp: any) => { this.client
.get("/suggestions")
.then((resp: any) => {
let followSuggestions: [Account] = resp.data; let followSuggestions: [Account] = resp.data;
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidLoad: true, viewDidLoad: true,
followSuggestions followSuggestions
});
}) })
}).catch((err: Error) => { .catch((err: Error) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.name viewDidErrorCode: err.name
}); });
console.error(err.message); console.error(err.message);
}) });
} }
followMember(acct: Account) { followMember(acct: Account) {
this.client.post(`/accounts/${acct.id}/follow`).then((resp: any) => { this.client
this.props.enqueueSnackbar('You are now following this account.'); .post(`/accounts/${acct.id}/follow`)
.then((resp: any) => {
this.props.enqueueSnackbar("You are now following this account.");
this.client.del(`/suggestions/${acct.id}`).then((resp: any) => { this.client.del(`/suggestions/${acct.id}`).then((resp: any) => {
let followSuggestions = this.state.followSuggestions; let followSuggestions = this.state.followSuggestions;
if (followSuggestions) { if (followSuggestions) {
@ -79,158 +107,203 @@ class RecommendationsPage extends Component<IRecommendationsPageProps, IRecommen
}); });
this.setState({ followSuggestions }); this.setState({ followSuggestions });
} }
});
}) })
}).catch((err: Error) => { .catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't follow account: " + err.name, { variant: 'error' }); this.props.enqueueSnackbar("Couldn't follow account: " + err.name, {
variant: "error"
});
console.error(err.message); console.error(err.message);
}) });
} }
handleFollowRequest(acct: Account, type: "authorize" | "reject") { handleFollowRequest(acct: Account, type: "authorize" | "reject") {
this.client.post(`/follow_requests/${acct.id}/${type}`).then((resp: any) => { this.client
.post(`/follow_requests/${acct.id}/${type}`)
.then((resp: any) => {
let requestedFollows = this.state.requestedFollows; let requestedFollows = this.state.requestedFollows;
if (requestedFollows) { if (requestedFollows) {
requestedFollows.forEach((request: Account, index: number) => { requestedFollows.forEach((request: Account, index: number) => {
if (requestedFollows && request.id === acct.id) { if (requestedFollows && request.id === acct.id) {
requestedFollows.splice(index, 1); requestedFollows.splice(index, 1);
}; }
}); });
}; }
this.setState({requestedFollows}); this.setState({ requestedFollows });
let verb: string = type; let verb: string = type;
verb === "authorize"? verb = "authorized": verb = "rejected"; verb === "authorize" ? (verb = "authorized") : (verb = "rejected");
this.props.enqueueSnackbar(`You have ${verb} this request.`); this.props.enqueueSnackbar(`You have ${verb} this request.`);
}).catch((err: Error) => {
this.props.enqueueSnackbar(`Couldn't ${type} this request: ${err.name}`, { variant: 'error' });
console.error(err.message);
}) })
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't ${type} this request: ${err.name}`,
{ variant: "error" }
);
console.error(err.message);
});
} }
showFollowRequests() { showFollowRequests() {
const {classes} = this.props; const { classes } = this.props;
return ( return (
<div> <div>
<ListSubheader>Follow requests</ListSubheader> <ListSubheader>Follow requests</ListSubheader>
<Paper className={classes.pageListConstraints}> <Paper className={classes.pageListConstraints}>
<List> <List>
{ {this.state.requestedFollows
this.state.requestedFollows? ? this.state.requestedFollows.map((request: Account) => {
this.state.requestedFollows.map((request: Account) => {
return ( return (
<ListItem key={request.id}> <ListItem key={request.id}>
<ListItemAvatar> <ListItemAvatar>
<LinkableAvatar to={`/profile/${request.id}`} alt={request.username} src={request.avatar_static}/> <LinkableAvatar
to={`/profile/${request.id}`}
alt={request.username}
src={request.avatar_static}
/>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={request.display_name || request.acct} secondary={request.acct}/> <ListItemText
primary={request.display_name || request.acct}
secondary={request.acct}
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Tooltip title="Accept request"> <Tooltip title="Accept request">
<IconButton onClick={() => this.handleFollowRequest(request, "authorize")}> <IconButton
<CheckIcon/> onClick={() =>
this.handleFollowRequest(request, "authorize")
}
>
<CheckIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Reject request"> <Tooltip title="Reject request">
<IconButton onClick={() => this.handleFollowRequest(request, "reject")}> <IconButton
<CloseIcon/> onClick={() =>
this.handleFollowRequest(request, "reject")
}
>
<CloseIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="View profile"> <Tooltip title="View profile">
<LinkableIconButton to={`/profile/${request.id}`}> <LinkableIconButton to={`/profile/${request.id}`}>
<AccountCircleIcon/> <AccountCircleIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
); );
}): null })
} : null}
</List> </List>
</Paper> </Paper>
<br/> <br />
</div> </div>
) );
} }
showFollowSuggestions() { showFollowSuggestions() {
const {classes} = this.props; const { classes } = this.props;
return ( return (
<div> <div>
<ListSubheader>Suggested accounts</ListSubheader> <ListSubheader>Suggested accounts</ListSubheader>
<Paper className={classes.pageListConstraints}> <Paper className={classes.pageListConstraints}>
<List> <List>
{ {this.state.followSuggestions
this.state.followSuggestions? ? this.state.followSuggestions.map((suggestion: Account) => {
this.state.followSuggestions.map((suggestion: Account) => {
return ( return (
<ListItem key={suggestion.id}> <ListItem key={suggestion.id}>
<ListItemAvatar> <ListItemAvatar>
<LinkableAvatar to={`/profile/${suggestion.id}`} alt={suggestion.username} src={suggestion.avatar_static}/> <LinkableAvatar
to={`/profile/${suggestion.id}`}
alt={suggestion.username}
src={suggestion.avatar_static}
/>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={suggestion.display_name || suggestion.acct} secondary={suggestion.acct}/> <ListItemText
primary={suggestion.display_name || suggestion.acct}
secondary={suggestion.acct}
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Tooltip title="View profile"> <Tooltip title="View profile">
<LinkableIconButton to={`/profile/${suggestion.id}`}> <LinkableIconButton to={`/profile/${suggestion.id}`}>
<AssignmentIndIcon/> <AssignmentIndIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
<Tooltip title="Follow"> <Tooltip title="Follow">
<IconButton onClick={() => this.followMember(suggestion)}> <IconButton
<PersonAddIcon/> onClick={() => this.followMember(suggestion)}
>
<PersonAddIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
); );
}): null })
} : null}
</List> </List>
</Paper> </Paper>
<br/> <br />
</div> </div>
) );
} }
render() { render() {
const {classes} = this.props; const { classes } = this.props;
return ( return (
<div className={classes.pageLayoutConstraints}> <div className={classes.pageLayoutConstraints}>
{ {this.state.viewDidLoad ? (
this.state.viewDidLoad?
<div> <div>
{ {this.state.requestedFollows &&
this.state.requestedFollows && this.state.requestedFollows.length > 0? this.state.requestedFollows.length > 0 ? (
this.showFollowRequests(): this.showFollowRequests()
) : (
<div className={classes.pageLayoutEmptyTextConstraints}> <div className={classes.pageLayoutEmptyTextConstraints}>
<Typography variant="h6">You don't have any follow requests.</Typography> <Typography variant="h6">
<br/> You don't have any follow requests.
</Typography>
<br />
</div> </div>
} )}
<Divider/> <Divider />
<br/> <br />
{ {this.state.followSuggestions &&
this.state.followSuggestions && this.state.followSuggestions.length > 0? this.showFollowSuggestions(): this.state.followSuggestions.length > 0 ? (
this.showFollowSuggestions()
) : (
<div className={classes.pageLayoutEmptyTextConstraints}> <div className={classes.pageLayoutEmptyTextConstraints}>
<Typography variant="h5">We don't have any suggestions for you.</Typography> <Typography variant="h5">
<Typography paragraph>Why not interact with the fediverse a bit by creating a new post?</Typography> We don't have any suggestions for you.
</Typography>
<Typography paragraph>
Why not interact with the fediverse a bit by creating a new
post?
</Typography>
</div> </div>
} )}
</div>: null </div>
} ) : null}
{ {this.state.viewDidError ? (
this.state.viewDidError?
<Paper className={classes.errorCard}> <Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography> <Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this timeline.</Typography> <Typography variant="h6">
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography> Something went wrong when loading this timeline.
</Paper>: </Typography>
<span/> <Typography>
} {this.state.viewDidErrorCode ? this.state.viewDidErrorCode : ""}
{ </Typography>
this.state.viewIsLoading? </Paper>
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>: ) : (
<span/> <span />
} )}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress className={classes.progress} color="primary" />
</div>
) : (
<span />
)}
</div> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react'; import React, { Component } from "react";
import { import {
List, List,
ListItem, ListItem,
@ -13,19 +13,19 @@ import {
CircularProgress, CircularProgress,
Tooltip, Tooltip,
IconButton IconButton
} from '@material-ui/core'; } from "@material-ui/core";
import PersonIcon from '@material-ui/icons/Person'; import PersonIcon from "@material-ui/icons/Person";
import AssignmentIndIcon from '@material-ui/icons/AssignmentInd'; import AssignmentIndIcon from "@material-ui/icons/AssignmentInd";
import PersonAddIcon from '@material-ui/icons/PersonAdd'; import PersonAddIcon from "@material-ui/icons/PersonAdd";
import {styles} from './PageLayout.styles'; import { styles } from "./PageLayout.styles";
import {LinkableIconButton, LinkableAvatar} from '../interfaces/overrides'; import { LinkableIconButton, LinkableAvatar } from "../interfaces/overrides";
import Mastodon from 'megalodon'; import Mastodon from "megalodon";
import {parse as parseParams, ParsedQuery} from 'query-string'; import { parse as parseParams, ParsedQuery } from "query-string";
import { Results } from '../types/Search'; import { Results } from "../types/Search";
import { withSnackbar } from 'notistack'; import { withSnackbar } from "notistack";
import Post from '../components/Post'; import Post from "../components/Post";
import { Status } from '../types/Status'; import { Status } from "../types/Status";
import { Account } from '../types/Account'; import { Account } from "../types/Account";
interface ISearchPageState { interface ISearchPageState {
query: string[] | string; query: string[] | string;
@ -39,13 +39,15 @@ interface ISearchPageState {
} }
class SearchPage extends Component<any, ISearchPageState> { class SearchPage extends Component<any, ISearchPageState> {
client: Mastodon; client: Mastodon;
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') + "/api/v2"); this.client = new Mastodon(
localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v2"
);
let searchParams = this.getQueryAndType(props); let searchParams = this.getQueryAndType(props);
@ -53,18 +55,23 @@ class SearchPage extends Component<any, ISearchPageState> {
viewIsLoading: true, viewIsLoading: true,
query: searchParams.query, query: searchParams.query,
type: searchParams.type type: searchParams.type
} };
if (searchParams.type === "tag") { if (searchParams.type === "tag") {
this.searchForPostsWithTags(searchParams.query); this.searchForPostsWithTags(searchParams.query);
} else { } else {
this.searchQuery(searchParams.query); this.searchQuery(searchParams.query);
} }
} }
componentWillReceiveProps(props: any) { componentWillReceiveProps(props: any) {
this.setState({ viewDidLoad: false, viewIsLoading: true, viewDidError: false, viewDidErrorCode: '', results: undefined}); this.setState({
viewDidLoad: false,
viewIsLoading: true,
viewDidError: false,
viewDidErrorCode: "",
results: undefined
});
let searchParams = this.getQueryAndType(props); let searchParams = this.getQueryAndType(props);
this.setState({ query: searchParams.query, type: searchParams.type }); this.setState({ query: searchParams.query, type: searchParams.type });
if (searchParams.type === "tag") { if (searchParams.type === "tag") {
@ -76,7 +83,7 @@ class SearchPage extends Component<any, ISearchPageState> {
runQueryCheck(newLocation?: string): ParsedQuery { runQueryCheck(newLocation?: string): ParsedQuery {
let searchParams = ""; let searchParams = "";
if (newLocation !== undefined && typeof(newLocation) === "string") { if (newLocation !== undefined && typeof newLocation === "string") {
searchParams = newLocation.replace("#/search", ""); searchParams = newLocation.replace("#/search", "");
} else { } else {
searchParams = location.hash.replace("#/search", ""); searchParams = location.hash.replace("#/search", "");
@ -110,27 +117,38 @@ class SearchPage extends Component<any, ISearchPageState> {
} }
searchQuery(query: string | string[]) { searchQuery(query: string | string[]) {
this.client.get('/search', {q: query}).then((resp: any) => { this.client
.get("/search", { q: query })
.then((resp: any) => {
let results: Results = resp.data; let results: Results = resp.data;
this.setState({ this.setState({
results, results,
viewDidLoad: true, viewDidLoad: true,
viewIsLoading: false viewIsLoading: false
}); });
}).catch((err: Error) => { })
.catch((err: Error) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.message viewDidErrorCode: err.message
}) });
this.props.enqueueSnackbar(`Couldn't search for ${this.state.query}: ${err.name}`, { variant: 'error' }); this.props.enqueueSnackbar(
`Couldn't search for ${this.state.query}: ${err.name}`,
{ variant: "error" }
);
}); });
} }
searchForPostsWithTags(query: string | string[]) { searchForPostsWithTags(query: string | string[]) {
let client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') + "/api/v1"); let client = new Mastodon(
client.get(`/timelines/tag/${query}`).then((resp: any) => { localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1"
);
client
.get(`/timelines/tag/${query}`)
.then((resp: any) => {
let tagResults: [Status] = resp.data; let tagResults: [Status] = resp.data;
this.setState({ this.setState({
tagResults, tagResults,
@ -138,25 +156,37 @@ class SearchPage extends Component<any, ISearchPageState> {
viewIsLoading: false viewIsLoading: false
}); });
console.log(this.state.tagResults); console.log(this.state.tagResults);
}).catch((err: Error) => { })
.catch((err: Error) => {
this.setState({ this.setState({
viewIsLoading: false, viewIsLoading: false,
viewDidError: true, viewDidError: true,
viewDidErrorCode: err.message viewDidErrorCode: err.message
}) });
this.props.enqueueSnackbar(`Couldn't search for posts with tag ${this.state.query}: ${err.name}`, { variant: 'error' }); this.props.enqueueSnackbar(
`Couldn't search for posts with tag ${this.state.query}: ${err.name}`,
{ variant: "error" }
);
}); });
} }
followMemberFromQuery(acct: Account) { followMemberFromQuery(acct: Account) {
let client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') + "/api/v1"); let client = new Mastodon(
client.post(`/accounts/${acct.id}/follow`).then((resp: any) => { localStorage.getItem("access_token") as string,
this.props.enqueueSnackbar('You are now following this account.'); localStorage.getItem("baseurl") + "/api/v1"
}).catch((err: Error) => { );
this.props.enqueueSnackbar("Couldn't follow account: " + err.name, { variant: 'error' }); client
console.error(err.message); .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);
});
} }
showAllAccountsFromQuery() { showAllAccountsFromQuery() {
@ -165,26 +195,34 @@ class SearchPage extends Component<any, ISearchPageState> {
<div> <div>
<ListSubheader>Accounts</ListSubheader> <ListSubheader>Accounts</ListSubheader>
{ {this.state.results && this.state.results.accounts.length > 0 ? (
this.state.results && this.state.results.accounts.length > 0?
<Paper className={classes.pageListConstraints}> <Paper className={classes.pageListConstraints}>
<List> <List>
{ this.state.results.accounts.map((acct: Account) => { {this.state.results.accounts.map((acct: Account) => {
return ( return (
<ListItem key={acct.id}> <ListItem key={acct.id}>
<ListItemAvatar> <ListItemAvatar>
<LinkableAvatar to={`/profile/${acct.id}`} alt={acct.username} src={acct.avatar_static}/> <LinkableAvatar
to={`/profile/${acct.id}`}
alt={acct.username}
src={acct.avatar_static}
/>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={acct.display_name || acct.acct} secondary={acct.acct}/> <ListItemText
primary={acct.display_name || acct.acct}
secondary={acct.acct}
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Tooltip title="View profile"> <Tooltip title="View profile">
<LinkableIconButton to={`/profile/${acct.id}`}> <LinkableIconButton to={`/profile/${acct.id}`}>
<AssignmentIndIcon/> <AssignmentIndIcon />
</LinkableIconButton> </LinkableIconButton>
</Tooltip> </Tooltip>
<Tooltip title="Follow"> <Tooltip title="Follow">
<IconButton onClick={() => this.followMemberFromQuery(acct)}> <IconButton
<PersonAddIcon/> onClick={() => this.followMemberFromQuery(acct)}
>
<PersonAddIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</ListItemSecondaryAction> </ListItemSecondaryAction>
@ -192,43 +230,63 @@ class SearchPage extends Component<any, ISearchPageState> {
); );
})} })}
</List> </List>
</Paper>: <Typography variant="caption" className={classes.pageLayoutEmptyTextConstraints}>No results found</Typography> </Paper>
} ) : (
<Typography
variant="caption"
className={classes.pageLayoutEmptyTextConstraints}
>
No results found
</Typography>
)}
<br />
<br/>
</div> </div>
) );
} }
showAllPostsFromQuery() { showAllPostsFromQuery() {
const {classes} = this.props; const { classes } = this.props;
return ( return (
<div> <div>
<ListSubheader>Posts</ListSubheader> <ListSubheader>Posts</ListSubheader>
{ {this.state.results ? (
this.state.results? this.state.results.statuses.length > 0 ? (
this.state.results.statuses.length > 0?
this.state.results.statuses.map((post: Status) => { this.state.results.statuses.map((post: Status) => {
return <Post key={post.id} post={post} client={this.client}/> return <Post key={post.id} post={post} client={this.client} />;
}): <Typography variant="caption" className={classes.pageLayoutEmptyTextConstraints}>No results found.</Typography>: null })
} ) : (
<Typography
variant="caption"
className={classes.pageLayoutEmptyTextConstraints}
>
No results found.
</Typography>
)
) : null}
</div> </div>
); );
} }
showAllPostsWithTag() { showAllPostsWithTag() {
const {classes} = this.props; const { classes } = this.props;
return ( return (
<div> <div>
<ListSubheader>Tagged posts</ListSubheader> <ListSubheader>Tagged posts</ListSubheader>
{ {this.state.tagResults ? (
this.state.tagResults? this.state.tagResults.length > 0 ? (
this.state.tagResults.length > 0?
this.state.tagResults.map((post: Status) => { this.state.tagResults.map((post: Status) => {
return <Post key={post.id} post={post} client={this.client}/> return <Post key={post.id} post={post} client={this.client} />;
}): <Typography variant="caption" className={classes.pageLayoutEmptyTextConstraints}>No results found.</Typography>: null })
} ) : (
<Typography
variant="caption"
className={classes.pageLayoutEmptyTextConstraints}
>
No results found.
</Typography>
)
) : null}
</div> </div>
); );
} }
@ -237,32 +295,37 @@ class SearchPage extends Component<any, ISearchPageState> {
const { classes } = this.props; const { classes } = this.props;
return ( return (
<div className={classes.pageLayoutConstraints}> <div className={classes.pageLayoutConstraints}>
{ {this.state.type && this.state.type === "tag" ? (
this.state.type && this.state.type === "tag"? this.showAllPostsWithTag()
this.showAllPostsWithTag(): ) : (
<div> <div>
{this.showAllAccountsFromQuery()} {this.showAllAccountsFromQuery()}
{this.showAllPostsFromQuery()} {this.showAllPostsFromQuery()}
</div> </div>
} )}
{ {this.state.viewDidError ? (
this.state.viewDidError?
<Paper className={classes.errorCard}> <Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography> <Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this timeline.</Typography> <Typography variant="h6">
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography> Something went wrong when loading this timeline.
</Paper>: </Typography>
<span/> <Typography>
} {this.state.viewDidErrorCode ? this.state.viewDidErrorCode : ""}
{ </Typography>
this.state.viewIsLoading? </Paper>
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>: ) : (
<span/> <span />
} )}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress className={classes.progress} color="primary" />
</div>
) : (
<span />
)}
</div> </div>
); );
} }
} }
export default withStyles(styles)(withSnackbar(SearchPage)); export default withStyles(styles)(withSnackbar(SearchPage));

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react'; import React, { Component } from "react";
import { import {
List, List,
ListItem, ListItem,
@ -22,28 +22,44 @@ import {
Grid, Grid,
Theme, Theme,
Typography Typography
} from '@material-ui/core'; } from "@material-ui/core";
import {styles} from './PageLayout.styles'; import { styles } from "./PageLayout.styles";
import {setUserDefaultBool, getUserDefaultBool, getUserDefaultTheme, setUserDefaultTheme, getUserDefaultVisibility, setUserDefaultVisibility, getConfig} from '../utilities/settings'; import {
import {canSendNotifications, browserSupportsNotificationRequests} from '../utilities/notifications'; setUserDefaultBool,
import {themes, defaultTheme} from '../types/HyperspaceTheme'; getUserDefaultBool,
import ThemePreview from '../components/ThemePreview'; getUserDefaultTheme,
import {setHyperspaceTheme, getHyperspaceTheme, getDarkModeFromSystem} from '../utilities/themes'; setUserDefaultTheme,
import { Visibility } from '../types/Visibility'; getUserDefaultVisibility,
import {LinkableButton} from '../interfaces/overrides'; setUserDefaultVisibility,
getConfig
} from "../utilities/settings";
import {
canSendNotifications,
browserSupportsNotificationRequests
} from "../utilities/notifications";
import { themes, defaultTheme } from "../types/HyperspaceTheme";
import ThemePreview from "../components/ThemePreview";
import {
setHyperspaceTheme,
getHyperspaceTheme,
getDarkModeFromSystem
} from "../utilities/themes";
import { Visibility } from "../types/Visibility";
import { LinkableButton } from "../interfaces/overrides";
import { Config } from "../types/Config";
import OpenInNewIcon from '@material-ui/icons/OpenInNew'; import OpenInNewIcon from "@material-ui/icons/OpenInNew";
import DevicesIcon from '@material-ui/icons/Devices'; import DevicesIcon from "@material-ui/icons/Devices";
import Brightness3Icon from '@material-ui/icons/Brightness3'; import Brightness3Icon from "@material-ui/icons/Brightness3";
import PaletteIcon from '@material-ui/icons/Palette'; import PaletteIcon from "@material-ui/icons/Palette";
import AccountEditIcon from 'mdi-material-ui/AccountEdit'; import AccountEditIcon from "mdi-material-ui/AccountEdit";
import MastodonIcon from 'mdi-material-ui/Mastodon'; import MastodonIcon from "mdi-material-ui/Mastodon";
import VisibilityIcon from '@material-ui/icons/Visibility'; import VisibilityIcon from "@material-ui/icons/Visibility";
import NotificationsIcon from '@material-ui/icons/Notifications'; import NotificationsIcon from "@material-ui/icons/Notifications";
import BellAlertIcon from 'mdi-material-ui/BellAlert'; import BellAlertIcon from "mdi-material-ui/BellAlert";
import RefreshIcon from '@material-ui/icons/Refresh'; import RefreshIcon from "@material-ui/icons/Refresh";
import UndoIcon from '@material-ui/icons/Undo'; import UndoIcon from "@material-ui/icons/Undo";
import { Config } from '../types/Config'; import CancelIcon from "@material-ui/icons/Cancel";
interface ISettingsState { interface ISettingsState {
darkModeEnabled: boolean; darkModeEnabled: boolean;
@ -62,25 +78,28 @@ interface ISettingsState {
} }
class SettingsPage extends Component<any, ISettingsState> { class SettingsPage extends Component<any, ISettingsState> {
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.state = { this.state = {
darkModeEnabled: getUserDefaultBool('darkModeEnabled'), darkModeEnabled: getUserDefaultBool("darkModeEnabled"),
systemDecidesDarkMode: getUserDefaultBool('systemDecidesDarkMode'), systemDecidesDarkMode: getUserDefaultBool("systemDecidesDarkMode"),
pushNotificationsEnabled: canSendNotifications(), pushNotificationsEnabled: canSendNotifications(),
badgeDisplaysAllNotifs: getUserDefaultBool('displayAllOnNotificationBadge'), badgeDisplaysAllNotifs: getUserDefaultBool(
"displayAllOnNotificationBadge"
),
selectThemeName: getUserDefaultTheme().key, selectThemeName: getUserDefaultTheme().key,
themeDialogOpen: false, themeDialogOpen: false,
visibilityDialogOpen: false, visibilityDialogOpen: false,
resetHyperspaceDialog: false, resetHyperspaceDialog: false,
resetSettingsDialog: false, resetSettingsDialog: false,
previewTheme: setHyperspaceTheme(getUserDefaultTheme()) || setHyperspaceTheme(defaultTheme), previewTheme:
setHyperspaceTheme(getUserDefaultTheme()) ||
setHyperspaceTheme(defaultTheme),
defaultVisibility: getUserDefaultVisibility() || "public", defaultVisibility: getUserDefaultVisibility() || "public",
brandName: "Hyperspace", brandName: "Hyperspace",
federated: true federated: true
} };
this.toggleDarkMode = this.toggleDarkMode.bind(this); this.toggleDarkMode = this.toggleDarkMode.bind(this);
this.toggleSystemDarkMode = this.toggleSystemDarkMode.bind(this); this.toggleSystemDarkMode = this.toggleSystemDarkMode.bind(this);
@ -94,11 +113,13 @@ class SettingsPage extends Component<any, ISettingsState> {
} }
componentDidMount() { componentDidMount() {
getConfig().then((config: any) => { getConfig()
.then((config: any) => {
this.setState({ this.setState({
brandName: config.branding.name brandName: config.branding.name
});
}) })
}).catch((err: Error) => { .catch((err: Error) => {
console.error(err.message); console.error(err.message);
}); });
this.getFederatedStatus(); this.getFederatedStatus();
@ -109,32 +130,49 @@ class SettingsPage extends Component<any, ISettingsState> {
getConfig().then((result: any) => { getConfig().then((result: any) => {
if (result !== undefined) { if (result !== undefined) {
let config: Config = result; let config: Config = result;
console.log(config.federation.allowPublicPosts === false) console.log(config.federation.allowPublicPosts === false);
this.setState({ federated: config.federation.allowPublicPosts }); this.setState({
federated: config.federation.allowPublicPosts
});
} }
}) });
} }
toggleDarkMode() { toggleDarkMode() {
this.setState({ darkModeEnabled: !this.state.darkModeEnabled }); this.setState({ darkModeEnabled: !this.state.darkModeEnabled });
setUserDefaultBool('darkModeEnabled', !this.state.darkModeEnabled); setUserDefaultBool("darkModeEnabled", !this.state.darkModeEnabled);
window.location.reload(); window.location.reload();
} }
toggleSystemDarkMode() { toggleSystemDarkMode() {
this.setState({ systemDecidesDarkMode: !this.state.systemDecidesDarkMode }); this.setState({
setUserDefaultBool('systemDecidesDarkMode', !this.state.systemDecidesDarkMode); systemDecidesDarkMode: !this.state.systemDecidesDarkMode
});
setUserDefaultBool(
"systemDecidesDarkMode",
!this.state.systemDecidesDarkMode
);
window.location.reload(); window.location.reload();
} }
togglePushNotifications() { togglePushNotifications() {
this.setState({ pushNotificationsEnabled: !this.state.pushNotificationsEnabled }); this.setState({
setUserDefaultBool('enablePushNotifications', !this.state.pushNotificationsEnabled); pushNotificationsEnabled: !this.state.pushNotificationsEnabled
});
setUserDefaultBool(
"enablePushNotifications",
!this.state.pushNotificationsEnabled
);
} }
toggleBadgeCount() { toggleBadgeCount() {
this.setState({ badgeDisplaysAllNotifs: !this.state.badgeDisplaysAllNotifs }); this.setState({
setUserDefaultBool('displayAllOnNotificationBadge', !this.state.badgeDisplaysAllNotifs); badgeDisplaysAllNotifs: !this.state.badgeDisplaysAllNotifs
});
setUserDefaultBool(
"displayAllOnNotificationBadge",
!this.state.badgeDisplaysAllNotifs
);
} }
toggleThemeDialog() { toggleThemeDialog() {
@ -142,11 +180,15 @@ class SettingsPage extends Component<any, ISettingsState> {
} }
toggleVisibilityDialog() { toggleVisibilityDialog() {
this.setState({ visibilityDialogOpen: !this.state.visibilityDialogOpen }); this.setState({
visibilityDialogOpen: !this.state.visibilityDialogOpen
});
} }
toggleResetDialog() { toggleResetDialog() {
this.setState({ resetHyperspaceDialog: !this.state.resetHyperspaceDialog }); this.setState({
resetHyperspaceDialog: !this.state.resetHyperspaceDialog
});
} }
toggleResetSettingsDialog() { toggleResetSettingsDialog() {
@ -178,15 +220,22 @@ class SettingsPage extends Component<any, ISettingsState> {
} }
refresh() { refresh() {
let settings = ['darkModeEnabled', 'enablePushNotifications', 'clearNotificationsOnRead', 'theme', 'displayAllOnNotificationBadge', 'defaultVisibility']; let settings = [
"darkModeEnabled",
"enablePushNotifications",
"clearNotificationsOnRead",
"theme",
"displayAllOnNotificationBadge",
"defaultVisibility"
];
settings.forEach(setting => { settings.forEach(setting => {
localStorage.removeItem(setting); localStorage.removeItem(setting);
}) });
window.location.reload(); window.location.reload();
} }
showThemeDialog() { showThemeDialog() {
const {classes} = this.props; const { classes } = this.props;
return ( return (
<Dialog <Dialog
open={this.state.themeDialogOpen} open={this.state.themeDialogOpen}
@ -196,7 +245,9 @@ class SettingsPage extends Component<any, ISettingsState> {
fullWidth={true} fullWidth={true}
aria-labelledby="confirmation-dialog-title" aria-labelledby="confirmation-dialog-title"
> >
<DialogTitle id="confirmation-dialog-title">Choose a theme</DialogTitle> <DialogTitle id="confirmation-dialog-title">
Choose a theme
</DialogTitle>
<DialogContent> <DialogContent>
<Grid container spacing={16}> <Grid container spacing={16}>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
@ -204,17 +255,31 @@ class SettingsPage extends Component<any, ISettingsState> {
aria-label="Theme" aria-label="Theme"
name="colorScheme" name="colorScheme"
value={this.state.selectThemeName} value={this.state.selectThemeName}
onChange={(e, value) => this.changeThemeName(value)} onChange={(e, value) =>
this.changeThemeName(value)
}
> >
{themes.map(theme => ( {themes.map(theme => (
<FormControlLabel value={theme.key} key={theme.key} control={<Radio />} label={theme.name} /> <FormControlLabel
value={theme.key}
key={theme.key}
control={<Radio />}
label={theme.name}
/>
))} ))}
))} ))}
</RadioGroup> </RadioGroup>
</Grid> </Grid>
<Grid item xs={12} md={6} className={classes.desktopOnly}> <Grid
<Typography variant="h6" component="p">Theme preview</Typography> item
<ThemePreview theme={this.state.previewTheme}/> xs={12}
md={6}
className={classes.desktopOnly}
>
<Typography variant="h6" component="p">
Theme preview
</Typography>
<ThemePreview theme={this.state.previewTheme} />
</Grid> </Grid>
</Grid> </Grid>
</DialogContent> </DialogContent>
@ -240,22 +305,54 @@ class SettingsPage extends Component<any, ISettingsState> {
fullWidth={true} fullWidth={true}
aria-labelledby="confirmation-dialog-title" aria-labelledby="confirmation-dialog-title"
> >
<DialogTitle id="confirmation-dialog-title">Set your default visibility</DialogTitle> <DialogTitle id="confirmation-dialog-title">
Set your default visibility
</DialogTitle>
<DialogContent> <DialogContent>
<RadioGroup <RadioGroup
aria-label="Visibility" aria-label="Visibility"
name="visibility" name="visibility"
value={this.state.defaultVisibility} value={this.state.defaultVisibility}
onChange={(e, value) => this.changeVisibility(value as Visibility)} onChange={(e, value) =>
this.changeVisibility(value as Visibility)
}
> >
<FormControlLabel value={"public"} key={"public"} control={<Radio />} label={`Public ${this.state.federated? "": "(disabled by provider)"}`} disabled={!this.state.federated}/> <FormControlLabel
<FormControlLabel value={"unlisted"} key={"unlisted"} control={<Radio />} label={"Unlisted"} /> value={"public"}
<FormControlLabel value={"private"} key={"private"} control={<Radio />} label={"Private (followers only)"} /> key={"public"}
<FormControlLabel value={"direct"} key={"direct"} control={<Radio />} label={"Direct"} /> control={<Radio />}
label={`Public ${
this.state.federated
? ""
: "(disabled by provider)"
}`}
disabled={!this.state.federated}
/>
<FormControlLabel
value={"unlisted"}
key={"unlisted"}
control={<Radio />}
label={"Unlisted"}
/>
<FormControlLabel
value={"private"}
key={"private"}
control={<Radio />}
label={"Private (followers only)"}
/>
<FormControlLabel
value={"direct"}
key={"direct"}
control={<Radio />}
label={"Direct"}
/>
</RadioGroup> </RadioGroup>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={this.toggleVisibilityDialog} color="default"> <Button
onClick={this.toggleVisibilityDialog}
color="default"
>
Cancel Cancel
</Button> </Button>
<Button onClick={this.setVisibility} color="secondary"> <Button onClick={this.setVisibility} color="secondary">
@ -272,14 +369,23 @@ class SettingsPage extends Component<any, ISettingsState> {
open={this.state.resetSettingsDialog} open={this.state.resetSettingsDialog}
onClose={() => this.toggleResetSettingsDialog()} onClose={() => this.toggleResetSettingsDialog()}
> >
<DialogTitle id="alert-dialog-title">Are you sure you want to refresh settings?</DialogTitle> <DialogTitle id="alert-dialog-title">
Are you sure you want to refresh settings?
</DialogTitle>
<DialogActions> <DialogActions>
<Button onClick={() => this.toggleResetSettingsDialog()} color="primary" autoFocus> <Button
onClick={() => this.toggleResetSettingsDialog()}
color="primary"
autoFocus
>
Cancel Cancel
</Button> </Button>
<Button onClick={() => { <Button
onClick={() => {
this.refresh(); this.refresh();
}} color="primary"> }}
color="primary"
>
Refresh Refresh
</Button> </Button>
</DialogActions> </DialogActions>
@ -293,19 +399,30 @@ class SettingsPage extends Component<any, ISettingsState> {
open={this.state.resetHyperspaceDialog} open={this.state.resetHyperspaceDialog}
onClose={() => this.toggleResetDialog()} onClose={() => this.toggleResetDialog()}
> >
<DialogTitle id="alert-dialog-title">Reset {this.state.brandName}?</DialogTitle> <DialogTitle id="alert-dialog-title">
Reset {this.state.brandName}?
</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText id="alert-dialog-description"> <DialogContentText id="alert-dialog-description">
Are you sure you want to reset {this.state.brandName}? You'll need to re-authorize {this.state.brandName} access again. Are you sure you want to reset {this.state.brandName}?
You'll need to re-authorize {this.state.brandName}{" "}
access again.
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => this.toggleResetDialog()} color="primary" autoFocus> <Button
onClick={() => this.toggleResetDialog()}
color="primary"
autoFocus
>
Cancel Cancel
</Button> </Button>
<Button onClick={() => { <Button
onClick={() => {
this.reset(); this.reset();
}} color="primary"> }}
color="primary"
>
Reset Reset
</Button> </Button>
</DialogActions> </DialogActions>
@ -322,9 +439,12 @@ class SettingsPage extends Component<any, ISettingsState> {
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<DevicesIcon color="action"/> <DevicesIcon color="action" />
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Match system appearance" secondary="Obey light/dark theme from your system"/> <ListItemText
primary="Match system appearance"
secondary="Obey light/dark theme from your system"
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Switch <Switch
checked={this.state.systemDecidesDarkMode} checked={this.state.systemDecidesDarkMode}
@ -334,9 +454,12 @@ class SettingsPage extends Component<any, ISettingsState> {
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Brightness3Icon color="action"/> <Brightness3Icon color="action" />
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Dark mode" secondary="Toggles light or dark theme"/> <ListItemText
primary="Dark mode"
secondary="Toggles light or dark theme"
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Switch <Switch
disabled={this.state.systemDecidesDarkMode} disabled={this.state.systemDecidesDarkMode}
@ -347,9 +470,12 @@ class SettingsPage extends Component<any, ISettingsState> {
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<PaletteIcon color="action"/> <PaletteIcon color="action" />
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Interface theme" secondary="The color palette used for the interface"/> <ListItemText
primary="Interface theme"
secondary="The color palette used for the interface"
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Button onClick={this.toggleThemeDialog}> <Button onClick={this.toggleThemeDialog}>
Set theme Set theme
@ -358,41 +484,69 @@ class SettingsPage extends Component<any, ISettingsState> {
</ListItem> </ListItem>
</List> </List>
</Paper> </Paper>
<br/> <br />
<ListSubheader>Your Account</ListSubheader> <ListSubheader>Your Account</ListSubheader>
<Paper className={classes.pageListConstraints}> <Paper className={classes.pageListConstraints}>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<AccountEditIcon color="action"/> <AccountEditIcon color="action" />
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Edit your profile" secondary="Change your bio, display name, and images"/> <ListItemText
primary="Edit your profile"
secondary="Change your bio, display name, and images"
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<LinkableButton to="/you">Edit</LinkableButton> <LinkableButton to="/you">Edit</LinkableButton>
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<MastodonIcon color="action"/> <CancelIcon color="action" />
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Configure on Mastodon"/> <ListItemText
primary="Manage blocked servers"
secondary="View and manage servers that you've blocked"
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<IconButton href={(localStorage.getItem("baseurl") as string) + "/settings/preferences"} target="_blank" rel="noreferrer"> <LinkableButton to="/blocked">
<OpenInNewIcon/> Manage
</LinkableButton>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<MastodonIcon color="action" />
</ListItemAvatar>
<ListItemText primary="Configure on Mastodon" />
<ListItemSecondaryAction>
<IconButton
href={
(localStorage.getItem(
"baseurl"
) as string) + "/settings/preferences"
}
target="_blank"
rel="noreferrer"
>
<OpenInNewIcon />
</IconButton> </IconButton>
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
</List> </List>
</Paper> </Paper>
<br/> <br />
<ListSubheader>Composer</ListSubheader> <ListSubheader>Composer</ListSubheader>
<Paper className={classes.pageListConstraints}> <Paper className={classes.pageListConstraints}>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<VisibilityIcon color="action"/> <VisibilityIcon color="action" />
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Default visibility" secondary="New posts in composer will use this visiblity"/> <ListItemText
primary="Default visibility"
secondary="New posts in composer will use this visiblity"
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Button onClick={this.toggleVisibilityDialog}> <Button onClick={this.toggleVisibilityDialog}>
Change Change
@ -401,35 +555,42 @@ class SettingsPage extends Component<any, ISettingsState> {
</ListItem> </ListItem>
</List> </List>
</Paper> </Paper>
<br/> <br />
<ListSubheader>Notifications</ListSubheader> <ListSubheader>Notifications</ListSubheader>
<Paper className={classes.pageListConstraints}> <Paper className={classes.pageListConstraints}>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<NotificationsIcon color="action"/> <NotificationsIcon color="action" />
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary="Enable push notifications" primary="Enable push notifications"
secondary={ secondary={
getUserDefaultBool('userDeniedNotification')? getUserDefaultBool("userDeniedNotification")
"Check your browser's notification permissions.": ? "Check your browser's notification permissions."
browserSupportsNotificationRequests()? : browserSupportsNotificationRequests()
"Send a push notification when not focused.": ? "Send a push notification when not focused."
"Notifications aren't supported." : "Notifications aren't supported."
} }
/> />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Switch <Switch
checked={this.state.pushNotificationsEnabled} checked={
this.state.pushNotificationsEnabled
}
onChange={this.togglePushNotifications} onChange={this.togglePushNotifications}
disabled={!browserSupportsNotificationRequests() || getUserDefaultBool('userDeniedNotification')} disabled={
!browserSupportsNotificationRequests() ||
getUserDefaultBool(
"userDeniedNotification"
)
}
/> />
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<BellAlertIcon color="action"/> <BellAlertIcon color="action" />
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary="Notification badge counts all notifications" primary="Notification badge counts all notifications"
@ -446,28 +607,40 @@ class SettingsPage extends Component<any, ISettingsState> {
</ListItem> </ListItem>
</List> </List>
</Paper> </Paper>
<br/> <br />
<ListSubheader>Advanced</ListSubheader> <ListSubheader>Advanced</ListSubheader>
<Paper className={classes.pageListConstraints}> <Paper className={classes.pageListConstraints}>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<RefreshIcon color="action"/> <RefreshIcon color="action" />
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Refresh settings" secondary="Reset the settings to defaults."/> <ListItemText
primary="Refresh settings"
secondary="Reset the settings to defaults."
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Button onClick={() => this.toggleResetSettingsDialog()}> <Button
onClick={() =>
this.toggleResetSettingsDialog()
}
>
Refresh Refresh
</Button> </Button>
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<UndoIcon color="action"/> <UndoIcon color="action" />
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={`Reset ${this.state.brandName}`} secondary="Deletes all data and resets the app"/> <ListItemText
primary={`Reset ${this.state.brandName}`}
secondary="Deletes all data and resets the app"
/>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Button onClick={() => this.toggleResetDialog()}> <Button
onClick={() => this.toggleResetDialog()}
>
Reset Reset
</Button> </Button>
</ListItemSecondaryAction> </ListItemSecondaryAction>
@ -481,7 +654,6 @@ class SettingsPage extends Component<any, ISettingsState> {
</div> </div>
); );
} }
} }
export default withStyles(styles)(SettingsPage); export default withStyles(styles)(SettingsPage);

View File

@ -1,15 +1,29 @@
import React, { Component, ChangeEvent } from 'react'; import React, { Component, ChangeEvent } from "react";
import {withStyles, Paper, Typography, Button, TextField, Fade, Link, CircularProgress, Tooltip, Dialog, DialogTitle, DialogActions, DialogContent} from '@material-ui/core'; import {
import {styles} from './WelcomePage.styles'; withStyles,
import Mastodon from 'megalodon'; Paper,
import {SaveClientSession} from '../types/SessionData'; Typography,
import { createHyperspaceApp, getRedirectAddress } from '../utilities/login'; Button,
import {parseUrl} from 'query-string'; TextField,
import { getConfig } from '../utilities/settings'; Fade,
import { isDarwinApp } from '../utilities/desktop'; Link,
import axios from 'axios'; CircularProgress,
import {withSnackbar, withSnackbarProps} from 'notistack'; Tooltip,
import { Config } from '../types/Config'; Dialog,
DialogTitle,
DialogActions,
DialogContent
} from "@material-ui/core";
import { styles } from "./WelcomePage.styles";
import Mastodon from "megalodon";
import { SaveClientSession } from "../types/SessionData";
import { createHyperspaceApp, getRedirectAddress } from "../utilities/login";
import { parseUrl } from "query-string";
import { getConfig } from "../utilities/settings";
import { isDarwinApp } from "../utilities/desktop";
import axios from "axios";
import { withSnackbar, withSnackbarProps } from "notistack";
import { Config } from "../types/Config";
interface IWelcomeProps extends withSnackbarProps { interface IWelcomeProps extends withSnackbarProps {
classes: any; classes: any;
@ -40,7 +54,6 @@ interface IWelcomeState {
} }
class WelcomePage extends Component<IWelcomeProps, IWelcomeState> { class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
client: any; client: any;
constructor(props: any) { constructor(props: any) {
@ -52,35 +65,48 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
userInputError: false, userInputError: false,
foundSavedLogin: false, foundSavedLogin: false,
authority: false, authority: false,
userInputErrorMessage: '', userInputErrorMessage: "",
defaultRedirectAddress: '', defaultRedirectAddress: "",
openAuthDialog: false, openAuthDialog: false,
authCode: '', authCode: "",
emergencyMode: false, emergencyMode: false,
version: '' version: ""
} };
getConfig().then((result: any) => { getConfig()
.then((result: any) => {
if (result !== undefined) { if (result !== undefined) {
let config: Config = result; let config: Config = result;
if (result.location === "dynamic") { if (result.location === "dynamic") {
console.warn("Recirect URI is set to dynamic, which may affect how sign-in works for some users. Careful!"); console.warn(
"Recirect URI is set to dynamic, which may affect how sign-in works for some users. Careful!"
);
} }
this.setState({ this.setState({
logoUrl: config.branding? result.branding.logo: "logo.png", logoUrl: config.branding ? result.branding.logo : "logo.png",
backgroundUrl: config.branding? result.branding.background: "background.png", backgroundUrl: config.branding
brandName: config.branding? result.branding.name: "Hyperspace", ? result.branding.background
registerBase: config.registration? result.registration.defaultInstance: "", : "background.png",
brandName: config.branding ? result.branding.name : "Hyperspace",
registerBase: config.registration
? result.registration.defaultInstance
: "",
federates: config.federation.universalLogin, federates: config.federation.universalLogin,
license: config.license.url, license: config.license.url,
repo: config.repository, repo: config.repository,
defaultRedirectAddress: config.location != "dynamic"? config.location: `https://${window.location.host}`, defaultRedirectAddress:
config.location != "dynamic"
? config.location
: `https://${window.location.host}`,
version: config.version version: config.version
}); });
} }
}).catch(() => {
console.error('config.json is missing. If you want to customize Hyperspace, please include config.json');
}) })
.catch(() => {
console.error(
"config.json is missing. If you want to customize Hyperspace, please include config.json"
);
});
} }
componentDidMount() { componentDidMount() {
@ -88,7 +114,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
this.getSavedSession(); this.getSavedSession();
this.setState({ this.setState({
foundSavedLogin: true foundSavedLogin: true
}) });
this.checkForToken(); this.checkForToken();
} }
} }
@ -106,7 +132,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
} }
readyForAuth() { readyForAuth() {
if (localStorage.getItem('baseurl')) { if (localStorage.getItem("baseurl")) {
return true; return true;
} else { } else {
return false; return false;
@ -122,14 +148,14 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
clientSecret: session.clientSecret, clientSecret: session.clientSecret,
authUrl: session.authUrl, authUrl: session.authUrl,
emergencyMode: session.emergency emergencyMode: session.emergency
}) });
} }
} }
startEmergencyLogin() { startEmergencyLogin() {
if (!this.state.emergencyMode) { if (!this.state.emergencyMode) {
this.createEmergencyLogin(); this.createEmergencyLogin();
}; }
this.toggleAuthDialog(); this.toggleAuthDialog();
} }
@ -142,13 +168,11 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
} }
watchUsernameField(event: any) { watchUsernameField(event: any) {
if (event.keyCode === 13) if (event.keyCode === 13) this.startLogin();
this.startLogin()
} }
watchAuthField(event: any) { watchAuthField(event: any) {
if (event.keyCode === 13) if (event.keyCode === 13) this.authorizeEmergencyLogin();
this.authorizeEmergencyLogin()
} }
getLoginUser(user: string) { getLoginUser(user: string) {
@ -158,25 +182,37 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
this.setState({ user: newUser }); this.setState({ user: newUser });
return "https://" + newUser.split("@")[1]; return "https://" + newUser.split("@")[1];
} else { } else {
let newUser = `${user}@${this.state.registerBase? this.state.registerBase: "mastodon.social"}`; let newUser = `${user}@${
this.state.registerBase ? this.state.registerBase : "mastodon.social"
}`;
this.setState({ user: newUser }); this.setState({ user: newUser });
return "https://" + (this.state.registerBase? this.state.registerBase: "mastodon.social"); return (
"https://" +
(this.state.registerBase
? this.state.registerBase
: "mastodon.social")
);
} }
} else { } else {
let newUser = `${user}@${this.state.registerBase? this.state.registerBase: "mastodon.social"}`; let newUser = `${user}@${
this.state.registerBase ? this.state.registerBase : "mastodon.social"
}`;
this.setState({ user: newUser }); this.setState({ user: newUser });
return "https://" + (this.state.registerBase? this.state.registerBase: "mastodon.social"); return (
"https://" +
(this.state.registerBase ? this.state.registerBase : "mastodon.social")
);
} }
} }
startLogin() { startLogin() {
let error = this.checkForErrors(); let error = this.checkForErrors();
if (!error) { if (!error) {
const scopes = 'read write follow'; const scopes = "read write follow";
const baseurl = this.getLoginUser(this.state.user); const baseurl = this.getLoginUser(this.state.user);
localStorage.setItem("baseurl", baseurl); localStorage.setItem("baseurl", baseurl);
createHyperspaceApp( createHyperspaceApp(
this.state.brandName? this.state.brandName: "Hyperspace", this.state.brandName ? this.state.brandName : "Hyperspace",
scopes, scopes,
baseurl, baseurl,
getRedirectAddress(this.state.defaultRedirectAddress) getRedirectAddress(this.state.defaultRedirectAddress)
@ -186,26 +222,26 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
clientSecret: resp.clientSecret, clientSecret: resp.clientSecret,
authUrl: resp.url, authUrl: resp.url,
emergency: false emergency: false
} };
localStorage.setItem("login", JSON.stringify(saveSessionForCrashing)); localStorage.setItem("login", JSON.stringify(saveSessionForCrashing));
this.setState({ this.setState({
clientId: resp.clientId, clientId: resp.clientId,
clientSecret: resp.clientSecret, clientSecret: resp.clientSecret,
authUrl: resp.url, authUrl: resp.url,
wantsToLogin: true wantsToLogin: true
}) });
}) });
} else { } else {
} }
} }
createEmergencyLogin() { createEmergencyLogin() {
console.log("Creating an emergency login...") console.log("Creating an emergency login...");
const scopes = "read write follow"; const scopes = "read write follow";
const baseurl = localStorage.getItem('baseurl') || this.getLoginUser(this.state.user); const baseurl =
localStorage.getItem("baseurl") || this.getLoginUser(this.state.user);
Mastodon.registerApp( Mastodon.registerApp(
this.state.brandName? this.state.brandName: "Hyperspace", this.state.brandName ? this.state.brandName : "Hyperspace",
{ {
scopes: scopes scopes: scopes
}, },
@ -240,7 +276,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
authUrl: session.authUrl, authUrl: session.authUrl,
emergencyMode: session.emergency, emergencyMode: session.emergency,
wantsToLogin: true wantsToLogin: true
}) });
} }
} }
@ -255,15 +291,23 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
return true; return true;
} else { } else {
if (this.state.user.includes("@")) { if (this.state.user.includes("@")) {
if (this.state.federates && (this.state.federates === true)) { if (this.state.federates && this.state.federates === true) {
let baseUrl = this.state.user.split("@")[1]; let baseUrl = this.state.user.split("@")[1];
axios.get("https://" + baseUrl + "/api/v1/timelines/public").catch((err: Error) => { axios
.get("https://" + baseUrl + "/api/v1/timelines/public")
.catch((err: Error) => {
let userInputError = true; let userInputError = true;
let userInputErrorMessage = "Instance name is invalid."; let userInputErrorMessage = "Instance name is invalid.";
this.setState({ userInputError, userInputErrorMessage }); this.setState({ userInputError, userInputErrorMessage });
return true; return true;
}); });
} else if (this.state.user.includes(this.state.registerBase? this.state.registerBase: "mastodon.social")) { } else if (
this.state.user.includes(
this.state.registerBase
? this.state.registerBase
: "mastodon.social"
)
) {
this.setState({ userInputError, userInputErrorMessage }); this.setState({ userInputError, userInputErrorMessage });
return false; return false;
} else { } else {
@ -279,7 +323,6 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
this.setState({ userInputError, userInputErrorMessage }); this.setState({ userInputError, userInputErrorMessage });
return false; return false;
} }
} }
checkForToken() { checkForToken() {
@ -294,19 +337,33 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
clientLoginSession.clientId, clientLoginSession.clientId,
clientLoginSession.clientSecret, clientLoginSession.clientSecret,
code, code,
(localStorage.getItem("baseurl") as string), localStorage.getItem("baseurl") as string,
this.state.emergencyMode? this.state.emergencyMode
undefined: ? undefined
clientLoginSession.authUrl.includes("urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob")? : clientLoginSession.authUrl.includes(
undefined: "urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob"
window.location.protocol === "hyperspace:"? "hyperspace://hyperspace/app/": `https://${window.location.host}`, )
).then((tokenData: any) => { ? undefined
: window.location.protocol === "hyperspace:"
? "hyperspace://hyperspace/app/"
: `https://${window.location.host}`
)
.then((tokenData: any) => {
localStorage.setItem("access_token", tokenData.access_token); localStorage.setItem("access_token", tokenData.access_token);
window.location.href = window.location.protocol === "hyperspace:"? "hyperspace://hyperspace/app/": `https://${window.location.host}/#/`; window.location.href =
}).catch((err: Error) => { window.location.protocol === "hyperspace:"
this.props.enqueueSnackbar(`Couldn't authorize ${this.state.brandName? this.state.brandName: "Hyperspace"}: ${err.name}`, {variant: 'error'}); ? "hyperspace://hyperspace/app/"
console.error(err.message); : `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);
});
} }
} }
} }
@ -316,7 +373,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
if (isDarwinApp()) { if (isDarwinApp()) {
return ( return (
<div className={classes.titleBarRoot}> <div className={classes.titleBarRoot}>
<Typography className={classes.titleBarText}>{this.state.brandName? this.state.brandName: "Hyperspace"}</Typography> <Typography className={classes.titleBarText}>
{this.state.brandName ? this.state.brandName : "Hyperspace"}
</Typography>
</div> </div>
); );
} }
@ -328,46 +387,76 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
<div> <div>
<Typography variant="h5">Sign in</Typography> <Typography variant="h5">Sign in</Typography>
<Typography>with your Mastodon account</Typography> <Typography>with your Mastodon account</Typography>
<div className={classes.middlePadding}/> <div className={classes.middlePadding} />
<TextField <TextField
variant="outlined" variant="outlined"
label="Username" label="Username"
fullWidth fullWidth
placeholder="example@mastodon.example" placeholder="example@mastodon.example"
onChange={(event) => this.updateUserInfo(event.target.value)} onChange={event => this.updateUserInfo(event.target.value)}
onKeyDown={(event) => this.watchUsernameField(event)} onKeyDown={event => this.watchUsernameField(event)}
error={this.state.userInputError} error={this.state.userInputError}
onBlur={() => this.checkForErrors()} onBlur={() => this.checkForErrors()}
></TextField> ></TextField>
{ {this.state.userInputError ? (
this.state.userInputError? <Typography color="error">{this.state.userInputErrorMessage}</Typography> : null <Typography color="error">
} {this.state.userInputErrorMessage}
<br/> </Typography>
{ ) : null}
this.state.registerBase && this.state.federates? <Typography variant="caption">Not from <b>{this.state.registerBase? this.state.registerBase: "noinstance"}</b>? Sign in with your <Link href="https://docs.joinmastodon.org/usage/decentralization/#addressing-people" target="_blank" rel="noopener noreferrer" color="secondary">full username</Link>.</Typography>: null <br />
} {this.state.registerBase && this.state.federates ? (
<br/> <Typography variant="caption">
{ Not from{" "}
this.state.foundSavedLogin? <b>
{this.state.registerBase ? this.state.registerBase : "noinstance"}
</b>
? Sign in with your{" "}
<Link
href="https://docs.joinmastodon.org/usage/decentralization/#addressing-people"
target="_blank"
rel="noopener noreferrer"
color="secondary"
>
full username
</Link>
.
</Typography>
) : null}
<br />
{this.state.foundSavedLogin ? (
<Typography> <Typography>
Signing in from a previous session? <Link className={classes.welcomeLink} onClick={() => this.resumeLogin()}>Continue login</Link>. Signing in from a previous session?{" "}
</Typography>: null <Link
} className={classes.welcomeLink}
onClick={() => this.resumeLogin()}
>
Continue login
</Link>
.
</Typography>
) : null}
<div className={classes.middlePadding}/> <div className={classes.middlePadding} />
<div style={{ display: "flex" }}> <div style={{ display: "flex" }}>
<Tooltip title="Create account on site"> <Tooltip title="Create account on site">
<Button <Button
href={this.startRegistration()} href={this.startRegistration()}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
>Create account</Button> >
Create account
</Button>
</Tooltip> </Tooltip>
<div className={classes.flexGrow}/> <div className={classes.flexGrow} />
<Tooltip title="Continue sign-in"> <Tooltip title="Continue sign-in">
<Button color="primary" variant="contained" onClick={() => this.startLogin()}>Next</Button> <Button
color="primary"
variant="contained"
onClick={() => this.startLogin()}
>
Next
</Button>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
); );
@ -377,29 +466,43 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
const { classes } = this.props; const { classes } = this.props;
return ( return (
<div> <div>
<Typography variant="h5">Howdy, {this.state.user? this.state.user.split("@")[0]: "user"}</Typography> <Typography variant="h5">
<Typography>To continue, finish signing in on your instance's website and authorize {this.state.brandName? this.state.brandName: "Hyperspace"}.</Typography> Howdy, {this.state.user ? this.state.user.split("@")[0] : "user"}
<div className={classes.middlePadding}/> </Typography>
<Typography>
To continue, finish signing in on your instance's website and
authorize {this.state.brandName ? this.state.brandName : "Hyperspace"}
.
</Typography>
<div className={classes.middlePadding} />
<div style={{ display: "flex" }}> <div style={{ display: "flex" }}>
<div className={classes.flexGrow}/> <div className={classes.flexGrow} />
<Button <Button
color="primary" color="primary"
variant="contained" variant="contained"
size="large" size="large"
href={this.state.authUrl? this.state.authUrl: ""} href={this.state.authUrl ? this.state.authUrl : ""}
> >
Authorize Authorize
</Button> </Button>
<div className={classes.flexGrow}/> <div className={classes.flexGrow} />
</div> </div>
<div className={classes.middlePadding}/> <div className={classes.middlePadding} />
<Typography>Having trouble signing in? <Link onClick={() => this.startEmergencyLogin()} className={classes.welcomeLink}>Sign in with a code.</Link></Typography> <Typography>
Having trouble signing in?{" "}
<Link
onClick={() => this.startEmergencyLogin()}
className={classes.welcomeLink}
>
Sign in with a code.
</Link>
</Typography>
</div> </div>
); );
} }
showAuthDialog() { showAuthDialog() {
const {classes} = this.props; const { classes } = this.props;
return ( return (
<Dialog <Dialog
open={this.state.openAuthDialog} open={this.state.openAuthDialog}
@ -408,32 +511,40 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
maxWidth="sm" maxWidth="sm"
fullWidth={true} fullWidth={true}
> >
<DialogTitle> <DialogTitle>Authorize with a code</DialogTitle>
Authorize with a code
</DialogTitle>
<DialogContent> <DialogContent>
<Typography paragraph> <Typography paragraph>
If you're having trouble authorizing Hyperspace, you can manually request for an authorization code. Click 'Request Code' and then paste the code in the authorization code box to continue. If you're having trouble authorizing Hyperspace, you can manually
request for an authorization code. Click 'Request Code' and then
paste the code in the authorization code box to continue.
</Typography> </Typography>
<Button <Button
color="primary" color="primary"
variant="contained" variant="contained"
href={this.state.authUrl? this.state.authUrl: ""} href={this.state.authUrl ? this.state.authUrl : ""}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
>Request Code</Button> >
<br/><br/> Request Code
</Button>
<br />
<br />
<TextField <TextField
variant="outlined" variant="outlined"
label="Authorization code" label="Authorization code"
fullWidth fullWidth
onChange={(event) => this.updateAuthCode(event.target.value)} onChange={event => this.updateAuthCode(event.target.value)}
onKeyDown={(event) => this.watchAuthField(event)} onKeyDown={event => this.watchAuthField(event)}
></TextField> ></TextField>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => this.toggleAuthDialog()}>Cancel</Button> <Button onClick={() => this.toggleAuthDialog()}>Cancel</Button>
<Button color="secondary" onClick={() => this.authorizeEmergencyLogin()}>Authorize</Button> <Button
color="secondary"
onClick={() => this.authorizeEmergencyLogin()}
>
Authorize
</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );
@ -444,14 +555,17 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
return ( return (
<div> <div>
<Typography variant="h5">Authorizing</Typography> <Typography variant="h5">Authorizing</Typography>
<Typography>Please wait while Hyperspace authorizes with Mastodon. This shouldn't take long...</Typography> <Typography>
<div className={classes.middlePadding}/> Please wait while Hyperspace authorizes with Mastodon. This shouldn't
take long...
</Typography>
<div className={classes.middlePadding} />
<div style={{ display: "flex" }}> <div style={{ display: "flex" }}>
<div className={classes.flexGrow}/> <div className={classes.flexGrow} />
<CircularProgress/> <CircularProgress />
<div className={classes.flexGrow}/> <div className={classes.flexGrow} />
</div> </div>
<div className={classes.middlePadding}/> <div className={classes.middlePadding} />
</div> </div>
); );
} }
@ -461,31 +575,90 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
return ( return (
<div> <div>
{this.titlebar()} {this.titlebar()}
<div className={classes.root} style={{ backgroundImage: `url(${this.state !== null? this.state.backgroundUrl: "background.png"})`}}> <div
className={classes.root}
style={{
backgroundImage: `url(${
this.state !== null ? this.state.backgroundUrl : "background.png"
})`
}}
>
<Paper className={classes.paper}> <Paper className={classes.paper}>
<img className={classes.logo} alt={this.state? this.state.brandName: "Hyperspace"} src={this.state? this.state.logoUrl: "logo.png"}/> <img
<br/> className={classes.logo}
alt={this.state ? this.state.brandName : "Hyperspace"}
src={this.state ? this.state.logoUrl : "logo.png"}
/>
<br />
<Fade in={true}> <Fade in={true}>
{ {this.state.authority
this.state.authority? ? this.showAuthority()
this.showAuthority(): : this.state.wantsToLogin
this.state.wantsToLogin? ? this.showLoginAuth()
this.showLoginAuth(): : this.showLanding()}
this.showLanding()
}
</Fade> </Fade>
<br/> <br />
<Typography variant="caption"> <Typography variant="caption">
&copy; {new Date().getFullYear()} {this.state.brandName && this.state.brandName !== "Hyperspace"? `${this.state.brandName} developers and the `: ""} <Link className={classes.welcomeLink} href="https://hyperspace.marquiskurt.net" target="_blank" rel="noreferrer">Hyperspace</Link> developers. All rights reserved. &copy; {new Date().getFullYear()}{" "}
{this.state.brandName && this.state.brandName !== "Hyperspace"
? `${this.state.brandName} developers and the `
: ""}{" "}
<Link
className={classes.welcomeLink}
href="https://hyperspace.marquiskurt.net"
target="_blank"
rel="noreferrer"
>
Hyperspace
</Link>{" "}
developers. All rights reserved.
</Typography> </Typography>
<Typography variant="caption"> <Typography variant="caption">
{ this.state.repo? <span> {this.state.repo ? (
<Link className={classes.welcomeLink} href={this.state.repo? this.state.repo: "https://github.com/hyperspacedev"} target="_blank" rel="noreferrer">Source code</Link> | </span>: null} <span>
<Link className={classes.welcomeLink} href={this.state.license? this.state.license: "https://www.apache.org/licenses/LICENSE-2.0"} target="_blank" rel="noreferrer">License</Link> | <Link
<Link className={classes.welcomeLink} href="https://github.com/hyperspacedev/hyperspace/issues/new" target="_blank" rel="noreferrer">File an Issue</Link> className={classes.welcomeLink}
href={
this.state.repo
? this.state.repo
: "https://github.com/hyperspacedev"
}
target="_blank"
rel="noreferrer"
>
Source code
</Link>{" "}
|{" "}
</span>
) : null}
<Link
className={classes.welcomeLink}
href={
this.state.license
? this.state.license
: "https://www.apache.org/licenses/LICENSE-2.0"
}
target="_blank"
rel="noreferrer"
>
License
</Link>{" "}
|
<Link
className={classes.welcomeLink}
href="https://github.com/hyperspacedev/hyperspace/issues/new"
target="_blank"
rel="noreferrer"
>
File an Issue
</Link>
</Typography> </Typography>
<Typography variant="caption" color="textSecondary"> <Typography variant="caption" color="textSecondary">
{this.state.brandName? this.state.brandName: "Hypersapce"} v.{this.state.version} {this.state.brandName && this.state.brandName !== "Hyperspace"? "(Hyperspace-like)": null} {this.state.brandName ? this.state.brandName : "Hypersapce"} v.
{this.state.version}{" "}
{this.state.brandName && this.state.brandName !== "Hyperspace"
? "(Hyperspace-like)"
: null}
</Typography> </Typography>
</Paper> </Paper>
{this.showAuthDialog()} {this.showAuthDialog()}

View File

@ -1,36 +1,37 @@
import { Theme, createStyles } from '@material-ui/core'; import { Theme, createStyles } from "@material-ui/core";
export const styles = (theme: Theme) => createStyles({ export const styles = (theme: Theme) =>
createStyles({
root: { root: {
width: '100%', width: "100%",
height: '100%', height: "100%",
backgroundPosition: 'center', backgroundPosition: "center",
backgroundRepeat: 'no-repeat', backgroundRepeat: "no-repeat",
backgroundSize: 'cover', backgroundSize: "cover",
top: 0, top: 0,
left: 0, left: 0,
position: "absolute", position: "absolute",
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up("sm")]: {
paddingTop: theme.spacing.unit * 4, paddingTop: theme.spacing.unit * 4,
paddingLeft: '25%', paddingLeft: "25%",
paddingRight: '25%', paddingRight: "25%"
}, },
[theme.breakpoints.up('lg')]: { [theme.breakpoints.up("lg")]: {
paddingTop: theme.spacing.unit * 12, paddingTop: theme.spacing.unit * 12,
paddingLeft: '35%', paddingLeft: "35%",
paddingRight: '35%', paddingRight: "35%"
} }
}, },
titleBarRoot: { titleBarRoot: {
top: 0, top: 0,
left: 0, left: 0,
height: 24, height: 24,
width: '100%', width: "100%",
backgroundColor: "rgba(0, 0, 0, 0.2)", backgroundColor: "rgba(0, 0, 0, 0.2)",
textAlign: 'center', textAlign: "center",
zIndex: 1000, zIndex: 1000,
verticalAlign: 'middle', verticalAlign: "middle",
WebkitUserSelect: 'none', WebkitUserSelect: "none",
WebkitAppRegion: "drag", WebkitAppRegion: "drag",
position: "absolute" position: "absolute"
}, },
@ -41,18 +42,18 @@ export const styles = (theme: Theme) => createStyles({
paddingBottom: 1 paddingBottom: 1
}, },
paper: { paper: {
height: '100%', height: "100%",
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up("sm")]: {
height: 'auto', height: "auto",
paddingLeft: theme.spacing.unit * 8, paddingLeft: theme.spacing.unit * 8,
paddingRight: theme.spacing.unit * 8, paddingRight: theme.spacing.unit * 8,
paddingTop: theme.spacing.unit * 6, paddingTop: theme.spacing.unit * 6
}, },
paddingTop: theme.spacing.unit * 12, paddingTop: theme.spacing.unit * 12,
paddingLeft: theme.spacing.unit * 4, paddingLeft: theme.spacing.unit * 4,
paddingRight: theme.spacing.unit * 4, paddingRight: theme.spacing.unit * 4,
paddingBottom: theme.spacing.unit * 6, paddingBottom: theme.spacing.unit * 6,
textAlign: 'center', textAlign: "center"
}, },
welcomeLink: { welcomeLink: {
color: theme.palette.primary.light color: theme.palette.primary.light
@ -64,9 +65,9 @@ export const styles = (theme: Theme) => createStyles({
height: theme.spacing.unit * 6 height: theme.spacing.unit * 6
}, },
logo: { logo: {
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up("sm")]: {
height: 64, height: 64,
width: "auto" width: "auto"
},
} }
}); }
});

View File

@ -1,12 +1,24 @@
import React, {Component} from 'react'; import React, { Component } from "react";
import {withStyles, Typography, Paper, Avatar, Button, TextField, ListItem, ListItemText, ListItemAvatar, List, Grid} from '@material-ui/core'; import {
import {withSnackbar, withSnackbarProps} from 'notistack'; withStyles,
import {styles} from './PageLayout.styles'; Typography,
import { Account } from '../types/Account'; Paper,
import Mastodon from 'megalodon'; Avatar,
import filedialog from 'file-dialog'; Button,
TextField,
ListItem,
ListItemText,
ListItemAvatar,
List,
Grid
} from "@material-ui/core";
import { withSnackbar, withSnackbarProps } from "notistack";
import { styles } from "./PageLayout.styles";
import { Account } from "../types/Account";
import Mastodon from "megalodon";
import filedialog from "file-dialog";
import PersonIcon from '@material-ui/icons/Person'; import PersonIcon from "@material-ui/icons/Person";
interface IYouProps extends withSnackbarProps { interface IYouProps extends withSnackbarProps {
classes: any; classes: any;
@ -19,21 +31,23 @@ interface IYouState {
} }
class You extends Component<IYouProps, IYouState> { class You extends Component<IYouProps, IYouState> {
client: Mastodon; client: Mastodon;
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1"); this.client = new Mastodon(
localStorage.getItem("access_token") as string,
(localStorage.getItem("baseurl") as string) + "/api/v1"
);
this.state = { this.state = {
currentAccount: this.getAccount() currentAccount: this.getAccount()
} };
} }
getAccount() { getAccount() {
let acct = localStorage.getItem('account'); let acct = localStorage.getItem("account");
if (acct) { if (acct) {
return JSON.parse(acct); return JSON.parse(acct);
} }
@ -43,144 +57,229 @@ class You extends Component<IYouProps, IYouState> {
filedialog({ filedialog({
multiple: false, multiple: false,
accept: "image/*" accept: "image/*"
}).then((images: FileList) => { })
.then((images: FileList) => {
if (images.length > 0) { if (images.length > 0) {
this.props.enqueueSnackbar("Updating avatar...", { persist: true, key: "persistAvatar" }); this.props.enqueueSnackbar("Updating avatar...", {
persist: true,
key: "persistAvatar"
});
let upload = new FormData(); let upload = new FormData();
upload.append("avatar", images[0]); upload.append("avatar", images[0]);
this.client.patch("/accounts/update_credentials", upload).then((acct: any) => { this.client
.patch("/accounts/update_credentials", upload)
.then((acct: any) => {
let currentAccount: Account = acct.data; let currentAccount: Account = acct.data;
this.setState({ currentAccount }); this.setState({ currentAccount });
localStorage.setItem("account", JSON.stringify(currentAccount)); localStorage.setItem("account", JSON.stringify(currentAccount));
this.props.closeSnackbar("persistAvatar"); this.props.closeSnackbar("persistAvatar");
this.props.enqueueSnackbar("Avatar updated successfully."); this.props.enqueueSnackbar("Avatar updated successfully.");
}).catch((err: Error) => { })
.catch((err: Error) => {
this.props.closeSnackbar("persistAvatar"); this.props.closeSnackbar("persistAvatar");
this.props.enqueueSnackbar("Couldn't update avatar: " + err.name, { variant: "error" }); this.props.enqueueSnackbar(
}) "Couldn't update avatar: " + err.name,
{ variant: "error" }
);
});
} }
}).catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't update avatar: " + err.name);
}) })
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't update avatar: " + err.name);
});
} }
updateHeader() { updateHeader() {
filedialog({ filedialog({
multiple: false, multiple: false,
accept: "image/*" accept: "image/*"
}).then((images: FileList) => { })
.then((images: FileList) => {
if (images.length > 0) { if (images.length > 0) {
this.props.enqueueSnackbar("Updating header...", { persist: true, key: "persistHeader" }); this.props.enqueueSnackbar("Updating header...", {
persist: true,
key: "persistHeader"
});
let upload = new FormData(); let upload = new FormData();
upload.append("header", images[0]); upload.append("header", images[0]);
this.client.patch("/accounts/update_credentials", upload).then((acct: any) => { this.client
.patch("/accounts/update_credentials", upload)
.then((acct: any) => {
let currentAccount: Account = acct.data; let currentAccount: Account = acct.data;
this.setState({ currentAccount }); this.setState({ currentAccount });
localStorage.setItem("account", JSON.stringify(currentAccount)); localStorage.setItem("account", JSON.stringify(currentAccount));
this.props.closeSnackbar("persistHeader"); this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar("Header updated successfully."); this.props.enqueueSnackbar("Header updated successfully.");
}).catch((err: Error) => { })
.catch((err: Error) => {
this.props.closeSnackbar("persistHeader"); this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar("Couldn't update header: " + err.name, { variant: "error" }); this.props.enqueueSnackbar(
}) "Couldn't update header: " + err.name,
{ variant: "error" }
);
});
} }
}).catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't update header: " + err.name);
}) })
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't update header: " + err.name);
});
} }
removeHTMLContent(text: string) { removeHTMLContent(text: string) {
const div = document.createElement('div'); const div = document.createElement("div");
div.innerHTML = text; div.innerHTML = text;
let innerContent = div.textContent || div.innerText || ""; let innerContent = div.textContent || div.innerText || "";
return innerContent; return innerContent;
} }
changeDisplayName() { changeDisplayName() {
this.client.patch('/accounts/update_credentials', { this.client
display_name: this.state.newDisplayName? this.state.newDisplayName: this.state.currentAccount.display_name .patch("/accounts/update_credentials", {
display_name: this.state.newDisplayName
? this.state.newDisplayName
: this.state.currentAccount.display_name
}) })
.then((acct: any) =>{ .then((acct: any) => {
let currentAccount: Account = acct.data let currentAccount: Account = acct.data;
this.setState({currentAccount}); this.setState({ currentAccount });
localStorage.setItem('account', JSON.stringify(currentAccount)); localStorage.setItem("account", JSON.stringify(currentAccount));
this.props.closeSnackbar("persistHeader"); this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar("Display name updated to " + this.state.newDisplayName); this.props.enqueueSnackbar(
} ).catch((err:Error) => { "Display name updated to " + this.state.newDisplayName
console.error(err.name) );
this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar("Couldn't update display name: " + err.name, { variant: "error" })
}) })
.catch((err: Error) => {
console.error(err.name);
this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar(
"Couldn't update display name: " + err.name,
{ variant: "error" }
);
});
} }
updateDisplayname(name: string) { updateDisplayname(name: string) {
this.setState({ newDisplayName: name }); this.setState({ newDisplayName: name });
}; }
changeBio() { changeBio() {
this.client.patch('/accounts/update_credentials', {note: this.state.newBio? this.state.newBio: this.state.currentAccount.note}) this.client
.then((acct:any) => { .patch("/accounts/update_credentials", {
let currentAccount: Account = acct.data note: this.state.newBio
this.setState({currentAccount}); ? this.state.newBio
localStorage.setItem('account', JSON.stringify(currentAccount)); : this.state.currentAccount.note
})
.then((acct: any) => {
let currentAccount: Account = acct.data;
this.setState({ currentAccount });
localStorage.setItem("account", JSON.stringify(currentAccount));
this.props.closeSnackbar("persistHeader"); this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar("Bio updated successfully."); this.props.enqueueSnackbar("Bio updated successfully.");
}).catch((err: Error) => {
console.error(err.name)
this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar("Couldn't update bio: " + err.name, { variant: "error"});
}) })
.catch((err: Error) => {
console.error(err.name);
this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar("Couldn't update bio: " + err.name, {
variant: "error"
});
});
} }
updateBio(bio:string){ updateBio(bio: string) {
this.setState({newBio:bio}) this.setState({ newBio: bio });
} }
render() { render() {
const {classes} = this.props; const { classes } = this.props;
return ( return (
<div className={classes.pageLayoutMinimalConstraints}> <div className={classes.pageLayoutMinimalConstraints}>
<div className={classes.pageHeroBackground}> <div className={classes.pageHeroBackground}>
<div className={classes.pageHeroBackgroundImage} style={{ backgroundImage: `url("${this.state.currentAccount.header_static}")`}}/> <div
className={classes.pageHeroBackgroundImage}
style={{
backgroundImage: `url("${this.state.currentAccount.header_static}")`
}}
/>
<div className={classes.pageHeroContent}> <div className={classes.pageHeroContent}>
<Avatar className={classes.pageProfileAvatar} src={this.state.currentAccount.avatar_static}/> <Avatar
<Typography variant="h4" color="inherit" component="h1">Edit your profile</Typography> className={classes.pageProfileAvatar}
<br/> src={this.state.currentAccount.avatar_static}
/>
<Typography variant="h4" color="inherit" component="h1">
Edit your profile
</Typography>
<br />
<div> <div>
<Button className={classes.pageProfileFollowButton} variant="contained" onClick={() => this.updateAvatar()}>Change Avatar</Button> <Button
<Button className={classes.pageProfileFollowButton} variant="contained" onClick={() => this.updateHeader()}>Change Header</Button> className={classes.pageProfileFollowButton}
variant="contained"
onClick={() => this.updateAvatar()}
>
Change Avatar
</Button>
<Button
className={classes.pageProfileFollowButton}
variant="contained"
onClick={() => this.updateHeader()}
>
Change Header
</Button>
</div> </div>
<br/> <br />
</div> </div>
</div> </div>
<div className={classes.pageContentLayoutConstraints}> <div className={classes.pageContentLayoutConstraints}>
<Paper className={classes.youPaper}> <Paper className={classes.youPaper}>
<Typography variant="h5" component="h2">Display Name</Typography> <Typography variant="h5" component="h2">
<br/> Display Name
<TextField className = {classes.TextField} </Typography>
defaultValue = {this.state.currentAccount.display_name} <br />
rowsMax = "1" <TextField
variant = "outlined" className={classes.TextField}
defaultValue={this.state.currentAccount.display_name}
rowsMax="1"
variant="outlined"
fullWidth fullWidth
onChange = {(event: any) => this.updateDisplayname(event.target.value)}> onChange={(event: any) =>
</TextField> this.updateDisplayname(event.target.value)
<div style = {{textAlign: "right"}}> }
<Button className={classes.pageProfileFollowButton} color = "primary" onClick = {() => this.changeDisplayName()}>Update display Name</Button> ></TextField>
<div style={{ textAlign: "right" }}>
<Button
className={classes.pageProfileFollowButton}
color="primary"
onClick={() => this.changeDisplayName()}
>
Update display Name
</Button>
</div> </div>
</Paper> </Paper>
<br/> <br />
<Paper className={classes.youPaper}> <Paper className={classes.youPaper}>
<Typography variant="h5" component="h2">About you</Typography> <Typography variant="h5" component="h2">
<br/> About you
<TextField className = {classes.TextField} </Typography>
defaultValue = {this.state.currentAccount.note? this.removeHTMLContent(this.state.currentAccount.note): "Tell a little bit about yourself"} <br />
<TextField
className={classes.TextField}
defaultValue={
this.state.currentAccount.note
? this.removeHTMLContent(this.state.currentAccount.note)
: "Tell a little bit about yourself"
}
multiline multiline
variant = "outlined" variant="outlined"
rows = "2" rows="2"
rowsMax = "5" rowsMax="5"
fullWidth fullWidth
onChange = {(event:any) =>this.updateBio(event.target.value)}> onChange={(event: any) => this.updateBio(event.target.value)}
</TextField> ></TextField>
<div style={{textAlign: "right"}}> <div style={{ textAlign: "right" }}>
<Button className={classes.pageProfileFollowButton} color = "primary" onClick = {() => this.changeBio()}>Update biography</Button> <Button
className={classes.pageProfileFollowButton}
color="primary"
onClick={() => this.changeBio()}
>
Update biography
</Button>
</div> </div>
</Paper> </Paper>
</div> </div>

View File

@ -1,5 +1,5 @@
import { MastodonEmoji } from './Emojis'; import { MastodonEmoji } from "./Emojis";
import { Field } from './Field'; import { Field } from "./Field";
/** /**
* Basic type for an account on Mastodon * Basic type for an account on Mastodon
@ -24,11 +24,11 @@ export type Account = {
moved: Account | null; moved: Account | null;
fields: [Field]; fields: [Field];
bot: boolean | null; bot: boolean | null;
} };
export type UAccount = { export type UAccount = {
id: string; id: string;
acct: string; acct: string;
display_name: string; display_name: string;
avatar_static: string; avatar_static: string;
} };

View File

@ -10,4 +10,4 @@ export type Attachment = {
text_url: string | null; text_url: string | null;
meta: any | null; meta: any | null;
description: string | null; description: string | null;
} };

View File

@ -14,4 +14,4 @@ export type Card = {
html: string | null; html: string | null;
width: number | null; width: number | null;
height: number | null; height: number | null;
} };

View File

@ -17,15 +17,15 @@ export type Config = {
}; };
license: License; license: License;
repository?: string; repository?: string;
} };
export type License = { export type License = {
name: string; name: string;
url: string; url: string;
} };
export type Federation = { export type Federation = {
universalLogin: boolean; universalLogin: boolean;
allowPublicPosts: boolean; allowPublicPosts: boolean;
enablePublicTimeline: boolean; enablePublicTimeline: boolean;
} };

View File

@ -1,6 +1,6 @@
import { Status } from './Status'; import { Status } from "./Status";
export type Context = { export type Context = {
ancestors: [Status]; ancestors: [Status];
descendants: [Status]; descendants: [Status];
} };

View File

@ -5,4 +5,4 @@ export type Field = {
name: string; name: string;
value: string; value: string;
verified_at: string | null; verified_at: string | null;
} };

View File

@ -1,6 +1,21 @@
import {Color} from '@material-ui/core'; import { Color } from "@material-ui/core";
import { deepPurple, red, lightGreen, yellow, purple, deepOrange, indigo, lightBlue, orange, blue, amber, pink, brown, blueGrey } from '@material-ui/core/colors'; import {
import { isDarwinApp } from '../utilities/desktop'; deepPurple,
red,
lightGreen,
yellow,
purple,
deepOrange,
indigo,
lightBlue,
orange,
blue,
amber,
pink,
brown,
blueGrey
} from "@material-ui/core/colors";
import { isDarwinApp } from "../utilities/desktop";
/** /**
* Basic theme colors for Hyperspace. * Basic theme colors for Hyperspace.
@ -9,14 +24,18 @@ export type HyperspaceTheme = {
key: string; key: string;
name: string; name: string;
palette: { palette: {
primary: { primary:
| {
main: string; main: string;
} | Color;
secondary: {
main: string;
} | Color;
} }
} | Color;
secondary:
| {
main: string;
}
| Color;
};
};
export const defaultTheme: HyperspaceTheme = { export const defaultTheme: HyperspaceTheme = {
key: "defaultTheme", key: "defaultTheme",
@ -25,7 +44,7 @@ export const defaultTheme: HyperspaceTheme = {
primary: deepPurple, primary: deepPurple,
secondary: red secondary: red
} }
} };
export const gardenerTheme: HyperspaceTheme = { export const gardenerTheme: HyperspaceTheme = {
key: "gardnerTheme", key: "gardnerTheme",
@ -34,7 +53,7 @@ export const gardenerTheme: HyperspaceTheme = {
primary: lightGreen, primary: lightGreen,
secondary: yellow secondary: yellow
} }
} };
export const teacherTheme: HyperspaceTheme = { export const teacherTheme: HyperspaceTheme = {
key: "teacherTheme", key: "teacherTheme",
@ -43,7 +62,7 @@ export const teacherTheme: HyperspaceTheme = {
primary: purple, primary: purple,
secondary: deepOrange secondary: deepOrange
} }
} };
export const jokerTheme: HyperspaceTheme = { export const jokerTheme: HyperspaceTheme = {
key: "jokerTheme", key: "jokerTheme",
@ -52,7 +71,7 @@ export const jokerTheme: HyperspaceTheme = {
primary: indigo, primary: indigo,
secondary: lightBlue secondary: lightBlue
} }
} };
export const guardTheme: HyperspaceTheme = { export const guardTheme: HyperspaceTheme = {
key: "guardTheme", key: "guardTheme",
@ -61,7 +80,7 @@ export const guardTheme: HyperspaceTheme = {
primary: blue, primary: blue,
secondary: deepOrange secondary: deepOrange
} }
} };
export const entertainerTheme: HyperspaceTheme = { export const entertainerTheme: HyperspaceTheme = {
key: "entertainerTheme", key: "entertainerTheme",
@ -70,7 +89,7 @@ export const entertainerTheme: HyperspaceTheme = {
primary: pink, primary: pink,
secondary: purple secondary: purple
} }
} };
export const classicTheme: HyperspaceTheme = { export const classicTheme: HyperspaceTheme = {
key: "classicTheme", key: "classicTheme",
@ -83,7 +102,7 @@ export const classicTheme: HyperspaceTheme = {
main: "#5c2d91" main: "#5c2d91"
} }
} }
} };
export const dragonTheme: HyperspaceTheme = { export const dragonTheme: HyperspaceTheme = {
key: "dragonTheme", key: "dragonTheme",
@ -92,7 +111,7 @@ export const dragonTheme: HyperspaceTheme = {
primary: purple, primary: purple,
secondary: purple secondary: purple
} }
} };
export const memoriumTheme: HyperspaceTheme = { export const memoriumTheme: HyperspaceTheme = {
key: "memoriumTheme", key: "memoriumTheme",
@ -101,7 +120,7 @@ export const memoriumTheme: HyperspaceTheme = {
primary: red, primary: red,
secondary: red secondary: red
} }
} };
export const blissTheme: HyperspaceTheme = { export const blissTheme: HyperspaceTheme = {
key: "blissTheme", key: "blissTheme",
@ -112,19 +131,31 @@ export const blissTheme: HyperspaceTheme = {
}, },
secondary: lightBlue secondary: lightBlue
} }
} };
export const attractTheme: HyperspaceTheme = { export const attractTheme: HyperspaceTheme = {
key: "attractTheme", key: "attractTheme",
name: "Attract", name: "Attract",
palette: { palette: {
primary: { primary: {
main: '#E57373', main: "#E57373"
}, },
secondary: { secondary: {
main: "#78909C", main: "#78909C"
} }
} }
} };
export const themes = [defaultTheme, gardenerTheme, teacherTheme, jokerTheme, guardTheme, entertainerTheme, classicTheme, dragonTheme, memoriumTheme, blissTheme, attractTheme] export const themes = [
defaultTheme,
gardenerTheme,
teacherTheme,
jokerTheme,
guardTheme,
entertainerTheme,
classicTheme,
dragonTheme,
memoriumTheme,
blissTheme,
attractTheme
];

View File

@ -12,4 +12,4 @@ export type Instance = {
stats: Field; stats: Field;
languages: [string]; languages: [string];
contact_account: Account; contact_account: Account;
} };

View File

@ -6,4 +6,4 @@ export type Mention = {
username: string; username: string;
acct: string; acct: string;
id: string; id: string;
} };

View File

@ -7,4 +7,4 @@ export type Notification = {
created_at: string; created_at: string;
account: Account; account: Account;
status: Status | null; status: Status | null;
} };

View File

@ -9,7 +9,7 @@ export type Poll = {
votes_count: number; votes_count: number;
options: [PollOption]; options: [PollOption];
voted: boolean | null; voted: boolean | null;
} };
/** /**
* Basic type for a Poll option in a Poll * Basic type for a Poll option in a Poll
@ -17,14 +17,14 @@ export type Poll = {
export type PollOption = { export type PollOption = {
title: string; title: string;
votes_count: number | null; votes_count: number | null;
} };
export type PollWizard = { export type PollWizard = {
expires_at: string; expires_at: string;
multiple: boolean; multiple: boolean;
options: PollWizardOption[]; options: PollWizardOption[];
} };
export type PollWizardOption = { export type PollWizardOption = {
title: string; title: string;
} };

View File

@ -9,4 +9,4 @@ export type Relationship = {
domain_blocking: boolean; domain_blocking: boolean;
showing_reblogs: boolean; showing_reblogs: boolean;
endorsed: boolean; endorsed: boolean;
} };

View File

@ -6,4 +6,4 @@ export type Results = {
accounts: [Account]; accounts: [Account];
statuses: [Status]; statuses: [Status];
hashtags: [Tag]; hashtags: [Tag];
} };

View File

@ -3,4 +3,4 @@ export type SaveClientSession = {
clientSecret: string; clientSecret: string;
authUrl: string; authUrl: string;
emergency: boolean; emergency: boolean;
} };

View File

@ -1,11 +1,11 @@
import { MastodonEmoji } from './Emojis'; import { MastodonEmoji } from "./Emojis";
import { Visibility } from './Visibility'; import { Visibility } from "./Visibility";
import { Account } from './Account'; import { Account } from "./Account";
import { Attachment } from './Attachment'; import { Attachment } from "./Attachment";
import { Mention } from './Mention'; import { Mention } from "./Mention";
import { Poll } from './Poll'; import { Poll } from "./Poll";
import { Card } from './Card'; import { Card } from "./Card";
import { Tag } from './Tag'; import { Tag } from "./Tag";
/** /**
* Basic type for a status on Mastodon * Basic type for a status on Mastodon
@ -37,4 +37,4 @@ export type Status = {
poll: Poll | null; poll: Poll | null;
application: any; application: any;
pinned: boolean | null; pinned: boolean | null;
} };

View File

@ -1,4 +1,4 @@
export type Tag = { export type Tag = {
name: string; name: string;
url: string; url: string;
} };

View File

@ -1,7 +1,7 @@
import Mastodon from "megalodon"; import Mastodon from "megalodon";
export function userLoggedIn(): boolean { export function userLoggedIn(): boolean {
if (localStorage.getItem('baseurl') && localStorage.getItem('access_token')) { if (localStorage.getItem("baseurl") && localStorage.getItem("access_token")) {
return true; return true;
} else { } else {
return false; return false;
@ -9,13 +9,22 @@ export function userLoggedIn(): boolean {
} }
export function refreshUserAccountData() { export function refreshUserAccountData() {
let client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1"); let client = new Mastodon(
client.get('/accounts/verify_credentials').then((resp: any) => { localStorage.getItem("access_token") as string,
localStorage.setItem('account', JSON.stringify(resp.data)); (localStorage.getItem("baseurl") as string) + "/api/v1"
}).catch((err: Error) => { );
client
.get("/accounts/verify_credentials")
.then((resp: any) => {
localStorage.setItem("account", JSON.stringify(resp.data));
})
.catch((err: Error) => {
console.error(err.message); console.error(err.message);
}); });
client.get('/instance').then((resp: any) => { client.get("/instance").then((resp: any) => {
localStorage.setItem('isPleroma', (resp.data.version.match(/Pleroma/) ? "true" : "false")) localStorage.setItem(
}) "isPleroma",
resp.data.version.match(/Pleroma/) ? "true" : "false"
);
});
} }

View File

@ -17,7 +17,7 @@ export function isDesktopApp(): boolean {
* Determines whether the app is the macOS application * Determines whether the app is the macOS application
*/ */
export function isDarwinApp(): boolean { export function isDarwinApp(): boolean {
return isDesktopApp() && navigator.userAgent.includes("Macintosh") return isDesktopApp() && navigator.userAgent.includes("Macintosh");
} }
/** /**
@ -26,6 +26,6 @@ export function isDarwinApp(): boolean {
export function isDarkMode() { export function isDarkMode() {
// Lift window to an ElectronWindow and add use require() // Lift window to an ElectronWindow and add use require()
const eWin = window as ElectronWindow; const eWin = window as ElectronWindow;
const {remote} = eWin.require('electron'); const { remote } = eWin.require("electron");
return remote.systemPreferences.isDarkMode() return remote.systemPreferences.isDarkMode();
} }

View File

@ -1,5 +1,5 @@
import {MastodonEmoji} from '../types/Emojis'; import { MastodonEmoji } from "../types/Emojis";
import Mastodon from 'megalodon'; import Mastodon from "megalodon";
/** /**
* Takes a given string and replaces emoji codes with their respective image tags. * Takes a given string and replaces emoji codes with their respective image tags.
@ -8,36 +8,51 @@ import Mastodon from 'megalodon';
* @param className The associated class for the string * @param className The associated class for the string
* @returns String with image tags for emojis * @returns String with image tags for emojis
*/ */
export function emojifyString(contents: string, emojis: [MastodonEmoji], className?: any): string { export function emojifyString(
contents: string,
emojis: [MastodonEmoji],
className?: any
): string {
let newContents: string = contents; let newContents: string = contents;
emojis.forEach((emoji: MastodonEmoji) => { emojis.forEach((emoji: MastodonEmoji) => {
let filter = new RegExp(`:${emoji.shortcode}:`, 'g'); let filter = new RegExp(`:${emoji.shortcode}:`, "g");
newContents = newContents.replace(filter, `<img src=${emoji.static_url} ${className? `class="${className}"`: ""}/>`) newContents = newContents.replace(
}) filter,
`<img src=${emoji.static_url} ${
className ? `class="${className}"` : ""
}/>`
);
});
return newContents; return newContents;
} }
export function collectEmojisFromServer() { export function collectEmojisFromServer() {
let client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') + "/api/v1"); let client = new Mastodon(
let emojisPath = localStorage.getItem('emojis'); localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1"
);
let emojisPath = localStorage.getItem("emojis");
let emojis: any[] = []; let emojis: any[] = [];
if (emojisPath === null) { if (emojisPath === null) {
client.get('/custom_emojis').then((resp: any) => { client
.get("/custom_emojis")
.then((resp: any) => {
resp.data.forEach((emoji: MastodonEmoji) => { resp.data.forEach((emoji: MastodonEmoji) => {
let customEmoji = { let customEmoji = {
name: emoji.shortcode, name: emoji.shortcode,
emoticons: [''], emoticons: [""],
short_names: [emoji.shortcode], short_names: [emoji.shortcode],
imageUrl: emoji.static_url, imageUrl: emoji.static_url,
keywords: ['mastodon', 'custom'] keywords: ["mastodon", "custom"]
} };
emojis.push(customEmoji); emojis.push(customEmoji);
localStorage.setItem("emojis", JSON.stringify(emojis)); localStorage.setItem("emojis", JSON.stringify(emojis));
});
}) })
}).catch((err: Error) => { .catch((err: Error) => {
console.error(err.message); console.error(err.message);
}) });
} }
} }

View File

@ -1,4 +1,4 @@
import Mastodon from 'megalodon'; import Mastodon from "megalodon";
/** /**
* Creates the Hyperspace app with the appropriate Redirect URI * Creates the Hyperspace app with the appropriate Redirect URI
@ -7,29 +7,46 @@ import Mastodon from 'megalodon';
* @param baseurl The base URL of the instance * @param baseurl The base URL of the instance
* @param redirect_uri The URL to redirect to when authorizing * @param redirect_uri The URL to redirect to when authorizing
*/ */
export function createHyperspaceApp(name: string, scopes: string, baseurl: string, redirect_uri: string) { export function createHyperspaceApp(
let appName = name === "Hyperspace"? "Hyperspace": `${name} (Hyperspace-like)` name: string,
return Mastodon.createApp(appName, { scopes: string,
baseurl: string,
redirect_uri: string
) {
let appName =
name === "Hyperspace" ? "Hyperspace" : `${name} (Hyperspace-like)`;
return Mastodon.createApp(
appName,
{
scopes: scopes, scopes: scopes,
redirect_uris: redirect_uri, redirect_uris: redirect_uri,
website: 'https://hyperspace.marquiskurt.net', website: "https://hyperspace.marquiskurt.net"
}, baseurl).then(appData => { },
return Mastodon.generateAuthUrl(appData.clientId, appData.clientSecret, { baseurl
).then(appData => {
return Mastodon.generateAuthUrl(
appData.clientId,
appData.clientSecret,
{
redirect_uri: redirect_uri, redirect_uri: redirect_uri,
scope: scopes scope: scopes
}, baseurl).then(url => { },
baseurl
).then(url => {
appData.url = url; appData.url = url;
return appData; return appData;
}) });
}) });
} }
/** /**
* Gets the appropriate redirect address. * Gets the appropriate redirect address.
* @param type The address or configuration to use * @param type The address or configuration to use
*/ */
export function getRedirectAddress(type: "desktop" | "dynamic" | string): string { export function getRedirectAddress(
switch(type) { type: "desktop" | "dynamic" | string
): string {
switch (type) {
case "desktop": case "desktop":
return "hyperspace://hyperspace/app/"; return "hyperspace://hyperspace/app/";
case "dynamic": case "dynamic":

View File

@ -1,22 +1,24 @@
import {getUserDefaultBool, setUserDefaultBool} from './settings'; import { getUserDefaultBool, setUserDefaultBool } from "./settings";
/** /**
* Get the person's permission to send notification requests. * Get the person's permission to send notification requests.
*/ */
export function getNotificationRequestPermission() { export function getNotificationRequestPermission() {
if ('Notification' in window) { if ("Notification" in window) {
Notification.requestPermission(); Notification.requestPermission();
let request = Notification.permission; let request = Notification.permission;
if (request === "granted") { if (request === "granted") {
setUserDefaultBool('enablePushNotifications', true); setUserDefaultBool("enablePushNotifications", true);
setUserDefaultBool('userDeniedNotification', false); setUserDefaultBool("userDeniedNotification", false);
} else { } else {
setUserDefaultBool('enablePushNotifications', false); setUserDefaultBool("enablePushNotifications", false);
setUserDefaultBool('userDeniedNotification', true); setUserDefaultBool("userDeniedNotification", true);
} }
} else { } else {
console.warn("Notifications aren't supported in this browser. The setting will be disabled."); console.warn(
setUserDefaultBool('enablePushNotifications', false); "Notifications aren't supported in this browser. The setting will be disabled."
);
setUserDefaultBool("enablePushNotifications", false);
} }
} }
@ -25,7 +27,7 @@ export function getNotificationRequestPermission() {
* @returns Boolean value that determines whether the browser supports the Notification API * @returns Boolean value that determines whether the browser supports the Notification API
*/ */
export function browserSupportsNotificationRequests(): boolean { export function browserSupportsNotificationRequests(): boolean {
return ('Notification' in window); return "Notification" in window;
} }
/** /**
@ -33,7 +35,7 @@ export function browserSupportsNotificationRequests(): boolean {
* @returns Boolean value of `enablePushNotifications` * @returns Boolean value of `enablePushNotifications`
*/ */
export function canSendNotifications() { export function canSendNotifications() {
return getUserDefaultBool('enablePushNotifications'); return getUserDefaultBool("enablePushNotifications");
} }
/** /**
@ -51,6 +53,6 @@ export function sendNotificationRequest(title: string, body: string) {
window.focus(); window.focus();
}; };
} else { } else {
console.warn('The person has opted to not receive push notifications.'); console.warn("The person has opted to not receive push notifications.");
} }
} }

View File

@ -1,18 +1,18 @@
import { defaultTheme, themes } from "../types/HyperspaceTheme"; import { defaultTheme, themes } from "../types/HyperspaceTheme";
import { getNotificationRequestPermission } from './notifications'; import { getNotificationRequestPermission } from "./notifications";
import axios from 'axios'; import axios from "axios";
import { Config } from "../types/Config"; import { Config } from "../types/Config";
import { Visibility } from "../types/Visibility"; import { Visibility } from "../types/Visibility";
type SettingsTemplate = { type SettingsTemplate = {
[key:string]: any; [key: string]: any;
darkModeEnabled: boolean; darkModeEnabled: boolean;
systemDecidesDarkMode: boolean; systemDecidesDarkMode: boolean;
enablePushNotifications: boolean; enablePushNotifications: boolean;
clearNotificationsOnRead: boolean; clearNotificationsOnRead: boolean;
displayAllOnNotificationBadge: boolean; displayAllOnNotificationBadge: boolean;
defaultVisibility: string; defaultVisibility: string;
} };
/** /**
* Gets the user default from localStorage * Gets the user default from localStorage
@ -21,7 +21,9 @@ type SettingsTemplate = {
*/ */
export function getUserDefaultBool(key: string): boolean { export function getUserDefaultBool(key: string): boolean {
if (localStorage.getItem(key) === null) { if (localStorage.getItem(key) === null) {
console.warn('This key has not been set before, so the default value is FALSE for now.'); console.warn(
"This key has not been set before, so the default value is FALSE for now."
);
return false; return false;
} else { } else {
return localStorage.getItem(key) === "true"; return localStorage.getItem(key) === "true";
@ -35,7 +37,7 @@ export function getUserDefaultBool(key: string): boolean {
*/ */
export function setUserDefaultBool(key: string, value: boolean) { export function setUserDefaultBool(key: string, value: boolean) {
if (localStorage.getItem(key) === null) { if (localStorage.getItem(key) === null) {
console.warn('This key has not been set before.'); console.warn("This key has not been set before.");
} }
localStorage.setItem(key, value.toString()); localStorage.setItem(key, value.toString());
} }
@ -46,7 +48,9 @@ export function setUserDefaultBool(key: string, value: boolean) {
*/ */
export function getUserDefaultVisibility(): Visibility { export function getUserDefaultVisibility(): Visibility {
if (localStorage.getItem("defaultVisibility") === null) { if (localStorage.getItem("defaultVisibility") === null) {
console.warn('This key has not been set before, so the default value is PUBLIC for now.'); console.warn(
"This key has not been set before, so the default value is PUBLIC for now."
);
return "public"; return "public";
} else { } else {
return localStorage.getItem("defaultVisibility") as Visibility; return localStorage.getItem("defaultVisibility") as Visibility;
@ -59,7 +63,7 @@ export function getUserDefaultVisibility(): Visibility {
*/ */
export function setUserDefaultVisibility(key: string) { export function setUserDefaultVisibility(key: string) {
if (localStorage.getItem("defaultVisibility") === null) { if (localStorage.getItem("defaultVisibility") === null) {
console.warn('This key has not been set before.'); console.warn("This key has not been set before.");
} }
localStorage.setItem("defaultVisibility", key.toString()); localStorage.setItem("defaultVisibility", key.toString());
} }
@ -69,8 +73,8 @@ export function setUserDefaultVisibility(key: string) {
*/ */
export function getUserDefaultTheme() { export function getUserDefaultTheme() {
let returnTheme = defaultTheme; let returnTheme = defaultTheme;
themes.forEach((theme) => { themes.forEach(theme => {
if(theme.key === localStorage.getItem('theme')) { if (theme.key === localStorage.getItem("theme")) {
returnTheme = theme; returnTheme = theme;
} }
}); });
@ -82,7 +86,7 @@ export function getUserDefaultTheme() {
* @param themeName The name of the theme * @param themeName The name of the theme
*/ */
export function setUserDefaultTheme(themeName: string) { export function setUserDefaultTheme(themeName: string) {
localStorage.setItem('theme', themeName); localStorage.setItem("theme", themeName);
} }
/** /**
@ -96,9 +100,15 @@ export function createUserDefaults() {
clearNotificationsOnRead: false, clearNotificationsOnRead: false,
displayAllOnNotificationBadge: false, displayAllOnNotificationBadge: false,
defaultVisibility: "public" defaultVisibility: "public"
} };
let settings = ["darkModeEnabled", "systemDecidesDarkMode", "clearNotificationsOnRead", "displayAllOnNotificationBadge", "defaultVisibility"]; let settings = [
"darkModeEnabled",
"systemDecidesDarkMode",
"clearNotificationsOnRead",
"displayAllOnNotificationBadge",
"defaultVisibility"
];
migrateExistingSettings(); migrateExistingSettings();
@ -109,9 +119,8 @@ export function createUserDefaults() {
} else { } else {
localStorage.setItem(setting, defaults[setting].toString()); localStorage.setItem(setting, defaults[setting].toString());
} }
} }
}) });
getNotificationRequestPermission(); getNotificationRequestPermission();
} }
@ -121,17 +130,21 @@ export function createUserDefaults() {
*/ */
export async function getConfig(): Promise<Config | undefined> { export async function getConfig(): Promise<Config | undefined> {
try { try {
const resp = await axios.get('config.json'); const resp = await axios.get("config.json");
let config: Config = resp.data; let config: Config = resp.data;
return config; return config;
} } catch (err) {
catch (err) { console.error(
console.error("Couldn't configure Hyperspace with the config file. Reason: " + err.name); "Couldn't configure Hyperspace with the config file. Reason: " + err.name
);
} }
} }
export function migrateExistingSettings() { export function migrateExistingSettings() {
if (localStorage.getItem('prefers-dark-mode')) { if (localStorage.getItem("prefers-dark-mode")) {
setUserDefaultBool('darkModeEnabled', localStorage.getItem('prefers-dark-mode') === "true") setUserDefaultBool(
"darkModeEnabled",
localStorage.getItem("prefers-dark-mode") === "true"
);
} }
} }

View File

@ -1,7 +1,11 @@
import { createMuiTheme, Theme } from '@material-ui/core'; import { createMuiTheme, Theme } from "@material-ui/core";
import { HyperspaceTheme, themes, defaultTheme } from '../types/HyperspaceTheme'; import {
import { getUserDefaultBool } from './settings'; HyperspaceTheme,
import { isDarwinApp, isDarkMode } from './desktop'; themes,
defaultTheme
} from "../types/HyperspaceTheme";
import { getUserDefaultBool } from "./settings";
import { isDarwinApp, isDarkMode } from "./desktop";
/** /**
* Locates a Hyperspace theme from the themes catalog * Locates a Hyperspace theme from the themes catalog
@ -27,35 +31,38 @@ export function setHyperspaceTheme(theme: HyperspaceTheme): Theme {
return createMuiTheme({ return createMuiTheme({
typography: { typography: {
fontFamily: [ fontFamily: [
'-apple-system', "-apple-system",
'BlinkMacSystemFont', "BlinkMacSystemFont",
'"Segoe UI"', '"Segoe UI"',
'Roboto', "Roboto",
'"Helvetica Neue"', '"Helvetica Neue"',
'Arial', "Arial",
'sans-serif', "sans-serif",
'"Apple Color Emoji"', '"Apple Color Emoji"',
'"Segoe UI Emoji"', '"Segoe UI Emoji"',
'"Segoe UI Symbol"', '"Segoe UI Symbol"'
].join(','), ].join(","),
useNextVariants: true, useNextVariants: true
}, },
palette: { palette: {
primary: theme.palette.primary, primary: theme.palette.primary,
secondary: theme.palette.secondary, secondary: theme.palette.secondary,
type: getUserDefaultBool('darkModeEnabled')? "dark": type: getUserDefaultBool("darkModeEnabled")
getDarkModeFromSystem() === "dark"? "dark": "light" ? "dark"
: getDarkModeFromSystem() === "dark"
? "dark"
: "light"
} }
}) });
} }
export function getDarkModeFromSystem(): string { export function getDarkModeFromSystem(): string {
if (getUserDefaultBool('systemDecidesDarkMode')) { if (getUserDefaultBool("systemDecidesDarkMode")) {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) { if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark"; return "dark";
} else { } else {
if (isDarwinApp()) { if (isDarwinApp()) {
return isDarkMode()? "dark": "light"; return isDarkMode() ? "dark" : "light";
} else { } else {
return "light"; return "light";
} }
@ -72,9 +79,9 @@ export function getDarkModeFromSystem(): string {
*/ */
export function darkMode(theme: Theme, setting: boolean): Theme { export function darkMode(theme: Theme, setting: boolean): Theme {
if (setting) { if (setting) {
theme.palette.type = 'dark'; theme.palette.type = "dark";
} else { } else {
theme.palette.type = 'light'; theme.palette.type = "light";
} }
return theme; return theme;
} }