Merge pull request #95 from hyperspacedev/beta7

1.0.0beta7 Final Check
This commit is contained in:
Marquis Kurt 2019-10-04 14:03:33 -04:00 committed by GitHub
commit 37c3b1ce5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
87 changed files with 7389 additions and 5995 deletions

View File

@ -1,4 +0,0 @@
steps:
- script: |
npm run build
displayName: 'Build project files'

View File

@ -1,25 +0,0 @@
steps:
- task: DownloadSecureFile@1
inputs:
secureFile: 'embedded.provisionprofile'
displayName: 'Download Mac App Store provisioning profile'
- task: DownloadSecureFile@1
inputs:
secureFile: 'nonmas.provisionprofile'
displayName: 'Download regular macOS provisioning profile'
- task: DownloadSecureFile@1
inputs:
secureFile: 'entitlements.mac.plist'
displayName: 'Download regular macOS entitlements'
- task: DownloadSecureFile@1
inputs:
secureFile: 'entitlements.mas.plist'
displayName: 'Download Mac App Store entitlements'
- task: DownloadSecureFile@1
inputs:
secureFile: 'info.plist'
displayName: 'Download info.plist'
- script: mv $(Agent.TempDirectory)/*.plist desktop/
displayName: 'Move entitlements and info to Electron folder'
- script: mv $(Agent.TempDirectory)/*.provisionprofile desktop/
displayName: 'Move provisioning profiles to Electron folder'

View File

@ -1,9 +0,0 @@
steps:
- task: NodeTool@0
inputs:
versionSpec: '8.x'
displayName: 'Install Node.js'
- script: |
npm install
displayName: 'Install dependencies'

12
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,12 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: hyperspacedev
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

21
.github/workflows/ci-linux.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: Build Linux Client
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Clone source code
uses: actions/checkout@v1
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 10.x
- name: Install dependencies and build
run: |
npm install
npm run build --if-present
npm run build-desktop-linux

49
.github/workflows/ci-mac.yml vendored Normal file
View File

@ -0,0 +1,49 @@
name: Build macOS Client
on: [push, pull_request]
jobs:
build:
runs-on: macos-latest
steps:
- name: Clone source code
uses: actions/checkout@v1
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 10.x
- name: Run pre-build setup
run: |
echo "Downloading certificates and profiles..."
echo "$ascCertificates" > certs.b64
echo "$ascMasProfile" > mas.b64
echo "$ascMacProfile" > mac.b64
echo "$ascEntitlementsMas" > entmas.b64
echo "$ascEntitlementsMac" > entmac.b64
echo "$ascInfoPlist" > info.b64
echo "Installing certificates and profiles..."
base64 --decode certs.b64 > Certificates.p12
base64 --decode mas.b64 > desktop/embedded.provisionprofile
base64 --decode mac.b64 > desktop/nonmas.provisionprofile
base64 --decode entmas.b64 > desktop/entitlements.mas.plist
base64 --decode entmac.b64 > desktop/entitlements.mac.plist
base64 --decode info.b64 > desktop/info.plist
security add-generic-password -a "appleseed@marquiskurt.net" -w "$ascPassword" -s "AC_PASSWORD"
sudo security import Certificates.p12 -P "$ascCertsPassword" -k /Library/Keychains/System.keychain
env:
ascPassword: ${{ secrets.ASC_PASSWORD }}
ascCertificates: ${{ secrets.ASC_CERTS }}
ascCertsPassword: ${{ secrets.ASC_CERTS_PASSWORD }}
ascMacProfile: ${{ secrets.ASC_NONMAS_PROFILE }}
ascMasProfile: ${{ secrets.ASC_EMBEDDED_PROFILE }}
ascEntitlementsMas: ${{ secrets.ASC_MAS_ENTITLEMENTS }}
ascEntitlementsMac: ${{ secrets.ASC_MAC_ENTITLEMENTS }}
ascInfoPlist: ${{ secrets.ASC_INFO_PLIST }}
- name: Install dependencies and build
run: |
npm install
npm run build --if-present
npm run build-desktop-darwin-nosign

26
.github/workflows/ci-standard.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: Node CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [8.x, 10.x, 12.x]
steps:
- name: Clone source code
uses: actions/checkout@v1
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies and build
run: |
npm install
npm run build --if-present
env:
CI: true

21
.github/workflows/ci-win.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: Build Windows Client
on: [push, pull_request]
jobs:
build:
runs-on: windows-latest
steps:
- name: Clone source code
uses: actions/checkout@v1
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 10.x
- name: Install dependencies and build
run: |
npm install
npm run build --if-present
npm run build-desktop-win

3
.gitignore vendored
View File

@ -70,3 +70,6 @@ dist/
# Electron app files
desktop/*.plist
desktop/*.provisionprofile
# JetBrains IDEA directory
.idea/

4
.prettierrc Normal file
View File

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

View File

@ -5,14 +5,14 @@ The new beautiful, fluffy client for the fediverse written in TypeScript and Rea
![Screenshot](screenshot.png)
[![Matrix room](https://img.shields.io/matrix/hypermasto:matrix.org.svg)](https://matrix.to/#/#hypermasto:matrix.org)
[![Discord server](https://img.shields.io/discord/554108687434907660.svg?color=blueviolet&label=discord)](https://discord.gg/c69AXwk)
[![Build Status](https://dev.azure.com/hyperspacedev/Hyperspace/_apis/build/status/CI%20Tests?branchName=master)](https://dev.azure.com/hyperspacedev/Hyperspace/_build/latest?definitionId=1&branchName=master)
[![Discord server](https://img.shields.io/discord/554108687434907660.svg?color=blueviolet&label=discord)](https://discord.gg/c69AXwk)
![Build Status](https://github.com/hyperspacedev/hyperspace/workflows/Node%20CI/badge.svg)
Hyperspace is the fluffiest client for Mastodon and other fediverse networks written in TypeScript and React. Hyperspace offers a fun, clean, fast, and responsive design that scales beautifully across devices and enhances the fediverse experience.
> Note: For more information on how Hyperspace 1.0 is different from the *Hyperspace Classic (0.x)* series, please see [MIGRATING.md](MIGRATING.md).
## Build instrictions
## Build instructions
### Prerequisites

0
desktop/linux/128x128.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

0
desktop/linux/16x16.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 770 B

After

Width:  |  Height:  |  Size: 770 B

0
desktop/linux/256x256.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

0
desktop/linux/32x32.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

0
desktop/linux/48x48.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

2116
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "hyperspace",
"productName": "Hyperspace",
"version": "1.0.0-beta6",
"version": "1.0.0-beta7",
"description": "A beautiful, fluffy client for the fediverse",
"author": "Marquis Kurt <hyperspacedev@marquiskurt.net>",
"repository": "https://github.com/hyperspacedev/hyperspace.git",
@ -19,8 +19,8 @@
"@types/react-router-dom": "^4.3.4",
"@types/react-swipeable-views": "latest",
"axios": "^0.19.0",
"electron": "^5.0.8",
"electron-builder": "^21.1.5",
"electron": "^6.0.10",
"electron-builder": "^21.2.0",
"emoji-mart": "^2.11.1",
"file-dialog": "^0.0.7",
"material-ui-pickers": "^2.2.4",
@ -28,6 +28,7 @@
"megalodon": "^0.6.4",
"moment": "^2.24.0",
"notistack": "^0.5.1",
"prettier": "1.18.2",
"query-string": "^6.8.2",
"react": "^16.8.6",
"react-dom": "^16.8.6",
@ -44,7 +45,7 @@
},
"main": "public/electron.js",
"scripts": {
"start": "HTTPS=true BROWSER='Safari Technology Preview' react-scripts start",
"start": "HTTPS=true react-scripts start",
"electrify": "npm run build; electron .",
"electrify-nobuild": "electron .",
"build": "react-scripts build",

View File

@ -1,5 +1,5 @@
{
"version": "1.0.0beta6",
"version": "1.0.0beta7",
"location": "https://hyperspaceapp-next.herokuapp.com",
"branding": {
"name": "Hyperspace",

View File

@ -20,7 +20,7 @@ let mainWindow;
// to when authorizing Hyperspace.
protocol.registerSchemesAsPrivileged([
{ scheme: 'hyperspace', privileges: { standard: true, secure: true } }
])
]);
/**
* Determine whether the desktop app is on macOS
@ -30,10 +30,6 @@ function darwin() {
return process.platform === "darwin";
}
function catalina() {
return os.release() >= "19.0.0";
}
/**
* Register the protocol for Hyperspace
*/
@ -138,10 +134,10 @@ function createWindow() {
webPreferences: {nodeIntegration: true},
// Set some preferences that are specific to macOS.
titleBarStyle: 'hidden',
vibrancy: catalina()? "sidebar": systemPreferences.isDarkMode()? "ultra-dark": "light",
titleBarStyle: 'hiddenInset',
vibrancy: "sidebar",
transparent: darwin(),
backgroundColor: darwin()? "#80FFFFFF": "#FFF"
backgroundColor: darwin()? "#80000000": "#FFF"
}
);
@ -151,16 +147,19 @@ function createWindow() {
// Load the main app and open the index page.
mainWindow.loadURL("hyperspace://hyperspace/app/");
// Watch for a change in macOS's dark mode and reload the window to apply changes
// Watch for a change in macOS's dark mode and reload the window to apply changes, as well as accent color
if (darwin()) {
systemPreferences.subscribeNotification('AppleInterfaceThemeChangedNotification', () => {
if (mainWindow != null) {
if (!catalina()) {
mainWindow.setVibrancy(systemPreferences.isDarkMode()? "ultra-dark": "light");
if (mainWindow != null) {
mainWindow.webContents.reload();
}
mainWindow.webContents.reload();
}
})
});
systemPreferences.subscribeNotification('AppleColorPreferencesChangedNotification', () => {
if (mainWindow != null) {
mainWindow.webContents.reload();
}
});
}
// Delete the window when closed
@ -176,6 +175,19 @@ function createWindow() {
});
}
/**
* Go to a URL in the main window. If it doesn't exist,
* create the window and then navigate to it.
* @param url The URL to visit in the main window
*/
function safelyGoTo(url) {
if (mainWindow == null) {
registerProtocol();
createWindow();
}
mainWindow.loadURL(url);
}
/**
* Create the menu bar and attach it to a window
*/
@ -198,7 +210,15 @@ function createMenubar() {
createWindow();
}
}
},
{
label: 'New Post',
accelerator: 'Shift+CmdOrCtrl+N',
click() {
safelyGoTo("hyperspace://hyperspace/app/#compose")
}
}
]
},
{
@ -218,8 +238,27 @@ function createMenubar() {
{
label: 'View',
submenu: [
{
label: 'Back',
accelerator: 'CmdOrCtrl+[',
click() {
if (mainWindow != null && mainWindow.webContents.canGoBack()) {
mainWindow.webContents.goBack()
}
}
},
{
label: 'Forward',
accelerator: 'CmdOrCtrl+]',
click() {
if (mainWindow != null && mainWindow.webContents.canGoForward()) {
mainWindow.webContents.goForward()
}
}
},
{ role: 'reload' },
{ role: 'forcereload' },
{ type: 'separator' },
{
label: 'Open Dev Tools',
click () {
@ -236,29 +275,126 @@ function createMenubar() {
{ role: 'togglefullscreen' }
]
},
{
label: "Timelines",
submenu: [
{
label: 'Home',
accelerator: "CmdOrCtrl+0",
click() {
safelyGoTo("hyperspace://hyperspace/app/#/home")
}
},
{
label: 'Local',
accelerator: "CmdOrCtrl+1",
click() {
safelyGoTo("hyperspace://hyperspace/app/#/local")
}
},
{
label: 'Public',
accelerator: "CmdOrCtrl+2",
click() {
safelyGoTo("hyperspace://hyperspace/app/#/public")
}
},
{
label: 'Messages',
accelerator: "CmdOrCtrl+3",
click() {
safelyGoTo("hyperspace://hyperspace/app/#/messages")
}
}
]
},
{
label: "Account",
submenu: [
{
label: 'Notifications',
accelerator: "Alt+CmdOrCtrl+N",
click() {
safelyGoTo("hyperspace://hyperspace/app/#/notifications")
}
},
{
label: 'Recommendations...',
accelerator: "Alt+CmdOrCtrl+R",
click() {
safelyGoTo("hyperspace://hyperspace/app/#/recommended")
}
},
{ type: 'separator' },
{
label: 'Edit Profile',
accelerator: "Shift+CmdOrCtrl+P",
click() {
safelyGoTo("hyperspace://hyperspace/app/#/you")
}
},
{
label: 'Blocked Servers',
accelerator: "Shift+CmdOrCtrl+B",
click() {
safelyGoTo("hyperspace://hyperspace/app/#/blocked")
}
},
{ type: 'separator'},
{
label: 'Switch Accounts...',
click() {
safelyGoTo("hyperspace://hyperspace/app/#/welcome")
}
}
]
},
{
role: 'window',
submenu: [
{ role: 'minimize' },
{ role: 'close' }
{ role: 'close' },
{ type: 'separator' },
]
},
{
role: 'help',
submenu: [
{
label: 'Hyperspace Docs',
click () { require('electron').shell.openExternal('https://hyperspace.marquiskurt.net/docs/') }
},
{
label: 'Report a Bug',
click () { require('electron').shell.openExternal('https://github.com/hyperspacedev/hyperspace/issues') }
},
{ type: 'separator' },
{
label: 'Acknowledgements',
click () { require('electron').shell.openExternal('https://github.com/hyperspacedev/hyperspace/blob/master/patreon.md') }
}
]
}
]
];
if (process.platform === 'darwin') {
menuBar.unshift({
label: app.getName(),
submenu: [
{ role: 'about' },
{
label: 'About Hyperspace',
click() {
safelyGoTo("hyperspace://hyperspace/app/#/about")
}
},
{ type: 'separator' },
{
label: "Preferences...",
accelerator: 'Cmd+,',
click() {
safelyGoTo("hyperspace://hyperspace/app/#/settings");
}
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
@ -268,7 +404,7 @@ function createMenubar() {
{ type: 'separator' },
{ role: 'quit' }
]
})
});
// Edit menu
menuBar[2].submenu.push(
@ -280,10 +416,10 @@ function createMenubar() {
{ role: 'stopspeaking' }
]
}
)
);
// Window menu
menuBar[4].submenu = [
menuBar[6].submenu = [
{ role: 'close' },
{ role: 'minimize' },
{ role: 'zoom' },

View File

@ -28,10 +28,11 @@
<link href="%PUBLIC_URL%/splashes/apple_splash_640.png" sizes="640x1136" rel="apple-touch-startup-image" />
<style>
body::-webkit-scrollbar {
display: none;
width: 0 !important;
}
body {
scrollbar-width: none;
overflow: -moz-scrollbars-none;
-ms-overflow-style: none;
}
</style>
<title>Hyperspace</title>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 3.0 MiB

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { AppLayout } from './AppLayout';
import { withStyles } from '@material-ui/core';
import { styles } from './AppLayout.styles';
import {withSnackbar} from 'notistack';
import { AppLayout } from "./AppLayout";
import { withStyles } from "@material-ui/core";
import { styles } from "./AppLayout.styles";
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({
mediaContainer: {
padding: theme.spacing.unit * 2,
},
mediaObject: {
width: '100%',
height: '100%'
},
mediaSlide: {
backgroundColor: theme.palette.primary.light,
width: '100%',
height: 'auto'
}
});
export const styles = (theme: Theme) =>
createStyles({
mediaContainer: {
padding: theme.spacing.unit * 2
},
mediaObject: {
width: "100%",
height: "100%"
},
mediaSlide: {
backgroundColor: theme.palette.primary.light,
width: "100%",
height: "auto"
}
});

View File

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

View File

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

View File

@ -1,17 +1,18 @@
import {Theme, createStyles} from '@material-ui/core';
import { Theme, createStyles } from "@material-ui/core";
export const styles = (theme: Theme) => createStyles({
attachmentArea: {
height: 175,
width: 268,
backgroundColor: theme.palette.primary.light,
color: theme.palette.common.white
},
attachmentBar: {
marginLeft: 0
},
attachmentText: {
backgroundColor: theme.palette.background.paper,
opacity: 0.5
}
});
export const styles = (theme: Theme) =>
createStyles({
attachmentArea: {
height: 175,
width: 268,
backgroundColor: theme.palette.primary.light,
color: theme.palette.common.white
},
attachmentBar: {
marginLeft: 0
},
attachmentText: {
backgroundColor: theme.palette.background.paper,
opacity: 0.5
}
});

View File

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

View File

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

View File

@ -1,98 +1,99 @@
import { Theme, createStyles } from "@material-ui/core";
export const styles = (theme: Theme) => createStyles({
post: {
marginTop: theme.spacing.unit,
marginBottom: theme.spacing.unit
},
postReblogChip: {
color: theme.palette.common.white,
'&:hover': {
backgroundColor: theme.palette.secondary.light
},
backgroundColor: theme.palette.secondary.main,
marginBottom: theme.spacing.unit
},
postContent: {
paddingTop: 0,
paddingBottom: 0,
'& a': {
textDecoration: 'none',
color: theme.palette.secondary.light,
'&:hover': {
textDecoration: 'underline'
export const styles = (theme: Theme) =>
createStyles({
post: {
marginTop: theme.spacing.unit,
marginBottom: theme.spacing.unit
},
'&.u-url.mention': {
textDecoration: 'none',
color: 'inherit',
fontWeight: 'bold'
postReblogChip: {
color: theme.palette.common.white,
"&:hover": {
backgroundColor: theme.palette.secondary.light
},
backgroundColor: theme.palette.secondary.main,
marginBottom: theme.spacing.unit
},
'&.mention.hashtag': {
textDecoration: 'none',
color: 'inherit',
fontWeight: 'bold'
postContent: {
paddingTop: 0,
paddingBottom: 0,
"& a": {
textDecoration: "none",
color: theme.palette.secondary.light,
"&:hover": {
textDecoration: "underline"
},
"&.u-url.mention": {
textDecoration: "none",
color: "inherit",
fontWeight: "bold"
},
"&.mention.hashtag": {
textDecoration: "none",
color: "inherit",
fontWeight: "bold"
}
}
},
postCard: {
"& a:hover": {
textDecoration: "none"
}
},
postEmoji: {
height: theme.typography.fontSize
},
postMedia: {
height: 0,
paddingTop: "56.25%" // 16:9
},
postActionsReply: {
marginLeft: theme.spacing.unit,
marginRight: theme.spacing.unit
},
postFlexGrow: {
flexGrow: 1
},
postTypeIconDiv: {
marginRight: theme.spacing.unit * 2
},
postTypeIcon: {
color: theme.palette.grey[500]
},
postWarningIcon: {
marginRight: theme.spacing.unit,
color: "inherit"
},
postDidAction: {
color: theme.palette.secondary.main
},
postMention: {
marginRight: theme.spacing.unit,
marginBottom: theme.spacing.unit
},
nsfwCard: {
backgroundColor: theme.palette.error.main
},
postTags: {
paddingTop: theme.spacing.unit,
paddingBottom: theme.spacing.unit
},
postAuthorEmoji: {
height: theme.typography.fontSize,
verticalAlign: "middle"
},
heading: {
color: "inherit"
},
mobileOnly: {
[theme.breakpoints.up("sm")]: {
display: "none"
}
},
desktopOnly: {
display: "none",
[theme.breakpoints.up("sm")]: {
display: "block"
}
}
}
},
postCard: {
'& a:hover': {
textDecoration: 'none'
}
},
postEmoji: {
height: theme.typography.fontSize
},
postMedia: {
height: 0,
paddingTop: '56.25%', // 16:9
},
postActionsReply: {
marginLeft: theme.spacing.unit,
marginRight: theme.spacing.unit
},
postFlexGrow: {
flexGrow: 1
},
postTypeIconDiv: {
marginRight: theme.spacing.unit * 2
},
postTypeIcon: {
color: theme.palette.grey[500]
},
postWarningIcon: {
marginRight: theme.spacing.unit,
color: "inherit"
},
postDidAction: {
color: theme.palette.secondary.main
},
postMention: {
marginRight: theme.spacing.unit,
marginBottom: theme.spacing.unit
},
nsfwCard: {
backgroundColor: theme.palette.error.main
},
postTags: {
paddingTop: theme.spacing.unit,
paddingBottom: theme.spacing.unit
},
postAuthorEmoji: {
height: theme.typography.fontSize,
verticalAlign: "middle"
},
heading: {
color: "inherit"
},
mobileOnly: {
[theme.breakpoints.up('sm')]: {
display: 'none'
}
},
desktopOnly: {
display: 'none',
[theme.breakpoints.up('sm')]: {
display: 'block'
}
}
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,20 @@
import * as React from 'react';
import webShare, { WebShareInterface } from 'react-web-share-api';
import {MenuItem} from '@material-ui/core';
import * as React from "react";
import webShare, { WebShareInterface } from "react-web-share-api";
import { MenuItem } from "@material-ui/core";
export interface OwnProps {
style: object;
style: object;
}
const ShareMenu: React.FunctionComponent<WebShareInterface & OwnProps> = ({
share, isSupported, style,
}) => isSupported
? <MenuItem onClick={share} style={style}>Share</MenuItem>
: null
share,
isSupported,
style
}) =>
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 { withStyles } from '@material-ui/core';
import { styles } from './Post.styles';
import { withSnackbar } from 'notistack'
import { Post } from "./Post";
import { withStyles } from "@material-ui/core";
import { styles } from "./Post.styles";
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 {MuiThemeProvider, 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';
import React, { Component } from "react";
import {
MuiThemeProvider,
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 {
theme: Theme;
@ -17,41 +26,68 @@ class ThemePreview extends Component<IThemePreviewProps, IThemePreviewState> {
this.state = {
theme: this.props.theme
}
};
}
render() {
return (
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<MuiThemeProvider theme={this.props.theme}>
<CssBaseline/>
<CssBaseline />
<Paper>
<AppBar color="primary" position="static">
<Toolbar>
<MenuIcon style={{ marginRight: 20, marginLeft: -4 }}/>
<Typography variant="h6" color="inherit">Hyperspace</Typography>
<MenuIcon
style={{ marginRight: 20, marginLeft: -4 }}
/>
<Typography variant="h6" color="inherit">
Hyperspace
</Typography>
</Toolbar>
</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">
This is your theme.
</Typography>
<br/>
<br />
<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>
</div>
<div style={{ textAlign: 'right' }}>
<Fab color="secondary" style={{ marginRight: 8, marginBottom: 8 }}>
<EditIcon/>
<div style={{ textAlign: "right" }}>
<Fab
color="secondary"
style={{ marginRight: 8, marginBottom: 8 }}
>
<EditIcon />
</Fab>
</div>
</Paper>
</MuiThemeProvider>
</div>
)
);
}
}
export default ThemePreview;
export default ThemePreview;

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 ReactDOM from 'react-dom';
import App from './App';
import { HashRouter } from 'react-router-dom';
import * as serviceWorker from './serviceWorker';
import {createUserDefaults, getConfig} from './utilities/settings';
import {collectEmojisFromServer} from './utilities/emojis';
import {SnackbarProvider} from 'notistack';
import { userLoggedIn, refreshUserAccountData } from './utilities/accounts';
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { HashRouter } from "react-router-dom";
import * as serviceWorker from "./serviceWorker";
import { createUserDefaults, getConfig } from "./utilities/settings";
import { collectEmojisFromServer } from "./utilities/emojis";
import { SnackbarProvider } from "notistack";
import { userLoggedIn, refreshUserAccountData } from "./utilities/accounts";
getConfig().then((config: any) => {
document.title = config.branding.name || "Hyperspace";
}).catch((err: Error) => {
console.error(err);
})
getConfig()
.then((config: any) => {
document.title = config.branding.name || "Hyperspace";
})
.catch((err: Error) => {
console.error(err);
});
createUserDefaults();
if (userLoggedIn()) {
@ -20,19 +22,25 @@ if (userLoggedIn()) {
refreshUserAccountData();
}
window.onstorage = (event: any) => {
if (event.key == "account") {
window.location.reload();
}
};
ReactDOM.render(
<HashRouter>
<SnackbarProvider
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
vertical: "bottom",
horizontal: "left"
}}
>
<App />
</SnackbarProvider>
</HashRouter>,
document.getElementById('root'));
</HashRouter>,
document.getElementById("root")
);
// If you want your app to work offline and load faster, you can change
// 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 IconButton, { IconButtonProps } from "@material-ui/core/IconButton";
import { Link, Route, Redirect, RouteProps } from "react-router-dom";
import Chip, { ChipProps } from '@material-ui/core/Chip';
import { MenuItemProps } from '@material-ui/core/MenuItem';
import { MenuItem } from '@material-ui/core';
import Button, { ButtonProps } from '@material-ui/core/Button';
import Fab, { FabProps } from '@material-ui/core/Fab';
import Avatar, { AvatarProps } from '@material-ui/core/Avatar';
import { userLoggedIn } from '../utilities/accounts';
import Chip, { ChipProps } from "@material-ui/core/Chip";
import { MenuItemProps } from "@material-ui/core/MenuItem";
import { MenuItem } from "@material-ui/core";
import Button, { ButtonProps } from "@material-ui/core/Button";
import Fab, { FabProps } from "@material-ui/core/Fab";
import Avatar, { AvatarProps } from "@material-ui/core/Avatar";
import { userLoggedIn } from "../utilities/accounts";
export interface ILinkableListItemProps extends ListItemProps {
to: string;
@ -46,50 +46,52 @@ export interface ILinkableAvatarProps extends AvatarProps {
}
export const LinkableListItem = (props: ILinkableListItemProps) => (
<ListItem {...props} component={Link as any}/>
)
<ListItem {...props} component={Link as any} />
);
export const LinkableIconButton = (props: ILinkableIconButtonProps) => (
<IconButton {...props} component={Link as any}/>
)
<IconButton {...props} component={Link as any} />
);
export const LinkableChip = (props: ILinkableChipProps) => (
<Chip {...props} component={Link as any}/>
)
<Chip {...props} component={Link as any} />
);
export const LinkableMenuItem = (props: ILinkableMenuItemProps) => (
<MenuItem {...props} component={Link as any}/>
)
<MenuItem {...props} component={Link as any} />
);
export const LinkableButton = (props: ILinkableButtonProps) => (
<Button {...props} component={Link as any}/>
)
<Button {...props} component={Link as any} />
);
export const LinkableFab = (props: ILinkableFabProps) => (
<Fab {...props} component={Link as any}/>
)
<Fab {...props} component={Link as any} />
);
export const LinkableAvatar = (props: ILinkableAvatarProps) => (
<Avatar {...props} component={Link as any}/>
)
<Avatar {...props} component={Link as any} />
);
export const ProfileRoute = (rest: any, component: Component) => (
<Route {...rest} render={props => (
<Component {...props}/>
)}/>
)
<Route {...rest} render={props => <Component {...props} />} />
);
export const PrivateRoute = (props: IPrivateRouteProps) => {
const { component, render, ...rest } = props;
return (<Route {...rest}
render={(compProps: any) => (
userLoggedIn()?
React.createElement(component, compProps):
<Redirect to="/welcome"/>
)}
/>
)}
return (
<Route
{...rest}
render={(compProps: any) =>
userLoggedIn() ? (
React.createElement(component, compProps)
) : (
<Redirect to="/welcome" />
)
}
/>
);
};
interface IPrivateRouteProps extends RouteProps {
component: any
component: any;
}

View File

@ -110,6 +110,135 @@ class AboutPage extends Component<any, IAboutPageState> {
return (
<div className={classes.pageLayoutConstraints}>
<Paper>
<div
className={classes.instanceHeaderPaper}
style={{
backgroundImage: `url("${
this.state.brandBg ? this.state.brandBg : ""
}")`
}}
>
<div className={classes.instanceToolbar}>
{this.state.repository ? (
<Tooltip title="View source code">
<IconButton
href={this.state.repository}
target="_blank"
rel="noreferrer"
color="inherit"
>
<CodeIcon />
</IconButton>
</Tooltip>
) : null}
</div>
<div className={classes.instanceHeaderText}>
<Typography variant="h4" component="p">
{this.state.brandName? this.state.brandName: "Hyperspace"}
</Typography>
<Typography>Version {`${this.state? this.state.versionNumber: "1.0.x"} ${this.state && this.state.brandName !== "Hyperspace"? "(Hyperspace-like)": ""}`}</Typography>
</div>
</div>
<List className={classes.pageListConstraints}>
<ListItem>
<ListItemAvatar>
<LinkableAvatar
to={`/profile/${
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin.id
: 0
}`}
src={
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin.avatar_static
: ""
}
>
<PersonIcon />
</LinkableAvatar>
</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"
}
/>
<ListItemSecondaryAction>
<Tooltip title="Send a post or message">
<LinkableIconButton
to={`/compose?visibility=${
this.state.federated ? "public" : "private"
}&acct=${
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin.acct
: ""
}`}
>
<ChatIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip title="View profile">
<LinkableIconButton
to={`/profile/${
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin.id
: 0
}`}
>
<AssignmentIndIcon />
</LinkableIconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar>
<NotesIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="License"
secondary={this.state.license.name}
/>
<ListItemSecondaryAction>
<Tooltip title="View license">
<IconButton
href={this.state.license.url}
target="_blank"
rel="noreferrer"
>
<OpenInNewIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar>
<UpdateIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="Release channel"
secondary={
this.state
? this.state.developer
? "Developer"
: "Release"
: "Loading..."
}
/>
</ListItem>
</List>
</Paper>
<br />
<Paper>
<div
className={classes.instanceHeaderPaper}
@ -130,13 +259,10 @@ class AboutPage extends Component<any, IAboutPageState> {
>
<OpenInNewIcon />
</IconButton>
<Typography
className={classes.instanceHeaderText}
variant="h4"
component="p"
>
{this.state.instance ? this.state.instance.uri : "Loading..."}
</Typography>
<div className={classes.instanceHeaderText}>
<Typography variant="h4" component="p">{this.state.instance ? this.state.instance.uri: "Loading..."}</Typography>
<Typography>Server version {this.state.instance? this.state.instance.version: "x.x.x"}</Typography>
</div>
</div>
<List className={classes.pageListConstraints}>
{localStorage["isPleroma"] == "false" && (
@ -238,168 +364,9 @@ class AboutPage extends Component<any, IAboutPageState> {
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar>
<MastodonIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="Mastodon version"
secondary={
this.state.instance ? this.state.instance.version : "x.x.x"
}
/>
</ListItem>
</List>
</Paper>
<br />
<Paper>
<div
className={classes.instanceHeaderPaper}
style={{
backgroundImage: `url("${
this.state.brandBg ? this.state.brandBg : ""
}")`
}}
>
<div className={classes.instanceToolbar}>
{this.state.repository ? (
<Tooltip title="View source code">
<IconButton
href={this.state.repository}
target="_blank"
rel="noreferrer"
color="inherit"
>
<CodeIcon />
</IconButton>
</Tooltip>
) : null}
</div>
<Typography
className={classes.instanceHeaderText}
variant="h4"
component="p"
>
{this.state.brandName ? this.state.brandName : "Hyperspace"}
</Typography>
</div>
<List className={classes.pageListConstraints}>
<ListItem>
<ListItemAvatar>
<LinkableAvatar
to={`/profile/${
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin.id
: 0
}`}
src={
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin.avatar_static
: ""
}
>
<PersonIcon />
</LinkableAvatar>
</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"
}
/>
<ListItemSecondaryAction>
<Tooltip title="Send a post or message">
<LinkableIconButton
to={`/compose?visibility=${
this.state.federated ? "public" : "private"
}&acct=${
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin.acct
: ""
}`}
>
<ChatIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip title="View profile">
<LinkableIconButton
to={`/profile/${
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin.id
: 0
}`}
>
<AssignmentIndIcon />
</LinkableIconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar>
<NotesIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="License"
secondary={this.state.license.name}
/>
<ListItemSecondaryAction>
<Tooltip title="View license">
<IconButton
href={this.state.license.url}
target="_blank"
rel="noreferrer"
>
<OpenInNewIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar>
<UpdateIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="Release channel"
secondary={
this.state
? this.state.developer
? "Developer"
: "Release"
: "Loading..."
}
/>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar>
<InfoIcon />
</Avatar>
</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)"
: ""
}`}
/>
</ListItem>
</List>
</Paper>
<br />
<ListSubheader>Federation status</ListSubheader>
<Paper>

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

@ -0,0 +1,247 @@
import React, { Component } from "react";
import { styles } from "./PageLayout.styles";
import {
Button,
CircularProgress,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
ListSubheader,
Paper,
Typography,
Tooltip,
withStyles,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
TextField
} from "@material-ui/core";
import { withSnackbar } from "notistack";
import DomainIcon from "@material-ui/icons/Domain";
import CloseIcon from "@material-ui/icons/Close";
import Mastodon from "megalodon";
interface IBlockedState {
viewIsLoading: boolean;
viewDidLoad: boolean;
viewDidError: boolean;
addBlockOpen: boolean;
blockedServers?: [string];
blockTextField: string;
}
class Blocked extends Component<any, IBlockedState> {
client: any;
constructor(props: any) {
super(props);
this.client = new Mastodon(
localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1"
);
this.state = {
addBlockOpen: false,
viewIsLoading: true,
viewDidLoad: false,
viewDidError: false,
blockTextField: ""
};
}
componentDidMount() {
this.client
.get("/domain_blocks")
.then((resp: any) => {
this.setState({
blockedServers: resp.data,
viewDidLoad: true,
viewIsLoading: false
});
})
.catch((err: Error) => {
console.error(err);
this.setState({
viewIsLoading: false,
viewDidError: true
});
});
}
addBlock(domain: string) {
this.client.post("/domain_blocks", { domain }).then((resp: any) => {
this.props.enqueueSnackbar(`Blocked ${domain} successfully.`);
let blockedServers = this.state.blockedServers;
if (blockedServers && blockedServers.length > 0) {
blockedServers.push(domain);
} else {
blockedServers = [domain];
}
this.setState({
blockTextField: "",
addBlockOpen: false,
blockedServers
});
});
}
removeBlock(domain: string) {
this.client
.del("/domain_blocks", { domain })
.then((resp: any) => {
this.props.enqueueSnackbar(`Removed ${domain} from blacklist.`);
let blockedServers = this.state.blockedServers;
if (blockedServers && blockedServers.length > 0) {
blockedServers.splice(blockedServers.indexOf(domain), 1);
}
this.setState({
blockedServers
});
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't remove ${domain}: ${err.name}`,
{ variant: "error" }
);
});
}
updateTextField(value: string) {
this.setState({
blockTextField: value
});
}
showAddBlockDialog() {
return (
<Dialog
open={this.state.addBlockOpen}
onClose={() => this.toggleAddBlockState()}
>
<DialogTitle id="alert-dialog-title">Add a domain</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
Type the domain that you want to block. You won't see
any posts from this server or receive notifications from
them.
</DialogContentText>
<TextField
variant="outlined"
fullWidth
value={this.state.blockTextField}
placeholder="mastodon.social"
onChange={e => this.updateTextField(e.target.value)}
></TextField>
</DialogContent>
<DialogActions>
<Button
onClick={() => this.toggleAddBlockState()}
color="primary"
autoFocus
>
Cancel
</Button>
<Button
color="primary"
onClick={e => this.addBlock(this.state.blockTextField)}
>
Add
</Button>
</DialogActions>
</Dialog>
);
}
toggleAddBlockState() {
this.setState({ addBlockOpen: !this.state.addBlockOpen });
}
render() {
const { classes } = this.props;
return (
<div className={classes.pageLayoutConstraints}>
<ListSubheader>Blocked servers</ListSubheader>
<Button
className={classes.clearAllButton}
variant="text"
onClick={() => this.toggleAddBlockState()}
>
Add
</Button>
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress
className={classes.progress}
color="primary"
/>
</div>
) : (
<span />
)}
{this.state.blockedServers &&
this.state.blockedServers.length > 0 ? (
<Paper className={classes.pageListConstraints}>
<List>
{this.state.blockedServers &&
this.state.blockedServers.length > 0
? this.state.blockedServers.map(
(domain: string) => (
<ListItem key={domain}>
<ListItemAvatar>
<DomainIcon color="action" />
</ListItemAvatar>
<ListItemText primary={domain} />
<ListItemSecondaryAction>
<Tooltip title="Remove block">
<IconButton
onClick={() =>
this.removeBlock(
domain
)
}
>
<CloseIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
)
)
: null}
</List>
</Paper>
) : this.state.viewDidError ? (
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">
Something went wrong when loading blocked servers.
</Typography>
</Paper>
) : (
<Typography variant="h6" component="p">
No blocked servers found.
</Typography>
)}
<br />
<Typography variant={"caption"}>
You won't see any public posts and notifications from the
following servers, and any followers from these servers are
automatically removed.
</Typography>
{this.showAddBlockDialog()}
</div>
);
}
}
export default withStyles(styles)(withSnackbar(Blocked));

View File

@ -1,48 +1,49 @@
import { Theme, createStyles } from "@material-ui/core";
export const styles = (theme: Theme) => createStyles({
dialog: {
minHeight: 400
},
dialogContent: {
paddingBottom: 0
},
dialogActions: {
paddingLeft: theme.spacing.unit * 1.25
},
charsReachingLimit: {
color: theme.palette.error.main
},
warningCaption: {
height: 16,
verticalAlign: "text-bottom"
},
composeAttachmentArea: {
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-around',
overflow: 'hidden'
},
composeAttachmentAreaGridList: {
height: 250,
width: '100%'
},
composeEmoji: {
marginTop: theme.spacing.unit * 8
},
desktopOnly: {
display: "none",
[theme.breakpoints.up('sm')]: {
display: "block"
export const styles = (theme: Theme) =>
createStyles({
dialog: {
minHeight: 400
},
dialogContent: {
paddingBottom: 0
},
dialogActions: {
paddingLeft: theme.spacing.unit * 1.25
},
charsReachingLimit: {
color: theme.palette.error.main
},
warningCaption: {
height: 16,
verticalAlign: "text-bottom"
},
composeAttachmentArea: {
display: "flex",
flexWrap: "wrap",
justifyContent: "space-around",
overflow: "hidden"
},
composeAttachmentAreaGridList: {
height: 250,
width: "100%"
},
composeEmoji: {
marginTop: theme.spacing.unit * 8
},
desktopOnly: {
display: "none",
[theme.breakpoints.up("sm")]: {
display: "block"
}
},
pollWizardOptionIcon: {
marginRight: theme.spacing.unit * 2,
marginTop: 4,
marginBottom: 4,
color: theme.palette.grey[700]
},
pollWizardFlexGrow: {
flexGrow: 1
}
},
pollWizardOptionIcon: {
marginRight: theme.spacing.unit * 2,
marginTop: 4,
marginBottom: 4,
color: theme.palette.grey[700]
},
pollWizardFlexGrow: {
flexGrow: 1
}
});
});

View File

@ -1,26 +1,45 @@
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 {parse as parseParams, ParsedQuery} from 'query-string';
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';
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 { parse as parseParams, ParsedQuery } from "query-string";
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 {
account: UAccount;
@ -40,31 +59,37 @@ interface IComposerState {
}
class Composer extends Component<any, IComposerState> {
client: Mastodon;
constructor(props: any) {
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 = {
account: JSON.parse(localStorage.getItem('account') as string),
account: JSON.parse(localStorage.getItem("account") as string),
visibility: getUserDefaultVisibility(),
sensitive: false,
visibilityMenu: false,
text: '',
text: "",
remainingChars: 500,
showEmojis: false,
federated: true
}
};
}
componentDidMount() {
let state = this.getComposerParams(this.props);
let text = state.acct? `@${state.acct}: `: '';
let text = state.acct ? `@${state.acct}: ` : "";
this.client.get("/accounts/verify_credentials").then((resp: any) => {
let account: UAccount = resp.data;
this.setState({ account });
});
getConfig().then((config: any) => {
this.setState({
this.setState({
federated: config.federation.allowPublicPosts,
reply: state.reply,
acct: state.acct,
@ -72,26 +97,24 @@ class Composer extends Component<any, IComposerState> {
text,
remainingChars: 500 - text.length
});
})
});
}
componentWillReceiveProps(props: any) {
let state = this.getComposerParams(props);
let text = state.acct? `@${state.acct}: `: '';
let text = state.acct ? `@${state.acct}: ` : "";
this.setState({
reply: state.reply,
acct: state.acct,
visibility: state.visibility,
text,
remainingChars: 500 - text.length
})
});
}
checkComposerParams(location?: string): ParsedQuery {
let params = "";
if (location !== undefined && typeof(location) === "string") {
if (location !== undefined && typeof location === "string") {
params = location.replace("#/compose", "");
} else {
params = window.location.hash.replace("#/compose", "");
@ -103,7 +126,7 @@ class Composer extends Component<any, IComposerState> {
let params = this.checkComposerParams(props.location);
let reply: string = "";
let acct: string = "";
let visibility= this.state.visibility;
let visibility = this.state.visibility;
if (params.reply) {
reply = params.reply.toString();
@ -118,7 +141,7 @@ class Composer extends Component<any, IComposerState> {
reply,
acct,
visibility
}
};
}
updateTextFromField(text: string) {
@ -132,34 +155,47 @@ class Composer extends Component<any, IComposerState> {
changeVisibility(visibility: Visibility) {
this.setState({ visibility });
}
uploadMedia() {
filedialog({
multiple: false,
accept: "image/*, video/*"
}).then((media: FileList) => {
let mediaForm = new FormData();
mediaForm.append('file', media[0]);
this.props.enqueueSnackbar("Uploading media...", { persist: true, key: "media-upload" })
this.client.post('/media', mediaForm).then((resp: any) => {
let attachment: Attachment = resp.data;
let attachments = this.state.attachments;
if (attachments) {
attachments.push(attachment);
} else {
attachments = [attachment];
}
this.setState({ attachments });
this.props.closeSnackbar("media-upload");
this.props.enqueueSnackbar('Media uploaded.');
}).catch((err: Error) => {
this.props.closeSnackbar("media-upload");
this.props.enqueueSnackbar("Couldn't upload media: " + err.name, { variant: "error" });
})
.then((media: FileList) => {
let mediaForm = new FormData();
mediaForm.append("file", media[0]);
this.props.enqueueSnackbar("Uploading media...", {
persist: true,
key: "media-upload"
});
this.client
.post("/media", mediaForm)
.then((resp: any) => {
let attachment: Attachment = resp.data;
let attachments = this.state.attachments;
if (attachments) {
attachments.push(attachment);
} else {
attachments = [attachment];
}
this.setState({ attachments });
this.props.closeSnackbar("media-upload");
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) => {
this.props.enqueueSnackbar("Couldn't get media: " + err.name, { variant: "error" });
console.error(err.message);
});
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't get media: " + err.name, {
variant: "error"
});
console.error(err.message);
});
}
getOnlyMediaIds() {
@ -179,7 +215,7 @@ class Composer extends Component<any, IComposerState> {
if (attach.id === attachment.id && attachments) {
attachments[attachments.indexOf(attach)] = attachment;
}
})
});
this.setState({ attachments });
}
}
@ -192,21 +228,21 @@ class Composer extends Component<any, IComposerState> {
attachments.splice(attachments.indexOf(attach), 1);
}
this.setState({ attachments });
})
});
this.props.enqueueSnackbar("Attachment removed.");
}
}
insertEmoji(e: any) {
if (e.custom) {
let text = this.state.text + e.colons
this.setState({
let text = this.state.text + e.colons;
this.setState({
text,
remainingChars: 500 - text.length
});
} else {
let text = this.state.text + e.native
this.setState({
let text = this.state.text + e.native;
this.setState({
text,
remainingChars: 500 - text.length
});
@ -218,12 +254,13 @@ class Composer extends Component<any, IComposerState> {
let expiration = new Date();
let current = new Date();
expiration.setMinutes(expiration.getMinutes() + 30);
let expiryDifference = (expiration.getTime() - current.getTime() / 1000);
let expiryDifference =
expiration.getTime() - current.getTime() / 1000;
let temporaryPoll: PollWizard = {
expires_at: expiryDifference.toString(),
multiple: false,
options: [{title: 'Option 1'}, {title: 'Option 2'}]
}
options: [{ title: "Option 1" }, { title: "Option 2" }]
};
this.setState({
poll: temporaryPoll,
pollExpiresDate: expiration
@ -232,8 +269,11 @@ class Composer extends Component<any, IComposerState> {
}
addPollItem() {
if (this.state.poll !== undefined && this.state.poll.options.length < 4) {
let newOption = {title: 'New option'}
if (
this.state.poll !== undefined &&
this.state.poll.options.length < 4
) {
let newOption = { title: "New option" };
let options = this.state.poll.options;
let poll = this.state.poll;
options.push(newOption);
@ -241,9 +281,12 @@ class Composer extends Component<any, IComposerState> {
poll.multiple = true;
this.setState({
poll: poll
})
});
} 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,12 +303,15 @@ class Composer extends Component<any, IComposerState> {
this.setState({
poll: poll
});
this.props.enqueueSnackbar('Option edited.');
this.props.enqueueSnackbar("Option edited.");
}
}
removePollItem(item: string) {
if (this.state.poll !== undefined && this.state.poll.options.length > 2) {
if (
this.state.poll !== undefined &&
this.state.poll.options.length > 2
) {
let options = this.state.poll.options;
let poll = this.state.poll;
options.forEach((option: PollWizardOption) => {
@ -279,9 +325,11 @@ class Composer extends Component<any, IComposerState> {
}
this.setState({
poll: poll
})
});
} 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 +338,17 @@ class Composer extends Component<any, IComposerState> {
let newDate = new Date(date);
let poll = this.state.poll;
if (poll) {
let expiry = ((newDate.getTime() - currentDate.getTime()) / 1000);
let expiry = (newDate.getTime() - currentDate.getTime()) / 1000;
console.log(expiry);
if (expiry >= 1800) {
poll.expires_at = expiry.toString();
this.setState({ poll, pollExpiresDate: date });
this.props.enqueueSnackbar("Expiration updated.")
this.props.enqueueSnackbar("Expiration updated.");
} 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 +370,32 @@ class Composer extends Component<any, IComposerState> {
if (this.state.poll) {
this.state.poll.options.forEach((option: PollWizardOption) => {
pollOptions.push(option.title);
})
});
}
this.client.post('/statuses', {
status: this.state.text,
media_ids: this.getOnlyMediaIds(),
visibility: this.state.visibility,
sensitive: this.state.sensitive,
spoiler_text: this.state.sensitiveText,
in_reply_to_id: this.state.reply,
poll: this.state.poll? {
options: pollOptions,
expires_in: this.state.poll.expires_at,
multiple: this.state.poll.multiple
}: null
}).then(() => {
this.props.enqueueSnackbar('Posted!');
window.history.back();
}).catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't post: " + err.name);
console.log(err.message);
})
this.client
.post("/statuses", {
status: this.state.text,
media_ids: this.getOnlyMediaIds(),
visibility: this.state.visibility,
sensitive: this.state.sensitive,
spoiler_text: this.state.sensitiveText,
in_reply_to_id: this.state.reply,
poll: this.state.poll
? {
options: pollOptions,
expires_in: this.state.poll.expires_at,
multiple: this.state.poll.multiple
}
: null
})
.then(() => {
this.props.enqueueSnackbar("Posted!");
window.history.back();
})
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't post: " + err.name);
console.log(err.message);
});
}
toggleSensitive() {
@ -355,167 +411,297 @@ class Composer extends Component<any, IComposerState> {
}
render() {
const {classes} = this.props;
const { classes } = this.props;
console.log(this.state);
return (
<Dialog open={true} maxWidth="sm" fullWidth={true} className={classes.dialog} onClose={() => window.history.back()}>
<CardHeader
avatar={
<Avatar src={this.state.account.avatar_static} />
}
<Dialog
open={true}
maxWidth="sm"
fullWidth={true}
className={classes.dialog}
onClose={() => window.history.back()}
>
<CardHeader
avatar={<Avatar src={this.state.account.avatar_static} />}
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}>
{
this.state.sensitive?
<Fade in={this.state.sensitive}>
<TextField
variant="outlined"
fullWidth
label="Content warning"
margin="dense"
onChange={(event) => this.updateWarningFromField(event.target.value)}
></TextField>
</Fade>: null
}
{
this.state.visibility === "direct"?
<Typography variant="caption" >
<WarningIcon className={classes.warningCaption}/> Don't forget to add the usernames of the accounts you want to message in your post.
</Typography>: null
}
<TextField
variant="outlined"
{this.state.sensitive ? (
<Fade in={this.state.sensitive}>
<TextField
variant="outlined"
fullWidth
label="Content warning"
margin="dense"
onChange={event =>
this.updateWarningFromField(
event.target.value
)
}
/>
</Fade>
) : null}
{this.state.visibility === "direct" ? (
<Typography variant="caption">
<WarningIcon className={classes.warningCaption} />{" "}
Don't forget to add the usernames of the accounts
you want to message in your post.
</Typography>
) : null}
<TextField
variant="outlined"
multiline
fullWidth
placeholder="What's on your mind?"
margin="normal"
onChange={(event) => this.updateTextFromField(event.target.value)}
onKeyDown={(event) => this.postViaKeyboard(event)}
inputProps = {
{
maxLength: 500
}
onChange={event =>
this.updateTextFromField(event.target.value)
}
onKeyDown={event => this.postViaKeyboard(event)}
inputProps={{
maxLength: 500
}}
value={this.state.text}
/>
<Typography variant="caption" className={this.state.remainingChars <= 100? classes.charsReachingLimit: null}>
{`${this.state.remainingChars} character${this.state.remainingChars === 1? '': 's'} remaining`}
<Typography
variant="caption"
className={
this.state.remainingChars <= 100
? classes.charsReachingLimit
: null
}
>
{`${this.state.remainingChars} character${
this.state.remainingChars === 1 ? "" : "s"
} remaining`}
</Typography>
{
this.state.attachments && this.state.attachments.length > 0?
<div className={classes.composeAttachmentArea}>
<GridList cellHeight={48} className={classes.composeAttachmentAreaGridList}>
<GridListTile key="Subheader-composer" cols={2} style={{ height: 'auto' }}>
<ListSubheader>Attachments</ListSubheader>
</GridListTile>
{
this.state.attachments.map((attachment: Attachment) => {
let c = <ComposeMediaAttachment
client={this.client}
attachment={attachment}
onAttachmentUpdate={(attachment: Attachment) => this.fetchAttachmentAfterUpdate(attachment)}
onDeleteCallback={(attachment: Attachment) => this.deleteMediaAttachment(attachment)}
/>;
return (c);
})
}
</GridList>
</div>: null
}
{
this.state.poll?
<div style={{ marginTop: 4}}>
{
this.state.poll?
this.state.poll.options.map((option: PollWizardOption, index: number) => {
let c = <div style={{ display: "flex" }} key={"compose_option_" + index.toString()}>
<RadioButtonCheckedIcon className={classes.pollWizardOptionIcon}/>
<TextField
onBlur={(event: any) => this.editPollItem(index, event)}
defaultValue={option.title}/>
<div className={classes.pollWizardFlexGrow}/>
<Tooltip title="Remove poll option">
<IconButton onClick={() => this.removePollItem(option.title)}>
<DeleteIcon/>
</IconButton>
</Tooltip>
</div>
return c;
}): null
{this.state.attachments &&
this.state.attachments.length > 0 ? (
<div className={classes.composeAttachmentArea}>
<GridList
cellHeight={48}
className={
classes.composeAttachmentAreaGridList
}
<div style={{ display: "flex"}}>
<MuiPickersUtilsProvider utils={MomentUtils}>
<DateTimePicker
value={this.state.pollExpiresDate? this.state.pollExpiresDate: new Date()}
onChange={(date: any) => {
this.setPollExpires(date.toISOString());
}}
label="Poll exipres on"
disablePast
/>
</MuiPickersUtilsProvider>
<div className={classes.pollWizardFlexGrow}/>
<Button onClick={() => this.addPollItem()}>Add Option</Button>
</div>
</div>: null
}
>
<GridListTile
key="Subheader-composer"
cols={2}
style={{ height: "auto" }}
>
<ListSubheader>Attachments</ListSubheader>
</GridListTile>
{this.state.attachments.map(
(attachment: Attachment) => {
let c = (
<ComposeMediaAttachment
client={this.client}
attachment={attachment}
onAttachmentUpdate={(
attachment: Attachment
) =>
this.fetchAttachmentAfterUpdate(
attachment
)
}
onDeleteCallback={(
attachment: Attachment
) =>
this.deleteMediaAttachment(
attachment
)
}
/>
);
return c;
}
)}
</GridList>
</div>
) : null}
{this.state.poll ? (
<div style={{ marginTop: 4 }}>
{this.state.poll
? this.state.poll.options.map(
(
option: PollWizardOption,
index: number
) => {
let c = (
<div
style={{ display: "flex" }}
key={
"compose_option_" +
index.toString()
}
>
<RadioButtonCheckedIcon
className={
classes.pollWizardOptionIcon
}
/>
<TextField
onBlur={(event: any) =>
this.editPollItem(
index,
event
)
}
defaultValue={
option.title
}
/>
<div
className={
classes.pollWizardFlexGrow
}
/>
<Tooltip title="Remove poll option">
<IconButton
onClick={() =>
this.removePollItem(
option.title
)
}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</div>
);
return c;
}
)
: null}
<div style={{ display: "flex" }}>
<MuiPickersUtilsProvider utils={MomentUtils}>
<DateTimePicker
value={
this.state.pollExpiresDate
? this.state.pollExpiresDate
: new Date()
}
onChange={(date: any) => {
this.setPollExpires(
date.toISOString()
);
}}
label="Poll exipres on"
disablePast
/>
</MuiPickersUtilsProvider>
<div className={classes.pollWizardFlexGrow} />
<Button onClick={() => this.addPollItem()}>
Add Option
</Button>
</div>
</div>
) : null}
</DialogContent>
<Toolbar className={classes.dialogActions}>
<Tooltip title="Add photos or videos">
<IconButton disabled={this.state.poll !== undefined} onClick={() => this.uploadMedia()} id="compose-media">
<CameraAltIcon/>
<IconButton
disabled={this.state.poll !== undefined}
onClick={() => this.uploadMedia()}
id="compose-media"
>
<CameraAltIcon />
</IconButton>
</Tooltip>
<Tooltip title="Insert emoji">
<IconButton id="compose-emoji" onClick={() => this.toggleEmojis()} className={classes.desktopOnly}>
<TagFacesIcon/>
<IconButton
id="compose-emoji"
onClick={() => this.toggleEmojis()}
className={classes.desktopOnly}
>
<TagFacesIcon />
</IconButton>
</Tooltip>
<Menu
open={this.state.showEmojis}
anchorEl={document.getElementById('compose-emoji')}
anchorEl={document.getElementById("compose-emoji")}
onClose={() => this.toggleEmojis()}
className={classes.composeEmoji}
>
<EmojiPicker onGetEmoji={(emoji: any) => this.insertEmoji(emoji)}/>
<EmojiPicker
onGetEmoji={(emoji: any) => this.insertEmoji(emoji)}
/>
</Menu>
<Tooltip title="Add/remove a poll">
<IconButton disabled={this.state.attachments && this.state.attachments.length > 0} id="compose-poll" onClick={() => {
this.state.poll?
this.removePoll():
this.createPoll()
}}>
<HowToVoteIcon/>
<IconButton
disabled={
this.state.attachments &&
this.state.attachments.length > 0
}
id="compose-poll"
onClick={() => {
this.state.poll
? this.removePoll()
: this.createPoll();
}}
>
<HowToVoteIcon />
</IconButton>
</Tooltip>
<Tooltip title="Change who sees your post">
<IconButton id="compose-visibility" onClick={() => this.toggleVisibilityMenu()}>
<VisibilityIcon/>
<IconButton
id="compose-visibility"
onClick={() => this.toggleVisibilityMenu()}
>
<VisibilityIcon />
</IconButton>
</Tooltip>
<Tooltip title="Set a content warning">
<IconButton onClick={() => this.toggleSensitive()} id="compose-warning">
<WarningIcon/>
<IconButton
onClick={() => this.toggleSensitive()}
id="compose-warning"
>
<WarningIcon />
</IconButton>
</Tooltip>
<Menu open={this.state.visibilityMenu} anchorEl={document.getElementById('compose-visibility')} onClose={() => this.toggleVisibilityMenu()}>
<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
open={this.state.visibilityMenu}
anchorEl={document.getElementById("compose-visibility")}
onClose={() => this.toggleVisibilityMenu()}
>
<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>
</Toolbar>
<DialogActions>
<Button color="secondary" onClick={() => this.post()}>Post</Button>
<Button color="secondary" onClick={() => this.post()}>
Post
</Button>
</DialogActions>
</Dialog>
)
);
}
}
export default withStyles(styles)(withSnackbar(Composer));
export default withStyles(styles)(withSnackbar(Composer));

View File

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

View File

@ -1,11 +1,20 @@
import React, { Component } from 'react';
import { withStyles, CircularProgress, Typography, Paper, Button, 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';
import React, { Component } from "react";
import {
withStyles,
CircularProgress,
Typography,
Paper,
Button,
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 {
posts?: [Status];
@ -16,9 +25,7 @@ interface IHomePageState {
viewDidErrorCode?: any;
}
class HomePage extends Component<any, IHomePageState> {
client: Mastodon;
streamListener: StreamListener;
@ -28,68 +35,74 @@ class HomePage extends Component<any, IHomePageState> {
this.state = {
viewIsLoading: true,
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() {
this.streamListener.on('connect', () => {
this.client.get('/timelines/home', {limit: 40}).then((resp: any) => {
let statuses: [Status] = resp.data;
this.setState({
posts: statuses,
viewIsLoading: false,
viewDidLoad: true,
viewDidError: false
this.streamListener.on("connect", () => {
this.client
.get("/timelines/home", { limit: 40 })
.then((resp: any) => {
let statuses: [Status] = resp.data;
this.setState({
posts: statuses,
viewIsLoading: false,
viewDidLoad: true,
viewDidError: false
});
})
}).catch((resp: any) => {
this.setState({
viewIsLoading: false,
viewDidLoad: true,
viewDidError: true,
viewDidErrorCode: String(resp)
})
this.props.enqueueSnackbar("Failed to get posts.", {
variant: 'error',
.catch((resp: any) => {
this.setState({
viewIsLoading: false,
viewDidLoad: true,
viewDidError: true,
viewDidErrorCode: String(resp)
});
this.props.enqueueSnackbar("Failed to get posts.", {
variant: "error"
});
});
})
});
this.streamListener.on('update', (status: Status) => {
this.streamListener.on("update", (status: Status) => {
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.streamListener.on('delete', (id: number) => {
this.streamListener.on("delete", (id: number) => {
let posts = this.state.posts;
if (posts) {
posts.forEach((post: Status) => {
if (posts && parseInt(post.id) === id) {
posts.splice(posts.indexOf(post), 1);
}
})
});
this.setState({ posts });
}
})
});
this.streamListener.on('error', (err: Error) => {
this.streamListener.on("error", (err: Error) => {
this.setState({
viewDidError: true,
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() {
@ -102,89 +115,123 @@ class HomePage extends Component<any, IHomePageState> {
let backlog = this.state.backlogPosts;
if (posts && backlog && backlog.length > 0) {
let push = backlog.concat(posts);
this.setState({ posts: push as [Status], backlogPosts: null })
this.setState({ posts: push as [Status], backlogPosts: null });
}
}
loadMoreTimelinePieces() {
this.setState({ viewDidLoad: false, viewIsLoading: true})
this.setState({ viewDidLoad: false, viewIsLoading: true });
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) => {
let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status];
newPosts.forEach((post: Status) => {
posts.push(post);
});
this.setState({
viewIsLoading: false,
viewDidLoad: true,
posts
this.client
.get("/timelines/home", {
max_id: this.state.posts[this.state.posts.length - 1].id,
limit: 20
})
}).catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
.then((resp: any) => {
let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status];
newPosts.forEach((post: Status) => {
posts.push(post);
});
this.setState({
viewIsLoading: false,
viewDidLoad: true,
posts
});
})
this.props.enqueueSnackbar("Failed to get posts", {
variant: 'error',
.catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
});
this.props.enqueueSnackbar("Failed to get posts", {
variant: "error"
});
});
})
}
}
render() {
const {classes} = this.props;
const { classes } = this.props;
return (
<div className={classes.pageLayoutMaxConstraints}>
{
this.state.backlogPosts?
{this.state.backlogPosts ? (
<div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}>
<Slide direction="down" in={true}>
<Chip
avatar={
<Avatar>
<ArrowUpwardIcon/>
<ArrowUpwardIcon />
</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"
className={classes.pageTopChip}
onClick={() => this.insertBacklog()}
clickable
/>
/>
</Slide>
</div>
</div>: null
}
{ this.state.posts?
</div>
) : null}
{this.state.posts ? (
<div>
{ this.state.posts.map((post: Status) => {
return <Post key={post.id} post={post} client={this.client}/>
}) }
<br/>
{
this.state.viewDidLoad && !this.state.viewDidError? <div style={{textAlign: "center"}} onClick={() => this.loadMoreTimelinePieces()}><Button variant="contained">Load more</Button></div>: null
}
</div>:
<span/>
}
{
this.state.viewDidError?
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this timeline.</Typography>
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography>
</Paper>:
<span/>
}
{
this.state.viewIsLoading?
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>:
<span/>
}
{this.state.posts.map((post: Status) => {
return (
<Post
key={post.id}
post={post}
client={this.client}
/>
);
})}
<br />
{this.state.viewDidLoad && !this.state.viewDidError ? (
<div
style={{ textAlign: "center" }}
onClick={() => this.loadMoreTimelinePieces()}
>
<Button variant="contained">Load more</Button>
</div>
) : null}
</div>
) : (
<span />
)}
{this.state.viewDidError ? (
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">
Something went wrong when loading this timeline.
</Typography>
<Typography>
{this.state.viewDidErrorCode
? this.state.viewDidErrorCode
: ""}
</Typography>
</Paper>
) : (
<span />
)}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress
className={classes.progress}
color="primary"
/>
</div>
) : (
<span />
)}
</div>
);
}

View File

@ -1,11 +1,20 @@
import React, { Component } from 'react';
import { withStyles, CircularProgress, Typography, Paper, Button, 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';
import React, { Component } from "react";
import {
withStyles,
CircularProgress,
Typography,
Paper,
Button,
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 {
posts?: [Status];
@ -16,9 +25,7 @@ interface ILocalPageState {
viewDidErrorCode?: any;
}
class LocalPage extends Component<any, ILocalPageState> {
client: Mastodon;
streamListener: StreamListener;
@ -28,68 +35,74 @@ class LocalPage extends Component<any, ILocalPageState> {
this.state = {
viewIsLoading: true,
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() {
this.streamListener.on('connect', () => {
this.client.get('/timelines/public', {limit: 40, local: true}).then((resp: any) => {
let statuses: [Status] = resp.data;
this.setState({
posts: statuses,
viewIsLoading: false,
viewDidLoad: true,
viewDidError: false
this.streamListener.on("connect", () => {
this.client
.get("/timelines/public", { limit: 40, local: true })
.then((resp: any) => {
let statuses: [Status] = resp.data;
this.setState({
posts: statuses,
viewIsLoading: false,
viewDidLoad: true,
viewDidError: false
});
})
}).catch((resp: any) => {
this.setState({
viewIsLoading: false,
viewDidLoad: true,
viewDidError: true,
viewDidErrorCode: String(resp)
})
this.props.enqueueSnackbar("Failed to get posts.", {
variant: 'error',
.catch((resp: any) => {
this.setState({
viewIsLoading: false,
viewDidLoad: true,
viewDidError: true,
viewDidErrorCode: String(resp)
});
this.props.enqueueSnackbar("Failed to get posts.", {
variant: "error"
});
});
})
});
this.streamListener.on('update', (status: Status) => {
this.streamListener.on("update", (status: Status) => {
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.streamListener.on('delete', (id: number) => {
this.streamListener.on("delete", (id: number) => {
let posts = this.state.posts;
if (posts) {
posts.forEach((post: Status) => {
if (posts && parseInt(post.id) === id) {
posts.splice(posts.indexOf(post), 1);
}
})
});
this.setState({ posts });
}
})
});
this.streamListener.on('error', (err: Error) => {
this.streamListener.on("error", (err: Error) => {
this.setState({
viewDidError: true,
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() {
@ -102,89 +115,124 @@ class LocalPage extends Component<any, ILocalPageState> {
let backlog = this.state.backlogPosts;
if (posts && backlog && backlog.length > 0) {
let push = backlog.concat(posts);
this.setState({ posts: push as [Status], backlogPosts: null })
this.setState({ posts: push as [Status], backlogPosts: null });
}
}
loadMoreTimelinePieces() {
this.setState({ viewDidLoad: false, viewIsLoading: true})
this.setState({ viewDidLoad: false, viewIsLoading: true });
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) => {
let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status];
newPosts.forEach((post: Status) => {
posts.push(post);
});
this.setState({
viewIsLoading: false,
viewDidLoad: true,
posts
this.client
.get("/timelines/public", {
max_id: this.state.posts[this.state.posts.length - 1].id,
limit: 20,
local: true
})
}).catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
.then((resp: any) => {
let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status];
newPosts.forEach((post: Status) => {
posts.push(post);
});
this.setState({
viewIsLoading: false,
viewDidLoad: true,
posts
});
})
this.props.enqueueSnackbar("Failed to get posts", {
variant: 'error',
.catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
});
this.props.enqueueSnackbar("Failed to get posts", {
variant: "error"
});
});
})
}
}
render() {
const {classes} = this.props;
const { classes } = this.props;
return (
<div className={classes.pageLayoutMaxConstraints}>
{
this.state.backlogPosts?
{this.state.backlogPosts ? (
<div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}>
<Slide direction="down" in={true}>
<Chip
avatar={
<Avatar>
<ArrowUpwardIcon/>
<ArrowUpwardIcon />
</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"
className={classes.pageTopChip}
onClick={() => this.insertBacklog()}
clickable
/>
/>
</Slide>
</div>
</div>: null
}
{ this.state.posts?
</div>
) : null}
{this.state.posts ? (
<div>
{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? <div style={{textAlign: "center"}} onClick={() => this.loadMoreTimelinePieces()}><Button variant="contained">Load more</Button></div>: null
}
</div>:
<span/>
}
{
this.state.viewDidError?
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this timeline.</Typography>
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography>
</Paper>:
<span/>
}
{
this.state.viewIsLoading?
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>:
<span/>
}
<br />
{this.state.viewDidLoad && !this.state.viewDidError ? (
<div
style={{ textAlign: "center" }}
onClick={() => this.loadMoreTimelinePieces()}
>
<Button variant="contained">Load more</Button>
</div>
) : null}
</div>
) : (
<span />
)}
{this.state.viewDidError ? (
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">
Something went wrong when loading this timeline.
</Typography>
<Typography>
{this.state.viewDidErrorCode
? this.state.viewDidErrorCode
: ""}
</Typography>
</Paper>
) : (
<span />
)}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress
className={classes.progress}
color="primary"
/>
</div>
) : (
<span />
)}
</div>
);
}

View File

@ -1,11 +1,23 @@
import React, { Component } from 'react';
import {withStyles, ListSubheader, Paper, List, ListItem, 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';
import React, { Component } from "react";
import {
withStyles,
ListSubheader,
Paper,
List,
ListItem,
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 {
posts?: [Status];
@ -16,44 +28,45 @@ interface IMessagesState {
}
class MessagesPage extends Component<any, IMessagesState> {
client: Mastodon;
constructor(props: any) {
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 = {
viewIsLoading: true
}
};
}
componentWillMount() {
this.client.get('/conversations')
.then((resp) => {
let data:any = resp.data;
let messages: any = [];
data.forEach((message: any) => {
if (message.last_status !== null) {
messages.push(message.last_status);
}
});
this.client.get("/conversations").then(resp => {
let data: any = resp.data;
let messages: any = [];
this.setState({
posts: messages,
viewIsLoading: false,
viewDidLoad: true
});
data.forEach((message: any) => {
if (message.last_status !== null) {
messages.push(message.last_status);
}
});
this.setState({
posts: messages,
viewIsLoading: false,
viewDidLoad: true
});
});
}
removeHTMLContent(text: string) {
const div = document.createElement('div');
const div = document.createElement("div");
div.innerHTML = text;
let innerContent = div.textContent || div.innerText || "";
innerContent = innerContent.slice(0, 100) + "..."
innerContent = innerContent.slice(0, 100) + "...";
return innerContent;
}
@ -61,47 +74,76 @@ class MessagesPage extends Component<any, IMessagesState> {
const { classes } = this.props;
return (
<div className={classes.pageLayoutConstraints}>
{
this.state.viewDidLoad?
<div className={classes.pageListContsraints}>
<ListSubheader>Recent messages</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
{
this.state.posts?
this.state.posts.map((message: Status) => {
return (
<ListItem>
<ListItemAvatar>
<LinkableAvatar to={`/profile/${message.account.id}`} alt={message.account.username} src={message.account.avatar_static}>
<PersonIcon/>
</LinkableAvatar>
</ListItemAvatar>
<ListItemText primary={message.account.display_name || "@" + message.account.acct} secondary={this.removeHTMLContent(message.content)}/>
<ListItemSecondaryAction>
<Tooltip title="View conversation">
<LinkableIconButton to={`/conversation/${message.id}`}>
<ForumIcon/>
</LinkableIconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
)
}): null
}
</List>
</Paper>
<br/>
</div>: null
}
{
this.state.viewIsLoading?
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>:
null
}
{this.state.viewDidLoad ? (
<div className={classes.pageListContsraints}>
<ListSubheader>Recent messages</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
{this.state.posts
? this.state.posts.map(
(message: Status) => {
return (
<ListItem>
<ListItemAvatar>
<LinkableAvatar
to={`/profile/${message.account.id}`}
alt={
message
.account
.username
}
src={
message
.account
.avatar_static
}
>
<PersonIcon />
</LinkableAvatar>
</ListItemAvatar>
<ListItemText
primary={
message.account
.display_name ||
"@" +
message
.account
.acct
}
secondary={this.removeHTMLContent(
message.content
)}
/>
<ListItemSecondaryAction>
<Tooltip title="View conversation">
<LinkableIconButton
to={`/conversation/${message.id}`}
>
<ForumIcon />
</LinkableIconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}
)
: null}
</List>
</Paper>
<br />
</div>
) : null}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress
className={classes.progress}
color="primary"
/>
</div>
) : null}
</div>
)
);
}
}
export default withStyles(styles)(MessagesPage);
export default withStyles(styles)(MessagesPage);

View File

@ -1,25 +1,32 @@
import React, {Component} from 'react';
import {withStyles, Typography} from '@material-ui/core';
import {styles} from './PageLayout.styles';
import {LinkableButton} from '../interfaces/overrides';
import React, { Component } from "react";
import { withStyles, Typography } from "@material-ui/core";
import { styles } from "./PageLayout.styles";
import { LinkableButton } from "../interfaces/overrides";
class Missingno extends Component<any, any> {
render() {
const {classes} = this.props;
const { classes } = this.props;
return (
<div className={classes.pageLayoutConstraints}>
<div>
<Typography variant="h4" component="h1"><b>Uh oh!</b></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">
<Typography variant="h4" component="h1">
<b>Uh oh!</b>
</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"
>
Go back to home timeline
</LinkableButton>
</div>
</div>
)
);
}
}
export default withStyles(styles)(Missingno);
export default withStyles(styles)(Missingno);

View File

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

View File

@ -2,281 +2,303 @@ import { Theme, createStyles } from "@material-ui/core";
import { isDarwinApp } from "../utilities/desktop";
import { isAppbarExpanded } from "../utilities/appbar";
export const styles = (theme: Theme) => createStyles({
root: {
width: '100%',
display: 'flex',
height: '100%'
},
pageLayoutConstraints: {
marginTop: 72,
flexGrow: 1,
padding: theme.spacing.unit * 3,
paddingLeft: theme.spacing.unit,
paddingRight: theme.spacing.unit,
[theme.breakpoints.up('md')]: {
marginLeft: 250,
marginTop: 88,
paddingLeft: theme.spacing.unit * 24,
paddingRight: theme.spacing.unit * 24
},
backgroundColor: theme.palette.background.default,
minHeight: isDarwinApp()? "100vh": 'auto',
},
pageLayoutMaxConstraints: {
marginTop: 72,
flexGrow: 1,
paddingTop: theme.spacing.unit * 2,
padding: theme.spacing.unit,
[theme.breakpoints.up('md')]: {
marginLeft: 250,
marginTop: 88,
padding: theme.spacing.unit * 3,
paddingLeft: theme.spacing.unit * 16,
paddingRight: theme.spacing.unit * 16,
},
[theme.breakpoints.up('lg')]: {
marginLeft: 250,
marginTop: 88,
padding: theme.spacing.unit * 3,
paddingLeft: theme.spacing.unit * 32,
paddingRight: theme.spacing.unit * 32,
},
[theme.breakpoints.up('xl')]: {
marginLeft: 250,
marginTop: 88,
padding: theme.spacing.unit * 3,
paddingLeft: theme.spacing.unit * 40,
paddingRight: theme.spacing.unit * 40,
},
backgroundColor: theme.palette.background.default,
minHeight: isDarwinApp()? "100vh": 'auto',
},
pageLayoutMinimalConstraints: {
flexGrow: 1,
[theme.breakpoints.up('md')]: {
marginLeft: 250,
},
backgroundColor: theme.palette.background.default,
minHeight: isDarwinApp()? "100vh": 'auto',
},
pageLayoutEmptyTextConstraints: {
paddingLeft: theme.spacing.unit * 2,
paddingRight: theme.spacing.unit * 2
},
pageHeroBackground: {
position: 'relative',
height: 'intrinsic',
backgroundColor: theme.palette.primary.dark,
width: '100%',
color: theme.palette.common.white,
zIndex: 1,
top: isAppbarExpanded()? 80: 64,
},
pageHeroBackgroundImage: {
position: 'absolute',
bottom: 0,
left: 0,
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
height: '100%',
width: '100%',
opacity: 0.35,
zIndex: -1,
filter: 'blur(2px)'
},
pageHeroContent: {
padding: 16,
paddingTop: 8,
textAlign: 'center',
width: '100%',
height: '100%',
[theme.breakpoints.up('md')]: {
paddingLeft: '5%',
paddingRight: '5%',
},
position: "relative",
zIndex: 1
},
pageHeroToolbar: {
position: "absolute",
right: theme.spacing.unit * 2,
marginTop: -16,
},
pageListConstraints: {
paddingLeft: theme.spacing.unit,
paddingRight: theme.spacing.unit,
[theme.breakpoints.up('sm')]: {
export const styles = (theme: Theme) =>
createStyles({
root: {
width: "100%",
display: "flex",
height: "100%"
},
pageLayoutConstraints: {
marginTop: 72,
flexGrow: 1,
padding: theme.spacing.unit * 3,
paddingLeft: theme.spacing.unit,
paddingRight: theme.spacing.unit,
[theme.breakpoints.up("md")]: {
marginLeft: 250,
marginTop: 88,
paddingLeft: theme.spacing.unit * 24,
paddingRight: theme.spacing.unit * 24
},
backgroundColor: theme.palette.background.default,
minHeight: isDarwinApp() ? "100vh" : "auto"
},
pageLayoutMaxConstraints: {
marginTop: 72,
flexGrow: 1,
paddingTop: theme.spacing.unit * 2,
padding: theme.spacing.unit,
[theme.breakpoints.up("md")]: {
marginLeft: 250,
marginTop: 88,
padding: theme.spacing.unit * 3,
paddingLeft: theme.spacing.unit * 16,
paddingRight: theme.spacing.unit * 16
},
[theme.breakpoints.up("lg")]: {
marginLeft: 250,
marginTop: 88,
padding: theme.spacing.unit * 3,
paddingLeft: theme.spacing.unit * 32,
paddingRight: theme.spacing.unit * 32
},
[theme.breakpoints.up("xl")]: {
marginLeft: 250,
marginTop: 88,
padding: theme.spacing.unit * 3,
paddingLeft: theme.spacing.unit * 40,
paddingRight: theme.spacing.unit * 40
},
backgroundColor: theme.palette.background.default,
minHeight: isDarwinApp() ? "100vh" : "auto"
},
pageLayoutMinimalConstraints: {
flexGrow: 1,
[theme.breakpoints.up("md")]: {
marginLeft: 250
},
backgroundColor: theme.palette.background.default,
minHeight: isDarwinApp() ? "100vh" : "auto"
},
pageLayoutEmptyTextConstraints: {
paddingLeft: theme.spacing.unit * 2,
paddingRight: theme.spacing.unit * 2
},
//backgroundColor: theme.palette.background.default
},
profileToolbar: {
zIndex: 2,
paddingTop: 8,
},
profileContent: {
padding: 16,
[theme.breakpoints.up('md')]: {
paddingLeft: '5%',
paddingRight: '5%',
paddingBottom: 48,
paddingTop: 24,
pageMinimalBreak: {
height: isAppbarExpanded() ? 80 : 64,
[theme.breakpoints.up("md")]: {
height: isAppbarExpanded() ? 88 : 64
}
},
width: '100%',
height: '100%',
position: 'relative',
zIndex: 1,
display: 'flex',
paddingBottom: 24,
paddingTop: 24,
},
profileAvatar: {
width: 64,
height: 64,
[theme.breakpoints.up('md')]: {
pageHeroBackground: {
position: "relative",
height: "intrinsic",
backgroundColor: theme.palette.primary.dark,
width: "100%",
color: theme.palette.common.white,
zIndex: 1,
top: isAppbarExpanded() ? 80 : 64,
[theme.breakpoints.up("md")]: {
top: isAppbarExpanded() ? 88 : 64
}
},
pageHeroBackgroundImage: {
position: "absolute",
bottom: 0,
left: 0,
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
height: "100%",
width: "100%",
opacity: 0.35,
zIndex: -1,
filter: "blur(2px)"
},
pageHeroContent: {
padding: 16,
paddingTop: 8,
width: "100%",
height: "100%",
[theme.breakpoints.up("md")]: {
paddingLeft: "5%",
paddingRight: "5%"
},
position: "relative",
zIndex: 1
},
pageHeroToolbar: {
position: "absolute",
right: theme.spacing.unit * 2,
marginTop: -16
},
pageListConstraints: {
paddingLeft: theme.spacing.unit,
paddingRight: theme.spacing.unit,
[theme.breakpoints.up("sm")]: {
paddingLeft: theme.spacing.unit * 2,
paddingRight: theme.spacing.unit * 2
}
//backgroundColor: theme.palette.background.default
},
profileToolbar: {
zIndex: 2,
paddingTop: 8
},
profileContent: {
padding: 16,
[theme.breakpoints.up("md")]: {
paddingLeft: "5%",
paddingRight: "5%",
paddingBottom: 48,
paddingTop: 24
},
width: "100%",
height: "100%",
position: "relative",
zIndex: 1,
display: "flex",
paddingBottom: 24,
paddingTop: 24
},
profileAvatar: {
width: 64,
height: 64,
[theme.breakpoints.up("md")]: {
width: 128,
height: 128
},
backgroundColor: theme.palette.primary.main
},
profileUserBox: {
paddingLeft: theme.spacing.unit * 2
},
pageProfileAvatar: {
width: 128,
height: 128,
marginLeft: "auto",
marginRight: "auto",
marginBottom: theme.spacing.unit,
backgroundColor: theme.palette.primary.main
},
backgroundColor: theme.palette.primary.main
},
profileUserBox: {
paddingLeft: theme.spacing.unit * 2
},
pageProfileAvatar: {
width: 128,
height: 128,
marginLeft: 'auto',
marginRight: 'auto',
marginBottom: theme.spacing.unit,
backgroundColor: theme.palette.primary.main
},
pageProfileNameEmoji: {
height: theme.typography.h4.fontSize,
fontWeight: theme.typography.fontWeightMedium,
},
pageProfileStatsDiv: {
display: 'inline-flex',
marginTop: theme.spacing.unit * 2,
marginBottom: theme.spacing.unit * 2,
},
pageProfileStat: {
marginLeft: theme.spacing.unit,
marginRight: theme.spacing.unit
},
pageProfileFollowButton: {
marginTop: theme.spacing.unit,
marginLeft: theme.spacing.unit,
marginRight: theme.spacing.unit,
zIndex: 3
},
pageContentLayoutConstraints: {
paddingLeft: theme.spacing.unit,
paddingRight: theme.spacing.unit,
paddingTop: theme.spacing.unit * 12,
paddingBottom: theme.spacing.unit * 2,
[theme.breakpoints.up('lg')]: {
paddingLeft: theme.spacing.unit * 32,
paddingRight: theme.spacing.unit * 32
},
//backgroundColor: theme.palette.background.default,
},
errorCard: {
padding: theme.spacing.unit * 4,
backgroundColor: theme.palette.error.main,
},
pageTopChipContainer: {
zIndex: 24,
position: "fixed",
width: '100%'
},
pageTopChips: {
textAlign: 'center',
[theme.breakpoints.up('md')]: {
marginRight: '55%'
},
[theme.breakpoints.up('xl')]: {
marginRight: '50%'
}
},
pageTopChip: {
boxShadow: theme.shadows[10]
},
clearAllButton: {
zIndex: 3,
position: 'absolute',
right: 24,
top: 100,
[theme.breakpoints.up('md')]: {
top: 116,
right: theme.spacing.unit * 24,
}
},
mobileOnly: {
[theme.breakpoints.up('sm')]: {
display: 'none'
}
},
desktopOnly: {
display: 'none',
[theme.breakpoints.up('sm')]: {
display: 'block'
}
},
pageLayoutFooter: {
'& a': {
color: theme.palette.primary.light
}
},
youHeadingAvatar: {
height: 88,
width: 88
},
youPaper: {
padding: theme.spacing.unit * 2,
},
youGrid: {
textAlign: "center",
'& *': {
marginLeft: "auto",
marginRight: "auto",
}
},
youGridAvatar: {
height: 128,
width: 128
},
youGridImage: {
width: 'auto',
height: 128
},
instanceHeaderPaper: {
height: 200,
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
position: "relative",
backgroundColor: theme.palette.primary.dark,
borderTopLeftRadius: theme.shape.borderRadius,
borderTopRightRadius: theme.shape.borderRadius
},
instanceHeaderText: {
position: "absolute",
bottom: theme.spacing.unit,
left: theme.spacing.unit * 2,
color: theme.palette.common.white,
textShadow: `0 0 4px ${theme.palette.grey[700]}`,
fontWeight: 600
},
instanceToolbar: {
position: "absolute",
top: theme.spacing.unit,
right: theme.spacing.unit,
color: theme.palette.common.white
},
pageGrow: {
flexGrow: 1
}
});
pageProfileNameEmoji: {
minHeight: theme.typography.h4.fontSize,
fontWeight: theme.typography.fontWeightMedium,
"& img": {
height: theme.typography.h4.fontSize
}
},
pageProfileBioEmoji: {
height: "0.875rem",
"& img": {
height: "0.875rem",
paddingLeft: 4,
paddingRight: 4
}
},
pageProfileStatsDiv: {
display: "inline-flex",
marginTop: theme.spacing.unit * 2,
marginBottom: theme.spacing.unit * 2
},
pageProfileStat: {
marginLeft: theme.spacing.unit,
marginRight: theme.spacing.unit
},
pageProfileFollowButton: {
marginTop: theme.spacing.unit,
marginLeft: theme.spacing.unit,
marginRight: theme.spacing.unit,
zIndex: 3
},
pageContentLayoutConstraints: {
paddingLeft: theme.spacing.unit,
paddingRight: theme.spacing.unit,
paddingTop: theme.spacing.unit * 12,
paddingBottom: theme.spacing.unit * 2,
[theme.breakpoints.up("lg")]: {
paddingLeft: theme.spacing.unit * 32,
paddingRight: theme.spacing.unit * 32
}
//backgroundColor: theme.palette.background.default,
},
errorCard: {
padding: theme.spacing.unit * 4,
backgroundColor: theme.palette.error.main
},
pageTopChipContainer: {
zIndex: 24,
position: "fixed",
width: "100%"
},
pageTopChips: {
textAlign: "center",
[theme.breakpoints.up("md")]: {
marginRight: "55%"
},
[theme.breakpoints.up("xl")]: {
marginRight: "50%"
}
},
pageTopChip: {
boxShadow: theme.shadows[10]
},
clearAllButton: {
zIndex: 3,
position: "absolute",
right: 24,
top: 100,
[theme.breakpoints.up("md")]: {
top: 116,
right: theme.spacing.unit * 24
}
},
mobileOnly: {
[theme.breakpoints.up("sm")]: {
display: "none"
}
},
desktopOnly: {
display: "none",
[theme.breakpoints.up("sm")]: {
display: "block"
}
},
pageLayoutFooter: {
"& a": {
color: theme.palette.primary.light
}
},
youHeadingAvatar: {
height: 88,
width: 88
},
youPaper: {
padding: theme.spacing.unit * 2
},
youGrid: {
textAlign: "center",
"& *": {
marginLeft: "auto",
marginRight: "auto"
}
},
youGridAvatar: {
height: 128,
width: 128
},
youGridImage: {
width: "auto",
height: 128
},
instanceHeaderPaper: {
height: 150,
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
position: "relative",
backgroundColor: theme.palette.primary.dark,
borderTopLeftRadius: theme.shape.borderRadius,
borderTopRightRadius: theme.shape.borderRadius
},
instanceHeaderText: {
position: "absolute",
bottom: theme.spacing.unit,
left: theme.spacing.unit * 2,
"& *": {
color: theme.palette.common.white,
textShadow: `0 0 4px ${theme.palette.grey[700]}`,
fontWeight: 600
}
},
instanceToolbar: {
position: "absolute",
top: theme.spacing.unit,
right: theme.spacing.unit,
color: theme.palette.common.white
},
pageGrow: {
flexGrow: 1
}
});

View File

@ -1,4 +1,4 @@
import React, {Component} from 'react';
import React, { Component } from "react";
import {
withStyles,
Typography,
@ -15,27 +15,25 @@ import {
DialogActions,
Toolbar,
IconButton
} from '@material-ui/core';
import {styles} from './PageLayout.styles';
import Mastodon from 'megalodon';
import { Account } from '../types/Account';
import { Status } from '../types/Status';
import { Relationship } from '../types/Relationship';
import Post from '../components/Post';
import {withSnackbar} from 'notistack';
import { LinkableIconButton } from '../interfaces/overrides';
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';
} from "@material-ui/core";
import { styles } from "./PageLayout.styles";
import Mastodon from "megalodon";
import { Account } from "../types/Account";
import { Status } from "../types/Status";
import { Relationship } from "../types/Relationship";
import Post from "../components/Post";
import { withSnackbar } from "notistack";
import { LinkableIconButton } from "../interfaces/overrides";
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";
interface IProfilePageState {
account?: Account;
@ -49,61 +47,67 @@ interface IProfilePageState {
}
class ProfilePage extends Component<any, IProfilePageState> {
client: Mastodon;
constructor(props: any) {
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 = {
viewIsLoading: true,
blockDialogOpen: false
}
};
}
toggleBlockDialog() {
if (this.state.relationship && !this.state.relationship.blocking)
this.setState({ blockDialogOpen: !this.state.blockDialogOpen })
else
this.toggleBlock()
this.setState({ blockDialogOpen: !this.state.blockDialogOpen });
else this.toggleBlock();
}
getAccountData(id: string) {
this.client.get(`/accounts/${id}`).then((resp: any) => {
let profile: Account = resp.data;
this.client
.get(`/accounts/${id}`)
.then((resp: any) => {
let profile: Account = resp.data;
const div = document.createElement('div');
div.innerHTML = profile.note;
profile.note = div.textContent || div.innerText || "";
const div = document.createElement("div");
div.innerHTML = profile.note;
profile.note = div.textContent || div.innerText || "";
this.setState({
account: profile
this.setState({
account: profile
});
})
}).catch((error: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: error.message
})
});
.catch((error: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: error.message
});
});
this.getRelationships();
this.client.get(`/accounts/${id}/statuses`).then((resp: any) => {
this.setState({
posts: resp.data,
viewIsLoading: false,
viewDidLoad: true,
viewDidError: false
this.client
.get(`/accounts/${id}/statuses`)
.then((resp: any) => {
this.setState({
posts: resp.data,
viewIsLoading: false,
viewDidLoad: true,
viewDidError: false
});
})
}).catch( (err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
})
});
.catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
});
});
}
componentWillReceiveProps(props: any) {
@ -112,244 +116,430 @@ class ProfilePage extends Component<any, IProfilePageState> {
}
componentWillMount() {
const { match: { params }} = this.props;
const {
match: { params }
} = this.props;
this.getAccountData(params.profileId);
}
isItMe(): boolean {
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 {
return false;
}
}
getRelationships() {
this.client.get("/accounts/relationships", {id: this.props.match.params.profileId }).then((resp: any) => {
let relationship: Relationship = resp.data[0];
this.setState({ relationship });
}).catch((error: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: error.message
this.client
.get("/accounts/relationships", {
id: this.props.match.params.profileId
})
});
}
loadMoreTimelinePieces() {
const { match: {params}} = this.props;
this.setState({ viewDidLoad: false, viewIsLoading: true})
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) => {
let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status];
if (newPosts.length <= 0) {
this.props.enqueueSnackbar("Reached end of posts", {
variant: 'error'
});
} else {
newPosts.forEach((post: Status) => {
posts.push(post);
});
}
this.setState({
viewIsLoading: false,
viewDidLoad: true,
posts
})
}).catch((err: Error) => {
.then((resp: any) => {
let relationship: Relationship = resp.data[0];
this.setState({ relationship });
})
.catch((error: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
})
this.props.enqueueSnackbar("Failed to get posts", {
variant: 'error',
viewDidErrorCode: error.message
});
});
}
loadMoreTimelinePieces() {
const {
match: { params }
} = this.props;
this.setState({ viewDidLoad: false, viewIsLoading: true });
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) => {
let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status];
if (newPosts.length <= 0) {
this.props.enqueueSnackbar("Reached end of posts", {
variant: "error"
});
} else {
newPosts.forEach((post: Status) => {
posts.push(post);
});
}
this.setState({
viewIsLoading: false,
viewDidLoad: true,
posts
});
})
.catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
});
this.props.enqueueSnackbar("Failed to get posts", {
variant: "error"
});
});
})
} else {
this.props.enqueueSnackbar("Reached end of posts", { variant: 'error'} );
this.props.enqueueSnackbar("Reached end of posts", {
variant: "error"
});
this.setState({
viewIsLoading: false,
viewDidLoad: true
})
});
}
}
}
toggleFollow() {
if (this.state.relationship) {
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) => {
let relationship: Relationship = resp.data;
this.setState({ relationship });
this.props.enqueueSnackbar('You are no longer following this account.');
}).catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't unfollow account: " + err.name, { variant: 'error' });
console.error(err.message);
})
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;
this.setState({ relationship });
this.props.enqueueSnackbar(
"You are no longer following this account."
);
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
"Couldn't unfollow account: " + err.name,
{ variant: "error" }
);
console.error(err.message);
});
} else {
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;
this.setState({ relationship });
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);
})
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;
this.setState({ relationship });
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);
});
}
}
}
toggleBlock() {
if (this.state.relationship) {
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) => {
let relationship: Relationship = resp.data;
this.setState({ relationship });
this.props.enqueueSnackbar('You are no longer blocking this account.');
}).catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't unblock account: " + err.name, { variant: 'error' });
console.error(err.message);
})
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;
this.setState({ relationship });
this.props.enqueueSnackbar(
"You are no longer blocking this account."
);
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
"Couldn't unblock account: " + err.name,
{ variant: "error" }
);
console.error(err.message);
});
} else {
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;
this.setState({ relationship });
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);
})
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;
this.setState({ relationship });
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);
});
}
}
}
render() {
const { classes } = this.props;
return(
return (
<div className={classes.pageLayoutMinimalConstraints}>
<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}>
<div className={classes.pageGrow}/>
<Tooltip title={
this.isItMe()?
"You can't follow yourself.":
this.state.relationship && this.state.relationship.following?
"Unfollow":
"Follow"
}>
<IconButton color={"inherit"} disabled={this.isItMe()} onClick={() => this.toggleFollow()}>
{
this.isItMe()?
<PersonAddDisabledIcon/>:
this.state.relationship && this.state.relationship.following?
<AccountMinusIcon/>:
<PersonAddIcon/>
}
</IconButton>
</Tooltip>
<div className={classes.pageGrow} />
<Tooltip
title={
this.isItMe()
? "You can't follow yourself."
: this.state.relationship &&
this.state.relationship.following
? "Unfollow"
: "Follow"
}
>
<IconButton
color={"inherit"}
disabled={this.isItMe()}
onClick={() => this.toggleFollow()}
>
{this.isItMe() ? (
<PersonAddDisabledIcon />
) : this.state.relationship &&
this.state.relationship.following ? (
<AccountMinusIcon />
) : (
<PersonAddIcon />
)}
</IconButton>
</Tooltip>
<Tooltip title={"Send a message or post"}>
<LinkableIconButton to={`/compose?acct=${this.state.account? this.state.account.acct: ""}`} color={"inherit"}>
<ChatIcon/>
<LinkableIconButton
to={`/compose?acct=${
this.state.account
? this.state.account.acct
: ""
}`}
color={"inherit"}
>
<ChatIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip title={this.state.relationship && this.state.relationship.blocking? "Unblock this account": "Block this account"}>
<IconButton color={"inherit"} disabled={this.isItMe()} onClick={() => this.toggleBlockDialog()}>
{
this.state.relationship && this.state.relationship.blocking? <AccountHeartIcon/>: <AccountRemoveIcon/>
}
<Tooltip
title={
this.state.relationship &&
this.state.relationship.blocking
? "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>
</Tooltip>
<Tooltip title="Open in web">
<IconButton href={this.state.account? this.state.account.url: ""} target="_blank" rel={"nofollower noreferrer noopener"} color={"inherit"}>
<OpenInNewIcon/>
<IconButton
href={
this.state.account
? this.state.account.url
: ""
}
target="_blank"
rel={"nofollower noreferrer noopener"}
color={"inherit"}
>
<OpenInNewIcon />
</IconButton>
</Tooltip>
{
this.isItMe()?
<Tooltip title="Edit profile">
<LinkableIconButton to="/you" color="inherit">
<AccountEditIcon/>
</LinkableIconButton>
</Tooltip>: null
}
{this.isItMe() ? (
<Tooltip title="Edit profile">
<LinkableIconButton to="/you" color="inherit">
<AccountEditIcon />
</LinkableIconButton>
</Tooltip>
) : null}
</Toolbar>
<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}>
<Typography variant="h4" color="inherit" dangerouslySetInnerHTML={
{__html: this.state.account?
this.state.account.display_name?
emojifyString(this.state.account.display_name, this.state.account.emojis, classes.pageProfileNameEmoji)
<Typography
variant="h4"
color="inherit"
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
: ""}}
className={classes.pageProfileNameEmoji}/>
<Typography variant="caption" color="inherit">{this.state.account ? '@' + this.state.account.acct: ""}</Typography>
<Typography paragraph color="inherit">{
this.state.account ?
this.state.account.note?
this.state.account.note
: ""
}}
className={classes.pageProfileNameEmoji}
/>
<Typography variant="caption" color="inherit">
{this.state.account
? "@" + this.state.account.acct
: ""}
</Typography>
<Typography paragraph color="inherit" dangerouslySetInnerHTML={{ __html: this.state.account
? this.state.account.note
? emojifyString(this.state.account.note, this.state.account.emojis, classes.pageProfileBioEmoji)
: "No bio provided by user."
: "No bio available."
}</Typography>
: "No bio available."}}>
</Typography>
<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>
</div>
</div>
</div>
<div className={classes.pageContentLayoutConstraints}>
{
this.state.viewDidError?
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this profile.</Typography>
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography>
</Paper>:
<span/>
}
{
this.state.posts?
{this.state.viewDidError ? (
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">
Something went wrong when loading this profile.
</Typography>
<Typography>
{this.state.viewDidErrorCode
? this.state.viewDidErrorCode
: ""}
</Typography>
</Paper>
) : (
<span />
)}
{this.state.posts ? (
<div>
{
this.state.posts.map((post: Status) => {
return <Post key={post.id} post={post} client={this.client}/>;
})
}
<br/>
{
this.state.viewDidLoad && !this.state.viewDidError? <div style={{textAlign: "center"}} onClick={() => this.loadMoreTimelinePieces()}><Button variant="contained">Load more</Button></div>: null
}
</div>: <span/>
}
{
this.state.viewIsLoading?
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>:
<span/>
}
{this.state.posts.map((post: Status) => {
return (
<Post
key={post.id}
post={post}
client={this.client}
/>
);
})}
<br />
{this.state.viewDidLoad &&
!this.state.viewDidError ? (
<div
style={{ textAlign: "center" }}
onClick={() =>
this.loadMoreTimelinePieces()
}
>
<Button variant="contained">
Load more
</Button>
</div>
) : null}
</div>
) : (
<span />
)}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress
className={classes.progress}
color="primary"
/>
</div>
) : (
<span />
)}
<Dialog
open={this.state.blockDialogOpen}
onClose={() => this.toggleBlockDialog()}
>
<DialogTitle id="alert-dialog-title">Block this person?</DialogTitle>
>
<DialogTitle id="alert-dialog-title">
Block this person?
</DialogTitle>
<DialogContent>
<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>
</DialogContent>
<DialogActions>
<Button onClick={() => this.toggleBlockDialog()} color="primary" autoFocus>
Cancel
<Button
onClick={() => this.toggleBlockDialog()}
color="primary"
autoFocus
>
Cancel
</Button>
<Button onClick={() => {
this.toggleBlock();
this.toggleBlockDialog();
}} color="primary">
Block
<Button
onClick={() => {
this.toggleBlock();
this.toggleBlockDialog();
}}
color="primary"
>
Block
</Button>
</DialogActions>
</Dialog>
@ -359,4 +549,4 @@ class ProfilePage extends Component<any, IProfilePageState> {
}
}
export default withStyles(styles)(withSnackbar(ProfilePage));
export default withStyles(styles)(withSnackbar(ProfilePage));

View File

@ -1,11 +1,20 @@
import React, { Component } from 'react';
import { withStyles, CircularProgress, Typography, Paper, Button, 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';
import React, { Component } from "react";
import {
withStyles,
CircularProgress,
Typography,
Paper,
Button,
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 {
posts?: [Status];
@ -16,9 +25,7 @@ interface IPublicPageState {
viewDidErrorCode?: any;
}
class PublicPage extends Component<any, IPublicPageState> {
client: Mastodon;
streamListener: StreamListener;
@ -28,67 +35,74 @@ class PublicPage extends Component<any, IPublicPageState> {
this.state = {
viewIsLoading: true,
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() {
this.streamListener.on('connect', () => {
this.client.get('/timelines/public', {limit: 40}).then((resp: any) => {
let statuses: [Status] = resp.data;
this.setState({
posts: statuses,
viewIsLoading: false,
viewDidLoad: true,
viewDidError: false
this.streamListener.on("connect", () => {
this.client
.get("/timelines/public", { limit: 40 })
.then((resp: any) => {
let statuses: [Status] = resp.data;
this.setState({
posts: statuses,
viewIsLoading: false,
viewDidLoad: true,
viewDidError: false
});
})
}).catch((resp: any) => {
this.setState({
viewIsLoading: false,
viewDidLoad: true,
viewDidError: true,
viewDidErrorCode: String(resp)
})
this.props.enqueueSnackbar("Failed to get posts.", {
variant: 'error',
.catch((resp: any) => {
this.setState({
viewIsLoading: false,
viewDidLoad: true,
viewDidError: true,
viewDidErrorCode: String(resp)
});
this.props.enqueueSnackbar("Failed to get posts.", {
variant: "error"
});
});
})
});
this.streamListener.on('update', (status: Status) => {
this.streamListener.on("update", (status: Status) => {
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.streamListener.on('delete', (id: number) => {
this.streamListener.on("delete", (id: number) => {
let posts = this.state.posts;
if (posts) {
posts.forEach((post: Status) => {
if (posts && parseInt(post.id) === id) {
posts.splice(posts.indexOf(post), 1);
}
})
});
this.setState({ posts });
}
})
});
this.streamListener.on('error', (err: Error) => {
this.streamListener.on("error", (err: Error) => {
this.setState({
viewDidError: true,
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() {
@ -101,89 +115,123 @@ class PublicPage extends Component<any, IPublicPageState> {
let backlog = this.state.backlogPosts;
if (posts && backlog && backlog.length > 0) {
let push = backlog.concat(posts);
this.setState({ posts: push as [Status], backlogPosts: null })
this.setState({ posts: push as [Status], backlogPosts: null });
}
}
loadMoreTimelinePieces() {
this.setState({ viewDidLoad: false, viewIsLoading: true})
this.setState({ viewDidLoad: false, viewIsLoading: true });
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) => {
let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status];
newPosts.forEach((post: Status) => {
posts.push(post);
});
this.setState({
viewIsLoading: false,
viewDidLoad: true,
posts
this.client
.get("/timelines/public", {
max_id: this.state.posts[this.state.posts.length - 1].id,
limit: 20
})
}).catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
.then((resp: any) => {
let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status];
newPosts.forEach((post: Status) => {
posts.push(post);
});
this.setState({
viewIsLoading: false,
viewDidLoad: true,
posts
});
})
this.props.enqueueSnackbar("Failed to get posts", {
variant: 'error',
.catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
});
this.props.enqueueSnackbar("Failed to get posts", {
variant: "error"
});
});
})
}
}
render() {
const {classes} = this.props;
const { classes } = this.props;
return (
<div className={classes.pageLayoutMaxConstraints}>
{
this.state.backlogPosts?
{this.state.backlogPosts ? (
<div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}>
<Slide direction="down" in={true}>
<Chip
avatar={
<Avatar>
<ArrowUpwardIcon/>
<ArrowUpwardIcon />
</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"
className={classes.pageTopChip}
onClick={() => this.insertBacklog()}
clickable
/>
/>
</Slide>
</div>
</div>: null
}
{ this.state.posts?
</div>
) : null}
{this.state.posts ? (
<div>
{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? <div style={{textAlign: "center"}} onClick={() => this.loadMoreTimelinePieces()}><Button variant="contained">Load more</Button></div>: null
}
</div>:
<span/>
}
{
this.state.viewDidError?
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this timeline.</Typography>
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography>
</Paper>:
<span/>
}
{
this.state.viewIsLoading?
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>:
<span/>
}
<br />
{this.state.viewDidLoad && !this.state.viewDidError ? (
<div
style={{ textAlign: "center" }}
onClick={() => this.loadMoreTimelinePieces()}
>
<Button variant="contained">Load more</Button>
</div>
) : null}
</div>
) : (
<span />
)}
{this.state.viewDidError ? (
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">
Something went wrong when loading this timeline.
</Typography>
<Typography>
{this.state.viewDidErrorCode
? this.state.viewDidErrorCode
: ""}
</Typography>
</Paper>
) : (
<span />
)}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress
className={classes.progress}
color="primary"
/>
</div>
) : (
<span />
)}
</div>
);
}

View File

@ -1,15 +1,30 @@
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 {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';
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 { 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 {
classes: any;
@ -24,216 +39,327 @@ interface IRecommendationsPageState {
followSuggestions?: [Account];
}
class RecommendationsPage extends Component<IRecommendationsPageProps, IRecommendationsPageState> {
class RecommendationsPage extends Component<
IRecommendationsPageProps,
IRecommendationsPageState
> {
client: Mastodon;
constructor(props: any) {
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 = {
viewIsLoading: true
}
};
}
componentDidMount() {
this.client.get('/follow_requests').then((resp: any) => {
let requestedFollows: [Account] = resp.data;
this.setState({ requestedFollows })
}).catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.name
});
console.error(err.message);
})
this.client.get('/suggestions').then((resp: any) => {
let followSuggestions: [Account] = resp.data;
this.setState({
viewIsLoading: false,
viewDidLoad: true,
followSuggestions
this.client
.get("/follow_requests")
.then((resp: any) => {
let requestedFollows: [Account] = resp.data;
this.setState({ requestedFollows });
})
}).catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.name
.catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.name
});
console.error(err.message);
});
this.client
.get("/suggestions")
.then((resp: any) => {
let followSuggestions: [Account] = resp.data;
this.setState({
viewIsLoading: false,
viewDidLoad: true,
followSuggestions
});
})
.catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.name
});
console.error(err.message);
});
console.error(err.message);
})
}
followMember(acct: Account) {
this.client.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) => {
let followSuggestions = this.state.followSuggestions;
if (followSuggestions) {
followSuggestions.forEach((suggestion: Account, index: number) => {
if (followSuggestions && suggestion.id === acct.id) {
followSuggestions.splice(index, 1);
}
});
this.setState({ followSuggestions });
}
this.client
.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) => {
let followSuggestions = this.state.followSuggestions;
if (followSuggestions) {
followSuggestions.forEach(
(suggestion: Account, index: number) => {
if (
followSuggestions &&
suggestion.id === acct.id
) {
followSuggestions.splice(index, 1);
}
}
);
this.setState({ followSuggestions });
}
});
})
}).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);
});
}
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;
if (requestedFollows) {
requestedFollows.forEach(
(request: Account, index: number) => {
if (requestedFollows && request.id === acct.id) {
requestedFollows.splice(index, 1);
}
}
);
}
this.setState({ requestedFollows });
let requestedFollows = this.state.requestedFollows;
if (requestedFollows) {
requestedFollows.forEach((request: Account, index: number) => {
if (requestedFollows && request.id === acct.id) {
requestedFollows.splice(index, 1);
};
});
};
this.setState({requestedFollows});
let verb: string = type;
verb === "authorize"? verb = "authorized": verb = "rejected";
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);
})
let verb: string = type;
verb === "authorize"
? (verb = "authorized")
: (verb = "rejected");
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);
});
}
showFollowRequests() {
const {classes} = this.props;
const { classes } = this.props;
return (
<div>
<ListSubheader>Follow requests</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
{
this.state.requestedFollows?
this.state.requestedFollows.map((request: Account) => {
return (
<ListItem key={request.id}>
<ListItemAvatar>
<LinkableAvatar to={`/profile/${request.id}`} alt={request.username} src={request.avatar_static}/>
</ListItemAvatar>
<ListItemText primary={request.display_name || request.acct} secondary={request.acct}/>
<ListItemSecondaryAction>
<Tooltip title="Accept request">
<IconButton onClick={() => this.handleFollowRequest(request, "authorize")}>
<CheckIcon/>
</IconButton>
</Tooltip>
<Tooltip title="Reject request">
<IconButton onClick={() => this.handleFollowRequest(request, "reject")}>
<CloseIcon/>
</IconButton>
</Tooltip>
<Tooltip title="View profile">
<LinkableIconButton to={`/profile/${request.id}`}>
<AccountCircleIcon/>
</LinkableIconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}): null
}
</List>
</Paper>
<br/>
<Paper className={classes.pageListConstraints}>
<List>
{this.state.requestedFollows
? this.state.requestedFollows.map(
(request: Account) => {
return (
<ListItem key={request.id}>
<ListItemAvatar>
<LinkableAvatar
to={`/profile/${request.id}`}
alt={request.username}
src={
request.avatar_static
}
/>
</ListItemAvatar>
<ListItemText
primary={
request.display_name ||
request.acct
}
secondary={request.acct}
/>
<ListItemSecondaryAction>
<Tooltip title="Accept request">
<IconButton
onClick={() =>
this.handleFollowRequest(
request,
"authorize"
)
}
>
<CheckIcon />
</IconButton>
</Tooltip>
<Tooltip title="Reject request">
<IconButton
onClick={() =>
this.handleFollowRequest(
request,
"reject"
)
}
>
<CloseIcon />
</IconButton>
</Tooltip>
<Tooltip title="View profile">
<LinkableIconButton
to={`/profile/${request.id}`}
>
<AccountCircleIcon />
</LinkableIconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}
)
: null}
</List>
</Paper>
<br />
</div>
)
);
}
showFollowSuggestions() {
const {classes} = this.props;
const { classes } = this.props;
return (
<div>
<ListSubheader>Suggested accounts</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
{
this.state.followSuggestions?
this.state.followSuggestions.map((suggestion: Account) => {
return (
<ListItem key={suggestion.id}>
<ListItemAvatar>
<LinkableAvatar to={`/profile/${suggestion.id}`} alt={suggestion.username} src={suggestion.avatar_static}/>
</ListItemAvatar>
<ListItemText primary={suggestion.display_name || suggestion.acct} secondary={suggestion.acct}/>
<ListItemSecondaryAction>
<Tooltip title="View profile">
<LinkableIconButton to={`/profile/${suggestion.id}`}>
<AssignmentIndIcon/>
</LinkableIconButton>
</Tooltip>
<Tooltip title="Follow">
<IconButton onClick={() => this.followMember(suggestion)}>
<PersonAddIcon/>
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}): null
}
</List>
</Paper>
<br/>
<Paper className={classes.pageListConstraints}>
<List>
{this.state.followSuggestions
? this.state.followSuggestions.map(
(suggestion: Account) => {
return (
<ListItem key={suggestion.id}>
<ListItemAvatar>
<LinkableAvatar
to={`/profile/${suggestion.id}`}
alt={suggestion.username}
src={
suggestion.avatar_static
}
/>
</ListItemAvatar>
<ListItemText
primary={
suggestion.display_name ||
suggestion.acct
}
secondary={suggestion.acct}
/>
<ListItemSecondaryAction>
<Tooltip title="View profile">
<LinkableIconButton
to={`/profile/${suggestion.id}`}
>
<AssignmentIndIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip title="Follow">
<IconButton
onClick={() =>
this.followMember(
suggestion
)
}
>
<PersonAddIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}
)
: null}
</List>
</Paper>
<br />
</div>
)
);
}
render() {
const {classes} = this.props;
const { classes } = this.props;
return (
<div className={classes.pageLayoutConstraints}>
{
this.state.viewDidLoad?
<div>
{
this.state.requestedFollows && this.state.requestedFollows.length > 0?
this.showFollowRequests():
<div className={classes.pageLayoutEmptyTextConstraints}>
<Typography variant="h6">You don't have any follow requests.</Typography>
<br/>
</div>
}
<Divider/>
<br/>
{
this.state.followSuggestions && this.state.followSuggestions.length > 0? this.showFollowSuggestions():
<div className={classes.pageLayoutEmptyTextConstraints}>
<Typography variant="h5">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>: null
}
{
this.state.viewDidError?
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this timeline.</Typography>
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography>
</Paper>:
<span/>
}
{
this.state.viewIsLoading?
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>:
<span/>
}
{this.state.viewDidLoad ? (
<div>
{this.state.requestedFollows &&
this.state.requestedFollows.length > 0 ? (
this.showFollowRequests()
) : (
<div
className={
classes.pageLayoutEmptyTextConstraints
}
>
<Typography variant="h6">
You don't have any follow requests.
</Typography>
<br />
</div>
)}
<Divider />
<br />
{this.state.followSuggestions &&
this.state.followSuggestions.length > 0 ? (
this.showFollowSuggestions()
) : (
<div
className={
classes.pageLayoutEmptyTextConstraints
}
>
<Typography variant="h5">
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>
) : null}
{this.state.viewDidError ? (
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">
Something went wrong when loading this timeline.
</Typography>
<Typography>
{this.state.viewDidErrorCode
? this.state.viewDidErrorCode
: ""}
</Typography>
</Paper>
) : (
<span />
)}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress
className={classes.progress}
color="primary"
/>
</div>
) : (
<span />
)}
</div>
);
}
}
export default withStyles(styles)(withSnackbar(RecommendationsPage));
export default withStyles(styles)(withSnackbar(RecommendationsPage));

View File

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

View File

@ -1,14 +1,14 @@
import React, { Component } from 'react';
import React, { Component } from "react";
import {
List,
List,
ListItem,
ListItemAvatar,
ListItemText,
ListSubheader,
ListItemSecondaryAction,
Paper,
IconButton,
withStyles,
ListItemText,
ListSubheader,
ListItemSecondaryAction,
Paper,
IconButton,
withStyles,
Button,
Switch,
Dialog,
@ -21,29 +21,51 @@ import {
DialogContentText,
Grid,
Theme,
Typography
} from '@material-ui/core';
import {styles} from './PageLayout.styles';
import {setUserDefaultBool, getUserDefaultBool, getUserDefaultTheme, setUserDefaultTheme, getUserDefaultVisibility, 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';
Typography,
Avatar,
Toolbar,
Tooltip
} from "@material-ui/core";
import { styles } from "./PageLayout.styles";
import {
setUserDefaultBool,
getUserDefaultBool,
getUserDefaultTheme,
setUserDefaultTheme,
getUserDefaultVisibility,
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, LinkableIconButton } from "../interfaces/overrides";
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import DevicesIcon from '@material-ui/icons/Devices';
import Brightness3Icon from '@material-ui/icons/Brightness3';
import PaletteIcon from '@material-ui/icons/Palette';
import AccountEditIcon from 'mdi-material-ui/AccountEdit';
import MastodonIcon from 'mdi-material-ui/Mastodon';
import VisibilityIcon from '@material-ui/icons/Visibility';
import NotificationsIcon from '@material-ui/icons/Notifications';
import BellAlertIcon from 'mdi-material-ui/BellAlert';
import RefreshIcon from '@material-ui/icons/Refresh';
import UndoIcon from '@material-ui/icons/Undo';
import { Config } from '../types/Config';
import OpenInNewIcon from "@material-ui/icons/OpenInNew";
import DevicesIcon from "@material-ui/icons/Devices";
import Brightness3Icon from "@material-ui/icons/Brightness3";
import PaletteIcon from "@material-ui/icons/Palette";
import AccountEditIcon from "mdi-material-ui/AccountEdit";
import MastodonIcon from "mdi-material-ui/Mastodon";
import VisibilityIcon from "@material-ui/icons/Visibility";
import NotificationsIcon from "@material-ui/icons/Notifications";
import BellAlertIcon from "mdi-material-ui/BellAlert";
import RefreshIcon from "@material-ui/icons/Refresh";
import UndoIcon from "@material-ui/icons/Undo";
import DomainDisabledIcon from "@material-ui/icons/DomainDisabled";
import { Config } from "../types/Config";
import { Account } from "../types/Account";
import Mastodon from "megalodon";
import { isDarwinApp } from "../utilities/desktop";
interface ISettingsState {
darkModeEnabled: boolean;
@ -59,28 +81,39 @@ interface ISettingsState {
defaultVisibility: Visibility;
brandName: string;
federated: boolean;
currentUser?: Account;
}
class SettingsPage extends Component<any, ISettingsState> {
client: Mastodon;
constructor(props: any) {
super(props);
this.client = new Mastodon(
localStorage.getItem("access_token") as string,
(localStorage.getItem("baseurl") as string) + "/api/v1"
);
this.state = {
darkModeEnabled: getUserDefaultBool('darkModeEnabled'),
systemDecidesDarkMode: getUserDefaultBool('systemDecidesDarkMode'),
darkModeEnabled: getUserDefaultBool("darkModeEnabled"),
systemDecidesDarkMode: getUserDefaultBool("systemDecidesDarkMode"),
pushNotificationsEnabled: canSendNotifications(),
badgeDisplaysAllNotifs: getUserDefaultBool('displayAllOnNotificationBadge'),
badgeDisplaysAllNotifs: getUserDefaultBool(
"displayAllOnNotificationBadge"
),
selectThemeName: getUserDefaultTheme().key,
themeDialogOpen: false,
visibilityDialogOpen: false,
resetHyperspaceDialog: false,
resetSettingsDialog: false,
previewTheme: setHyperspaceTheme(getUserDefaultTheme()) || setHyperspaceTheme(defaultTheme),
previewTheme:
setHyperspaceTheme(getUserDefaultTheme()) ||
setHyperspaceTheme(defaultTheme),
defaultVisibility: getUserDefaultVisibility() || "public",
brandName: "Hyperspace",
federated: true
}
};
this.toggleDarkMode = this.toggleDarkMode.bind(this);
this.toggleSystemDarkMode = this.toggleSystemDarkMode.bind(this);
@ -94,47 +127,83 @@ class SettingsPage extends Component<any, ISettingsState> {
}
componentDidMount() {
getConfig().then((config: any) => {
this.setState({
brandName: config.branding.name
getConfig()
.then((config: any) => {
this.setState({
brandName: config.branding.name
});
})
}).catch((err: Error) => {
console.error(err.message);
});
.catch((err: Error) => {
console.error(err.message);
});
this.getFederatedStatus();
console.log(getDarkModeFromSystem());
this.client
.get("/accounts/verify_credentials")
.then((resp: any) => {
let data: Account = resp.data;
this.setState({ currentUser: data });
})
.catch((err: Error) => {
let acct = localStorage.getItem("account");
if (acct) {
this.setState({ currentUser: JSON.parse(acct) });
} else {
this.props.enqueueSnackbar(
"Couldn't find profile info: " + err.name
);
console.error(err.message);
}
});
}
getFederatedStatus() {
getConfig().then((result: any) => {
if (result !== undefined) {
let config: Config = result;
console.log(config.federation.allowPublicPosts === false)
this.setState({ federated: config.federation.allowPublicPosts });
console.log(!config.federation.allowPublicPosts);
this.setState({
federated: config.federation.allowPublicPosts
});
}
})
});
}
toggleDarkMode() {
this.setState({ darkModeEnabled: !this.state.darkModeEnabled });
setUserDefaultBool('darkModeEnabled', !this.state.darkModeEnabled);
setUserDefaultBool("darkModeEnabled", !this.state.darkModeEnabled);
window.location.reload();
}
toggleSystemDarkMode() {
this.setState({ systemDecidesDarkMode: !this.state.systemDecidesDarkMode });
setUserDefaultBool('systemDecidesDarkMode', !this.state.systemDecidesDarkMode);
this.setState({
systemDecidesDarkMode: !this.state.systemDecidesDarkMode
});
setUserDefaultBool(
"systemDecidesDarkMode",
!this.state.systemDecidesDarkMode
);
window.location.reload();
}
togglePushNotifications() {
this.setState({ pushNotificationsEnabled: !this.state.pushNotificationsEnabled });
setUserDefaultBool('enablePushNotifications', !this.state.pushNotificationsEnabled);
this.setState({
pushNotificationsEnabled: !this.state.pushNotificationsEnabled
});
setUserDefaultBool(
"enablePushNotifications",
!this.state.pushNotificationsEnabled
);
}
toggleBadgeCount() {
this.setState({ badgeDisplaysAllNotifs: !this.state.badgeDisplaysAllNotifs });
setUserDefaultBool('displayAllOnNotificationBadge', !this.state.badgeDisplaysAllNotifs);
this.setState({
badgeDisplaysAllNotifs: !this.state.badgeDisplaysAllNotifs
});
setUserDefaultBool(
"displayAllOnNotificationBadge",
!this.state.badgeDisplaysAllNotifs
);
}
toggleThemeDialog() {
@ -142,11 +211,15 @@ class SettingsPage extends Component<any, ISettingsState> {
}
toggleVisibilityDialog() {
this.setState({ visibilityDialogOpen: !this.state.visibilityDialogOpen });
this.setState({
visibilityDialogOpen: !this.state.visibilityDialogOpen
});
}
toggleResetDialog() {
this.setState({ resetHyperspaceDialog: !this.state.resetHyperspaceDialog });
this.setState({
resetHyperspaceDialog: !this.state.resetHyperspaceDialog
});
}
toggleResetSettingsDialog() {
@ -178,15 +251,22 @@ class SettingsPage extends Component<any, ISettingsState> {
}
refresh() {
let settings = ['darkModeEnabled', 'enablePushNotifications', 'clearNotificationsOnRead', 'theme', 'displayAllOnNotificationBadge', 'defaultVisibility'];
let settings = [
"darkModeEnabled",
"enablePushNotifications",
"clearNotificationsOnRead",
"theme",
"displayAllOnNotificationBadge",
"defaultVisibility"
];
settings.forEach(setting => {
localStorage.removeItem(setting);
})
});
window.location.reload();
}
showThemeDialog() {
const {classes} = this.props;
const { classes } = this.props;
return (
<Dialog
open={this.state.themeDialogOpen}
@ -196,7 +276,9 @@ class SettingsPage extends Component<any, ISettingsState> {
fullWidth={true}
aria-labelledby="confirmation-dialog-title"
>
<DialogTitle id="confirmation-dialog-title">Choose a theme</DialogTitle>
<DialogTitle id="confirmation-dialog-title">
Choose a theme
</DialogTitle>
<DialogContent>
<Grid container spacing={16}>
<Grid item xs={12} md={6}>
@ -204,17 +286,31 @@ class SettingsPage extends Component<any, ISettingsState> {
aria-label="Theme"
name="colorScheme"
value={this.state.selectThemeName}
onChange={(e, value) => this.changeThemeName(value)}
onChange={(e, value) =>
this.changeThemeName(value)
}
>
{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>
</Grid>
<Grid item xs={12} md={6} className={classes.desktopOnly}>
<Typography variant="h6" component="p">Theme preview</Typography>
<ThemePreview theme={this.state.previewTheme}/>
<Grid
item
xs={12}
md={6}
className={classes.desktopOnly}
>
<Typography variant="h6" component="p">
Theme preview
</Typography>
<ThemePreview theme={this.state.previewTheme} />
</Grid>
</Grid>
</DialogContent>
@ -240,22 +336,54 @@ class SettingsPage extends Component<any, ISettingsState> {
fullWidth={true}
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>
<RadioGroup
aria-label="Visibility"
name="visibility"
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 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"} />
<FormControlLabel
value={"public"}
key={"public"}
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>
</DialogContent>
<DialogActions>
<Button onClick={this.toggleVisibilityDialog} color="default">
<Button
onClick={this.toggleVisibilityDialog}
color="default"
>
Cancel
</Button>
<Button onClick={this.setVisibility} color="secondary">
@ -271,19 +399,28 @@ class SettingsPage extends Component<any, ISettingsState> {
<Dialog
open={this.state.resetSettingsDialog}
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>
<Button onClick={() => this.toggleResetSettingsDialog()} color="primary" autoFocus>
Cancel
<Button
onClick={() => this.toggleResetSettingsDialog()}
color="primary"
autoFocus
>
Cancel
</Button>
<Button onClick={() => {
this.refresh();
}} color="primary">
Refresh
<Button
onClick={() => {
this.refresh();
}}
color="primary"
>
Refresh
</Button>
</DialogActions>
</Dialog>
</Dialog>
);
}
@ -292,196 +429,314 @@ class SettingsPage extends Component<any, ISettingsState> {
<Dialog
open={this.state.resetHyperspaceDialog}
onClose={() => this.toggleResetDialog()}
>
<DialogTitle id="alert-dialog-title">Reset {this.state.brandName}?</DialogTitle>
>
<DialogTitle id="alert-dialog-title">
Reset {this.state.brandName}?
</DialogTitle>
<DialogContent>
<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>
</DialogContent>
<DialogActions>
<Button onClick={() => this.toggleResetDialog()} color="primary" autoFocus>
Cancel
<Button
onClick={() => this.toggleResetDialog()}
color="primary"
autoFocus
>
Cancel
</Button>
<Button onClick={() => {
this.reset();
}} color="primary">
Reset
<Button
onClick={() => {
this.reset();
}}
color="primary"
>
Reset
</Button>
</DialogActions>
</Dialog>
</Dialog>
);
}
render() {
const { classes } = this.props;
return (
<div className={classes.pageLayoutConstraints}>
<ListSubheader>Appearance</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
<ListItem>
<ListItemAvatar>
<DevicesIcon color="action"/>
</ListItemAvatar>
<ListItemText primary="Match system appearance" secondary="Obey light/dark theme from your system"/>
<ListItemSecondaryAction>
<Switch
checked={this.state.systemDecidesDarkMode}
onChange={this.toggleSystemDarkMode}
/>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<Brightness3Icon color="action"/>
</ListItemAvatar>
<ListItemText primary="Dark mode" secondary="Toggles light or dark theme"/>
<ListItemSecondaryAction>
<Switch
disabled={this.state.systemDecidesDarkMode}
checked={this.state.darkModeEnabled}
onChange={this.toggleDarkMode}
/>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<PaletteIcon color="action"/>
</ListItemAvatar>
<ListItemText primary="Interface theme" secondary="The color palette used for the interface"/>
<ListItemSecondaryAction>
<Button onClick={this.toggleThemeDialog}>
Set theme
</Button>
</ListItemSecondaryAction>
</ListItem>
</List>
</Paper>
<br/>
<ListSubheader>Your Account</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
<ListItem>
<ListItemAvatar>
<AccountEditIcon color="action"/>
</ListItemAvatar>
<ListItemText primary="Edit your profile" secondary="Change your bio, display name, and images"/>
<ListItemSecondaryAction>
<LinkableButton to="/you">Edit</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>
</ListItemSecondaryAction>
</ListItem>
</List>
</Paper>
<br/>
<ListSubheader>Composer</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
<ListItem>
<ListItemAvatar>
<VisibilityIcon color="action"/>
</ListItemAvatar>
<ListItemText primary="Default visibility" secondary="New posts in composer will use this visiblity"/>
<ListItemSecondaryAction>
<Button onClick={this.toggleVisibilityDialog}>
Change
</Button>
</ListItemSecondaryAction>
</ListItem>
</List>
</Paper>
<br/>
<ListSubheader>Notifications</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
<ListItem>
<ListItemAvatar>
<NotificationsIcon color="action"/>
</ListItemAvatar>
<ListItemText
primary="Enable push notifications"
secondary={
getUserDefaultBool('userDeniedNotification')?
"Check your browser's notification permissions.":
browserSupportsNotificationRequests()?
"Send a push notification when not focused.":
"Notifications aren't supported."
}
<div>
<div className={classes.pageLayoutMinimalConstraints}>
{this.state.currentUser ? (
<div className={classes.pageHeroBackground}>
<div
className={classes.pageHeroBackgroundImage}
style={{
backgroundImage: `url("${this.state.currentUser.header_static}")`
}}
/>
<ListItemSecondaryAction>
<Switch
checked={this.state.pushNotificationsEnabled}
onChange={this.togglePushNotifications}
disabled={!browserSupportsNotificationRequests() || getUserDefaultBool('userDeniedNotification')}
<div className={classes.profileContent}>
<br />
<Avatar
className={classes.profileAvatar}
src={this.state.currentUser.avatar_static}
/>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<BellAlertIcon color="action"/>
</ListItemAvatar>
<ListItemText
primary="Notification badge counts all notifications"
secondary={
"Counts all notifications, read or unread."
}
/>
<ListItemSecondaryAction>
<Switch
checked={this.state.badgeDisplaysAllNotifs}
onChange={this.toggleBadgeCount}
/>
</ListItemSecondaryAction>
</ListItem>
</List>
</Paper>
<br/>
<ListSubheader>Advanced</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
<ListItem>
<ListItemAvatar>
<RefreshIcon color="action"/>
</ListItemAvatar>
<ListItemText primary="Refresh settings" secondary="Reset the settings to defaults."/>
<ListItemSecondaryAction>
<Button onClick={() => this.toggleResetSettingsDialog()}>
Refresh
</Button>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<UndoIcon color="action"/>
</ListItemAvatar>
<ListItemText primary={`Reset ${this.state.brandName}`} secondary="Deletes all data and resets the app"/>
<ListItemSecondaryAction>
<Button onClick={() => this.toggleResetDialog()}>
Reset
</Button>
</ListItemSecondaryAction>
</ListItem>
</List>
</Paper>
{this.showThemeDialog()}
{this.showVisibilityDialog()}
{this.showResetDialog()}
{this.showResetSettingsDialog()}
<div
className={classes.profileUserBox}
style={{ margin: "auto" }}
>
<Typography
variant="h4"
color="inherit"
component="h1"
>
{this.state.currentUser.display_name ||
this.state.currentUser.username}
</Typography>
<Typography
color="inherit"
variant="h6"
component="p"
>
@{this.state.currentUser.acct}
</Typography>
</div>
<div className={classes.pageGrow} />
<Toolbar>
<Tooltip title="Edit Profile">
<LinkableIconButton
to={"/you"}
color="inherit"
>
<AccountEditIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip title="Manage blocked servers">
<LinkableIconButton
to={"/blocked"}
color="inherit"
>
<DomainDisabledIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip title="Configure on Mastodon">
<IconButton
href={
(localStorage.getItem(
"baseurl"
) as string) +
"/settings/preferences"
}
target="_blank"
rel="noreferrer"
color="inherit"
>
<MastodonIcon />
</IconButton>
</Tooltip>
</Toolbar>
</div>
</div>
) : null}
<div className={classes.pageContentLayoutConstraints}>
<ListSubheader>Appearance</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
<ListItem>
<ListItemAvatar>
<DevicesIcon color="action" />
</ListItemAvatar>
<ListItemText
primary="Match system appearance"
secondary="Obey light/dark theme from your system"
/>
<ListItemSecondaryAction>
<Switch
checked={
this.state.systemDecidesDarkMode
}
onChange={this.toggleSystemDarkMode}
/>
</ListItemSecondaryAction>
</ListItem>
{!isDarwinApp() ||
(isDarwinApp() &&
!this.state.systemDecidesDarkMode) ? (
<ListItem>
<ListItemAvatar>
<Brightness3Icon color="action" />
</ListItemAvatar>
<ListItemText
primary="Dark mode"
secondary="Toggles light or dark theme"
/>
<ListItemSecondaryAction>
<Switch
disabled={
this.state
.systemDecidesDarkMode
}
checked={
this.state.darkModeEnabled
}
onChange={this.toggleDarkMode}
/>
</ListItemSecondaryAction>
</ListItem>
) : null}
<ListItem>
<ListItemAvatar>
<PaletteIcon color="action" />
</ListItemAvatar>
<ListItemText
primary="Interface theme"
secondary="The color palette used for the interface"
/>
<ListItemSecondaryAction>
<Button
onClick={this.toggleThemeDialog}
>
Set theme
</Button>
</ListItemSecondaryAction>
</ListItem>
</List>
</Paper>
<br />
<ListSubheader>Composer</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
<ListItem>
<ListItemAvatar>
<VisibilityIcon color="action" />
</ListItemAvatar>
<ListItemText
primary="Default visibility"
secondary="New posts in composer will use this visiblity"
/>
<ListItemSecondaryAction>
<Button
onClick={
this.toggleVisibilityDialog
}
>
Change
</Button>
</ListItemSecondaryAction>
</ListItem>
</List>
</Paper>
<br />
<ListSubheader>Notifications</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
<ListItem>
<ListItemAvatar>
<NotificationsIcon color="action" />
</ListItemAvatar>
<ListItemText
primary="Enable push notifications"
secondary={
getUserDefaultBool(
"userDeniedNotification"
)
? "Check your browser's notification permissions."
: browserSupportsNotificationRequests()
? "Send a push notification when not focused."
: "Notifications aren't supported."
}
/>
<ListItemSecondaryAction>
<Switch
checked={
this.state
.pushNotificationsEnabled
}
onChange={
this.togglePushNotifications
}
disabled={
!browserSupportsNotificationRequests() ||
getUserDefaultBool(
"userDeniedNotification"
)
}
/>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<BellAlertIcon color="action" />
</ListItemAvatar>
<ListItemText
primary="Notification badge counts all notifications"
secondary={
"Counts all notifications, read or unread."
}
/>
<ListItemSecondaryAction>
<Switch
checked={
this.state
.badgeDisplaysAllNotifs
}
onChange={this.toggleBadgeCount}
/>
</ListItemSecondaryAction>
</ListItem>
</List>
</Paper>
<br />
<ListSubheader>Advanced</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
<ListItem>
<ListItemAvatar>
<RefreshIcon color="action" />
</ListItemAvatar>
<ListItemText
primary="Refresh settings"
secondary="Reset the settings to defaults."
/>
<ListItemSecondaryAction>
<Button
onClick={() =>
this.toggleResetSettingsDialog()
}
>
Refresh
</Button>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<UndoIcon color="action" />
</ListItemAvatar>
<ListItemText
primary={`Reset ${this.state.brandName}`}
secondary="Deletes all data and resets the app"
/>
<ListItemSecondaryAction>
<Button
onClick={() =>
this.toggleResetDialog()
}
>
Reset
</Button>
</ListItemSecondaryAction>
</ListItem>
</List>
</Paper>
{this.showThemeDialog()}
{this.showVisibilityDialog()}
{this.showResetDialog()}
{this.showResetSettingsDialog()}
</div>
</div>
</div>
);
}
}
export default withStyles(styles)(SettingsPage);
export default withStyles(styles)(SettingsPage);

View File

@ -1,15 +1,49 @@
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 {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';
import React, { Component, ChangeEvent } from "react";
import {
withStyles,
Paper,
Typography,
Button,
TextField,
Fade,
Link,
CircularProgress,
Tooltip,
Dialog,
DialogTitle,
DialogActions,
DialogContent,
List,
ListItem,
ListItemText,
ListItemAvatar,
ListItemSecondaryAction,
IconButton
} from "@material-ui/core";
import { styles } from "./WelcomePage.styles";
import Mastodon from "megalodon";
import { SaveClientSession } from "../types/SessionData";
import {
createHyperspaceApp,
getRedirectAddress,
inDisallowedDomains
} 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";
import {
addAccountToRegistry,
getAccountRegistry,
loginWithAccount,
removeAccountFromRegistry
} from "../utilities/accounts";
import { Account, MultiAccount } from "../types/Account";
import AccountCircleIcon from "@material-ui/icons/AccountCircle";
import CloseIcon from "@material-ui/icons/Close";
interface IWelcomeProps extends withSnackbarProps {
classes: any;
@ -21,7 +55,7 @@ interface IWelcomeState {
brandName?: string;
registerBase?: string;
federates?: boolean;
wantsToLogin: boolean;
proceedToGetCode: boolean;
user: string;
userInputError: boolean;
userInputErrorMessage: string;
@ -29,7 +63,7 @@ interface IWelcomeState {
clientSecret?: string;
authUrl?: string;
foundSavedLogin: boolean;
authority: boolean;
authorizing: boolean;
license?: string;
repo?: string;
defaultRedirectAddress: string;
@ -37,50 +71,76 @@ interface IWelcomeState {
authCode: string;
emergencyMode: boolean;
version: string;
willAddAccount: boolean;
}
class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
client: any;
constructor(props: any) {
super(props);
this.state = {
wantsToLogin: false,
proceedToGetCode: false,
user: "",
userInputError: false,
foundSavedLogin: false,
authority: false,
userInputErrorMessage: '',
defaultRedirectAddress: '',
authorizing: false,
userInputErrorMessage: "",
defaultRedirectAddress: "",
openAuthDialog: false,
authCode: '',
authCode: "",
emergencyMode: false,
version: ''
}
version: "",
willAddAccount: false
};
getConfig().then((result: any) => {
if (result !== undefined) {
let config: Config = result;
if (result.location === "dynamic") {
console.warn("Recirect URI is set to dynamic, which may affect how sign-in works for some users. Careful!");
}
getConfig()
.then((result: any) => {
if (result !== undefined) {
let config: Config = result;
if (result.location === "dynamic") {
console.warn(
"Redirect URI is set to dynamic, which may affect how sign-in works for some users. Careful!"
);
}
if (
inDisallowedDomains(result.registration.defaultInstance)
) {
console.warn(
`The default instance field in config.json contains an unsupported domain (${result.registration.defaultInstance}), so it's been reset to mastodon.social.`
);
result.registration.defaultInstance = "mastodon.social";
}
this.setState({
logoUrl: config.branding? result.branding.logo: "logo.png",
backgroundUrl: config.branding? result.branding.background: "background.png",
brandName: config.branding? result.branding.name: "Hyperspace",
registerBase: config.registration? result.registration.defaultInstance: "",
logoUrl: config.branding
? result.branding.logo
: "logo.png",
backgroundUrl: config.branding
? result.branding.background
: "background.png",
brandName: config.branding
? result.branding.name
: "Hyperspace",
registerBase: config.registration
? result.registration.defaultInstance
: "",
federates: config.federation.universalLogin,
license: config.license.url,
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
});
}
}).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() {
@ -88,7 +148,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
this.getSavedSession();
this.setState({
foundSavedLogin: true
})
});
this.checkForToken();
}
}
@ -106,13 +166,18 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
}
readyForAuth() {
if (localStorage.getItem('baseurl')) {
if (localStorage.getItem("baseurl")) {
return true;
} else {
return false;
}
}
clear() {
localStorage.removeItem("access_token");
localStorage.removeItem("baseurl");
}
getSavedSession() {
let loginData = localStorage.getItem("login");
if (loginData) {
@ -122,14 +187,14 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
clientSecret: session.clientSecret,
authUrl: session.authUrl,
emergencyMode: session.emergency
})
});
}
}
startEmergencyLogin() {
if (!this.state.emergencyMode) {
this.createEmergencyLogin();
};
}
this.toggleAuthDialog();
}
@ -142,13 +207,11 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
}
watchUsernameField(event: any) {
if (event.keyCode === 13)
this.startLogin()
if (event.keyCode === 13) this.startLogin();
}
watchAuthField(event: any) {
if (event.keyCode === 13)
this.authorizeEmergencyLogin()
if (event.keyCode === 13) this.authorizeEmergencyLogin();
}
getLoginUser(user: string) {
@ -158,27 +221,45 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
this.setState({ user: newUser });
return "https://" + newUser.split("@")[1];
} 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 });
return "https://" + (this.state.registerBase? this.state.registerBase: "mastodon.social");
return (
"https://" +
(this.state.registerBase
? this.state.registerBase
: "mastodon.social")
);
}
} 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 });
return "https://" + (this.state.registerBase? this.state.registerBase: "mastodon.social");
return (
"https://" +
(this.state.registerBase
? this.state.registerBase
: "mastodon.social")
);
}
}
startLogin() {
let error = this.checkForErrors();
if (!error) {
const scopes = 'read write follow';
const scopes = "read write follow";
const baseurl = this.getLoginUser(this.state.user);
localStorage.setItem("baseurl", baseurl);
createHyperspaceApp(
this.state.brandName? this.state.brandName: "Hyperspace",
scopes,
baseurl,
this.state.brandName ? this.state.brandName : "Hyperspace",
scopes,
baseurl,
getRedirectAddress(this.state.defaultRedirectAddress)
).then((resp: any) => {
let saveSessionForCrashing: SaveClientSession = {
@ -186,29 +267,33 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
clientSecret: resp.clientSecret,
authUrl: resp.url,
emergency: false
}
localStorage.setItem("login", JSON.stringify(saveSessionForCrashing));
};
localStorage.setItem(
"login",
JSON.stringify(saveSessionForCrashing)
);
this.setState({
clientId: resp.clientId,
clientSecret: resp.clientSecret,
authUrl: resp.url,
wantsToLogin: true
})
})
proceedToGetCode: true
});
});
} else {
}
}
createEmergencyLogin() {
console.log("Creating an emergency login...")
console.log("Creating an emergency login...");
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(
this.state.brandName? this.state.brandName: "Hyperspace",
this.state.brandName ? this.state.brandName : "Hyperspace",
{
scopes: scopes
},
},
baseurl
).then((appData: any) => {
let saveSessionForCrashing: SaveClientSession = {
@ -217,7 +302,10 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
authUrl: appData.url,
emergency: true
};
localStorage.setItem("login", JSON.stringify(saveSessionForCrashing));
localStorage.setItem(
"login",
JSON.stringify(saveSessionForCrashing)
);
this.setState({
clientId: appData.clientId,
clientSecret: appData.clientSecret,
@ -239,8 +327,8 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
clientSecret: session.clientSecret,
authUrl: session.authUrl,
emergencyMode: session.emergency,
wantsToLogin: true
})
proceedToGetCode: true
});
}
}
@ -255,20 +343,45 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
return true;
} else {
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];
axios.get("https://" + baseUrl + "/api/v1/timelines/public").catch((err: Error) => {
let userInputError = true;
let userInputErrorMessage = "Instance name is invalid.";
this.setState({ userInputError, userInputErrorMessage });
if (inDisallowedDomains(baseUrl)) {
this.setState({
userInputError: true,
userInputErrorMessage: `Signing in with an account from ${baseUrl} isn't supported.`
});
return true;
});
} else if (this.state.user.includes(this.state.registerBase? this.state.registerBase: "mastodon.social")) {
} else {
axios
.get(
"https://" +
baseUrl +
"/api/v1/timelines/public"
)
.catch((err: Error) => {
let userInputError = true;
let userInputErrorMessage =
"Instance name is invalid.";
this.setState({
userInputError,
userInputErrorMessage
});
return true;
});
}
} else if (
this.state.user.includes(
this.state.registerBase
? this.state.registerBase
: "mastodon.social"
)
) {
this.setState({ userInputError, userInputErrorMessage });
return false;
} else {
userInputError = true;
userInputErrorMessage = "You cannot sign in with this username.";
userInputErrorMessage =
"You cannot sign in with this username.";
this.setState({ userInputError, userInputErrorMessage });
return true;
}
@ -279,34 +392,54 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
this.setState({ userInputError, userInputErrorMessage });
return false;
}
}
checkForToken() {
let location = window.location.href;
if (location.includes("?code=")) {
let code = parseUrl(location).query.code as string;
this.setState({ authority: true });
this.setState({ authorizing: true });
let loginData = localStorage.getItem("login");
if (loginData) {
let clientLoginSession: SaveClientSession = JSON.parse(loginData);
let clientLoginSession: SaveClientSession = JSON.parse(
loginData
);
Mastodon.fetchAccessToken(
clientLoginSession.clientId,
clientLoginSession.clientSecret,
code,
(localStorage.getItem("baseurl") as string),
this.state.emergencyMode?
undefined:
clientLoginSession.authUrl.includes("urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob")?
undefined:
window.location.protocol === "hyperspace:"? "hyperspace://hyperspace/app/": `https://${window.location.host}`,
).then((tokenData: any) => {
localStorage.setItem("access_token", tokenData.access_token);
window.location.href = window.location.protocol === "hyperspace:"? "hyperspace://hyperspace/app/": `https://${window.location.host}/#/`;
}).catch((err: Error) => {
this.props.enqueueSnackbar(`Couldn't authorize ${this.state.brandName? this.state.brandName: "Hyperspace"}: ${err.name}`, {variant: 'error'});
console.error(err.message);
})
localStorage.getItem("baseurl") as string,
this.state.emergencyMode
? undefined
: clientLoginSession.authUrl.includes(
"urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob"
)
? undefined
: window.location.protocol === "hyperspace:"
? "hyperspace://hyperspace/app/"
: `https://${window.location.host}`
)
.then((tokenData: any) => {
localStorage.setItem(
"access_token",
tokenData.access_token
);
window.location.href =
window.location.protocol === "hyperspace:"
? "hyperspace://hyperspace/app/"
: `https://${window.location.host}/#/`;
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't authorize ${
this.state.brandName
? this.state.brandName
: "Hyperspace"
}: ${err.name}`,
{ variant: "error" }
);
console.error(err.message);
});
}
}
}
@ -314,92 +447,203 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
titlebar() {
const { classes } = this.props;
if (isDarwinApp()) {
return (
<div className={classes.titleBarRoot}>
<Typography className={classes.titleBarText}>{this.state.brandName? this.state.brandName: "Hyperspace"}</Typography>
</div>
);
return (
<div className={classes.titleBarRoot}>
<Typography className={classes.titleBarText}>
{this.state.brandName
? this.state.brandName
: "Hyperspace"}
</Typography>
</div>
);
}
}
}
showMultiAccount() {
const { classes } = this.props;
return (
<div>
<Typography variant="h5">Select an account</Typography>
<Typography>from the list below or add a new one</Typography>
<List>
{getAccountRegistry().map(
(account: MultiAccount, index: number) => (
<ListItem
onClick={() => {
loginWithAccount(account);
window.location.href =
window.location.protocol ===
"hyperspace:"
? "hyperspace://hyperspace/app/"
: `https://${window.location.host}/#/`;
}}
button={true}
>
<ListItemAvatar>
<AccountCircleIcon color="action" />
</ListItemAvatar>
<ListItemText
primary={`@${account.username}`}
secondary={account.host}
/>
<ListItemSecondaryAction>
<IconButton
onClick={(e: any) => {
e.preventDefault();
removeAccountFromRegistry(index);
window.location.reload();
}}
>
<CloseIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
)
)}
</List>
<div className={classes.middlePadding} />
<Button
onClick={() => {
this.setState({ willAddAccount: true });
this.clear();
}}
color={"primary"}
variant={"contained"}
>
Add Account
</Button>
</div>
);
}
showLanding() {
const { classes } = this.props;
return (
<div>
<Typography variant="h5">Sign in</Typography>
<Typography>with your Mastodon account</Typography>
<div className={classes.middlePadding}/>
<TextField
variant="outlined"
label="Username"
fullWidth
placeholder="example@mastodon.example"
onChange={(event) => this.updateUserInfo(event.target.value)}
onKeyDown={(event) => this.watchUsernameField(event)}
error={this.state.userInputError}
onBlur={() => this.checkForErrors()}
></TextField>
{
this.state.userInputError? <Typography color="error">{this.state.userInputErrorMessage}</Typography> : null
}
<br/>
{
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.foundSavedLogin?
<Typography>
Signing in from a previous session? <Link className={classes.welcomeLink} onClick={() => this.resumeLogin()}>Continue login</Link>.
</Typography>: null
}
<div className={classes.middlePadding}/>
<div style={{ display: "flex" }}>
<Tooltip title="Create account on site">
<Button
href={this.startRegistration()}
target="_blank"
rel="noreferrer"
>Create account</Button>
</Tooltip>
<div className={classes.flexGrow}/>
<Tooltip title="Continue sign-in">
<Button color="primary" variant="contained" onClick={() => this.startLogin()}>Next</Button>
</Tooltip>
</div>
<div>
<Typography variant="h5">Sign in</Typography>
<Typography>with your Mastodon account</Typography>
<div className={classes.middlePadding} />
<TextField
variant="outlined"
label="Username"
fullWidth
placeholder="example@mastodon.example"
onChange={event => this.updateUserInfo(event.target.value)}
onKeyDown={event => this.watchUsernameField(event)}
error={this.state.userInputError}
onBlur={() => this.checkForErrors()}
/>
{this.state.userInputError ? (
<Typography color="error">
{this.state.userInputErrorMessage}
</Typography>
) : null}
<br />
{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.foundSavedLogin ? (
<Typography>
Signing in from a previous session?{" "}
<Link
className={classes.welcomeLink}
onClick={() => this.resumeLogin()}
>
Continue login
</Link>
.
</Typography>
) : null}
<div className={classes.middlePadding} />
<div style={{ display: "flex" }}>
<Tooltip title="Create account on site">
<Button
href={this.startRegistration()}
target="_blank"
rel="noreferrer"
>
Create account
</Button>
</Tooltip>
<div className={classes.flexGrow} />
<Tooltip title="Continue sign-in">
<Button
color="primary"
variant="contained"
onClick={() => this.startLogin()}
>
Next
</Button>
</Tooltip>
</div>
</div>
);
}
showLoginAuth() {
const { classes } = this.props;
return (
<div>
<Typography variant="h5">Howdy, {this.state.user? this.state.user.split("@")[0]: "user"}</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 className={classes.flexGrow}/>
<Button
color="primary"
variant="contained"
size="large"
href={this.state.authUrl? this.state.authUrl: ""}
>
Authorize
</Button>
<div className={classes.flexGrow}/>
</div>
<div className={classes.middlePadding}/>
<Typography>Having trouble signing in? <Link onClick={() => this.startEmergencyLogin()} className={classes.welcomeLink}>Sign in with a code.</Link></Typography>
<div>
<Typography variant="h5">
Howdy,{" "}
{this.state.user ? this.state.user.split("@")[0] : "user"}
</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 className={classes.flexGrow} />
<Button
color="primary"
variant="contained"
size="large"
href={this.state.authUrl ? this.state.authUrl : ""}
>
Authorize
</Button>
<div className={classes.flexGrow} />
</div>
<div className={classes.middlePadding} />
<Typography>
Having trouble signing in?{" "}
<Link
onClick={() => this.startEmergencyLogin()}
className={classes.welcomeLink}
>
Sign in with a code.
</Link>
</Typography>
</div>
);
}
showAuthDialog() {
const {classes} = this.props;
const { classes } = this.props;
return (
<Dialog
open={this.state.openAuthDialog}
@ -408,51 +652,67 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
maxWidth="sm"
fullWidth={true}
>
<DialogTitle>
Authorize with a code
</DialogTitle>
<DialogTitle>Authorize with a code</DialogTitle>
<DialogContent>
<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>
<Button
color="primary"
variant="contained"
href={this.state.authUrl? this.state.authUrl: ""}
color="primary"
variant="contained"
href={this.state.authUrl ? this.state.authUrl : ""}
target="_blank"
rel="noopener noreferrer"
>Request Code</Button>
<br/><br/>
>
Request Code
</Button>
<br />
<br />
<TextField
variant="outlined"
label="Authorization code"
fullWidth
onChange={(event) => this.updateAuthCode(event.target.value)}
onKeyDown={(event) => this.watchAuthField(event)}
></TextField>
onChange={event =>
this.updateAuthCode(event.target.value)
}
onKeyDown={event => this.watchAuthField(event)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => this.toggleAuthDialog()}>Cancel</Button>
<Button color="secondary" onClick={() => this.authorizeEmergencyLogin()}>Authorize</Button>
<Button onClick={() => this.toggleAuthDialog()}>
Cancel
</Button>
<Button
color="secondary"
onClick={() => this.authorizeEmergencyLogin()}
>
Authorize
</Button>
</DialogActions>
</Dialog>
);
}
showAuthority() {
showAuthorizationLoader() {
const { classes } = this.props;
return (
<div>
<Typography variant="h5">Authorizing</Typography>
<Typography>Please wait while Hyperspace authorizes with Mastodon. This shouldn't take long...</Typography>
<div className={classes.middlePadding}/>
<div style={{ display: "flex" }}>
<div className={classes.flexGrow}/>
<CircularProgress/>
<div className={classes.flexGrow}/>
</div>
<div className={classes.middlePadding}/>
<div>
<Typography variant="h5">Authorizing</Typography>
<Typography>
Please wait while Hyperspace authorizes with Mastodon. This
shouldn't take long...
</Typography>
<div className={classes.middlePadding} />
<div style={{ display: "flex" }}>
<div className={classes.flexGrow} />
<CircularProgress />
<div className={classes.flexGrow} />
</div>
<div className={classes.middlePadding} />
</div>
);
}
@ -461,31 +721,102 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
return (
<div>
{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}>
<img className={classes.logo} alt={this.state? this.state.brandName: "Hyperspace"} src={this.state? this.state.logoUrl: "logo.png"}/>
<br/>
<Fade in={true}>
{
this.state.authority?
this.showAuthority():
this.state.wantsToLogin?
this.showLoginAuth():
this.showLanding()
<img
className={classes.logo}
alt={
this.state ? this.state.brandName : "Hyperspace"
}
src={this.state ? this.state.logoUrl : "logo.png"}
/>
<br />
<Fade in={true}>
{this.state.authorizing
? this.showAuthorizationLoader()
: this.state.proceedToGetCode
? this.showLoginAuth()
: getAccountRegistry().length > 0 &&
!this.state.willAddAccount
? this.showMultiAccount()
: this.showLanding()}
</Fade>
<br/>
<br />
<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 variant="caption">
{ this.state.repo? <span>
<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>
{this.state.repo ? (
<span>
<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 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>
</Paper>
{this.showAuthDialog()}
@ -495,4 +826,4 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
}
}
export default withStyles(styles)(withSnackbar(WelcomePage));
export default withStyles(styles)(withSnackbar(WelcomePage));

View File

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

View File

@ -1,41 +1,82 @@
import React, {Component} from 'react';
import {withStyles, Typography, Paper, Avatar, 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 React, { Component } from "react";
import {
Avatar,
Button,
CircularProgress,
Paper,
TextField,
Typography,
withStyles
} 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";
interface IYouProps extends withSnackbarProps {
classes: any;
}
interface IYouState {
currentAccount: Account;
currentAccount?: Account;
newDisplayName?: string;
newBio?: string;
viewIsLoading: boolean;
viewLoaded: boolean;
viewErrored: boolean;
}
class You extends Component<IYouProps, IYouState> {
client: Mastodon;
constructor(props: any) {
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 = {
currentAccount: this.getAccount()
}
viewIsLoading: true,
viewLoaded: false,
viewErrored: false
};
}
componentWillMount() {
this.client
.get("/accounts/verify_credentials")
.then((resp: any) => {
let currentAccount: Account = resp.data;
this.setState({
currentAccount,
viewIsLoading: false,
viewLoaded: true
});
})
.catch(() => {
if (this.getAccount()) {
this.setState({
currentAccount: this.getAccount(),
viewIsLoading: false,
viewLoaded: true
});
} else {
this.setState({
viewIsLoading: false,
viewErrored: true
});
}
});
}
getAccount() {
let acct = localStorage.getItem('account');
let acct = localStorage.getItem("account");
console.log(acct);
if (acct) {
return JSON.parse(acct);
return JSON.parse(acct);
}
}
@ -43,150 +84,309 @@ class You extends Component<IYouProps, IYouState> {
filedialog({
multiple: false,
accept: "image/*"
}).then((images: FileList) => {
if (images.length > 0) {
this.props.enqueueSnackbar("Updating avatar...", { persist: true, key: "persistAvatar" });
let upload = new FormData();
upload.append("avatar", images[0]);
this.client.patch("/accounts/update_credentials", upload).then((acct: any) => {
let currentAccount: Account = acct.data;
this.setState({ currentAccount });
localStorage.setItem("account", JSON.stringify(currentAccount));
this.props.closeSnackbar("persistAvatar");
this.props.enqueueSnackbar("Avatar updated successfully.");
}).catch((err: Error) => {
this.props.closeSnackbar("persistAvatar");
this.props.enqueueSnackbar("Couldn't update avatar: " + err.name, { variant: "error" });
})
}
}).catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't update avatar: " + err.name);
})
.then((images: FileList) => {
if (images.length > 0) {
this.props.enqueueSnackbar("Updating avatar...", {
persist: true,
key: "persistAvatar"
});
let upload = new FormData();
upload.append("avatar", images[0]);
this.client
.patch("/accounts/update_credentials", upload)
.then((acct: any) => {
let currentAccount: Account = acct.data;
this.setState({ currentAccount });
localStorage.setItem(
"account",
JSON.stringify(currentAccount)
);
this.props.closeSnackbar("persistAvatar");
this.props.enqueueSnackbar(
"Avatar updated successfully."
);
})
.catch((err: Error) => {
this.props.closeSnackbar("persistAvatar");
this.props.enqueueSnackbar(
"Couldn't update avatar: " + err.name,
{ variant: "error" }
);
});
}
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
"Couldn't update avatar: " + err.name
);
});
}
updateHeader() {
filedialog({
multiple: false,
accept: "image/*"
}).then((images: FileList) => {
if (images.length > 0) {
this.props.enqueueSnackbar("Updating header...", { persist: true, key: "persistHeader" });
let upload = new FormData();
upload.append("header", images[0]);
this.client.patch("/accounts/update_credentials", upload).then((acct: any) => {
let currentAccount: Account = acct.data;
this.setState({ currentAccount });
localStorage.setItem("account", JSON.stringify(currentAccount));
this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar("Header updated successfully.");
}).catch((err: Error) => {
this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar("Couldn't update header: " + err.name, { variant: "error" });
})
}
}).catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't update header: " + err.name);
})
.then((images: FileList) => {
if (images.length > 0) {
this.props.enqueueSnackbar("Updating header...", {
persist: true,
key: "persistHeader"
});
let upload = new FormData();
upload.append("header", images[0]);
this.client
.patch("/accounts/update_credentials", upload)
.then((acct: any) => {
let currentAccount: Account = acct.data;
this.setState({ currentAccount });
localStorage.setItem(
"account",
JSON.stringify(currentAccount)
);
this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar(
"Header updated successfully."
);
})
.catch((err: Error) => {
this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar(
"Couldn't update header: " + err.name,
{ variant: "error" }
);
});
}
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
"Couldn't update header: " + err.name
);
});
}
removeHTMLContent(text: string) {
const div = document.createElement('div');
const div = document.createElement("div");
div.innerHTML = text;
let innerContent = div.textContent || div.innerText || "";
return innerContent;
return div.textContent || div.innerText || "";
}
changeDisplayName() {
this.client.patch('/accounts/update_credentials', {
display_name: this.state.newDisplayName? this.state.newDisplayName: this.state.currentAccount.display_name
})
.then((acct: any) =>{
let currentAccount: Account = acct.data
this.setState({currentAccount});
localStorage.setItem('account', JSON.stringify(currentAccount));
this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar("Display name updated to " + this.state.newDisplayName);
} ).catch((err:Error) => {
console.error(err.name)
this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar("Couldn't update display name: " + err.name, { variant: "error" })
})
this.client
.patch("/accounts/update_credentials", {
display_name: this.state.newDisplayName
? this.state.newDisplayName
: this.state.currentAccount
? this.state.currentAccount.display_name
: ""
})
.then((acct: any) => {
let currentAccount: Account = acct.data;
this.setState({ currentAccount });
localStorage.setItem("account", JSON.stringify(currentAccount));
this.props.closeSnackbar("persistHeader");
this.props.enqueueSnackbar(
"Display name updated to " + this.state.newDisplayName
);
})
.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 });
};
}
changeBio() {
this.client.patch('/accounts/update_credentials', {note: this.state.newBio? this.state.newBio: 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.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"});
})
this.client
.patch("/accounts/update_credentials", {
note: this.state.newBio
? this.state.newBio
: this.state.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.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"
});
});
}
updateBio(bio:string){
this.setState({newBio:bio})
updateBio(bio: string) {
this.setState({ newBio: bio });
}
render() {
const {classes} = this.props;
const { classes } = this.props;
return (
<div className={classes.pageLayoutMinimalConstraints}>
<div className={classes.pageHeroBackground}>
<div className={classes.pageHeroBackgroundImage} style={{ backgroundImage: `url("${this.state.currentAccount.header_static}")`}}/>
<div className={classes.pageHeroContent}>
<Avatar className={classes.pageProfileAvatar} src={this.state.currentAccount.avatar_static}/>
<Typography variant="h4" color="inherit" component="h1">Edit your profile</Typography>
<br/>
<div>
<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>
<br/>
</div>
</div>
<div className={classes.pageContentLayoutConstraints}>
<Paper className={classes.youPaper}>
<Typography variant="h5" component="h2">Display Name</Typography>
<br/>
<TextField className = {classes.TextField}
defaultValue = {this.state.currentAccount.display_name}
rowsMax = "1"
variant = "outlined"
fullWidth
onChange = {(event: any) => this.updateDisplayname(event.target.value)}>
</TextField>
<div style = {{textAlign: "right"}}>
<Button className={classes.pageProfileFollowButton} color = "primary" onClick = {() => this.changeDisplayName()}>Update display Name</Button>
</div>
{this.state.viewErrored ? (
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">
Something went wrong when trying to get your account
information.
</Typography>
</Paper>
<br/>
<Paper className={classes.youPaper}>
<Typography variant="h5" component="h2">About you</Typography>
<br/>
<TextField className = {classes.TextField}
defaultValue = {this.state.currentAccount.note? this.removeHTMLContent(this.state.currentAccount.note): "Tell a little bit about yourself"}
multiline
variant = "outlined"
rows = "2"
rowsMax = "5"
fullWidth
onChange = {(event:any) =>this.updateBio(event.target.value)}>
</TextField>
<div style={{textAlign: "right"}}>
<Button className={classes.pageProfileFollowButton} color = "primary" onClick = {() => this.changeBio()}>Update biography</Button>
) : (
<span />
)}
{this.state.currentAccount ? (
<div>
<div className={classes.pageHeroBackground}>
<div
className={classes.pageHeroBackgroundImage}
style={{
backgroundImage: `url("${this.state.currentAccount.header_static}")`
}}
/>
<div className={classes.profileContent}>
<br />
<Avatar
className={classes.profileAvatar}
src={
this.state.currentAccount.avatar_static
}
/>
<div
className={classes.profileUserBox}
style={{ paddingTop: 8, paddingBottom: 8 }}
>
<Typography
variant="h4"
color="inherit"
component="h1"
>
Edit your profile
</Typography>
<Typography color="inherit">
Change information such as your display
name, bio, and images used here.
</Typography>
<div>
<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>
</div>
</div>
</Paper>
</div>
<div className={classes.pageContentLayoutConstraints}>
<Paper className={classes.youPaper}>
<Typography variant="h5" component="h2">
Display Name
</Typography>
<br />
<TextField
className={classes.TextField}
defaultValue={
this.state.currentAccount.display_name
}
rowsMax="1"
variant="outlined"
fullWidth
onChange={(event: any) =>
this.updateDisplayName(
event.target.value
)
}
/>
<div style={{ textAlign: "right" }}>
<Button
className={
classes.pageProfileFollowButton
}
color="primary"
onClick={() => this.changeDisplayName()}
>
Update display Name
</Button>
</div>
</Paper>
<br />
<Paper className={classes.youPaper}>
<Typography variant="h5" component="h2">
About you
</Typography>
<br />
<TextField
className={classes.TextField}
defaultValue={
this.state.currentAccount.note
? this.removeHTMLContent(
this.state.currentAccount.note
)
: "Tell a little bit about yourself"
}
multiline
variant="outlined"
rows="2"
rowsMax="5"
fullWidth
onChange={(event: any) =>
this.updateBio(event.target.value)
}
/>
<div style={{ textAlign: "right" }}>
<Button
className={
classes.pageProfileFollowButton
}
color="primary"
onClick={() => this.changeBio()}
>
Update biography
</Button>
</div>
</Paper>
</div>
</div>
) : (
"AAA"
)}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress
className={classes.progress}
color="primary"
/>
</div>
) : (
<span />
)}
</div>
);
}
}
export default withStyles(styles)(withSnackbar(You));
export default withStyles(styles)(withSnackbar(You));

View File

@ -1,5 +1,5 @@
import { MastodonEmoji } from './Emojis';
import { Field } from './Field';
import { MastodonEmoji } from "./Emojis";
import { Field } from "./Field";
/**
* Basic type for an account on Mastodon
@ -24,11 +24,34 @@ export type Account = {
moved: Account | null;
fields: [Field];
bot: boolean | null;
}
};
/**
* Watered-down type for Mastodon accounts
*/
export type UAccount = {
id: string;
acct: string;
display_name: string;
avatar_static: string;
}
};
/**
* Account type for use with multi-account support
*/
export type MultiAccount = {
/**
* The host name of the account (ex.: mastodon.social)
*/
host: string;
/**
* The username of the account (@test)
*/
username: string;
/**
* The access token generated from the login
*/
access_token: string;
};

View File

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

View File

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

View File

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

View File

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

View File

@ -14,4 +14,4 @@ export type MastodonEmoji = {
export type Emoji = {
name: string;
imageUrl: string;
};
};

View File

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

View File

@ -1,6 +1,21 @@
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 { isDarwinApp } from '../utilities/desktop';
import { Color } from "@material-ui/core";
import {
deepPurple,
red,
lightGreen,
yellow,
purple,
deepOrange,
indigo,
lightBlue,
blue,
pink
} from "@material-ui/core/colors";
import {
isDarkMode,
isDarwinApp,
getDarwinAccentColor
} from "../utilities/desktop";
/**
* Basic theme colors for Hyperspace.
@ -9,14 +24,18 @@ export type HyperspaceTheme = {
key: string;
name: string;
palette: {
primary: {
main: string;
} | Color;
secondary: {
main: string;
} | Color;
}
}
primary:
| {
main: string;
}
| Color;
secondary:
| {
main: string;
}
| Color;
};
};
export const defaultTheme: HyperspaceTheme = {
key: "defaultTheme",
@ -25,7 +44,7 @@ export const defaultTheme: HyperspaceTheme = {
primary: deepPurple,
secondary: red
}
}
};
export const gardenerTheme: HyperspaceTheme = {
key: "gardnerTheme",
@ -34,7 +53,7 @@ export const gardenerTheme: HyperspaceTheme = {
primary: lightGreen,
secondary: yellow
}
}
};
export const teacherTheme: HyperspaceTheme = {
key: "teacherTheme",
@ -43,7 +62,7 @@ export const teacherTheme: HyperspaceTheme = {
primary: purple,
secondary: deepOrange
}
}
};
export const jokerTheme: HyperspaceTheme = {
key: "jokerTheme",
@ -52,7 +71,7 @@ export const jokerTheme: HyperspaceTheme = {
primary: indigo,
secondary: lightBlue
}
}
};
export const guardTheme: HyperspaceTheme = {
key: "guardTheme",
@ -61,7 +80,7 @@ export const guardTheme: HyperspaceTheme = {
primary: blue,
secondary: deepOrange
}
}
};
export const entertainerTheme: HyperspaceTheme = {
key: "entertainerTheme",
@ -70,7 +89,7 @@ export const entertainerTheme: HyperspaceTheme = {
primary: pink,
secondary: purple
}
}
};
export const classicTheme: HyperspaceTheme = {
key: "classicTheme",
@ -83,7 +102,7 @@ export const classicTheme: HyperspaceTheme = {
main: "#5c2d91"
}
}
}
};
export const dragonTheme: HyperspaceTheme = {
key: "dragonTheme",
@ -92,7 +111,7 @@ export const dragonTheme: HyperspaceTheme = {
primary: purple,
secondary: purple
}
}
};
export const memoriumTheme: HyperspaceTheme = {
key: "memoriumTheme",
@ -101,7 +120,7 @@ export const memoriumTheme: HyperspaceTheme = {
primary: red,
secondary: red
}
}
};
export const blissTheme: HyperspaceTheme = {
key: "blissTheme",
@ -112,19 +131,75 @@ export const blissTheme: HyperspaceTheme = {
},
secondary: lightBlue
}
}
};
export const attractTheme: HyperspaceTheme = {
key: "attractTheme",
name: "Attract",
palette: {
primary: {
main: '#E57373',
main: "#E57373"
},
secondary: {
main: "#78909C",
main: "#78909C"
}
}
};
export const themes = [
defaultTheme,
gardenerTheme,
teacherTheme,
jokerTheme,
guardTheme,
entertainerTheme,
classicTheme,
dragonTheme,
memoriumTheme,
blissTheme,
attractTheme
];
/**
* Get the accent color from System Preferences.
*/
function getAquaAccentColor() {
switch (getDarwinAccentColor()) {
case 0:
return isDarkMode() ? "#ff453a" : "#FF3B30";
case 1:
return isDarkMode() ? "#ff9f0a" : "#ff9500";
case 2:
return isDarkMode() ? "#ffd60a" : "#ffcc00";
case 3:
return isDarkMode() ? "#32d74b" : "#28cd41";
case 5:
return isDarkMode() ? "#bf5af2" : "#af52de";
case 6:
return isDarkMode() ? "#ff375f" : "#ff2d55";
case -1:
return isDarkMode() ? "#98989d" : "#8e8e93";
default:
return isDarkMode() ? "#0A84FF" : "#007AFF";
}
}
export const themes = [defaultTheme, gardenerTheme, teacherTheme, jokerTheme, guardTheme, entertainerTheme, classicTheme, dragonTheme, memoriumTheme, blissTheme, attractTheme]
/**
* Inject macOS themes and watch for changes.
*/
if (isDarwinApp()) {
const aquaTheme: HyperspaceTheme = {
key: "aquaTheme",
name: "Aqua (Dynamic)",
palette: {
primary: {
main: isDarkMode() ? "#353538" : "#D6D6D6"
},
secondary: {
main: getAquaAccentColor()
}
}
};
themes.unshift(aquaTheme);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
/**
* Types of a post's visibility on Mastodon.
*/
export type Visibility = "direct" | "private" | "unlisted" | "public";
export type Visibility = "direct" | "private" | "unlisted" | "public";

View File

@ -1,21 +1,142 @@
import Mastodon from "megalodon";
import { MultiAccount, Account } from "../types/Account";
export function userLoggedIn(): boolean {
if (localStorage.getItem('baseurl') && localStorage.getItem('access_token')) {
return true;
} else {
return false;
}
return !!(
localStorage.getItem("baseurl") && localStorage.getItem("access_token")
);
}
export function refreshUserAccountData() {
let client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1");
client.get('/accounts/verify_credentials').then((resp: any) => {
localStorage.setItem('account', JSON.stringify(resp.data));
}).catch((err: Error) => {
console.error(err.message);
let host = localStorage.getItem("baseurl") as string;
let token = localStorage.getItem("access_token") as string;
let client = new Mastodon(token, host + "/api/v1");
client
.get("/accounts/verify_credentials")
.then((resp: any) => {
let account: Account = resp.data;
localStorage.setItem("account", JSON.stringify(account));
sessionStorage.setItem("id", account.id);
addAccountToRegistry(host, token, account.acct);
})
.catch((err: Error) => {
console.error(err.message);
});
client.get("/instance").then((resp: any) => {
localStorage.setItem(
"isPleroma",
resp.data.version.match(/Pleroma/) ? "true" : "false"
);
});
client.get('/instance').then((resp: any) => {
localStorage.setItem('isPleroma', (resp.data.version.match(/Pleroma/) ? "true" : "false"))
})
}
}
/**
* Set the access token and base URL to a given multi-account user.
* @param account The multi-account from localStorage to use
*/
export function loginWithAccount(account: MultiAccount) {
if (localStorage.getItem("access_token") !== null) {
console.info(
"Existing login detected. Removing and using assigned token..."
);
}
localStorage.setItem("access_token", account.access_token);
localStorage.setItem("baseurl", account.host);
}
/**
* Gets the account registry.
* @returns A list of accounts
*/
export function getAccountRegistry(): MultiAccount[] {
let accountRegistry: MultiAccount[] = [];
let accountRegistryString = localStorage.getItem("accountRegistry");
if (accountRegistryString !== null) {
accountRegistry = JSON.parse(accountRegistryString);
}
return accountRegistry;
}
/**
* Add an account to the multi-account registry if it doesn't exist already.
* @param base_url The base URL of the user (eg., the instance)
* @param access_token The access token for the user
* @param username The username of the user
*/
export function addAccountToRegistry(
base_url: string,
access_token: string,
username: string
) {
const newAccount: MultiAccount = {
host: base_url,
username,
access_token
};
let accountRegistry = getAccountRegistry();
const stringifiedRegistry = accountRegistry.map(account =>
JSON.stringify(account)
);
if (stringifiedRegistry.indexOf(JSON.stringify(newAccount)) === -1) {
accountRegistry.push(newAccount);
}
localStorage.setItem("accountRegistry", JSON.stringify(accountRegistry));
}
/**
* Remove an account from the multi-account registry, if possible
* @param accountIdentifier The index of the account from the registry or the MultiAccount object itself
*/
export function removeAccountFromRegistry(
accountIdentifier: number | MultiAccount
) {
let accountRegistry = getAccountRegistry();
if (typeof accountIdentifier === "number") {
if (accountRegistry.length > accountIdentifier) {
if (
localStorage.getItem("access_token") ===
accountRegistry[accountIdentifier].access_token
) {
localStorage.removeItem("baseurl");
localStorage.removeItem("access_token");
}
accountRegistry.splice(accountIdentifier);
} else {
console.log("Multi account index may be out of range");
}
} else {
const stringifiedRegistry = accountRegistry.map(account =>
JSON.stringify(account)
);
const stringifiedAccountId = JSON.stringify(accountIdentifier);
if (
stringifiedRegistry.indexOf(
JSON.stringify(stringifiedAccountId)
) !== -1
) {
if (
localStorage.getItem("access_token") ===
accountIdentifier.access_token
) {
localStorage.removeItem("baseurl");
localStorage.removeItem("access_token");
}
accountRegistry.splice(
stringifiedRegistry.indexOf(stringifiedAccountId)
);
}
}
localStorage.setItem("accountRegistry", JSON.stringify(accountRegistry));
}

View File

@ -7,5 +7,5 @@ import { isDarwinApp } from "./desktop";
* @returns Boolean dictating if the title bar is visible
*/
export function isAppbarExpanded(): boolean {
return isDarwinApp() || process.env.NODE_ENV === "development";
}
return isDarwinApp() || process.env.NODE_ENV === "development";
}

View File

@ -17,7 +17,7 @@ export function isDesktopApp(): boolean {
* Determines whether the app is the macOS application
*/
export function isDarwinApp(): boolean {
return isDesktopApp() && navigator.userAgent.includes("Macintosh")
return isDesktopApp() && navigator.userAgent.includes("Macintosh");
}
/**
@ -26,6 +26,33 @@ export function isDarwinApp(): boolean {
export function isDarkMode() {
// Lift window to an ElectronWindow and add use require()
const eWin = window as ElectronWindow;
const {remote} = eWin.require('electron');
return remote.systemPreferences.isDarkMode()
}
const { remote } = eWin.require("electron");
return remote.systemPreferences.isDarkMode();
}
/**
* Get the accent color from macOS.
*
* Note that the colors will go from left to right, starting from zero (eg.: -1 = Graphite, 0 = Red, 1 = Orange, etc.).
* Since AppleAccentColor might return an empty string for the blue color, -2 is used instead.
*
* @returns The corresponding integer for the accent color
*/
export function getDarwinAccentColor(): number {
const eWin = window as ElectronWindow;
const { remote } = eWin.require("electron");
const themeInteger = remote.systemPreferences.getUserDefault(
"AppleAccentColor",
"string"
);
return themeInteger === "" ? -2 : parseInt(themeInteger);
}
/**
* Get the app component from the desktop app
*/
export function getElectronApp() {
const eWin = window as ElectronWindow;
const { remote } = eWin.require("electron");
return remote.app;
}

View File

@ -1,5 +1,5 @@
import {MastodonEmoji} from '../types/Emojis';
import Mastodon from 'megalodon';
import { MastodonEmoji } from "../types/Emojis";
import Mastodon from "megalodon";
/**
* 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
* @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;
emojis.forEach((emoji: MastodonEmoji) => {
let filter = new RegExp(`:${emoji.shortcode}:`, 'g');
newContents = newContents.replace(filter, `<img src=${emoji.static_url} ${className? `class="${className}"`: ""}/>`)
})
let filter = new RegExp(`:${emoji.shortcode}:`, "g");
newContents = newContents.replace(
filter,
`<img src=${emoji.static_url} ${
className ? `class="${className}"` : ""
}/>`
);
});
return newContents;
}
export function collectEmojisFromServer() {
let client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') + "/api/v1");
let emojisPath = localStorage.getItem('emojis');
let client = new Mastodon(
localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1"
);
let emojisPath = localStorage.getItem("emojis");
let emojis: any[] = [];
if (emojisPath === null) {
client.get('/custom_emojis').then((resp: any) => {
resp.data.forEach((emoji: MastodonEmoji) => {
let customEmoji = {
name: emoji.shortcode,
emoticons: [''],
short_names: [emoji.shortcode],
imageUrl: emoji.static_url,
keywords: ['mastodon', 'custom']
}
emojis.push(customEmoji);
localStorage.setItem("emojis", JSON.stringify(emojis));
client
.get("/custom_emojis")
.then((resp: any) => {
resp.data.forEach((emoji: MastodonEmoji) => {
let customEmoji = {
name: emoji.shortcode,
emoticons: [""],
short_names: [emoji.shortcode],
imageUrl: emoji.static_url,
keywords: ["mastodon", "custom"]
};
emojis.push(customEmoji);
localStorage.setItem("emojis", JSON.stringify(emojis));
});
})
}).catch((err: Error) => {
console.error(err.message);
})
.catch((err: Error) => {
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
@ -7,29 +7,46 @@ import Mastodon from 'megalodon';
* @param baseurl The base URL of the instance
* @param redirect_uri The URL to redirect to when authorizing
*/
export function createHyperspaceApp(name: string, scopes: string, baseurl: string, redirect_uri: string) {
let appName = name === "Hyperspace"? "Hyperspace": `${name} (Hyperspace-like)`
return Mastodon.createApp(appName, {
scopes: scopes,
redirect_uris: redirect_uri,
website: 'https://hyperspace.marquiskurt.net',
}, baseurl).then(appData => {
return Mastodon.generateAuthUrl(appData.clientId, appData.clientSecret, {
redirect_uri: redirect_uri,
scope: scopes
}, baseurl).then(url => {
export function createHyperspaceApp(
name: string,
scopes: string,
baseurl: string,
redirect_uri: string
) {
let appName =
name === "Hyperspace" ? "Hyperspace" : `${name} (Hyperspace-like)`;
return Mastodon.createApp(
appName,
{
scopes: scopes,
redirect_uris: redirect_uri,
website: "https://hyperspace.marquiskurt.net"
},
baseurl
).then(appData => {
return Mastodon.generateAuthUrl(
appData.clientId,
appData.clientSecret,
{
redirect_uri: redirect_uri,
scope: scopes
},
baseurl
).then(url => {
appData.url = url;
return appData;
})
})
});
});
}
/**
* Gets the appropriate redirect address.
* @param type The address or configuration to use
*/
export function getRedirectAddress(type: "desktop" | "dynamic" | string): string {
switch(type) {
export function getRedirectAddress(
type: "desktop" | "dynamic" | string
): string {
switch (type) {
case "desktop":
return "hyperspace://hyperspace/app/";
case "dynamic":
@ -37,4 +54,14 @@ export function getRedirectAddress(type: "desktop" | "dynamic" | string): string
default:
return type;
}
}
/**
* Determine whether a base URL is in the 'disallowed' domains section.
* @param domain The URL to test
* @returns Boolean dictating the URL's presence in disallowed domains
*/
export function inDisallowedDomains(domain: string): boolean {
let disallowed = ["gab.com"];
return disallowed.includes(domain);
}

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.
*/
export function getNotificationRequestPermission() {
if ('Notification' in window) {
if ("Notification" in window) {
Notification.requestPermission();
let request = Notification.permission;
if (request === "granted") {
setUserDefaultBool('enablePushNotifications', true);
setUserDefaultBool('userDeniedNotification', false);
setUserDefaultBool("enablePushNotifications", true);
setUserDefaultBool("userDeniedNotification", false);
} else {
setUserDefaultBool('enablePushNotifications', false);
setUserDefaultBool('userDeniedNotification', true);
setUserDefaultBool("enablePushNotifications", false);
setUserDefaultBool("userDeniedNotification", true);
}
} else {
console.warn("Notifications aren't supported in this browser. The setting will be disabled.");
setUserDefaultBool('enablePushNotifications', false);
console.warn(
"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
*/
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`
*/
export function canSendNotifications() {
return getUserDefaultBool('enablePushNotifications');
return getUserDefaultBool("enablePushNotifications");
}
/**
@ -43,14 +45,12 @@ export function canSendNotifications() {
*/
export function sendNotificationRequest(title: string, body: string) {
if (canSendNotifications()) {
let notif = new Notification(title, {
body: body
});
let notif = new Notification(title, { body });
notif.onclick = () => {
window.focus();
};
} 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 { getNotificationRequestPermission } from './notifications';
import axios from 'axios';
import { getNotificationRequestPermission } from "./notifications";
import axios from "axios";
import { Config } from "../types/Config";
import { Visibility } from "../types/Visibility";
type SettingsTemplate = {
[key:string]: any;
[key: string]: any;
darkModeEnabled: boolean;
systemDecidesDarkMode: boolean;
enablePushNotifications: boolean;
clearNotificationsOnRead: boolean;
displayAllOnNotificationBadge: boolean;
defaultVisibility: string;
}
};
/**
* Gets the user default from localStorage
@ -21,7 +21,9 @@ type SettingsTemplate = {
*/
export function getUserDefaultBool(key: string): boolean {
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;
} else {
return localStorage.getItem(key) === "true";
@ -35,7 +37,7 @@ export function getUserDefaultBool(key: string): boolean {
*/
export function setUserDefaultBool(key: string, value: boolean) {
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());
}
@ -46,7 +48,9 @@ export function setUserDefaultBool(key: string, value: boolean) {
*/
export function getUserDefaultVisibility(): Visibility {
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";
} else {
return localStorage.getItem("defaultVisibility") as Visibility;
@ -59,7 +63,7 @@ export function getUserDefaultVisibility(): Visibility {
*/
export function setUserDefaultVisibility(key: string) {
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());
}
@ -69,8 +73,8 @@ export function setUserDefaultVisibility(key: string) {
*/
export function getUserDefaultTheme() {
let returnTheme = defaultTheme;
themes.forEach((theme) => {
if(theme.key === localStorage.getItem('theme')) {
themes.forEach(theme => {
if (theme.key === localStorage.getItem("theme")) {
returnTheme = theme;
}
});
@ -82,7 +86,7 @@ export function getUserDefaultTheme() {
* @param themeName The name of the theme
*/
export function setUserDefaultTheme(themeName: string) {
localStorage.setItem('theme', themeName);
localStorage.setItem("theme", themeName);
}
/**
@ -96,9 +100,15 @@ export function createUserDefaults() {
clearNotificationsOnRead: false,
displayAllOnNotificationBadge: false,
defaultVisibility: "public"
}
};
let settings = ["darkModeEnabled", "systemDecidesDarkMode", "clearNotificationsOnRead", "displayAllOnNotificationBadge", "defaultVisibility"];
let settings = [
"darkModeEnabled",
"systemDecidesDarkMode",
"clearNotificationsOnRead",
"displayAllOnNotificationBadge",
"defaultVisibility"
];
migrateExistingSettings();
@ -109,9 +119,8 @@ export function createUserDefaults() {
} else {
localStorage.setItem(setting, defaults[setting].toString());
}
}
})
});
getNotificationRequestPermission();
}
@ -121,17 +130,22 @@ export function createUserDefaults() {
*/
export async function getConfig(): Promise<Config | undefined> {
try {
const resp = await axios.get('config.json');
const resp = await axios.get("config.json");
let config: Config = resp.data;
return config;
}
catch (err) {
console.error("Couldn't configure Hyperspace with the config file. Reason: " + err.name);
} catch (err) {
console.error(
"Couldn't configure Hyperspace with the config file. Reason: " +
err.name
);
}
}
export function migrateExistingSettings() {
if (localStorage.getItem('prefers-dark-mode')) {
setUserDefaultBool('darkModeEnabled', localStorage.getItem('prefers-dark-mode') === "true")
if (localStorage.getItem("prefers-dark-mode")) {
setUserDefaultBool(
"darkModeEnabled",
localStorage.getItem("prefers-dark-mode") === "true"
);
}
}
}

View File

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