Merge branch 'develop-1.1.0-to-master' into 1.1.0-to-master
This commit is contained in:
commit
25b5748470
|
@ -1,6 +1,6 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
github: [alicerunsonfedora]
|
||||
patreon: hyperspacedev
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
|
|
|
@ -3,21 +3,21 @@ 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
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [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: false
|
||||
|
|
22
README.md
22
README.md
|
@ -39,7 +39,7 @@ Looking for the Mac App Store version? [Read more ›](https://hyperspace.m
|
|||
|
||||
To develop Hyperspace, you'll need the following tools and packages:
|
||||
|
||||
- Node.js 8 or later
|
||||
- Node.js v10 or later
|
||||
|
||||
### Installing dependencies
|
||||
|
||||
|
@ -57,23 +57,19 @@ npm install
|
|||
|
||||
### Testing changes
|
||||
|
||||
Before testing Hyperspace, you'll need to modify the `location` key in `public/config.json`. For example:
|
||||
|
||||
```json
|
||||
"location": "https://localhost:3000"
|
||||
```
|
||||
|
||||
The `location` key can take the following values during testing:
|
||||
|
||||
- **https://localhost:3000**: Most suitable for running `npm start` or running via `react-scripts`.
|
||||
- **desktop**: Most suitable for when testing the desktop application.
|
||||
|
||||
After changing this setting, run any of the following scripts to test:
|
||||
Run any of the following scripts to test:
|
||||
|
||||
- `npm start` - Starts a local server hosted at https://localhost:3000.
|
||||
- `npm run electrify` - Builds a copy of the source code and then runs the app through Electron. Ensure that the `location` key in `config.json` points to `"desktop"` before running this.
|
||||
- `npm run electrify-nobuild` - Similar to `electrify` but doesn't build the project before running.
|
||||
|
||||
The `location` key in `config.json` can take the following values during testing:
|
||||
|
||||
- **https://localhost:3000**: Most suitable for running `npm start` or running via `react-scripts`.
|
||||
- **desktop**: Most suitable for when testing the desktop application.
|
||||
|
||||
> Note: Hyperspace v1.1.0-beta3 and older versions require the location field to be changed to `"https://localhost:3000"` before running.
|
||||
|
||||
### Building a release
|
||||
|
||||
To build a release, run the following command:
|
||||
|
|
File diff suppressed because it is too large
Load Diff
215
package.json
215
package.json
|
@ -1,114 +1,115 @@
|
|||
{
|
||||
"name": "hyperspace",
|
||||
"productName": "Hyperspace Desktop",
|
||||
"version": "1.0.4",
|
||||
"description": "A beautiful, fluffy client for the fediverse",
|
||||
"author": "Marquis Kurt <hyperspacedev@marquiskurt.net>",
|
||||
"repository": "https://github.com/hyperspacedev/hyperspace.git",
|
||||
"private": true,
|
||||
"homepage": "./",
|
||||
"devDependencies": {
|
||||
"@date-io/moment": "^1.3.11",
|
||||
"@material-ui/core": "^3.9.3",
|
||||
"@material-ui/icons": "^3.0.2",
|
||||
"@types/emoji-mart": "^2.11.0",
|
||||
"@types/jest": "^24.0.18",
|
||||
"@types/node": "11.11.6",
|
||||
"@types/react": "16.8.8",
|
||||
"@types/react-dom": "16.8.3",
|
||||
"@types/react-router-dom": "^4.3.5",
|
||||
"@types/react-swipeable-views": "latest",
|
||||
"axios": "^0.19.0",
|
||||
"electron": "^6.0.11",
|
||||
"electron-builder": "^21.2.0",
|
||||
"emoji-mart": "^2.11.1",
|
||||
"file-dialog": "^0.0.7",
|
||||
"material-ui-pickers": "^2.2.4",
|
||||
"mdi-material-ui": "^5.18.0",
|
||||
"megalodon": "^0.6.4",
|
||||
"moment": "^2.24.0",
|
||||
"notistack": "^0.5.1",
|
||||
"prettier": "1.18.2",
|
||||
"query-string": "^6.8.3",
|
||||
"react": "^16.10.2",
|
||||
"react-dom": "^16.10.2",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-scripts": "^2.1.8",
|
||||
"react-swipeable-views": "^0.13.3",
|
||||
"react-web-share-api": "^0.0.2",
|
||||
"typescript": "3.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-notarize": "^0.1.1",
|
||||
"electron-updater": "^4.1.2",
|
||||
"electron-window-state": "^5.0.3"
|
||||
},
|
||||
"main": "public/electron.js",
|
||||
"scripts": {
|
||||
"start": "HTTPS=true react-scripts start",
|
||||
"electrify": "npm run build; electron .",
|
||||
"electrify-nobuild": "electron .",
|
||||
"build": "react-scripts build",
|
||||
"create-mac-icon": "cd desktop; iconutil -c icns app.iconset; cd ..",
|
||||
"build-desktop": "npm run build; npm run create-mac-icon; electron-builder -p 'never' -mwl deb AppImage snap",
|
||||
"build-desktop-win": "electron-builder -p 'never' -w",
|
||||
"build-desktop-darwin": "npm run create-mac-icon; electron-builder -p 'never' -m",
|
||||
"build-desktop-darwin-nosign": "npm run create-mac-icon; electron-builder -p 'never' -m dmg -c.mac.identity=null -c.afterSign=\"desktop/donothing.js\"",
|
||||
"build-desktop-linux": "electron-builder -p 'never' -l deb AppImage snap",
|
||||
"build-desktop-linux-select": "electron-builder -p 'never' -l ",
|
||||
"check-prettier": "prettier --check src/**/**.tsx",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all"
|
||||
],
|
||||
"build": {
|
||||
"appId": "net.marquiskurt.hyperspace",
|
||||
"afterSign": "desktop/notarize.js",
|
||||
"directories": {
|
||||
"buildResources": "desktop"
|
||||
"name": "hyperspace",
|
||||
"productName": "Hyperspace Desktop",
|
||||
"version": "1.1.0",
|
||||
"description": "A beautiful, fluffy client for the fediverse",
|
||||
"author": "Marquis Kurt <hyperspacedev@marquiskurt.net>",
|
||||
"repository": "https://github.com/hyperspacedev/hyperspace.git",
|
||||
"private": true,
|
||||
"homepage": "./",
|
||||
"devDependencies": {
|
||||
"@date-io/moment": "^1.3.13",
|
||||
"@material-ui/core": "^3.9.4",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@types/emoji-mart": "^2.11.3",
|
||||
"@types/jest": "^24.9.1",
|
||||
"@types/node": "11.11.6",
|
||||
"@types/react": "16.8.8",
|
||||
"@types/react-dom": "16.8.3",
|
||||
"@types/react-router-dom": "^4.3.5",
|
||||
"@types/react-swipeable-views": "latest",
|
||||
"axios": "^0.19.2",
|
||||
"electron": "^6.1.9",
|
||||
"electron-builder": "^21.2.0",
|
||||
"emoji-mart": "^2.11.2",
|
||||
"file-dialog": "^0.0.7",
|
||||
"material-ui-pickers": "^2.2.4",
|
||||
"mdi-material-ui": "^5.23.0",
|
||||
"megalodon": "^0.6.4",
|
||||
"moment": "^2.24.0",
|
||||
"notistack": "^0.5.1",
|
||||
"prettier": "1.18.2",
|
||||
"query-string": "^6.11.1",
|
||||
"react": "^16.13.0",
|
||||
"react-dom": "^16.13.0",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-scripts": "^3.4.0",
|
||||
"react-swipeable-views": "^0.13.9",
|
||||
"react-web-share-api": "^0.0.2",
|
||||
"typescript": "^3.8.3"
|
||||
},
|
||||
"mac": {
|
||||
"category": "public.app-category.social-networking",
|
||||
"icon": "desktop/app.icns",
|
||||
"target": [
|
||||
"dmg",
|
||||
"mas"
|
||||
],
|
||||
"darkModeSupport": true,
|
||||
"hardenedRuntime": true
|
||||
"dependencies": {
|
||||
"electron-notarize": "^0.1.1",
|
||||
"electron-updater": "^4.2.5",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"react-masonry-css": "^1.0.14"
|
||||
},
|
||||
"mas": {
|
||||
"entitlements": "desktop/entitlements.mas.plist",
|
||||
"entitlementsInherit": "desktop/entitlements.mas.inherit.plist",
|
||||
"provisioningProfile": "desktop/embedded.provisionprofile"
|
||||
"main": "public/electron.js",
|
||||
"scripts": {
|
||||
"start": "HTTPS=true react-scripts start",
|
||||
"electrify": "npm run build; electron .",
|
||||
"electrify-nobuild": "electron .",
|
||||
"build": "react-scripts build",
|
||||
"create-mac-icon": "cd desktop; iconutil -c icns app.iconset; cd ..",
|
||||
"build-desktop": "npm run build; npm run create-mac-icon; electron-builder -p 'never' -mwl deb AppImage snap",
|
||||
"build-desktop-win": "electron-builder -p 'never' -w",
|
||||
"build-desktop-darwin": "npm run create-mac-icon; electron-builder -p 'never' -m",
|
||||
"build-desktop-darwin-nosign": "npm run create-mac-icon; electron-builder -p 'never' -m dmg -c.mac.identity=null -c.afterSign=\"desktop/donothing.js\"",
|
||||
"build-desktop-linux": "electron-builder -p 'never' -l deb AppImage snap",
|
||||
"build-desktop-linux-select": "electron-builder -p 'never' -l ",
|
||||
"check-prettier": "prettier --check src/**/**.tsx",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"dmg": {
|
||||
"sign": false
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "desktop/app.ico"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"${@:1}"
|
||||
],
|
||||
"icon": "linux",
|
||||
"category": "Network"
|
||||
},
|
||||
"snap": {
|
||||
"confinement": "strict",
|
||||
"summary": "A beautiful, fluffy client for the fediverse"
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all"
|
||||
],
|
||||
"build": {
|
||||
"appId": "net.marquiskurt.hyperspace",
|
||||
"afterSign": "desktop/notarize.js",
|
||||
"directories": {
|
||||
"buildResources": "desktop"
|
||||
},
|
||||
"mac": {
|
||||
"category": "public.app-category.social-networking",
|
||||
"icon": "desktop/app.icns",
|
||||
"target": [
|
||||
"dmg",
|
||||
"mas"
|
||||
],
|
||||
"darkModeSupport": true,
|
||||
"hardenedRuntime": true
|
||||
},
|
||||
"mas": {
|
||||
"entitlements": "desktop/entitlements.mas.plist",
|
||||
"entitlementsInherit": "desktop/entitlements.mas.inherit.plist",
|
||||
"provisioningProfile": "desktop/embedded.provisionprofile"
|
||||
},
|
||||
"dmg": {
|
||||
"sign": false
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "desktop/app.ico"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"${@:1}"
|
||||
],
|
||||
"icon": "linux",
|
||||
"category": "Network"
|
||||
},
|
||||
"snap": {
|
||||
"confinement": "strict",
|
||||
"summary": "A beautiful, fluffy client for the fediverse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
Hyperspace has been made possible by the efforts of the Hyperspace development team and these amazing contributors on Patreon:
|
||||
|
||||
- LucasAzazer
|
||||
<!-- (Add contributors here) -->
|
||||
|
||||
Thanks for your continued support in helping us create the fluffiest client for the fediverse!
|
||||
Thanks for your continued support in helping us create the fluffiest client for the fediverse!
|
||||
|
||||
## Previous Contributors
|
||||
|
||||
- LucasAzazer
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "1.0.4",
|
||||
"version": "1.1.0",
|
||||
"location": "https://hyperspaceapp.herokuapp.com",
|
||||
"branding": {
|
||||
"name": "Hyperspace",
|
||||
|
|
|
@ -13,12 +13,12 @@ import LocalPage from "./pages/Local";
|
|||
import PublicPage from "./pages/Public";
|
||||
import Conversation from "./pages/Conversation";
|
||||
import NotificationsPage from "./pages/Notifications";
|
||||
import AnnouncementsPage from "./pages/Announcements";
|
||||
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";
|
||||
|
@ -86,8 +86,6 @@ class App extends Component<any, IAppState> {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
|
||||
this.removeBodyBackground();
|
||||
|
||||
return (
|
||||
|
@ -101,6 +99,10 @@ class App extends Component<any, IAppState> {
|
|||
<PrivateRoute path="/local" component={LocalPage} />
|
||||
<PrivateRoute path="/public" component={PublicPage} />
|
||||
<PrivateRoute path="/messages" component={MessagesPage} />
|
||||
<PrivateRoute
|
||||
path="/announcements"
|
||||
component={AnnouncementsPage}
|
||||
/>
|
||||
<PrivateRoute
|
||||
path="/notifications"
|
||||
component={NotificationsPage}
|
||||
|
|
|
@ -31,6 +31,7 @@ import {
|
|||
import MenuIcon from "@material-ui/icons/Menu";
|
||||
import SearchIcon from "@material-ui/icons/Search";
|
||||
import NotificationsIcon from "@material-ui/icons/Notifications";
|
||||
import AnnouncementIcon from "@material-ui/icons/Announcement";
|
||||
import MailIcon from "@material-ui/icons/Mail";
|
||||
import HomeIcon from "@material-ui/icons/Home";
|
||||
import DomainIcon from "@material-ui/icons/Domain";
|
||||
|
@ -232,6 +233,7 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
}
|
||||
|
||||
searchForQuery(what: string) {
|
||||
what = what.replace(/^#/g, "tag:");
|
||||
window.location.href = isDesktopApp()
|
||||
? "hyperspace://hyperspace/app/index.html#/search?query=" + what
|
||||
: "/#/search?query=" + what;
|
||||
|
@ -399,6 +401,16 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
<Divider />
|
||||
<div className={classes.drawerDisplayMobile}>
|
||||
<ListSubheader>Account</ListSubheader>
|
||||
<LinkableListItem
|
||||
button
|
||||
key="announcements-mobile"
|
||||
to="/announcements"
|
||||
>
|
||||
<ListItemIcon>
|
||||
<AnnouncementIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Announcements" />
|
||||
</LinkableListItem>
|
||||
<LinkableListItem
|
||||
button
|
||||
key="notifications-mobile"
|
||||
|
@ -506,6 +518,14 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
</div>
|
||||
<div className={classes.appBarFlexGrow} />
|
||||
<div className={classes.appBarActionButtons}>
|
||||
<Tooltip title="Announcements">
|
||||
<LinkableIconButton
|
||||
to="/announcements"
|
||||
color="inherit"
|
||||
>
|
||||
<AnnouncementIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Notifications">
|
||||
<LinkableIconButton
|
||||
color="inherit"
|
||||
|
|
|
@ -86,7 +86,11 @@ class AttachmentComponent extends Component<
|
|||
);
|
||||
case "unknown":
|
||||
return (
|
||||
<object data={slide.url} className={classes.mediaObject} />
|
||||
<object
|
||||
data={slide.url}
|
||||
className={classes.mediaObject}
|
||||
aria-label={`Slide: ${slide.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,140 @@
|
|||
import React, { Component } from "react";
|
||||
import {
|
||||
Toolbar,
|
||||
IconButton,
|
||||
withStyles,
|
||||
LinearProgress,
|
||||
Tooltip
|
||||
} from "@material-ui/core";
|
||||
|
||||
import FastRewindIcon from "@material-ui/icons/FastRewind";
|
||||
import FastForwardIcon from "@material-ui/icons/FastForward";
|
||||
import PlayArrowIcon from "@material-ui/icons/PlayArrow";
|
||||
import PauseIcon from "@material-ui/icons/Pause";
|
||||
import CloudDownloadIcon from "@material-ui/icons/CloudDownload";
|
||||
|
||||
import { styles } from "./AudioPlayer.styles";
|
||||
|
||||
interface IAudioPlayerProps {
|
||||
src: string;
|
||||
id: string;
|
||||
classes: any;
|
||||
}
|
||||
|
||||
interface IAudioPlayerState {
|
||||
src: string;
|
||||
elementId: string;
|
||||
playing: boolean;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
class AudioPlayer extends Component<IAudioPlayerProps, IAudioPlayerState> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
src: this.props.src,
|
||||
elementId: "audioplayer-" + this.props.id,
|
||||
playing: false,
|
||||
progress: 0
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
let audioPlayerElement = this.getAudioPlayer();
|
||||
|
||||
if (audioPlayerElement) {
|
||||
audioPlayerElement.ontimeupdate = () => {
|
||||
let music = audioPlayerElement as HTMLAudioElement;
|
||||
let progress = 100 * (music.currentTime / music.duration);
|
||||
this.setState({ progress });
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getAudioPlayer(): HTMLAudioElement | null {
|
||||
return document.getElementById(
|
||||
this.state.elementId
|
||||
) as HTMLAudioElement | null;
|
||||
}
|
||||
|
||||
toggleAudio() {
|
||||
let audioPlayerElement = this.getAudioPlayer();
|
||||
|
||||
if (audioPlayerElement && this.state.playing) {
|
||||
audioPlayerElement.pause();
|
||||
this.setState({ playing: false });
|
||||
} else if (audioPlayerElement) {
|
||||
audioPlayerElement.play();
|
||||
this.setState({ playing: true });
|
||||
}
|
||||
}
|
||||
|
||||
fastForward() {
|
||||
let audioPlayerElement = this.getAudioPlayer();
|
||||
|
||||
if (audioPlayerElement) {
|
||||
audioPlayerElement.currentTime += 15.0;
|
||||
}
|
||||
}
|
||||
|
||||
rewind() {
|
||||
let audioPlayerElement = this.getAudioPlayer();
|
||||
|
||||
if (audioPlayerElement) {
|
||||
audioPlayerElement.currentTime -= 15.0;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<audio
|
||||
id={this.state.elementId}
|
||||
src={this.state.src}
|
||||
autoPlay={false}
|
||||
/>
|
||||
<Toolbar>
|
||||
<Tooltip title="Rewind by 15s">
|
||||
<IconButton onClick={() => this.rewind()}>
|
||||
<FastRewindIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={this.state.playing ? "Pause" : "Play"}>
|
||||
<IconButton onClick={() => this.toggleAudio()}>
|
||||
{this.state.playing ? (
|
||||
<PauseIcon />
|
||||
) : (
|
||||
<PlayArrowIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Fast-forward by 15s">
|
||||
<IconButton onClick={() => this.fastForward()}>
|
||||
<FastForwardIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<LinearProgress
|
||||
className={classes.progressBar}
|
||||
variant="determinate"
|
||||
color={"secondary"}
|
||||
value={this.state.progress}
|
||||
/>
|
||||
<Tooltip title="Download">
|
||||
<IconButton
|
||||
href={this.state.src}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollower"
|
||||
className={classes.download}
|
||||
>
|
||||
<CloudDownloadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Toolbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(AudioPlayer);
|
|
@ -68,7 +68,10 @@ class ComposeMediaAttachment extends Component<
|
|||
) : attachment.type === "video" ? (
|
||||
<video autoPlay={false} src={attachment.url} />
|
||||
) : (
|
||||
<object data={attachment.url} />
|
||||
<object
|
||||
data={attachment.url}
|
||||
aria-label={`Attachment: ${attachment.id}`}
|
||||
/>
|
||||
)}
|
||||
<GridListTileBar
|
||||
classes={{ title: classes.attachmentBar }}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, { Component } from "react";
|
||||
import { Picker, PickerProps, CustomEmoji } from "emoji-mart";
|
||||
import { Picker, PickerProps } from "emoji-mart";
|
||||
import "emoji-mart/css/emoji-mart.css";
|
||||
|
||||
interface IEmojiPickerProps extends PickerProps {
|
||||
|
|
|
@ -6,6 +6,28 @@ export const styles = (theme: Theme) =>
|
|||
marginTop: theme.spacing.unit,
|
||||
marginBottom: theme.spacing.unit
|
||||
},
|
||||
postHeaderContent: {
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap"
|
||||
},
|
||||
postHeaderTitle: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
color: theme.palette.text.secondary
|
||||
},
|
||||
postAuthorNameAndAccount: {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis"
|
||||
},
|
||||
postAuthorName: {
|
||||
whiteSpace: "nowrap",
|
||||
color: theme.palette.text.primary
|
||||
},
|
||||
postAuthorAccount: {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
marginLeft: theme.spacing.unit * 0.5
|
||||
},
|
||||
postReblogChip: {
|
||||
color: theme.palette.common.white,
|
||||
"&:hover": {
|
||||
|
@ -81,6 +103,12 @@ export const styles = (theme: Theme) =>
|
|||
paddingTop: theme.spacing.unit,
|
||||
paddingBottom: theme.spacing.unit
|
||||
},
|
||||
postReblogIcon: {
|
||||
marginBottom: theme.spacing.unit * -0.5,
|
||||
marginLeft: theme.spacing.unit * 0.5,
|
||||
marginRight: theme.spacing.unit * 0.5,
|
||||
color: theme.palette.text.primary
|
||||
},
|
||||
postAuthorEmoji: {
|
||||
height: theme.typography.fontSize,
|
||||
verticalAlign: "middle"
|
||||
|
|
|
@ -25,8 +25,7 @@ import {
|
|||
RadioGroup,
|
||||
Tooltip,
|
||||
Typography,
|
||||
withStyles,
|
||||
Zoom
|
||||
withStyles
|
||||
} from "@material-ui/core";
|
||||
import MoreVertIcon from "@material-ui/icons/MoreVert";
|
||||
import ReplyIcon from "@material-ui/icons/Reply";
|
||||
|
@ -101,6 +100,11 @@ export class Post extends React.Component<any, IPostState> {
|
|||
});
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: any, nextState: any) {
|
||||
if (nextState === this.state) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
togglePostMenu() {
|
||||
this.setState({ menuIsOpen: !this.state.menuIsOpen });
|
||||
}
|
||||
|
@ -119,7 +123,7 @@ export class Post extends React.Component<any, IPostState> {
|
|||
})
|
||||
.catch((err: Error) => {
|
||||
this.props.enqueueSnackbar("Couldn't delete post: " + err.name);
|
||||
console.log(err.message);
|
||||
console.error(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -396,24 +400,61 @@ export class Post extends React.Component<any, IPostState> {
|
|||
|
||||
getReblogAuthors(post: Status) {
|
||||
const { classes } = this.props;
|
||||
if (post.reblog) {
|
||||
let author = post.reblog.account;
|
||||
let origString = `<span>${author.display_name ||
|
||||
author.username} (@${author.acct}) 🔄 ${post.account
|
||||
.display_name || post.account.username}</span>`;
|
||||
let emojis = author.emojis;
|
||||
emojis.concat(post.account.emojis);
|
||||
return emojifyString(origString, emojis, classes.postAuthorEmoji);
|
||||
} else {
|
||||
let author = post.account;
|
||||
let origString = `<span>${author.display_name ||
|
||||
author.username} (@${author.acct})</span>`;
|
||||
return emojifyString(
|
||||
origString,
|
||||
author.emojis,
|
||||
classes.postAuthorEmoji
|
||||
);
|
||||
|
||||
let author = post.reblog ? post.reblog.account : post.account;
|
||||
let emojis = author.emojis;
|
||||
let reblogger = post.reblog ? post.account : undefined;
|
||||
|
||||
if (reblogger !== undefined) {
|
||||
emojis.concat(reblogger.emojis);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={classes.postAuthorNameAndAccount}>
|
||||
<span
|
||||
className={classes.postAuthorName}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: emojifyString(
|
||||
author.display_name || author.username,
|
||||
emojis,
|
||||
classes.postAuthorEmoji
|
||||
)
|
||||
}}
|
||||
></span>
|
||||
<span
|
||||
className={classes.postAuthorAccount}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"@" +
|
||||
emojifyString(
|
||||
author.acct || author.username,
|
||||
emojis,
|
||||
classes.postAuthorEmoji
|
||||
)
|
||||
}}
|
||||
></span>
|
||||
</span>
|
||||
{reblogger ? (
|
||||
<div>
|
||||
<AutorenewIcon
|
||||
fontSize="small"
|
||||
className={classes.postReblogIcon}
|
||||
/>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: emojifyString(
|
||||
reblogger.display_name ||
|
||||
reblogger.username,
|
||||
emojis,
|
||||
classes.postAuthorEmoji
|
||||
)
|
||||
}}
|
||||
></span>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
getMentions(mention: [Mention]) {
|
||||
|
@ -500,86 +541,63 @@ export class Post extends React.Component<any, IPostState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the post's URL
|
||||
* @param post The post to get the URL from
|
||||
* @returns A string containing the post's URI
|
||||
*/
|
||||
getMastodonUrl(post: Status) {
|
||||
let url = "";
|
||||
if (post.reblog) {
|
||||
url = post.reblog.uri;
|
||||
} else {
|
||||
url = post.uri;
|
||||
}
|
||||
return url;
|
||||
return post.reblog ? post.reblog.uri : post.uri;
|
||||
}
|
||||
|
||||
toggleFavorited(post: Status) {
|
||||
let _this = this;
|
||||
if (post.favourited) {
|
||||
this.client
|
||||
.post(`/statuses/${post.id}/unfavourite`)
|
||||
.then((resp: any) => {
|
||||
let post: Status = resp.data;
|
||||
this.setState({ post });
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
_this.props.enqueueSnackbar(
|
||||
`Couldn't unfavorite post: ${err.name}`,
|
||||
{
|
||||
variant: "error"
|
||||
}
|
||||
);
|
||||
console.log(err.message);
|
||||
});
|
||||
} else {
|
||||
this.client
|
||||
.post(`/statuses/${post.id}/favourite`)
|
||||
.then((resp: any) => {
|
||||
let post: Status = resp.data;
|
||||
this.setState({ post });
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
_this.props.enqueueSnackbar(
|
||||
`Couldn't favorite post: ${err.name}`,
|
||||
{
|
||||
variant: "error"
|
||||
}
|
||||
);
|
||||
console.log(err.message);
|
||||
});
|
||||
/**
|
||||
* Tell server a post has been un/favorited and update post state
|
||||
* @param post The post to un/favorite
|
||||
*/
|
||||
async toggleFavorited(post: Status) {
|
||||
let action: string = post.favourited ? "unfavourite" : "favourite";
|
||||
try {
|
||||
// favorite the original post, not the reblog
|
||||
let resp: any = await this.client.post(
|
||||
`/statuses/${post.reblog ? post.reblog.id : post.id}/${action}`
|
||||
);
|
||||
// compensate for slow server update
|
||||
if (action === "unfavourite") {
|
||||
resp.data.favourites_count -= 1;
|
||||
// if you unlike both original and reblog before refresh
|
||||
// and the post has only one favorite:
|
||||
if (resp.data.favourites_count < 0) {
|
||||
resp.data.favourites_count = 0;
|
||||
}
|
||||
}
|
||||
this.setState({ post: resp.data as Status });
|
||||
} catch (e) {
|
||||
this.props.enqueueSnackbar(`Could not ${action} post: ${e.name}`);
|
||||
console.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
toggleReblogged(post: Status) {
|
||||
if (post.reblogged) {
|
||||
this.client
|
||||
.post(`/statuses/${post.id}/unreblog`)
|
||||
.then((resp: any) => {
|
||||
let post: Status = resp.data;
|
||||
this.setState({ post });
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.props.enqueueSnackbar(
|
||||
`Couldn't unboost post: ${err.name}`,
|
||||
{
|
||||
variant: "error"
|
||||
}
|
||||
);
|
||||
console.log(err.message);
|
||||
});
|
||||
} else {
|
||||
this.client
|
||||
.post(`/statuses/${post.id}/reblog`)
|
||||
.then((resp: any) => {
|
||||
let post: Status = resp.data;
|
||||
this.setState({ post });
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.props.enqueueSnackbar(
|
||||
`Couldn't boost post: ${err.name}`,
|
||||
{
|
||||
variant: "error"
|
||||
}
|
||||
);
|
||||
console.log(err.message);
|
||||
});
|
||||
/**
|
||||
* Tell server a post has been un/reblogged and update post state
|
||||
* @param post The post to un/reblog
|
||||
*/
|
||||
async toggleReblogged(post: Status) {
|
||||
let action: string =
|
||||
post.reblogged || post.reblog ? "unreblog" : "reblog";
|
||||
try {
|
||||
// modify the original post, not the reblog
|
||||
let resp: any = await this.client.post(
|
||||
`/statuses/${post.reblog ? post.reblog.id : post.id}/${action}`
|
||||
);
|
||||
// compensate for slow server update
|
||||
if (action === "unreblog") {
|
||||
resp.data.reblogs_count -= 1;
|
||||
}
|
||||
if (resp.data.reblog) resp.data = resp.data.reblog;
|
||||
this.setState({ post: resp.data as Status });
|
||||
} catch (e) {
|
||||
this.props.enqueueSnackbar(`Could not ${action} post: ${e.name}`);
|
||||
console.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -624,234 +642,222 @@ export class Post extends React.Component<any, IPostState> {
|
|||
const { classes } = this.props;
|
||||
const post = this.state.post;
|
||||
return (
|
||||
<Zoom in={true}>
|
||||
<Card
|
||||
className={classes.post}
|
||||
id={`post_${post.id}`}
|
||||
elevation={this.props.threadHeader ? 0 : 1}
|
||||
>
|
||||
<CardHeader
|
||||
avatar={
|
||||
<LinkableAvatar
|
||||
to={`/profile/${
|
||||
post.reblog
|
||||
? post.reblog.account.id
|
||||
: post.account.id
|
||||
}`}
|
||||
src={
|
||||
post.reblog
|
||||
? post.reblog.account.avatar_static
|
||||
: post.account.avatar_static
|
||||
}
|
||||
/>
|
||||
}
|
||||
action={
|
||||
<Tooltip title="More" placement="left">
|
||||
<IconButton
|
||||
key={`${post.id}_submenu`}
|
||||
id={`${post.id}_submenu`}
|
||||
onClick={() => this.togglePostMenu()}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
title={
|
||||
<Typography
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: this.getReblogAuthors(post)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
subheader={moment(post.created_at).format(
|
||||
"MMMM Do YYYY [at] h:mm A"
|
||||
)}
|
||||
/>
|
||||
{post.reblog ? this.getReblogOfPost(post.reblog) : null}
|
||||
{post.sensitive
|
||||
? this.getSensitiveContent(post.spoiler_text, post)
|
||||
: post.reblog
|
||||
? null
|
||||
: this.materializeContent(post)}
|
||||
{post.reblog && post.reblog.mentions.length > 0
|
||||
? this.getMentions(post.reblog.mentions)
|
||||
: this.getMentions(post.mentions)}
|
||||
{post.reblog && post.reblog.tags.length > 0
|
||||
? this.getTags(post.reblog.tags)
|
||||
: this.getTags(post.tags)}
|
||||
<CardActions>
|
||||
<Tooltip title="Reply">
|
||||
<LinkableIconButton
|
||||
to={`/compose?reply=${
|
||||
post.reblog ? post.reblog.id : post.id
|
||||
}&visibility=${post.visibility}&acct=${
|
||||
post.reblog
|
||||
? post.reblog.account.acct
|
||||
: post.account.acct
|
||||
}`}
|
||||
>
|
||||
<ReplyIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
<Typography>
|
||||
{post.reblog
|
||||
? post.reblog.replies_count
|
||||
: post.replies_count}
|
||||
</Typography>
|
||||
<Tooltip title="Favorite">
|
||||
<IconButton
|
||||
onClick={() => this.toggleFavorited(post)}
|
||||
>
|
||||
<FavoriteIcon
|
||||
className={
|
||||
post.reblog
|
||||
? post.reblog.favourited
|
||||
? classes.postDidAction
|
||||
: ""
|
||||
: post.favourited
|
||||
? classes.postDidAction
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Typography>
|
||||
{post.reblog
|
||||
? post.reblog.favourites_count
|
||||
: post.favourites_count}
|
||||
</Typography>
|
||||
<Tooltip title="Boost">
|
||||
<IconButton
|
||||
onClick={() => this.toggleReblogged(post)}
|
||||
>
|
||||
<AutorenewIcon
|
||||
className={
|
||||
post.reblog
|
||||
? post.reblog.reblogged
|
||||
? classes.postDidAction
|
||||
: ""
|
||||
: post.reblogged
|
||||
? classes.postDidAction
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Typography>
|
||||
{post.reblog
|
||||
? post.reblog.reblogs_count
|
||||
: post.reblogs_count}
|
||||
</Typography>
|
||||
<Tooltip
|
||||
className={classes.desktopOnly}
|
||||
title="View thread"
|
||||
>
|
||||
<LinkableIconButton
|
||||
to={`/conversation/${
|
||||
post.reblog ? post.reblog.id : post.id
|
||||
}`}
|
||||
>
|
||||
<ForumIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className={classes.desktopOnly}
|
||||
title="Open in Web"
|
||||
>
|
||||
<IconButton
|
||||
href={this.getMastodonUrl(post)}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<OpenInNewIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<div className={classes.postFlexGrow} />
|
||||
<div className={classes.postTypeIconDiv}>
|
||||
{this.showVisibilityIcon(post.visibility)}
|
||||
</div>
|
||||
</CardActions>
|
||||
<Menu
|
||||
id="postmenu"
|
||||
anchorEl={document.getElementById(`${post.id}_submenu`)}
|
||||
open={this.state.menuIsOpen}
|
||||
onClose={() => this.togglePostMenu()}
|
||||
>
|
||||
<ShareMenu
|
||||
config={{
|
||||
params: {
|
||||
title: `@${post.account.username} posted on Mastodon: `,
|
||||
text: post.content,
|
||||
url: this.getMastodonUrl(post)
|
||||
},
|
||||
onShareSuccess: () =>
|
||||
this.props.enqueueSnackbar("Post shared!", {
|
||||
variant: "success"
|
||||
}),
|
||||
onShareError: (error: Error) => {
|
||||
if (error.name != "AbortError")
|
||||
this.props.enqueueSnackbar(
|
||||
`Couldn't share post: ${error.name}`,
|
||||
{ variant: "error" }
|
||||
);
|
||||
}
|
||||
}}
|
||||
<Card
|
||||
className={classes.post}
|
||||
id={`post_${post.id}`}
|
||||
elevation={this.props.threadHeader ? 0 : 1}
|
||||
>
|
||||
<CardHeader
|
||||
classes={{
|
||||
content: classes.postHeaderContent,
|
||||
title: classes.postHeaderTitle
|
||||
}}
|
||||
avatar={
|
||||
<LinkableAvatar
|
||||
to={`/profile/${
|
||||
post.reblog
|
||||
? post.reblog.account.id
|
||||
: post.account.id
|
||||
}`}
|
||||
src={
|
||||
post.reblog
|
||||
? post.reblog.account.avatar_static
|
||||
: post.account.avatar_static
|
||||
}
|
||||
/>
|
||||
{post.reblog ? (
|
||||
<div className={classes.postReblogMenu}>
|
||||
<LinkableMenuItem
|
||||
to={`/profile/${post.reblog.account.id}`}
|
||||
>
|
||||
View author profile
|
||||
</LinkableMenuItem>
|
||||
<LinkableMenuItem
|
||||
to={`/profile/${post.account.id}`}
|
||||
>
|
||||
View reblogger profile
|
||||
</LinkableMenuItem>
|
||||
</div>
|
||||
) : (
|
||||
}
|
||||
action={
|
||||
<Tooltip title="More" placement="left">
|
||||
<IconButton
|
||||
key={`${post.id}_submenu`}
|
||||
id={`${post.id}_submenu`}
|
||||
onClick={() => this.togglePostMenu()}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
title={this.getReblogAuthors(post)}
|
||||
subheader={moment(post.created_at).format(
|
||||
"MMMM Do YYYY [at] h:mm A"
|
||||
)}
|
||||
/>
|
||||
{post.reblog ? this.getReblogOfPost(post.reblog) : null}
|
||||
{post.sensitive
|
||||
? this.getSensitiveContent(post.spoiler_text, post)
|
||||
: post.reblog
|
||||
? null
|
||||
: this.materializeContent(post)}
|
||||
{post.reblog && post.reblog.mentions.length > 0
|
||||
? this.getMentions(post.reblog.mentions)
|
||||
: this.getMentions(post.mentions)}
|
||||
{post.reblog && post.reblog.tags.length > 0
|
||||
? this.getTags(post.reblog.tags)
|
||||
: this.getTags(post.tags)}
|
||||
<CardActions>
|
||||
<Tooltip title="Reply">
|
||||
<LinkableIconButton
|
||||
to={`/compose?reply=${
|
||||
post.reblog ? post.reblog.id : post.id
|
||||
}&visibility=${post.visibility}&acct=${
|
||||
post.reblog
|
||||
? post.reblog.account.acct
|
||||
: post.account.acct
|
||||
}`}
|
||||
>
|
||||
<ReplyIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
<Typography>
|
||||
{post.reblog
|
||||
? post.reblog.replies_count
|
||||
: post.replies_count}
|
||||
</Typography>
|
||||
<Tooltip title="Favorite">
|
||||
<IconButton onClick={() => this.toggleFavorited(post)}>
|
||||
<FavoriteIcon
|
||||
className={
|
||||
post.reblog
|
||||
? post.reblog.favourited
|
||||
? classes.postDidAction
|
||||
: ""
|
||||
: post.favourited
|
||||
? classes.postDidAction
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Typography>
|
||||
{post.reblog
|
||||
? post.reblog.favourites_count
|
||||
: post.favourites_count}
|
||||
</Typography>
|
||||
<Tooltip title="Boost">
|
||||
<IconButton onClick={() => this.toggleReblogged(post)}>
|
||||
<AutorenewIcon
|
||||
className={
|
||||
post.reblog
|
||||
? post.reblog.reblogged
|
||||
? classes.postDidAction
|
||||
: ""
|
||||
: post.reblogged
|
||||
? classes.postDidAction
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Typography>
|
||||
{post.reblog
|
||||
? post.reblog.reblogs_count
|
||||
: post.reblogs_count}
|
||||
</Typography>
|
||||
<Tooltip
|
||||
className={classes.desktopOnly}
|
||||
title="View thread"
|
||||
>
|
||||
<LinkableIconButton
|
||||
to={`/conversation/${
|
||||
post.reblog ? post.reblog.id : post.id
|
||||
}`}
|
||||
>
|
||||
<ForumIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className={classes.desktopOnly}
|
||||
title="Open in Web"
|
||||
>
|
||||
<IconButton
|
||||
href={this.getMastodonUrl(post)}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<OpenInNewIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<div className={classes.postFlexGrow} />
|
||||
<div className={classes.postTypeIconDiv}>
|
||||
{this.showVisibilityIcon(post.visibility)}
|
||||
</div>
|
||||
</CardActions>
|
||||
<Menu
|
||||
id="postmenu"
|
||||
anchorEl={document.getElementById(`${post.id}_submenu`)}
|
||||
open={this.state.menuIsOpen}
|
||||
onClose={() => this.togglePostMenu()}
|
||||
>
|
||||
<ShareMenu
|
||||
config={{
|
||||
params: {
|
||||
title: `@${post.account.username} posted on Mastodon: `,
|
||||
text: post.content,
|
||||
url: this.getMastodonUrl(post)
|
||||
},
|
||||
onShareSuccess: () =>
|
||||
this.props.enqueueSnackbar("Post shared!", {
|
||||
variant: "success"
|
||||
}),
|
||||
onShareError: (error: Error) => {
|
||||
if (error.name !== "AbortError")
|
||||
this.props.enqueueSnackbar(
|
||||
`Couldn't share post: ${error.name}`,
|
||||
{ variant: "error" }
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{post.reblog ? (
|
||||
<div className={classes.postReblogMenu}>
|
||||
<LinkableMenuItem
|
||||
to={`/profile/${post.reblog.account.id}`}
|
||||
>
|
||||
View author profile
|
||||
</LinkableMenuItem>
|
||||
<LinkableMenuItem
|
||||
to={`/profile/${post.account.id}`}
|
||||
>
|
||||
View profile
|
||||
View reblogger profile
|
||||
</LinkableMenuItem>
|
||||
)}
|
||||
<div className={classes.mobileOnly}>
|
||||
</div>
|
||||
) : (
|
||||
<LinkableMenuItem to={`/profile/${post.account.id}`}>
|
||||
View profile
|
||||
</LinkableMenuItem>
|
||||
)}
|
||||
<div className={classes.mobileOnly}>
|
||||
<Divider />
|
||||
<LinkableMenuItem
|
||||
to={`/conversation/${
|
||||
post.reblog ? post.reblog.id : post.id
|
||||
}`}
|
||||
>
|
||||
View thread
|
||||
</LinkableMenuItem>
|
||||
<MenuItem
|
||||
component="a"
|
||||
href={this.getMastodonUrl(post)}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Open in Web
|
||||
</MenuItem>
|
||||
</div>
|
||||
{this.state.myAccount &&
|
||||
post.account.id === this.state.myAccount ? (
|
||||
<div>
|
||||
<Divider />
|
||||
<LinkableMenuItem
|
||||
to={`/conversation/${
|
||||
post.reblog ? post.reblog.id : post.id
|
||||
}`}
|
||||
>
|
||||
View thread
|
||||
</LinkableMenuItem>
|
||||
<MenuItem
|
||||
component="a"
|
||||
href={this.getMastodonUrl(post)}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
onClick={() => this.togglePostDeleteDialog()}
|
||||
>
|
||||
Open in Web
|
||||
Delete
|
||||
</MenuItem>
|
||||
</div>
|
||||
{this.state.myAccount &&
|
||||
post.account.id === this.state.myAccount ? (
|
||||
<div>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
this.togglePostDeleteDialog()
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</div>
|
||||
) : null}
|
||||
{this.showDeleteDialog()}
|
||||
</Menu>
|
||||
</Card>
|
||||
</Zoom>
|
||||
) : null}
|
||||
{this.showDeleteDialog()}
|
||||
</Menu>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,344 @@
|
|||
import React, { Component } from "react";
|
||||
import {
|
||||
withStyles,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
ListSubheader,
|
||||
Link,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemAvatar,
|
||||
ListItemSecondaryAction,
|
||||
Tooltip,
|
||||
IconButton
|
||||
} from "@material-ui/core";
|
||||
import { styles } from "./PageLayout.styles";
|
||||
import { UAccount, Account } from "../types/Account";
|
||||
import { Tag } from "../types/Tag";
|
||||
import Mastodon from "megalodon";
|
||||
import { LinkableAvatar, LinkableIconButton } from "../interfaces/overrides";
|
||||
import moment from "moment";
|
||||
|
||||
import FireplaceIcon from "@material-ui/icons/Fireplace";
|
||||
import TrendingUpIcon from "@material-ui/icons/TrendingUp";
|
||||
import SearchIcon from "@material-ui/icons/Search";
|
||||
import OpenInNewIcon from "@material-ui/icons/OpenInNew";
|
||||
import AssignmentIndIcon from "@material-ui/icons/AssignmentInd";
|
||||
|
||||
interface IActivityPageState {
|
||||
user?: UAccount;
|
||||
trendingTags?: [Tag];
|
||||
activeProfileDirectory?: [Account];
|
||||
newProfileDirectory?: [Account];
|
||||
viewLoading: boolean;
|
||||
viewLoaded?: boolean;
|
||||
viewErrored?: boolean;
|
||||
}
|
||||
|
||||
class ActivityPage extends Component<any, IActivityPageState> {
|
||||
client: Mastodon;
|
||||
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
|
||||
this.client = new Mastodon(
|
||||
localStorage.getItem("access_token") as string,
|
||||
localStorage.getItem("baseurl") + "/api/v1"
|
||||
);
|
||||
|
||||
this.state = {
|
||||
viewLoading: true
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.getAccountData();
|
||||
|
||||
this.client
|
||||
.get("/trends", { limit: 3 })
|
||||
.then((resp: any) => {
|
||||
let trendingTags: [Tag] = resp.data;
|
||||
this.setState({ trendingTags });
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.setState({
|
||||
viewLoading: false,
|
||||
viewErrored: true
|
||||
});
|
||||
console.error(err.message);
|
||||
});
|
||||
|
||||
this.client
|
||||
.get("/directory", { local: true, order: "active", limit: 5 })
|
||||
.then((resp: any) => {
|
||||
let profileDirectory: [Account] = resp.data;
|
||||
this.setState({
|
||||
activeProfileDirectory: profileDirectory
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.setState({
|
||||
viewLoading: false,
|
||||
viewErrored: true
|
||||
});
|
||||
console.error(err.message);
|
||||
});
|
||||
|
||||
this.client
|
||||
.get("/directory", { local: true, order: "new", limit: 5 })
|
||||
.then((resp: any) => {
|
||||
let profileDirectory: [Account] = resp.data;
|
||||
this.setState({
|
||||
newProfileDirectory: profileDirectory,
|
||||
viewLoading: false,
|
||||
viewLoaded: true
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.setState({
|
||||
viewLoading: false,
|
||||
viewErrored: true
|
||||
});
|
||||
console.error(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
getAccountData() {
|
||||
this.client
|
||||
.get("/accounts/verify_credentials")
|
||||
.then((resp: any) => {
|
||||
let data: UAccount = resp.data;
|
||||
this.setState({ user: data });
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.props.enqueueSnackbar(
|
||||
"Couldn't find profile info: " + err.name
|
||||
);
|
||||
console.error(err.message);
|
||||
let acct = localStorage.getItem("account") as string;
|
||||
this.setState({ user: JSON.parse(acct) });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div className={classes.pageLayoutConstraints}>
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center"
|
||||
}}
|
||||
>
|
||||
<FireplaceIcon style={{ fontSize: 64 }} color="action" />
|
||||
<Typography variant="h6">
|
||||
Hey there,{" "}
|
||||
{this.state.user
|
||||
? this.state.user.display_name ||
|
||||
this.state.user.acct
|
||||
: "user"}
|
||||
!
|
||||
</Typography>
|
||||
<Typography paragraph>
|
||||
Take a look at what's been happening on your instance.
|
||||
</Typography>
|
||||
</div>
|
||||
{this.state.viewLoaded ? (
|
||||
<div>
|
||||
<ListSubheader>Trending hashtags</ListSubheader>
|
||||
{this.state.trendingTags &&
|
||||
this.state.trendingTags.length > 0 ? (
|
||||
<Paper>
|
||||
<List className={classes.pageListConstraints}>
|
||||
{this.state.trendingTags.map((tag: Tag) => (
|
||||
<ListItem
|
||||
id={"trending_tag_" + tag.name}
|
||||
key={"trending_tag_" + tag.name}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<TrendingUpIcon />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={"#" + tag.name}
|
||||
secondary={
|
||||
tag.history
|
||||
? `${tag.history[0].accounts} people talking in ${tag.history[0].uses} posts`
|
||||
: "Couldn't determine usage"
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="Search">
|
||||
<LinkableIconButton
|
||||
to={`/search?query=tag:${tag.name}`}
|
||||
>
|
||||
<SearchIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="View on web">
|
||||
<IconButton>
|
||||
<OpenInNewIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
) : (
|
||||
<Typography paragraph>
|
||||
It looks like there aren't any trending tags on
|
||||
your instance as of right now.
|
||||
</Typography>
|
||||
)}
|
||||
<br />
|
||||
<ListSubheader>Who's been active</ListSubheader>
|
||||
{this.state.activeProfileDirectory &&
|
||||
this.state.activeProfileDirectory.length > 0 ? (
|
||||
<Paper>
|
||||
<List className={classes.pageListConstraints}>
|
||||
{this.state.activeProfileDirectory.map(
|
||||
(account: Account) => (
|
||||
<ListItem
|
||||
key={
|
||||
"account_active_" +
|
||||
account.acct
|
||||
}
|
||||
id={
|
||||
"account_active_" +
|
||||
account.acct
|
||||
}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<LinkableAvatar
|
||||
to={`/profile/${account.id}`}
|
||||
src={
|
||||
account.avatar_static
|
||||
}
|
||||
/>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
`${account.display_name} (@${account.username})` ||
|
||||
`@${account.username}`
|
||||
}
|
||||
secondary={`Last posted ${moment(
|
||||
account.last_status_at
|
||||
)
|
||||
.startOf("minute")
|
||||
.fromNow()}`}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="View account">
|
||||
<LinkableIconButton
|
||||
to={`/profile/${account.id}`}
|
||||
>
|
||||
<AssignmentIndIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
)
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
) : (
|
||||
<Typography paragraph>
|
||||
It looks like there aren't any active people in
|
||||
the profile directory yet.
|
||||
</Typography>
|
||||
)}
|
||||
<br />
|
||||
<ListSubheader>New arrivals</ListSubheader>
|
||||
{this.state.newProfileDirectory &&
|
||||
this.state.newProfileDirectory.length > 0 ? (
|
||||
<Paper>
|
||||
<List className={classes.pageListConstraints}>
|
||||
{this.state.newProfileDirectory.map(
|
||||
(account: Account) => (
|
||||
<ListItem
|
||||
key={
|
||||
"account_new_" +
|
||||
account.acct
|
||||
}
|
||||
id={
|
||||
"account_new_" +
|
||||
account.acct
|
||||
}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<LinkableAvatar
|
||||
to={`/profile/${account.id}`}
|
||||
src={
|
||||
account.avatar_static
|
||||
}
|
||||
/>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
`${account.display_name} (@${account.username})` ||
|
||||
`@${account.username}`
|
||||
}
|
||||
secondary={`Joined ${moment(
|
||||
account.created_at
|
||||
)
|
||||
.startOf("minute")
|
||||
.fromNow()}`}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="View account">
|
||||
<LinkableIconButton
|
||||
to={`/profile/${account.id}`}
|
||||
>
|
||||
<AssignmentIndIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
)
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
) : (
|
||||
<Typography paragraph>
|
||||
It looks like there aren't any new arrivals
|
||||
listed in the profile directory yet.
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{this.state.viewErrored ? (
|
||||
<Paper className={classes.errorCard}>
|
||||
<Typography variant="h4">Bummer.</Typography>
|
||||
<Typography variant="h6">
|
||||
Something went wrong when loading instance activity.
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{this.state.viewLoading ? (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<CircularProgress
|
||||
className={classes.progress}
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<br />
|
||||
<div>
|
||||
<Typography variant="caption">
|
||||
Trending hashtags and the profile directory may not
|
||||
appear here if your instance isn't up to date. Check the{" "}
|
||||
<Link href="/#/about">about page</Link> to see if your
|
||||
instance is running the latest version.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(ActivityPage);
|
|
@ -0,0 +1,211 @@
|
|||
import React, { Component } from "react";
|
||||
import {
|
||||
ListSubheader,
|
||||
withStyles,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Card,
|
||||
CardContent,
|
||||
Paper,
|
||||
CardHeader,
|
||||
Avatar
|
||||
} from "@material-ui/core";
|
||||
|
||||
import { styles } from "./PageLayout.styles";
|
||||
import AnnouncementIcon from "@material-ui/icons/Announcement";
|
||||
|
||||
import Mastodon from "megalodon";
|
||||
import { Announcement } from "../types/Announcement";
|
||||
import { withSnackbar } from "notistack";
|
||||
import moment from "moment";
|
||||
|
||||
/**
|
||||
* The state interface for the notifications page.
|
||||
*/
|
||||
interface IAnnouncementsPageState {
|
||||
/**
|
||||
* The list of notifications, if it exists.
|
||||
*/
|
||||
announcements?: [Announcement];
|
||||
|
||||
/**
|
||||
* Whether the view is still loading.
|
||||
*/
|
||||
viewIsLoading: boolean;
|
||||
|
||||
/**
|
||||
* Whether the view has loaded.
|
||||
*/
|
||||
viewDidLoad?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the view has loaded but in error.
|
||||
*/
|
||||
viewDidError?: boolean;
|
||||
|
||||
/**
|
||||
* The error code for an errored state, if possible.
|
||||
*/
|
||||
viewDidErrorCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The notifications page.
|
||||
*/
|
||||
class AnnouncementsPage extends Component<any, IAnnouncementsPageState> {
|
||||
/**
|
||||
* The Mastodon object to perform notification operations on.
|
||||
*/
|
||||
client: Mastodon;
|
||||
|
||||
/**
|
||||
* The stream listener for tuning in to notifications.
|
||||
*/
|
||||
streamListener: any;
|
||||
|
||||
/**
|
||||
* Construct the notifications page.
|
||||
* @param props The properties to pass in
|
||||
*/
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
|
||||
// Create the Mastodon object.
|
||||
this.client = new Mastodon(
|
||||
localStorage.getItem("access_token") as string,
|
||||
localStorage.getItem("baseurl") + "/api/v1"
|
||||
);
|
||||
|
||||
// Initialize the state.
|
||||
this.state = {
|
||||
viewIsLoading: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform pre-mount tasks
|
||||
*/
|
||||
async componentWillMount() {
|
||||
try {
|
||||
// Get the list of notifications
|
||||
let resp: any = await this.client.get("/announcements");
|
||||
let announcements: [Announcement] = resp.data;
|
||||
|
||||
this.setState({
|
||||
announcements,
|
||||
viewIsLoading: false,
|
||||
viewDidLoad: true
|
||||
});
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
viewDidLoad: true,
|
||||
viewIsLoading: false,
|
||||
viewDidError: true,
|
||||
viewDidErrorCode: e.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the announcements page.
|
||||
*/
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div className={classes.pageLayoutConstraints}>
|
||||
{this.state.viewDidLoad ? (
|
||||
this.state.announcements &&
|
||||
this.state.announcements.length > 0 ? (
|
||||
<div>
|
||||
<ListSubheader>Current announcements</ListSubheader>
|
||||
{this.state.announcements.map(
|
||||
(announcement: Announcement) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
avatar={
|
||||
<Avatar>
|
||||
<AnnouncementIcon />
|
||||
</Avatar>
|
||||
}
|
||||
title={`Published on ${moment(
|
||||
announcement.published_at
|
||||
).format(
|
||||
"MMMM Do, YYYY [at] hh:mmA"
|
||||
)}`}
|
||||
subheader={
|
||||
announcement.ends_at
|
||||
? `Expires ${moment(
|
||||
announcement.ends_at
|
||||
).format(
|
||||
"MMMM Do, YYYY"
|
||||
)}`
|
||||
: ""
|
||||
}
|
||||
></CardHeader>
|
||||
<CardContent>
|
||||
<Typography
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
announcement.content
|
||||
}}
|
||||
></Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={classes.pageLayoutEmptyTextConstraints}
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
<AnnouncementIcon
|
||||
color="action"
|
||||
style={{ fontSize: 48 }}
|
||||
/>
|
||||
<Typography variant="h6">
|
||||
No server announcements
|
||||
</Typography>
|
||||
<Typography paragraph>
|
||||
There aren't any announcements in your
|
||||
community. Announcements that use the
|
||||
announcement feature on Mastodon will appear
|
||||
here.
|
||||
</Typography>
|
||||
<br />
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{this.state.viewDidError ? (
|
||||
<Paper className={classes.errorCard}>
|
||||
<Typography variant="h4">Bummer.</Typography>
|
||||
<Typography variant="h6">
|
||||
Something went wrong when loading announcements.
|
||||
</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(AnnouncementsPage));
|
|
@ -23,7 +23,7 @@ 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 AttachFileIcon from "@material-ui/icons/AttachFile";
|
||||
import TagFacesIcon from "@material-ui/icons/TagFaces";
|
||||
import HowToVoteIcon from "@material-ui/icons/HowToVote";
|
||||
import VisibilityIcon from "@material-ui/icons/Visibility";
|
||||
|
@ -39,55 +39,140 @@ 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 {
|
||||
getUserDefaultVisibility,
|
||||
getConfig,
|
||||
getUserDefaultBool
|
||||
} from "../utilities/settings";
|
||||
import { draftExists, writeDraft, loadDraft } from "../utilities/compose";
|
||||
|
||||
/**
|
||||
* The state for the Composer page.
|
||||
*/
|
||||
interface IComposerState {
|
||||
/**
|
||||
* The current user as an Account.
|
||||
*/
|
||||
account: UAccount;
|
||||
|
||||
/**
|
||||
* The visibility of the post.
|
||||
*/
|
||||
visibility: Visibility;
|
||||
|
||||
/**
|
||||
* Whether there should be a content warning.
|
||||
*/
|
||||
sensitive: boolean;
|
||||
|
||||
/**
|
||||
* The content warning message.
|
||||
*/
|
||||
sensitiveText?: string;
|
||||
|
||||
/**
|
||||
* Whether the visibility drop-down should be visible.
|
||||
*/
|
||||
visibilityMenu: boolean;
|
||||
|
||||
/**
|
||||
* The text contents of the post.
|
||||
*/
|
||||
text: string;
|
||||
|
||||
/**
|
||||
* The remaining amount of characters.
|
||||
*/
|
||||
remainingChars: number;
|
||||
|
||||
/**
|
||||
* An optional reply ID.
|
||||
*/
|
||||
reply?: string;
|
||||
|
||||
/**
|
||||
* The account to reply to, if it exists.
|
||||
*/
|
||||
acct?: string;
|
||||
|
||||
/**
|
||||
* An optional list of media attachments.
|
||||
*/
|
||||
attachments?: [Attachment];
|
||||
|
||||
/**
|
||||
* An optional poll for the post.
|
||||
*/
|
||||
poll?: PollWizard;
|
||||
|
||||
/**
|
||||
* The expiration date of a poll, if it exists.
|
||||
*/
|
||||
pollExpiresDate?: any;
|
||||
|
||||
/**
|
||||
* Whether the emoji picker should be visible.
|
||||
*/
|
||||
showEmojis: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the account's instance is federated.
|
||||
*/
|
||||
federated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Compose page contains all of the information to create a UI for post creation.
|
||||
*/
|
||||
class Composer extends Component<any, IComposerState> {
|
||||
/**
|
||||
* The Mastodon client to work with.
|
||||
*/
|
||||
client: Mastodon;
|
||||
|
||||
/**
|
||||
* Construct the Compose page by generating the Mastodon client and setting default values.
|
||||
* @param props The properties passed into the Compose component, usually the page queries.
|
||||
*/
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
|
||||
// Generate the Mastodon client
|
||||
this.client = new Mastodon(
|
||||
localStorage.getItem("access_token") as string,
|
||||
localStorage.getItem("baseurl") + "/api/v1"
|
||||
);
|
||||
|
||||
// Set the initial state
|
||||
this.state = {
|
||||
account: JSON.parse(localStorage.getItem("account") as string),
|
||||
visibility: getUserDefaultVisibility(),
|
||||
sensitive: false,
|
||||
visibilityMenu: false,
|
||||
text: "",
|
||||
remainingChars: 500,
|
||||
remainingChars: getUserDefaultBool("imposeCharacterLimit")
|
||||
? 500
|
||||
: 9999999999999,
|
||||
showEmojis: false,
|
||||
federated: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run any additional state checks and setup once the page has mounted. This includes
|
||||
* parsing the query parameters and loading the configuration, as well as defining the
|
||||
* clipboard listener.
|
||||
*/
|
||||
componentDidMount() {
|
||||
// Parse the parameters and get the account information if available.
|
||||
let state = this.getComposerParams(this.props);
|
||||
let text = state.acct ? `@${state.acct}: ` : "";
|
||||
this.client.get("/accounts/verify_credentials").then((resp: any) => {
|
||||
let account: UAccount = resp.data;
|
||||
this.setState({ account });
|
||||
});
|
||||
|
||||
// Get the configuration and load the config values.
|
||||
getConfig().then((config: any) => {
|
||||
this.setState({
|
||||
federated: config.federation.allowPublicPosts,
|
||||
|
@ -95,11 +180,43 @@ class Composer extends Component<any, IComposerState> {
|
|||
acct: state.acct,
|
||||
visibility: state.visibility,
|
||||
text,
|
||||
remainingChars: 500 - text.length
|
||||
remainingChars: getUserDefaultBool("imposeCharacterLimit")
|
||||
? 500 - text.length
|
||||
: 99999999
|
||||
});
|
||||
});
|
||||
|
||||
// Attach the paste listener to listen for the clipboard and upload media
|
||||
// if possible.
|
||||
window.addEventListener("paste", (evt: Event) => {
|
||||
let thePasteEvent = evt as ClipboardEvent;
|
||||
let fileList: File[] = [];
|
||||
if (thePasteEvent.clipboardData != null) {
|
||||
let clipitems = thePasteEvent.clipboardData.items;
|
||||
if (clipitems !== undefined) {
|
||||
for (let i = 0; i < clipitems.length; i++) {
|
||||
if (clipitems[i].type.indexOf("image") !== -1) {
|
||||
let clipfile = clipitems[i].getAsFile();
|
||||
if (clipfile != null) {
|
||||
fileList.push(clipfile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileList.length > 0) {
|
||||
this.uploadMedia(fileList);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the properties and set the state to those new properties. This usually
|
||||
* occurs when the page is either reloaded or changes but React doesn't see the
|
||||
* properties change.
|
||||
* @param props The properties passed into the Compose component, usually the page queries.
|
||||
*/
|
||||
componentWillReceiveProps(props: any) {
|
||||
let state = this.getComposerParams(props);
|
||||
let text = state.acct ? `@${state.acct}: ` : "";
|
||||
|
@ -108,10 +225,42 @@ class Composer extends Component<any, IComposerState> {
|
|||
acct: state.acct,
|
||||
visibility: state.visibility,
|
||||
text,
|
||||
remainingChars: 500 - text.length
|
||||
remainingChars: getUserDefaultBool("imposeCharacterLimit")
|
||||
? 500 - text.length
|
||||
: 99999999
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is unsaved text and store it as a draft.
|
||||
*/
|
||||
componentWillUnmount() {
|
||||
if (this.state.text !== "") {
|
||||
writeDraft(
|
||||
this.state.text,
|
||||
this.state.reply ? Number(this.state.reply) : -999
|
||||
);
|
||||
this.props.enqueueSnackbar("Draft saved.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the draft from session storage and pre-load it into the state.
|
||||
*/
|
||||
restoreDraft() {
|
||||
const draft = loadDraft();
|
||||
const text = draft.contents;
|
||||
const reply =
|
||||
draft.replyId !== -999 ? draft.replyId.toString() : undefined;
|
||||
this.setState({ text, reply });
|
||||
this.props.enqueueSnackbar("Restored draft.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the location string and attempt to parse it into a parsed query.
|
||||
* @param location The location string from React Router.
|
||||
* @returns The ParsedQuery object containing all of the parameters.
|
||||
*/
|
||||
checkComposerParams(location?: string): ParsedQuery {
|
||||
let params = "";
|
||||
if (location !== undefined && typeof location === "string") {
|
||||
|
@ -122,6 +271,11 @@ class Composer extends Component<any, IComposerState> {
|
|||
return parseParams(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the property's location string, parse it, and return it.
|
||||
* @param props The properties passed into the Compose component, usually the page queries.
|
||||
* @returns An object containing the reply ID, reply account, and visibility.
|
||||
*/
|
||||
getComposerParams(props: any) {
|
||||
let params = this.checkComposerParams(props.location);
|
||||
let reply: string = "";
|
||||
|
@ -144,52 +298,44 @@ class Composer extends Component<any, IComposerState> {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the text in the state and calculate the remaining character length.
|
||||
* @param text The text to update the state to
|
||||
*/
|
||||
updateTextFromField(text: string) {
|
||||
this.setState({ text, remainingChars: 500 - text.length });
|
||||
this.setState({
|
||||
text,
|
||||
remainingChars: getUserDefaultBool("imposeCharacterLimit")
|
||||
? 500 - text.length
|
||||
: 99999999
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the content warning text in the state
|
||||
* @param sensitiveText The text to update the state to
|
||||
*/
|
||||
updateWarningFromField(sensitiveText: string) {
|
||||
this.setState({ sensitiveText });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the visibility in the state
|
||||
* @param visibility The visibility to update the state to
|
||||
*/
|
||||
changeVisibility(visibility: Visibility) {
|
||||
this.setState({ visibility });
|
||||
}
|
||||
|
||||
uploadMedia() {
|
||||
/**
|
||||
* Open a file dialog to let the user choose files to upload to the server and then upload them.
|
||||
*/
|
||||
promptMediaDialog() {
|
||||
filedialog({
|
||||
multiple: false,
|
||||
accept: "image/*, video/*"
|
||||
accept: ".jpeg,.jpg,.png,.gif,.webm,.mp4,.mov,.ogg,.wav,.mp3,.flac"
|
||||
})
|
||||
.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) => this.uploadMedia(media))
|
||||
.catch((err: Error) => {
|
||||
this.props.enqueueSnackbar("Couldn't get media: " + err.name, {
|
||||
variant: "error"
|
||||
|
@ -198,16 +344,68 @@ class Composer extends Component<any, IComposerState> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a list of files to Mastodon as attachments. Reads the first item in the list.
|
||||
* This also updates the attachments state after a successful upload.
|
||||
* @param media The list of files (`FileList` or `File[]`) to send to Mastodon.
|
||||
*/
|
||||
uploadMedia(media: FileList | File[]) {
|
||||
// Create a new FormData for Mastodon
|
||||
let mediaForm = new FormData();
|
||||
mediaForm.append("file", media[0]);
|
||||
|
||||
// Let the user know we're uploading the file
|
||||
this.props.enqueueSnackbar("Uploading media...", {
|
||||
persist: true,
|
||||
key: "media-upload"
|
||||
});
|
||||
|
||||
// Try to upload the media to the server.
|
||||
this.client
|
||||
.post("/media", mediaForm)
|
||||
|
||||
// If we succeed, get the attachments and update the state.
|
||||
.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.");
|
||||
})
|
||||
|
||||
// If we fail, display an error.
|
||||
.catch((err: Error) => {
|
||||
this.props.closeSnackbar("media-upload");
|
||||
this.props.enqueueSnackbar(
|
||||
"Couldn't upload media: " + err.name,
|
||||
{ variant: "error" }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate through the attachments and grab the attachments' IDs.
|
||||
* @returns A list of IDs as `string[]`
|
||||
*/
|
||||
getOnlyMediaIds() {
|
||||
let ids: string[] = [];
|
||||
if (this.state.attachments) {
|
||||
this.state.attachments.map((attachment: Attachment) => {
|
||||
ids.push(attachment.id);
|
||||
});
|
||||
return this.state.attachments.map(
|
||||
(attachment: Attachment) => attachment.id
|
||||
);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the list of attachments by inserting an attachment.
|
||||
* @param attachment The attachment to insert into the attachments list.
|
||||
*/
|
||||
fetchAttachmentAfterUpdate(attachment: Attachment) {
|
||||
let attachments = this.state.attachments;
|
||||
if (attachments) {
|
||||
|
@ -220,6 +418,10 @@ class Composer extends Component<any, IComposerState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an attachment from the list of attachments and update the state.
|
||||
* @param attachment The attachment to remove from the list
|
||||
*/
|
||||
deleteMediaAttachment(attachment: Attachment) {
|
||||
let attachments = this.state.attachments;
|
||||
if (attachments) {
|
||||
|
@ -233,6 +435,10 @@ class Composer extends Component<any, IComposerState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an emoji at the end of text string and update the state
|
||||
* @param e The emoji to insert into the text
|
||||
*/
|
||||
insertEmoji(e: any) {
|
||||
if (e.custom) {
|
||||
let text = this.state.text + e.colons;
|
||||
|
@ -249,6 +455,9 @@ class Composer extends Component<any, IComposerState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty poll.
|
||||
*/
|
||||
createPoll() {
|
||||
if (this.state.poll === undefined) {
|
||||
let expiration = new Date();
|
||||
|
@ -268,6 +477,9 @@ class Composer extends Component<any, IComposerState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new poll item into the poll.
|
||||
*/
|
||||
addPollItem() {
|
||||
if (
|
||||
this.state.poll !== undefined &&
|
||||
|
@ -282,7 +494,7 @@ class Composer extends Component<any, IComposerState> {
|
|||
this.setState({
|
||||
poll: poll
|
||||
});
|
||||
} else if (this.state.poll && this.state.poll.options.length == 4) {
|
||||
} else if (this.state.poll && this.state.poll.options.length === 4) {
|
||||
this.props.enqueueSnackbar(
|
||||
"You've reached the options limit in your poll.",
|
||||
{ variant: "error" }
|
||||
|
@ -290,6 +502,11 @@ class Composer extends Component<any, IComposerState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing poll item with new text
|
||||
* @param position The position of the poll item in the list
|
||||
* @param newTitle The new text to update
|
||||
*/
|
||||
editPollItem(position: number, newTitle: any) {
|
||||
if (this.state.poll !== undefined) {
|
||||
let poll = this.state.poll;
|
||||
|
@ -307,6 +524,10 @@ class Composer extends Component<any, IComposerState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a poll item from the poll
|
||||
* @param item The item to remove
|
||||
*/
|
||||
removePollItem(item: string) {
|
||||
if (
|
||||
this.state.poll !== undefined &&
|
||||
|
@ -333,13 +554,16 @@ class Composer extends Component<any, IComposerState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the expiration date of the poll.
|
||||
* @param date The new expiration date
|
||||
*/
|
||||
setPollExpires(date: string) {
|
||||
let currentDate = new Date();
|
||||
let newDate = new Date(date);
|
||||
let poll = this.state.poll;
|
||||
if (poll) {
|
||||
let expiry = (newDate.getTime() - currentDate.getTime()) / 1000;
|
||||
console.log(expiry);
|
||||
if (expiry >= 1800) {
|
||||
poll.expires_at = expiry.toString();
|
||||
this.setState({ poll, pollExpiresDate: date });
|
||||
|
@ -353,25 +577,38 @@ class Composer extends Component<any, IComposerState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the poll from the post.
|
||||
*/
|
||||
removePoll() {
|
||||
this.setState({
|
||||
poll: undefined
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user presses the Ctrl/Cmd+Enter key and post to the server if possible.
|
||||
* @param event The keyboard event
|
||||
*/
|
||||
postViaKeyboard(event: any) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.keyCode === 13) {
|
||||
this.post();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the post to Mastodon and return to the previous page, if possible.
|
||||
*/
|
||||
post() {
|
||||
// First, finalize the poll.
|
||||
let pollOptions: string[] = [];
|
||||
if (this.state.poll) {
|
||||
this.state.poll.options.forEach((option: PollWizardOption) => {
|
||||
pollOptions.push(option.title);
|
||||
});
|
||||
}
|
||||
|
||||
// Send a post request to Mastodon.
|
||||
this.client
|
||||
.post("/statuses", {
|
||||
status: this.state.text,
|
||||
|
@ -388,31 +625,52 @@ class Composer extends Component<any, IComposerState> {
|
|||
}
|
||||
: null
|
||||
})
|
||||
|
||||
// If we succeed, send a success message, clear the status
|
||||
// text field, and go back.
|
||||
.then(() => {
|
||||
this.props.enqueueSnackbar("Posted!");
|
||||
|
||||
// This is necessary to prevent session drafts from saving
|
||||
// posts that were already posted.
|
||||
this.setState({ text: "" });
|
||||
|
||||
window.history.back();
|
||||
})
|
||||
|
||||
// Otherwise, show an error message and don't do anything.
|
||||
.catch((err: Error) => {
|
||||
this.props.enqueueSnackbar("Couldn't post: " + err.name);
|
||||
console.log(err.message);
|
||||
console.error(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the content warning section.
|
||||
*/
|
||||
toggleSensitive() {
|
||||
this.setState({ sensitive: !this.state.sensitive });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the visibility drop down menu.
|
||||
*/
|
||||
toggleVisibilityMenu() {
|
||||
this.setState({ visibilityMenu: !this.state.visibilityMenu });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the emoji picker.
|
||||
*/
|
||||
toggleEmojis() {
|
||||
this.setState({ showEmojis: !this.state.showEmojis });
|
||||
}
|
||||
|
||||
/**
|
||||
* Render all of the components on the page given a set of classes.
|
||||
*/
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
console.log(this.state);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
@ -469,18 +727,28 @@ class Composer extends Component<any, IComposerState> {
|
|||
}}
|
||||
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>
|
||||
{getUserDefaultBool("imposeCharacterLimit") ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
className={
|
||||
this.state.remainingChars <= 100
|
||||
? classes.charsReachingLimit
|
||||
: null
|
||||
}
|
||||
>
|
||||
{`${this.state.remainingChars} character${
|
||||
this.state.remainingChars === 1 ? "" : "s"
|
||||
} remaining`}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="caption">
|
||||
<WarningIcon className={classes.warningCaption} />{" "}
|
||||
You have the character limit turned off. Make sure
|
||||
that your post matches your instance's character
|
||||
limit before posting.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{this.state.attachments &&
|
||||
this.state.attachments.length > 0 ? (
|
||||
<div className={classes.composeAttachmentArea}>
|
||||
|
@ -605,13 +873,13 @@ class Composer extends Component<any, IComposerState> {
|
|||
) : null}
|
||||
</DialogContent>
|
||||
<Toolbar className={classes.dialogActions}>
|
||||
<Tooltip title="Add photos or videos">
|
||||
<Tooltip title="Add photos, videos, or audio">
|
||||
<IconButton
|
||||
disabled={this.state.poll !== undefined}
|
||||
onClick={() => this.uploadMedia()}
|
||||
onClick={() => this.promptMediaDialog()}
|
||||
id="compose-media"
|
||||
>
|
||||
<CameraAltIcon />
|
||||
<AttachFileIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Insert emoji">
|
||||
|
@ -694,6 +962,21 @@ class Composer extends Component<any, IComposerState> {
|
|||
) : null}
|
||||
</Menu>
|
||||
</Toolbar>
|
||||
{draftExists() ? (
|
||||
<DialogContent className={classes.draftDisplayArea}>
|
||||
<Typography className={classes.draftText}>
|
||||
You have an unsaved post.
|
||||
</Typography>
|
||||
<div className={classes.draftFlexGrow} />
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => this.restoreDraft()}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
</DialogContent>
|
||||
) : null}
|
||||
<DialogActions>
|
||||
<Button color="secondary" onClick={() => this.post()}>
|
||||
Post
|
||||
|
|
|
@ -8,7 +8,6 @@ import {
|
|||
ListItemText,
|
||||
CircularProgress,
|
||||
ListItemAvatar,
|
||||
Avatar,
|
||||
ListItemSecondaryAction,
|
||||
Tooltip
|
||||
} from "@material-ui/core";
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React, { Component } from "react";
|
||||
import {
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
|
@ -17,72 +18,175 @@ import {
|
|||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
Menu,
|
||||
MenuItem
|
||||
} from "@material-ui/core";
|
||||
|
||||
import AssignmentIndIcon from "@material-ui/icons/AssignmentInd";
|
||||
import PersonIcon from "@material-ui/icons/Person";
|
||||
import PersonAddIcon from "@material-ui/icons/PersonAdd";
|
||||
import PersonRemoveIcon from "mdi-material-ui/AccountMinus";
|
||||
import DeleteIcon from "@material-ui/icons/Delete";
|
||||
import { styles } from "./PageLayout.styles";
|
||||
import { LinkableIconButton, LinkableAvatar } from "../interfaces/overrides";
|
||||
import {
|
||||
LinkableIconButton,
|
||||
LinkableAvatar,
|
||||
LinkableMenuItem
|
||||
} from "../interfaces/overrides";
|
||||
import ForumIcon from "@material-ui/icons/Forum";
|
||||
import ReplyIcon from "@material-ui/icons/Reply";
|
||||
import NotificationsIcon from "@material-ui/icons/Notifications";
|
||||
import MoreVertIcon from "@material-ui/icons/MoreVert";
|
||||
|
||||
import Mastodon from "megalodon";
|
||||
import { Notification } from "../types/Notification";
|
||||
import { Account } from "../types/Account";
|
||||
import { Relationship } from "../types/Relationship";
|
||||
import { withSnackbar } from "notistack";
|
||||
import { Dictionary } from "../interfaces/utils";
|
||||
|
||||
/**
|
||||
* The state interface for the notifications page.
|
||||
*/
|
||||
interface INotificationsPageState {
|
||||
/**
|
||||
* The list of notifications, if it exists.
|
||||
*/
|
||||
notifications?: [Notification];
|
||||
|
||||
/**
|
||||
* The relationships with all notification accounts
|
||||
*/
|
||||
relationships: { [id: string]: Relationship };
|
||||
|
||||
/**
|
||||
* Whether the view is still loading.
|
||||
*/
|
||||
viewIsLoading: boolean;
|
||||
|
||||
/**
|
||||
* Whether the view has loaded.
|
||||
*/
|
||||
viewDidLoad?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the view has loaded but in error.
|
||||
*/
|
||||
viewDidError?: boolean;
|
||||
|
||||
/**
|
||||
* The error code for an errored state, if possible.
|
||||
*/
|
||||
viewDidErrorCode?: string;
|
||||
|
||||
/**
|
||||
* Whether the delete confirmation dialog should be open.
|
||||
*/
|
||||
deleteDialogOpen: boolean;
|
||||
|
||||
/**
|
||||
* Whether the menu should be open on smaller devices.
|
||||
*/
|
||||
mobileMenuOpen: Dictionary<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The notifications page.
|
||||
*/
|
||||
class NotificationsPage extends Component<any, INotificationsPageState> {
|
||||
/**
|
||||
* The Mastodon object to perform notification operations on.
|
||||
*/
|
||||
client: Mastodon;
|
||||
|
||||
/**
|
||||
* The stream listener for tuning in to notifications.
|
||||
*/
|
||||
streamListener: any;
|
||||
|
||||
/**
|
||||
* Construct the notifications page.
|
||||
* @param props The properties to pass in
|
||||
*/
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
|
||||
// Create the Mastodon object.
|
||||
this.client = new Mastodon(
|
||||
localStorage.getItem("access_token") as string,
|
||||
localStorage.getItem("baseurl") + "/api/v1"
|
||||
);
|
||||
|
||||
// Initialize the state.
|
||||
this.state = {
|
||||
viewIsLoading: true,
|
||||
deleteDialogOpen: false
|
||||
deleteDialogOpen: false,
|
||||
mobileMenuOpen: {},
|
||||
relationships: {}
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
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
|
||||
});
|
||||
/**
|
||||
* Perform pre-mount tasks
|
||||
*/
|
||||
async componentWillMount() {
|
||||
try {
|
||||
// Get the list of notifications
|
||||
let resp: any = await this.client.get("/notifications");
|
||||
let notifications: [Notification] = resp.data;
|
||||
|
||||
// initialize all menus as closed
|
||||
let notifMenus: Dictionary<boolean> = {};
|
||||
notifications.forEach(
|
||||
(n: Notification) => (notifMenus[n.id] = false)
|
||||
);
|
||||
|
||||
// compile list of all notification account ids
|
||||
let accountIds: string[] = [];
|
||||
notifications.forEach(notif => {
|
||||
if (!accountIds.includes(notif.account.id)) {
|
||||
accountIds.push(notif.account.id);
|
||||
}
|
||||
});
|
||||
|
||||
// store relationships in id-relationship pairs
|
||||
resp = await this.client.get(`/accounts/relationships`, {
|
||||
id: accountIds
|
||||
});
|
||||
let relationships: Dictionary<Relationship> = {};
|
||||
resp.data.forEach((relation: Relationship) => {
|
||||
relationships[relation.id] = relation;
|
||||
});
|
||||
|
||||
this.setState({
|
||||
notifications,
|
||||
relationships,
|
||||
viewIsLoading: false,
|
||||
viewDidLoad: true,
|
||||
mobileMenuOpen: notifMenus
|
||||
});
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
viewDidLoad: true,
|
||||
viewIsLoading: false,
|
||||
viewDidError: true,
|
||||
viewDidErrorCode: e.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform post-mount tasks.
|
||||
*/
|
||||
componentDidMount() {
|
||||
// Start listening for new notifications after fetching.
|
||||
this.streamNotifications();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a stream listener and keep updating notifications.
|
||||
*/
|
||||
streamNotifications() {
|
||||
this.streamListener = this.client.stream("/streaming/user");
|
||||
|
||||
|
@ -95,10 +199,25 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the state of the delete dialog.
|
||||
*/
|
||||
toggleDeleteDialog() {
|
||||
this.setState({ deleteDialogOpen: !this.state.deleteDialogOpen });
|
||||
}
|
||||
|
||||
toggleMobileMenu(id: string) {
|
||||
let mobileMenuOpen = this.state.mobileMenuOpen;
|
||||
mobileMenuOpen[id] = !mobileMenuOpen[id];
|
||||
this.setState({ mobileMenuOpen });
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML content from a string containing HTML content.
|
||||
*
|
||||
* @param text The sanitized HTML to strip
|
||||
* @returns A string containing the contents of the sanitized HTML
|
||||
*/
|
||||
removeHTMLContent(text: string) {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = text;
|
||||
|
@ -108,6 +227,10 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
return innerContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a notification from the server.
|
||||
* @param id The notification's ID
|
||||
*/
|
||||
removeNotification(id: string) {
|
||||
this.client
|
||||
.post(`/notifications/${id}/dismiss`)
|
||||
|
@ -139,6 +262,9 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge all notifications from the server.
|
||||
*/
|
||||
removeAllNotifications() {
|
||||
this.client
|
||||
.post("/notifications/clear")
|
||||
|
@ -156,6 +282,10 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single notification unit to be used in a list
|
||||
* @param notif The notification to work with.
|
||||
*/
|
||||
createNotification(notif: Notification) {
|
||||
const { classes } = this.props;
|
||||
let primary = "";
|
||||
|
@ -228,6 +358,108 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
{this.getActions(notif)}
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un/follow an account and update relationships state.
|
||||
* @param acct The account to un/follow, if possible
|
||||
*/
|
||||
async toggleFollow(acct: Account) {
|
||||
let relationships = this.state.relationships;
|
||||
if (!relationships[acct.id].following) {
|
||||
try {
|
||||
let resp: any = await this.client.post(
|
||||
`/accounts/${acct.id}/follow`
|
||||
);
|
||||
relationships[acct.id] = resp.data;
|
||||
this.setState({ relationships });
|
||||
this.props.enqueueSnackbar(
|
||||
"You are now following this account."
|
||||
);
|
||||
} catch (e) {
|
||||
this.props.enqueueSnackbar(
|
||||
"Couldn't follow acccount: " + e.name
|
||||
);
|
||||
console.error(e.message);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
let resp: any = await this.client.post(
|
||||
`/accounts/${acct.id}/unfollow`
|
||||
);
|
||||
relationships[acct.id] = resp.data;
|
||||
this.setState({ relationships });
|
||||
this.props.enqueueSnackbar(
|
||||
"You are no longer following this account."
|
||||
);
|
||||
} catch (e) {
|
||||
this.props.enqueueSnackbar(
|
||||
"Couldn't unfollow acccount: " + e.name
|
||||
);
|
||||
console.error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getActions = (notif: Notification) => {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={() => this.toggleMobileMenu(notif.id)}
|
||||
className={classes.mobileOnly}
|
||||
id={`notification-list-${notif.id}`}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
<Menu
|
||||
open={this.state.mobileMenuOpen[notif.id]}
|
||||
anchorEl={document.getElementById(
|
||||
`notification-list-${notif.id}`
|
||||
)}
|
||||
onClose={() => this.toggleMobileMenu(notif.id)}
|
||||
>
|
||||
{notif.type === "follow" ? (
|
||||
<>
|
||||
<LinkableMenuItem
|
||||
to={`profile/${notif.account.id}`}
|
||||
>
|
||||
View Profile
|
||||
</LinkableMenuItem>
|
||||
<MenuItem
|
||||
onClick={() => this.toggleFollow(notif.account)}
|
||||
>
|
||||
{this.state.relationships[notif.account.id]
|
||||
.following
|
||||
? "Unfollow"
|
||||
: "Follow"}
|
||||
</MenuItem>
|
||||
</>
|
||||
) : null}
|
||||
{notif.type === "mention" && notif.status ? (
|
||||
<LinkableMenuItem
|
||||
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
|
||||
}`}
|
||||
>
|
||||
Reply
|
||||
</LinkableMenuItem>
|
||||
) : null}
|
||||
<MenuItem onClick={() => this.removeNotification(notif.id)}>
|
||||
Remove
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<div className={classes.desktopOnly}>
|
||||
{notif.type === "follow" ? (
|
||||
<span>
|
||||
<Tooltip title="View profile">
|
||||
|
@ -237,15 +469,28 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
<AssignmentIndIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Follow account">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
this.followMember(notif.account)
|
||||
}
|
||||
>
|
||||
<PersonAddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{!this.state.relationships[notif.account.id]
|
||||
.following ? (
|
||||
<Tooltip title="Follow account">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
this.toggleFollow(notif.account)
|
||||
}
|
||||
>
|
||||
<PersonAddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Unfollow account">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
this.toggleFollow(notif.account)
|
||||
}
|
||||
>
|
||||
<PersonRemoveIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
) : notif.status ? (
|
||||
<span>
|
||||
|
@ -285,28 +530,14 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the notification page.
|
||||
*/
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
|
@ -337,12 +568,20 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
</Paper>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classes.pageLayoutEmptyTextConstraints}>
|
||||
<Typography variant="h4">All clear!</Typography>
|
||||
<div
|
||||
className={classes.pageLayoutEmptyTextConstraints}
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
<NotificationsIcon
|
||||
color="action"
|
||||
style={{ fontSize: 48 }}
|
||||
/>
|
||||
<Typography variant="h6">All clear!</Typography>
|
||||
<Typography paragraph>
|
||||
It looks like you have no notifications. Why not
|
||||
get the conversation going with a new post?
|
||||
</Typography>
|
||||
<br />
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
@ -372,6 +611,17 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
<span />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classes.pageLayoutEmptyTextConstraints}
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
<Typography>
|
||||
<Link href="/#/settings#sp-notifications">
|
||||
Manage notification settings
|
||||
</Link>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={this.state.deleteDialogOpen}
|
||||
onClose={() => this.toggleDeleteDialog()}
|
||||
|
|
|
@ -3,7 +3,6 @@ import {
|
|||
withStyles,
|
||||
Typography,
|
||||
Avatar,
|
||||
Divider,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
ListSubheader,
|
||||
ListItemSecondaryAction,
|
||||
ListItemAvatar,
|
||||
Avatar,
|
||||
Paper,
|
||||
withStyles,
|
||||
Typography,
|
||||
|
@ -14,7 +13,7 @@ import {
|
|||
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";
|
||||
|
@ -86,7 +85,7 @@ class SearchPage extends Component<any, ISearchPageState> {
|
|||
if (newLocation !== undefined && typeof newLocation === "string") {
|
||||
searchParams = newLocation.replace("#/search", "");
|
||||
} else {
|
||||
searchParams = location.hash.replace("#/search", "");
|
||||
searchParams = this.props.location.hash.replace("#/search", "");
|
||||
}
|
||||
return parseParams(searchParams);
|
||||
}
|
||||
|
@ -155,7 +154,7 @@ class SearchPage extends Component<any, ISearchPageState> {
|
|||
viewDidLoad: true,
|
||||
viewIsLoading: false
|
||||
});
|
||||
console.log(this.state.tagResults);
|
||||
// console.log(this.state.tagResults);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.setState({
|
||||
|
|
|
@ -38,19 +38,15 @@ import {
|
|||
} from "../utilities/settings";
|
||||
import {
|
||||
canSendNotifications,
|
||||
browserSupportsNotificationRequests
|
||||
browserSupportsNotificationRequests,
|
||||
getNotificationRequestPermission
|
||||
} from "../utilities/notifications";
|
||||
import { themes, defaultTheme } from "../types/HyperspaceTheme";
|
||||
import ThemePreview from "../components/ThemePreview";
|
||||
import {
|
||||
setHyperspaceTheme,
|
||||
getHyperspaceTheme,
|
||||
getDarkModeFromSystem
|
||||
} from "../utilities/themes";
|
||||
import { setHyperspaceTheme, getHyperspaceTheme } from "../utilities/themes";
|
||||
import { Visibility } from "../types/Visibility";
|
||||
import { LinkableButton, LinkableIconButton } from "../interfaces/overrides";
|
||||
import { 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";
|
||||
|
@ -62,10 +58,15 @@ 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 AccountSettingsIcon from "mdi-material-ui/AccountSettings";
|
||||
import AlphabeticalVariantOffIcon from "mdi-material-ui/AlphabeticalVariantOff";
|
||||
import DashboardIcon from "@material-ui/icons/Dashboard";
|
||||
import InfiniteIcon from "@material-ui/icons/AllInclusive";
|
||||
|
||||
import { Config } from "../types/Config";
|
||||
import { Account } from "../types/Account";
|
||||
import Mastodon from "megalodon";
|
||||
import { isDarwinApp } from "../utilities/desktop";
|
||||
import { withSnackbar } from "notistack";
|
||||
|
||||
interface ISettingsState {
|
||||
darkModeEnabled: boolean;
|
||||
|
@ -82,6 +83,9 @@ interface ISettingsState {
|
|||
brandName: string;
|
||||
federated: boolean;
|
||||
currentUser?: Account;
|
||||
imposeCharacterLimit: boolean;
|
||||
masonryLayout?: boolean;
|
||||
infiniteScroll?: boolean;
|
||||
}
|
||||
|
||||
class SettingsPage extends Component<any, ISettingsState> {
|
||||
|
@ -112,7 +116,10 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
setHyperspaceTheme(defaultTheme),
|
||||
defaultVisibility: getUserDefaultVisibility() || "public",
|
||||
brandName: "Hyperspace",
|
||||
federated: true
|
||||
federated: true,
|
||||
imposeCharacterLimit: getUserDefaultBool("imposeCharacterLimit"),
|
||||
masonryLayout: getUserDefaultBool("isMasonryLayout"),
|
||||
infiniteScroll: getUserDefaultBool("isInfiniteScroll")
|
||||
};
|
||||
|
||||
this.toggleDarkMode = this.toggleDarkMode.bind(this);
|
||||
|
@ -121,11 +128,22 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
this.toggleBadgeCount = this.toggleBadgeCount.bind(this);
|
||||
this.toggleThemeDialog = this.toggleThemeDialog.bind(this);
|
||||
this.toggleVisibilityDialog = this.toggleVisibilityDialog.bind(this);
|
||||
this.toggleMasonryLayout = this.toggleMasonryLayout.bind(this);
|
||||
this.toggleInfiniteScroll = this.toggleInfiniteScroll.bind(this);
|
||||
this.changeThemeName = this.changeThemeName.bind(this);
|
||||
this.changeTheme = this.changeTheme.bind(this);
|
||||
this.setVisibility = this.setVisibility.bind(this);
|
||||
}
|
||||
|
||||
componentWillReceiveProps() {
|
||||
const path = window.location.hash.split("#");
|
||||
const lastPath = document.getElementById(path[path.length - 1]);
|
||||
if (lastPath !== null) {
|
||||
lastPath.scrollIntoView();
|
||||
window.scrollBy(0, -64);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
getConfig()
|
||||
.then((config: any) => {
|
||||
|
@ -155,13 +173,20 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
console.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
const path = window.location.hash.split("#");
|
||||
const lastPath = document.getElementById(path[path.length - 1]);
|
||||
if (lastPath !== null) {
|
||||
lastPath.scrollIntoView();
|
||||
window.scrollBy(0, -64);
|
||||
}
|
||||
}
|
||||
|
||||
getFederatedStatus() {
|
||||
getConfig().then((result: any) => {
|
||||
if (result !== undefined) {
|
||||
let config: Config = result;
|
||||
console.log(!config.federation.allowPublicPosts);
|
||||
// console.log(!config.federation.allowPublicPosts);
|
||||
this.setState({
|
||||
federated: config.federation.allowPublicPosts
|
||||
});
|
||||
|
@ -186,14 +211,44 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the setting for enabling/disabling push notifications.
|
||||
*
|
||||
* If the notification permission wasn't set yet (i.e., `Notification.permission`)
|
||||
* is in `"default"` state, get the permission request first.
|
||||
*/
|
||||
togglePushNotifications() {
|
||||
this.setState({
|
||||
pushNotificationsEnabled: !this.state.pushNotificationsEnabled
|
||||
});
|
||||
setUserDefaultBool(
|
||||
"enablePushNotifications",
|
||||
!this.state.pushNotificationsEnabled
|
||||
);
|
||||
if (Notification.permission === "default") {
|
||||
getNotificationRequestPermission()
|
||||
.then(permission => {
|
||||
if (permission === "granted") {
|
||||
setUserDefaultBool(
|
||||
"enablePushNotifications",
|
||||
!this.state.pushNotificationsEnabled
|
||||
);
|
||||
this.setState({
|
||||
pushNotificationsEnabled: !this.state
|
||||
.pushNotificationsEnabled
|
||||
});
|
||||
} else if (permission === "denied") {
|
||||
this.props.enqueueSnackbar(
|
||||
"Permission request was denied.",
|
||||
{ variant: "error" }
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(reason =>
|
||||
this.props.enqueueSnackbar(reason, { variant: "error" })
|
||||
);
|
||||
} else {
|
||||
setUserDefaultBool(
|
||||
"enablePushNotifications",
|
||||
!this.state.pushNotificationsEnabled
|
||||
);
|
||||
this.setState({
|
||||
pushNotificationsEnabled: !this.state.pushNotificationsEnabled
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleBadgeCount() {
|
||||
|
@ -216,6 +271,16 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
});
|
||||
}
|
||||
|
||||
toggleCharacterLimit() {
|
||||
this.setState({
|
||||
imposeCharacterLimit: !this.state.imposeCharacterLimit
|
||||
});
|
||||
setUserDefaultBool(
|
||||
"imposeCharacterLimit",
|
||||
!this.state.imposeCharacterLimit
|
||||
);
|
||||
}
|
||||
|
||||
toggleResetDialog() {
|
||||
this.setState({
|
||||
resetHyperspaceDialog: !this.state.resetHyperspaceDialog
|
||||
|
@ -226,6 +291,16 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
this.setState({ resetSettingsDialog: !this.state.resetSettingsDialog });
|
||||
}
|
||||
|
||||
toggleMasonryLayout() {
|
||||
this.setState({ masonryLayout: !this.state.masonryLayout });
|
||||
setUserDefaultBool("isMasonryLayout", !this.state.masonryLayout);
|
||||
}
|
||||
|
||||
toggleInfiniteScroll() {
|
||||
this.setState({ infiniteScroll: !this.state.infiniteScroll });
|
||||
setUserDefaultBool("isInfiniteScroll", !this.state.infiniteScroll);
|
||||
}
|
||||
|
||||
changeTheme() {
|
||||
setUserDefaultTheme(this.state.selectThemeName);
|
||||
window.location.reload();
|
||||
|
@ -265,6 +340,227 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
window.location.reload();
|
||||
}
|
||||
|
||||
settingsList = () => {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<>
|
||||
<ListSubheader id="sp-appearance">Appearance</ListSubheader>
|
||||
<Paper className={classes.pageListConstraints}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<DevicesIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Match system appearance"
|
||||
secondary="Follows your device's preferences to toggle dark mode"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={this.state.systemDecidesDarkMode}
|
||||
onChange={this.toggleSystemDarkMode}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
{!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="Defines the color palette used for the interface"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Button onClick={this.toggleThemeDialog}>
|
||||
Set theme
|
||||
</Button>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<DashboardIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Show more posts"
|
||||
secondary="Shows additional columns of posts on wider screens"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={this.state.masonryLayout}
|
||||
onChange={this.toggleMasonryLayout}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<InfiniteIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Enable infinite scroll"
|
||||
secondary="Automatically load more posts when scrolling"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={this.state.infiniteScroll}
|
||||
onChange={this.toggleInfiniteScroll}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Paper>
|
||||
<br />
|
||||
<ListSubheader id="sp-composer">Composer</ListSubheader>
|
||||
<Paper className={classes.pageListConstraints}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<VisibilityIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Default post visibility"
|
||||
secondary="Creating posts in the composer will use this visiblity"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Button onClick={this.toggleVisibilityDialog}>
|
||||
Change
|
||||
</Button>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<AlphabeticalVariantOffIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Impose character limit"
|
||||
secondary="Impose a character limit when creating posts"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={this.state.imposeCharacterLimit}
|
||||
onChange={() => this.toggleCharacterLimit()}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Paper>
|
||||
<br />
|
||||
<ListSubheader id="sp-notifications">
|
||||
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()
|
||||
? "Sends a push notification when not focused."
|
||||
: "Notifications aren't supported."
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={
|
||||
this.state.pushNotificationsEnabled
|
||||
}
|
||||
onChange={this.togglePushNotifications}
|
||||
disabled={
|
||||
!browserSupportsNotificationRequests()
|
||||
}
|
||||
/>
|
||||
</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 id="sp-advanced">Advanced</ListSubheader>
|
||||
<Paper className={classes.pageListConstraints}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<RefreshIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Refresh settings"
|
||||
secondary="Resets 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
showThemeDialog() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
|
@ -502,7 +798,7 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
</div>
|
||||
<div className={classes.pageGrow} />
|
||||
<Toolbar>
|
||||
<Tooltip title="Edit Profile">
|
||||
<Tooltip title="Edit profile">
|
||||
<LinkableIconButton
|
||||
to={"/you"}
|
||||
color="inherit"
|
||||
|
@ -518,6 +814,14 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
<DomainDisabledIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Manage follow requests">
|
||||
<LinkableIconButton
|
||||
to={"/requests"}
|
||||
color="inherit"
|
||||
>
|
||||
<AccountSettingsIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Configure on Mastodon">
|
||||
<IconButton
|
||||
href={
|
||||
|
@ -536,196 +840,38 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
</Toolbar>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className={classes.pageHeroBackground}>
|
||||
<div className={classes.pageHeroBackgroundImage} />
|
||||
<div className={classes.profileContent}>
|
||||
<br />
|
||||
<Avatar className={classes.settingsAvatar} />
|
||||
<div
|
||||
className={classes.profileUserBox}
|
||||
style={{ margin: "auto" }}
|
||||
>
|
||||
<Typography
|
||||
className={classes.settingsHeaderText}
|
||||
color="inherit"
|
||||
component="h1"
|
||||
>
|
||||
{"Loading..."}
|
||||
</Typography>
|
||||
<Typography
|
||||
color="inherit"
|
||||
className={classes.settingsDetailText}
|
||||
component="p"
|
||||
>
|
||||
@{"..."}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={classes.pageGrow} />
|
||||
<Toolbar />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={classes.pageContentLayoutConstraints}>
|
||||
<ListSubheader>Appearance</ListSubheader>
|
||||
<Paper className={classes.pageListConstraints}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<DevicesIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Match system appearance"
|
||||
secondary="Follows your device's preferences to toggle dark mode"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={
|
||||
this.state.systemDecidesDarkMode
|
||||
}
|
||||
onChange={this.toggleSystemDarkMode}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
{!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="Defines 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 post visibility"
|
||||
secondary="Creating posts in the 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()
|
||||
? "Sends 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="Resets 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.settingsList()}
|
||||
{this.showThemeDialog()}
|
||||
{this.showVisibilityDialog()}
|
||||
{this.showResetDialog()}
|
||||
|
@ -737,4 +883,4 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(SettingsPage);
|
||||
export default withStyles(styles)(withSnackbar(SettingsPage));
|
||||
|
|
|
@ -0,0 +1,437 @@
|
|||
import React, { Component } from "react";
|
||||
import {
|
||||
withStyles,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
Paper,
|
||||
Button,
|
||||
Chip,
|
||||
Avatar,
|
||||
Slide,
|
||||
StyledComponentProps
|
||||
} 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, withSnackbarProps } from "notistack";
|
||||
import Masonry from "react-masonry-css";
|
||||
import { getUserDefaultBool } from "../utilities/settings";
|
||||
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
|
||||
|
||||
/**
|
||||
* The basic interface for a timeline page's properties.
|
||||
*/
|
||||
interface ITimelinePageProps extends withSnackbarProps, StyledComponentProps {
|
||||
/**
|
||||
* The API endpoint for the timeline to fetch after starting
|
||||
* a stream.
|
||||
*/
|
||||
timeline: string;
|
||||
|
||||
/**
|
||||
* The API endpoint for the timeline to stream.
|
||||
*/
|
||||
stream: string;
|
||||
classes?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* The base interface for the timeline page's state.
|
||||
*/
|
||||
interface ITimelinePageState {
|
||||
/**
|
||||
* The list of posts from the timeline.
|
||||
*/
|
||||
posts?: [Status];
|
||||
|
||||
/**
|
||||
* The list of posts stored temporarily while viewing the timeline.
|
||||
*
|
||||
* Can be cleared when user pushes "Show x posts" button.
|
||||
*/
|
||||
backlogPosts?: [Status] | null;
|
||||
|
||||
/**
|
||||
* Whether the view is currently loading.
|
||||
*/
|
||||
viewIsLoading: boolean;
|
||||
|
||||
/**
|
||||
* Whether the view loaded successfully.
|
||||
*/
|
||||
viewDidLoad?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the view errored.
|
||||
*/
|
||||
viewDidError?: boolean;
|
||||
|
||||
/**
|
||||
* The view's error code, if it errored.
|
||||
*/
|
||||
viewDidErrorCode?: any;
|
||||
|
||||
/**
|
||||
* Whether or not to use the masonry layout as defined in
|
||||
* the user settings.
|
||||
*/
|
||||
isMasonryLayout?: boolean;
|
||||
|
||||
/**
|
||||
* Whether posts should automatically load when scrolling.
|
||||
*/
|
||||
isInfiniteScroll?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The base class for a timeline page.
|
||||
*
|
||||
* The timeline page streams a specific timeline. When the stream is connected,
|
||||
* the page will fetch a particular timeline list of posts. The timeline page will
|
||||
* also off-load incoming posts from the stream into a backlog that the user can
|
||||
* then insert by clicking a button.
|
||||
*/
|
||||
class TimelinePage extends Component<ITimelinePageProps, ITimelinePageState> {
|
||||
/**
|
||||
* The client to use.
|
||||
*/
|
||||
client: Mastodon;
|
||||
|
||||
/**
|
||||
* The page's stream listener.
|
||||
*/
|
||||
streamListener: StreamListener;
|
||||
|
||||
/**
|
||||
* Construct the timeline page.
|
||||
* @param props The timeline page's properties
|
||||
*/
|
||||
constructor(props: ITimelinePageProps) {
|
||||
super(props);
|
||||
|
||||
// Initialize the state.
|
||||
this.state = {
|
||||
viewIsLoading: true,
|
||||
backlogPosts: null,
|
||||
isMasonryLayout: getUserDefaultBool("isMasonryLayout"),
|
||||
isInfiniteScroll: getUserDefaultBool("isInfiniteScroll")
|
||||
};
|
||||
|
||||
// Generate the client.
|
||||
this.client = new Mastodon(
|
||||
localStorage.getItem("access_token") as string,
|
||||
(localStorage.getItem("baseurl") as string) + "/api/v1"
|
||||
);
|
||||
|
||||
// Create the stream listener from the properties.
|
||||
this.streamListener = this.client.stream(this.props.stream);
|
||||
|
||||
this.loadMoreTimelinePieces = this.loadMoreTimelinePieces.bind(this);
|
||||
this.shouldLoadMorePosts = this.shouldLoadMorePosts.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect the stream listener and listen for new posts.
|
||||
*/
|
||||
componentWillMount() {
|
||||
this.streamListener.on("connect", () => {
|
||||
// Get the latest posts from this timeline.
|
||||
this.client
|
||||
.get(this.props.timeline, { limit: 50 })
|
||||
// If we succeeded, update the state and turn off loading.
|
||||
.then((resp: any) => {
|
||||
let statuses: [Status] = resp.data;
|
||||
this.setState({
|
||||
posts: statuses,
|
||||
viewIsLoading: false,
|
||||
viewDidLoad: true,
|
||||
viewDidError: false
|
||||
});
|
||||
})
|
||||
|
||||
// Otherwise, update the state in error.
|
||||
.catch((resp: any) => {
|
||||
this.setState({
|
||||
viewIsLoading: false,
|
||||
viewDidLoad: true,
|
||||
viewDidError: true,
|
||||
viewDidErrorCode: String(resp)
|
||||
});
|
||||
|
||||
// Notify the user with a snackbar.
|
||||
this.props.enqueueSnackbar("Failed to get posts.", {
|
||||
variant: "error"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Store incoming posts into a backlog if possible.
|
||||
this.streamListener.on("update", (status: Status) => {
|
||||
let queue = this.state.backlogPosts;
|
||||
if (queue !== null && queue !== undefined) {
|
||||
queue.unshift(status);
|
||||
} else {
|
||||
queue = [status];
|
||||
}
|
||||
this.setState({ backlogPosts: queue });
|
||||
});
|
||||
|
||||
// When a post is deleted in the backend, find the post in the list
|
||||
// and remove it from the list.
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Display an error if the stream encounters and error.
|
||||
this.streamListener.on("error", (err: Error) => {
|
||||
this.setState({
|
||||
viewDidError: true,
|
||||
viewDidErrorCode: err.message
|
||||
});
|
||||
this.props.enqueueSnackbar("An error occured.", {
|
||||
variant: "error"
|
||||
});
|
||||
});
|
||||
|
||||
this.streamListener.on("heartbeat", () => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a delay between repeated function calls
|
||||
* codeburst.io/throttling-and-debouncing-in-javascript-646d076d0a44
|
||||
* @param delay How long to wait before calling function (ms)
|
||||
* @param fn The function to call
|
||||
*/
|
||||
debounced(delay: number, fn: Function) {
|
||||
let lastCall = 0;
|
||||
return function(...args: any) {
|
||||
const now = new Date().getTime();
|
||||
if (now - lastCall < delay) {
|
||||
return;
|
||||
}
|
||||
lastCall = now;
|
||||
return fn(...args);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for when scroll position changes
|
||||
*/
|
||||
componentDidMount() {
|
||||
if (this.state.isInfiniteScroll) {
|
||||
window.addEventListener(
|
||||
"scroll",
|
||||
this.debounced(200, this.shouldLoadMorePosts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Halt the stream and scroll listeners when unmounting the component.
|
||||
*/
|
||||
componentWillUnmount() {
|
||||
this.streamListener.stop();
|
||||
if (this.state.isInfiniteScroll) {
|
||||
window.removeEventListener("scroll", this.shouldLoadMorePosts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the posts from the backlog into the current list of posts
|
||||
* and clear the backlog.
|
||||
*/
|
||||
insertBacklog() {
|
||||
window.scrollTo(0, 0);
|
||||
let posts = this.state.posts;
|
||||
let backlog = this.state.backlogPosts;
|
||||
if (posts && backlog && backlog.length > 0) {
|
||||
let push = backlog.concat(posts);
|
||||
this.setState({ posts: push as [Status], backlogPosts: null });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the next set of posts, if it exists.
|
||||
*/
|
||||
loadMoreTimelinePieces() {
|
||||
// Reinstate the loading status.
|
||||
this.setState({ viewDidLoad: false, viewIsLoading: true });
|
||||
|
||||
// If there are any posts, get the next set.
|
||||
if (this.state.posts) {
|
||||
this.client
|
||||
.get(this.props.timeline, {
|
||||
max_id: this.state.posts[this.state.posts.length - 1].id,
|
||||
limit: 50
|
||||
})
|
||||
|
||||
// If we succeeded, append them to the end of the list of posts.
|
||||
.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
|
||||
});
|
||||
})
|
||||
|
||||
// If we errored, display the error and don't do anything.
|
||||
.catch((err: Error) => {
|
||||
this.setState({
|
||||
viewIsLoading: false,
|
||||
viewDidError: true,
|
||||
viewDidErrorCode: err.message
|
||||
});
|
||||
this.props.enqueueSnackbar("Failed to get posts", {
|
||||
variant: "error"
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more posts when scroll is near the end of the page
|
||||
*/
|
||||
shouldLoadMorePosts(e: Event) {
|
||||
let difference =
|
||||
document.body.clientHeight - window.scrollY - window.innerHeight;
|
||||
if (difference < 10000 && this.state.viewIsLoading === false) {
|
||||
this.loadMoreTimelinePieces();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the timeline page.
|
||||
*/
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
const containerClasses = `${classes.pageLayoutMaxConstraints}${
|
||||
this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : ""
|
||||
}`;
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{this.state.backlogPosts ? (
|
||||
<div className={classes.pageTopChipContainer}>
|
||||
<div className={classes.pageTopChips}>
|
||||
<Slide direction="down" in={true}>
|
||||
<Chip
|
||||
avatar={
|
||||
<Avatar>
|
||||
<ArrowUpwardIcon />
|
||||
</Avatar>
|
||||
}
|
||||
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>
|
||||
{this.state.isMasonryLayout ? (
|
||||
<Masonry
|
||||
breakpointCols={{
|
||||
default: 4,
|
||||
2000: 3,
|
||||
1400: 2,
|
||||
1050: 1
|
||||
}}
|
||||
className={classes.masonryGrid}
|
||||
columnClassName={
|
||||
classes["my-masonry-grid_column"]
|
||||
}
|
||||
>
|
||||
{this.state.posts.map((post: Status) => {
|
||||
return (
|
||||
<div
|
||||
className={classes.masonryGrid_item}
|
||||
key={post.id}
|
||||
>
|
||||
<Post
|
||||
post={post}
|
||||
client={this.client}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Masonry>
|
||||
) : (
|
||||
<div>
|
||||
{this.state.posts.map((post: Status) => {
|
||||
return (
|
||||
<Post
|
||||
key={post.id}
|
||||
post={post}
|
||||
client={this.client}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(withSnackbar(TimelinePage));
|
|
@ -36,51 +36,166 @@ 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 { MultiAccount } from "../types/Account";
|
||||
|
||||
import AccountCircleIcon from "@material-ui/icons/AccountCircle";
|
||||
import CloseIcon from "@material-ui/icons/Close";
|
||||
|
||||
/**
|
||||
* Basic props for Welcome page
|
||||
*/
|
||||
interface IWelcomeProps extends withSnackbarProps {
|
||||
classes: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic state for welcome page
|
||||
*/
|
||||
interface IWelcomeState {
|
||||
/**
|
||||
* The custom-defined URL to the logo to display
|
||||
*/
|
||||
logoUrl?: string;
|
||||
|
||||
/**
|
||||
* The custom-defined URL to the background image to display
|
||||
*/
|
||||
backgroundUrl?: string;
|
||||
|
||||
/**
|
||||
* The custom-defined brand name of this app
|
||||
*/
|
||||
brandName?: string;
|
||||
|
||||
/**
|
||||
* The custom-defined server address to register to
|
||||
*/
|
||||
registerBase?: string;
|
||||
|
||||
/**
|
||||
* Whether this version of Hyperspace has federation
|
||||
*/
|
||||
federates?: boolean;
|
||||
|
||||
/**
|
||||
* Whether Hyperspace is ready to get the auth code
|
||||
*/
|
||||
proceedToGetCode: boolean;
|
||||
|
||||
/**
|
||||
* The currently "logged-in" user after the first step
|
||||
*/
|
||||
user: string;
|
||||
|
||||
/**
|
||||
* Whether the user's input errors
|
||||
*/
|
||||
userInputError: boolean;
|
||||
|
||||
/**
|
||||
* The user input error message, if any
|
||||
*/
|
||||
userInputErrorMessage: string;
|
||||
|
||||
/**
|
||||
* The app's client ID, if registered
|
||||
*/
|
||||
clientId?: string;
|
||||
|
||||
/**
|
||||
* The app's client secret, if registered
|
||||
*/
|
||||
clientSecret?: string;
|
||||
|
||||
/**
|
||||
* The authorization URL provided by Mastodon from the
|
||||
* client ID and secret
|
||||
*/
|
||||
authUrl?: string;
|
||||
|
||||
/**
|
||||
* Whether a previous login attempt is present
|
||||
*/
|
||||
foundSavedLogin: boolean;
|
||||
|
||||
/**
|
||||
* Whether Hyperspace is in the process of authorizing
|
||||
*/
|
||||
authorizing: boolean;
|
||||
|
||||
/**
|
||||
* The custom-defined license for the Hyperspace source code
|
||||
*/
|
||||
license?: string;
|
||||
|
||||
/**
|
||||
* The custom-defined URL to the source code of Hyperspace
|
||||
*/
|
||||
repo?: string;
|
||||
|
||||
/**
|
||||
* The default address to redirect to. Used in login inits and
|
||||
* when the authorization code completes.
|
||||
*/
|
||||
defaultRedirectAddress: string;
|
||||
|
||||
/**
|
||||
* Whether the redirect address is set to 'dynamic'.
|
||||
*/
|
||||
redirectAddressIsDynamic: boolean;
|
||||
|
||||
/**
|
||||
* Whether the authorization dialog for the emergency login is
|
||||
* open.
|
||||
*/
|
||||
openAuthDialog: boolean;
|
||||
|
||||
/**
|
||||
* The authorization code to fetch an access token with
|
||||
*/
|
||||
authCode: string;
|
||||
|
||||
/**
|
||||
* Whether the Emergency Mode has been initiated
|
||||
*/
|
||||
emergencyMode: boolean;
|
||||
|
||||
/**
|
||||
* The current app version
|
||||
*/
|
||||
version: string;
|
||||
|
||||
/**
|
||||
* Whether we are in the process of adding a new account or not
|
||||
*/
|
||||
willAddAccount: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The base class for the Welcome page.
|
||||
*
|
||||
* The Welcome page is responsible for handling the registration,
|
||||
* login, and authorization of accounts into the Hyperspace app.
|
||||
*/
|
||||
class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
||||
/**
|
||||
* The associated Mastodon client to handle logins/authorizations
|
||||
* with
|
||||
*/
|
||||
client: any;
|
||||
|
||||
/**
|
||||
* Construct the state and other components of the Welcome page
|
||||
* @param props The properties passed onto the page
|
||||
*/
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
|
||||
// Set up our state
|
||||
this.state = {
|
||||
proceedToGetCode: false,
|
||||
user: "",
|
||||
|
@ -89,6 +204,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
authorizing: false,
|
||||
userInputErrorMessage: "",
|
||||
defaultRedirectAddress: "",
|
||||
redirectAddressIsDynamic: false,
|
||||
openAuthDialog: false,
|
||||
authCode: "",
|
||||
emergencyMode: false,
|
||||
|
@ -96,15 +212,21 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
willAddAccount: false
|
||||
};
|
||||
|
||||
// Read the configuration data and update the state
|
||||
getConfig()
|
||||
.then((result: any) => {
|
||||
if (result !== undefined) {
|
||||
let config: Config = result;
|
||||
|
||||
// Warn if the location is dynamic (unexpected behavior)
|
||||
if (result.location === "dynamic") {
|
||||
console.warn(
|
||||
"Redirect URI is set to dynamic, which may affect how sign-in works for some users. Careful!"
|
||||
);
|
||||
}
|
||||
|
||||
// Reset to mastodon.social if the location is a disallowed
|
||||
// domain.
|
||||
if (
|
||||
inDisallowedDomains(result.registration.defaultInstance)
|
||||
) {
|
||||
|
@ -113,6 +235,8 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
);
|
||||
result.registration.defaultInstance = "mastodon.social";
|
||||
}
|
||||
|
||||
// Update the state as per the configuration
|
||||
this.setState({
|
||||
logoUrl: config.branding
|
||||
? result.branding.logo
|
||||
|
@ -130,13 +254,16 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
license: config.license.url,
|
||||
repo: config.repository,
|
||||
defaultRedirectAddress:
|
||||
config.location != "dynamic"
|
||||
config.location !== "dynamic"
|
||||
? config.location
|
||||
: `https://${window.location.host}`,
|
||||
redirectAddressIsDynamic: config.location === "dynamic",
|
||||
version: config.version
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
// Print an error if the config wasn't found.
|
||||
.catch(() => {
|
||||
console.error(
|
||||
"config.json is missing. If you want to customize Hyperspace, please include config.json"
|
||||
|
@ -144,6 +271,10 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for any existing logins and tokens before presenting
|
||||
* the login page
|
||||
*/
|
||||
componentDidMount() {
|
||||
if (localStorage.getItem("login")) {
|
||||
this.getSavedSession();
|
||||
|
@ -154,18 +285,33 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user field in the state
|
||||
* @param user The string to update the state to
|
||||
*/
|
||||
updateUserInfo(user: string) {
|
||||
this.setState({ user });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the auth code in the state
|
||||
* @param code The authorization code to update the state to
|
||||
*/
|
||||
updateAuthCode(code: string) {
|
||||
this.setState({ authCode: code });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the visibility of the authorization dialog
|
||||
*/
|
||||
toggleAuthDialog() {
|
||||
this.setState({ openAuthDialog: !this.state.openAuthDialog });
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the app is ready to open the authorization
|
||||
* process.
|
||||
*/
|
||||
readyForAuth() {
|
||||
if (localStorage.getItem("baseurl")) {
|
||||
return true;
|
||||
|
@ -174,11 +320,18 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current access token and base URL
|
||||
*/
|
||||
clear() {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("baseurl");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current saved session from the previous login
|
||||
* attempt and update the state
|
||||
*/
|
||||
getSavedSession() {
|
||||
let loginData = localStorage.getItem("login");
|
||||
if (loginData) {
|
||||
|
@ -192,6 +345,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the emergency login mode.
|
||||
*/
|
||||
startEmergencyLogin() {
|
||||
if (!this.state.emergencyMode) {
|
||||
this.createEmergencyLogin();
|
||||
|
@ -199,6 +355,11 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
this.toggleAuthDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the registration process.
|
||||
* @returns A URL pointing to the signup page of the base as defined
|
||||
* in the config's `registerBase` field
|
||||
*/
|
||||
startRegistration() {
|
||||
if (this.state.registerBase) {
|
||||
return "https://" + this.state.registerBase + "/auth/sign_up";
|
||||
|
@ -207,15 +368,33 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the keyboard and start the login procedure if the user
|
||||
* presses the ENTER/RETURN key
|
||||
* @param event The keyboard event
|
||||
*/
|
||||
watchUsernameField(event: any) {
|
||||
if (event.keyCode === 13) this.startLogin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the keyboard and start the emergency login auth procedure
|
||||
* if the user presses the ENTER/RETURN key
|
||||
* @param event The keyboard event
|
||||
*/
|
||||
watchAuthField(event: any) {
|
||||
if (event.keyCode === 13) this.authorizeEmergencyLogin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the "logged-in" user by reading the username string
|
||||
* from the first field on the login page.
|
||||
* @param user The user string to parse
|
||||
* @returns The base URL of the user
|
||||
*/
|
||||
getLoginUser(user: string) {
|
||||
// Did the user include "@"? They probably are not from the
|
||||
// server defined in config
|
||||
if (user.includes("@")) {
|
||||
if (this.state.federates) {
|
||||
let newUser = user;
|
||||
|
@ -235,7 +414,10 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
: "mastodon.social")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// Otherwise, treat them as if they're from the server
|
||||
else {
|
||||
let newUser = `${user}@${
|
||||
this.state.registerBase
|
||||
? this.state.registerBase
|
||||
|
@ -251,70 +433,104 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the user string for any errors and then create a client with an
|
||||
* ID and secret to start the authorization process.
|
||||
*/
|
||||
startLogin() {
|
||||
// Check if we have errored
|
||||
let error = this.checkForErrors();
|
||||
|
||||
// If we didn't, create the Hyperspace app to register onto that Mastodon
|
||||
// server.
|
||||
if (!error) {
|
||||
// Define the app's scopes and base URL
|
||||
const scopes = "read write follow";
|
||||
const baseurl = this.getLoginUser(this.state.user);
|
||||
localStorage.setItem("baseurl", baseurl);
|
||||
|
||||
// Create the Hyperspace app
|
||||
createHyperspaceApp(
|
||||
this.state.brandName ? this.state.brandName : "Hyperspace",
|
||||
scopes,
|
||||
baseurl,
|
||||
getRedirectAddress(this.state.defaultRedirectAddress)
|
||||
).then((resp: any) => {
|
||||
let saveSessionForCrashing: SaveClientSession = {
|
||||
clientId: resp.clientId,
|
||||
clientSecret: resp.clientSecret,
|
||||
authUrl: resp.url,
|
||||
emergency: false
|
||||
};
|
||||
localStorage.setItem(
|
||||
"login",
|
||||
JSON.stringify(saveSessionForCrashing)
|
||||
);
|
||||
this.setState({
|
||||
clientId: resp.clientId,
|
||||
clientSecret: resp.clientSecret,
|
||||
authUrl: resp.url,
|
||||
proceedToGetCode: true
|
||||
)
|
||||
// If we succeeded, create a login attempt for later reference
|
||||
.then((resp: any) => {
|
||||
let saveSessionForCrashing: SaveClientSession = {
|
||||
clientId: resp.clientId,
|
||||
clientSecret: resp.clientSecret,
|
||||
authUrl: resp.url,
|
||||
emergency: false
|
||||
};
|
||||
localStorage.setItem(
|
||||
"login",
|
||||
JSON.stringify(saveSessionForCrashing)
|
||||
);
|
||||
|
||||
// Finally, update the state
|
||||
this.setState({
|
||||
clientId: resp.clientId,
|
||||
clientSecret: resp.clientSecret,
|
||||
authUrl: resp.url,
|
||||
proceedToGetCode: true
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an emergency mode login. This is usually initiated when the
|
||||
* "click-to-authorize" method fails and the user needs to copy and paste
|
||||
* an authorization code manually.
|
||||
*/
|
||||
createEmergencyLogin() {
|
||||
console.log("Creating an emergency login...");
|
||||
|
||||
// Set up the scopes and base URL
|
||||
const scopes = "read write follow";
|
||||
const baseurl =
|
||||
localStorage.getItem("baseurl") ||
|
||||
this.getLoginUser(this.state.user);
|
||||
|
||||
// Register the Mastodon app with the Mastodon server
|
||||
Mastodon.registerApp(
|
||||
this.state.brandName ? this.state.brandName : "Hyperspace",
|
||||
{
|
||||
scopes: scopes
|
||||
},
|
||||
baseurl
|
||||
).then((appData: any) => {
|
||||
let saveSessionForCrashing: SaveClientSession = {
|
||||
clientId: appData.clientId,
|
||||
clientSecret: appData.clientSecret,
|
||||
authUrl: appData.url,
|
||||
emergency: true
|
||||
};
|
||||
localStorage.setItem(
|
||||
"login",
|
||||
JSON.stringify(saveSessionForCrashing)
|
||||
);
|
||||
this.setState({
|
||||
clientId: appData.clientId,
|
||||
clientSecret: appData.clientSecret,
|
||||
authUrl: appData.url
|
||||
)
|
||||
// If we succeed, create a login attempt for later reference
|
||||
.then((appData: any) => {
|
||||
let saveSessionForCrashing: SaveClientSession = {
|
||||
clientId: appData.clientId,
|
||||
clientSecret: appData.clientSecret,
|
||||
authUrl: appData.url,
|
||||
emergency: true
|
||||
};
|
||||
localStorage.setItem(
|
||||
"login",
|
||||
JSON.stringify(saveSessionForCrashing)
|
||||
);
|
||||
|
||||
// Finally, update the state
|
||||
this.setState({
|
||||
clientId: appData.clientId,
|
||||
clientSecret: appData.clientSecret,
|
||||
authUrl: appData.url
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the URL to redirect to an authorization sequence from an emergency
|
||||
* login.
|
||||
*
|
||||
* Since Hyperspace reads the auth code from the URL, we need to redirect to
|
||||
* a URL with the code inside to trigger an auth
|
||||
*/
|
||||
authorizeEmergencyLogin() {
|
||||
let redirAddress =
|
||||
this.state.defaultRedirectAddress === "desktop"
|
||||
|
@ -323,6 +539,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
window.location.href = `${redirAddress}/?code=${this.state.authCode}#/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a login attempt from a session
|
||||
*/
|
||||
resumeLogin() {
|
||||
let loginData = localStorage.getItem("login");
|
||||
if (loginData) {
|
||||
|
@ -337,10 +556,14 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the user input string for any possible errors
|
||||
*/
|
||||
checkForErrors(): boolean {
|
||||
let userInputError = false;
|
||||
let userInputErrorMessage = "";
|
||||
|
||||
// Is the user string blank?
|
||||
if (this.state.user === "") {
|
||||
userInputError = true;
|
||||
userInputErrorMessage = "Username cannot be blank.";
|
||||
|
@ -350,6 +573,8 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
if (this.state.user.includes("@")) {
|
||||
if (this.state.federates && this.state.federates === true) {
|
||||
let baseUrl = this.state.user.split("@")[1];
|
||||
|
||||
// Is the user's domain in the disallowed list?
|
||||
if (inDisallowedDomains(baseUrl)) {
|
||||
this.setState({
|
||||
userInputError: true,
|
||||
|
@ -357,6 +582,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
});
|
||||
return true;
|
||||
} else {
|
||||
// Are we unable to ping the server?
|
||||
axios
|
||||
.get(
|
||||
"https://instances.social/api/1.0/instances/show?name=" +
|
||||
|
@ -403,56 +629,89 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the URL and determine whether or not there's an auth code
|
||||
* in the URL. If there is, try to authorize and get the access
|
||||
* token for storage.
|
||||
*/
|
||||
checkForToken() {
|
||||
let location = window.location.href;
|
||||
|
||||
// Is there an auth code?
|
||||
if (location.includes("?code=")) {
|
||||
let code = parseUrl(location).query.code as string;
|
||||
this.setState({ authorizing: true });
|
||||
let loginData = localStorage.getItem("login");
|
||||
|
||||
// If there's login data, try to fetch an access token
|
||||
if (loginData) {
|
||||
let clientLoginSession: SaveClientSession = JSON.parse(
|
||||
loginData
|
||||
);
|
||||
Mastodon.fetchAccessToken(
|
||||
clientLoginSession.clientId,
|
||||
clientLoginSession.clientSecret,
|
||||
code,
|
||||
localStorage.getItem("baseurl") as string,
|
||||
this.state.emergencyMode
|
||||
? undefined
|
||||
: clientLoginSession.authUrl.includes(
|
||||
"urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob"
|
||||
)
|
||||
? undefined
|
||||
: window.location.protocol === "hyperspace:"
|
||||
? "hyperspace://hyperspace/app/"
|
||||
: `https://${window.location.host}`
|
||||
)
|
||||
.then((tokenData: any) => {
|
||||
localStorage.setItem(
|
||||
"access_token",
|
||||
tokenData.access_token
|
||||
);
|
||||
window.location.href =
|
||||
window.location.protocol === "hyperspace:"
|
||||
? "hyperspace://hyperspace/app/"
|
||||
: `https://${window.location.host}/#/`;
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.props.enqueueSnackbar(
|
||||
`Couldn't authorize ${
|
||||
this.state.brandName
|
||||
? this.state.brandName
|
||||
: "Hyperspace"
|
||||
}: ${err.name}`,
|
||||
{ variant: "error" }
|
||||
);
|
||||
console.error(err.message);
|
||||
});
|
||||
|
||||
getConfig().then((resp: any) => {
|
||||
if (resp === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let conf: Config = resp;
|
||||
|
||||
let redirectUrl: string | undefined =
|
||||
this.state.emergencyMode ||
|
||||
clientLoginSession.authUrl.includes(
|
||||
"urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob"
|
||||
)
|
||||
? undefined
|
||||
: getRedirectAddress(conf.location);
|
||||
|
||||
Mastodon.fetchAccessToken(
|
||||
clientLoginSession.clientId,
|
||||
clientLoginSession.clientSecret,
|
||||
code,
|
||||
localStorage.getItem("baseurl") as string,
|
||||
redirectUrl
|
||||
)
|
||||
.then((tokenData: any) => {
|
||||
localStorage.setItem(
|
||||
"access_token",
|
||||
tokenData.access_token
|
||||
);
|
||||
window.location.href =
|
||||
window.location.protocol === "hyperspace:"
|
||||
? "hyperspace://hyperspace/app/"
|
||||
: this.state.defaultRedirectAddress;
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.props.enqueueSnackbar(
|
||||
`Couldn't authorize ${
|
||||
this.state.brandName
|
||||
? this.state.brandName
|
||||
: "Hyperspace"
|
||||
}: ${err.name}`,
|
||||
{ variant: "error" }
|
||||
);
|
||||
console.error(err.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to the app's main view after a login.
|
||||
*/
|
||||
redirectToApp() {
|
||||
window.location.href =
|
||||
window.location.protocol === "hyperspace:"
|
||||
? "hyperspace://hyperspace/app"
|
||||
: this.state.redirectAddressIsDynamic
|
||||
? `https://${window.location.host}/#/`
|
||||
: this.state.defaultRedirectAddress + "/#/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the title bar for macOS
|
||||
*/
|
||||
titlebar() {
|
||||
const { classes } = this.props;
|
||||
if (isDarwinApp()) {
|
||||
|
@ -468,6 +727,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the multi-user account panel
|
||||
*/
|
||||
showMultiAccount() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
|
@ -481,11 +743,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
<ListItem
|
||||
onClick={() => {
|
||||
loginWithAccount(account);
|
||||
window.location.href =
|
||||
window.location.protocol ===
|
||||
"hyperspace:"
|
||||
? "hyperspace://hyperspace/app/"
|
||||
: `https://${window.location.host}/#/`;
|
||||
this.redirectToApp();
|
||||
}}
|
||||
button={true}
|
||||
>
|
||||
|
@ -527,6 +785,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the main landing panel
|
||||
*/
|
||||
showLanding() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
|
@ -560,7 +821,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
</b>
|
||||
? Sign in with your{" "}
|
||||
<Link
|
||||
href="https://docs.joinmastodon.org/usage/decentralization/#addressing-people"
|
||||
href="https://docs.joinmastodon.org/user/signup/#address"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="secondary"
|
||||
|
@ -610,6 +871,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the login auth panel
|
||||
*/
|
||||
showLoginAuth() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
|
@ -651,8 +915,10 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the emergency login panel
|
||||
*/
|
||||
showAuthDialog() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<Dialog
|
||||
open={this.state.openAuthDialog}
|
||||
|
@ -705,6 +971,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the authorizing panel
|
||||
*/
|
||||
showAuthorizationLoader() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
|
@ -725,6 +994,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the page
|
||||
*/
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
|
|
|
@ -74,7 +74,7 @@ class You extends Component<IYouProps, IYouState> {
|
|||
|
||||
getAccount() {
|
||||
let acct = localStorage.getItem("account");
|
||||
console.log(acct);
|
||||
// console.log(acct);
|
||||
if (acct) {
|
||||
return JSON.parse(acct);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import { Account } from "./Account";
|
||||
import { Tag } from "./Tag";
|
||||
import { MastodonEmoji } from "./Emojis";
|
||||
|
||||
export type Announcement = {
|
||||
id: string;
|
||||
content: string;
|
||||
starts_at?: string;
|
||||
ends_at?: string;
|
||||
all_day: boolean;
|
||||
published_at: string;
|
||||
updated_at: string;
|
||||
read: boolean;
|
||||
mentions: [Account];
|
||||
tags: [Tag];
|
||||
emojis: [MastodonEmoji];
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Base draft type for a cached draft.
|
||||
*/
|
||||
export type Draft = {
|
||||
/**
|
||||
* The contents of the draft (i.e, its post text).
|
||||
*/
|
||||
contents: string;
|
||||
/**
|
||||
* The ID of the post it replies to, if applicable. If there isn't one, it should be set to -999.
|
||||
*/
|
||||
replyId: number;
|
||||
};
|
|
@ -0,0 +1,43 @@
|
|||
import { Draft } from "../types/Draft";
|
||||
|
||||
/**
|
||||
* Check whether a cached draft exists.
|
||||
*/
|
||||
export function draftExists(): boolean {
|
||||
return sessionStorage.getItem("cachedDraft") !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a draft to session storage.
|
||||
* @param draft The text of the post.
|
||||
* @param replyId The post's reply ID, if available.
|
||||
*/
|
||||
export function writeDraft(draft: string, replyId?: number) {
|
||||
let cachedDraft = {
|
||||
contents: draft,
|
||||
replyId: replyId ? replyId : -999
|
||||
};
|
||||
sessionStorage.setItem("cachedDraft", JSON.stringify(cachedDraft));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the cached draft and remove it from session storage.
|
||||
* @returns A Draft object with the draft's contents and reply ID (or -999).
|
||||
*/
|
||||
export function loadDraft(): Draft {
|
||||
let contents = "";
|
||||
let replyId = -999;
|
||||
if (draftExists()) {
|
||||
let draft = sessionStorage.getItem("cachedDraft");
|
||||
sessionStorage.removeItem("cachedDraft");
|
||||
if (draft != null) {
|
||||
const draftObject = JSON.parse(draft);
|
||||
contents = draftObject.contents;
|
||||
replyId = draftObject.replyId;
|
||||
}
|
||||
}
|
||||
return {
|
||||
contents: contents,
|
||||
replyId: replyId
|
||||
};
|
||||
}
|
|
@ -2,23 +2,28 @@ import { getUserDefaultBool, setUserDefaultBool } from "./settings";
|
|||
|
||||
/**
|
||||
* Get the person's permission to send notification requests.
|
||||
*
|
||||
* @returns Promise containing the notification permission, or a rejection if
|
||||
* either the browser doesn't support notifications.
|
||||
*/
|
||||
export function getNotificationRequestPermission() {
|
||||
if ("Notification" in window) {
|
||||
Notification.requestPermission();
|
||||
let request = Notification.permission;
|
||||
if (request === "granted") {
|
||||
setUserDefaultBool("enablePushNotifications", true);
|
||||
setUserDefaultBool("userDeniedNotification", false);
|
||||
} else {
|
||||
setUserDefaultBool("enablePushNotifications", false);
|
||||
setUserDefaultBool("userDeniedNotification", true);
|
||||
}
|
||||
Notification.requestPermission().then(request => {
|
||||
setUserDefaultBool(
|
||||
"enablePushNotifications",
|
||||
request === "granted"
|
||||
);
|
||||
setUserDefaultBool("userDeniedNotification", request === "denied");
|
||||
});
|
||||
return Promise.resolve(Notification.permission);
|
||||
} else {
|
||||
console.warn(
|
||||
"Notifications aren't supported in this browser. The setting will be disabled."
|
||||
);
|
||||
setUserDefaultBool("enablePushNotifications", false);
|
||||
return Promise.reject(
|
||||
"Notifications are not supported in this browser."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -35,7 +40,10 @@ export function browserSupportsNotificationRequests(): boolean {
|
|||
* @returns Boolean value of `enablePushNotifications`
|
||||
*/
|
||||
export function canSendNotifications() {
|
||||
return getUserDefaultBool("enablePushNotifications");
|
||||
return (
|
||||
getUserDefaultBool("enablePushNotifications") &&
|
||||
Notification.permission === "granted"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { defaultTheme, themes } from "../types/HyperspaceTheme";
|
||||
import { getNotificationRequestPermission } from "./notifications";
|
||||
import axios from "axios";
|
||||
import { Config } from "../types/Config";
|
||||
import { Visibility } from "../types/Visibility";
|
||||
|
@ -12,6 +11,8 @@ type SettingsTemplate = {
|
|||
clearNotificationsOnRead: boolean;
|
||||
displayAllOnNotificationBadge: boolean;
|
||||
defaultVisibility: string;
|
||||
imposeCharacterLimit: boolean;
|
||||
canSendNotifications: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -99,7 +100,10 @@ export function createUserDefaults() {
|
|||
enablePushNotifications: true,
|
||||
clearNotificationsOnRead: false,
|
||||
displayAllOnNotificationBadge: false,
|
||||
defaultVisibility: "public"
|
||||
defaultVisibility: "public",
|
||||
imposeCharacterLimit: true,
|
||||
isMasonryLayout: false,
|
||||
canSendNotifications: false
|
||||
};
|
||||
|
||||
let settings = [
|
||||
|
@ -107,7 +111,10 @@ export function createUserDefaults() {
|
|||
"systemDecidesDarkMode",
|
||||
"clearNotificationsOnRead",
|
||||
"displayAllOnNotificationBadge",
|
||||
"defaultVisibility"
|
||||
"defaultVisibility",
|
||||
"imposeCharacterLimit",
|
||||
"isMasonryLayout",
|
||||
"canSendNotifications"
|
||||
];
|
||||
|
||||
migrateExistingSettings();
|
||||
|
@ -121,7 +128,8 @@ export function createUserDefaults() {
|
|||
}
|
||||
}
|
||||
});
|
||||
getNotificationRequestPermission();
|
||||
|
||||
setUserDefaultBool("userDeniedNotications", false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -131,6 +139,21 @@ export function createUserDefaults() {
|
|||
export async function getConfig(): Promise<Config | undefined> {
|
||||
try {
|
||||
const resp = await axios.get("config.json");
|
||||
|
||||
let { location } = resp.data;
|
||||
|
||||
if (!location.endsWith("/")) {
|
||||
console.info(
|
||||
"Location does not have a backslash, so Hyperspace has added it automatically."
|
||||
);
|
||||
resp.data.location = location + "/";
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
resp.data.location = "http://localhost:3000/";
|
||||
console.info("Location field has been updated to localhost:3000.");
|
||||
}
|
||||
|
||||
return resp.data as Config;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
|
|
Loading…
Reference in New Issue