Merge branch 'develop-1.1.0-to-master' into 1.1.0-to-master

This commit is contained in:
Marquis Kurt 2020-04-10 18:25:36 -04:00
commit 25b5748470
No known key found for this signature in database
GPG Key ID: 725636D259F5402D
31 changed files with 11119 additions and 10425 deletions

2
.github/FUNDING.yml vendored
View File

@ -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

View File

@ -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

View File

@ -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:

17532
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}
}
}

View File

@ -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

View File

@ -1,5 +1,5 @@
{
"version": "1.0.4",
"version": "1.1.0",
"location": "https://hyperspaceapp.herokuapp.com",
"branding": {
"name": "Hyperspace",

View File

@ -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}

View File

@ -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"

View File

@ -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}`}
/>
);
}
}

View File

@ -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);

View File

@ -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 }}

View File

@ -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 {

View File

@ -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"

View File

@ -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>
);
}
}

344
src/pages/Activity.tsx Normal file
View File

@ -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);

211
src/pages/Announcements.tsx Normal file
View File

@ -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));

View File

@ -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

View File

@ -8,7 +8,6 @@ import {
ListItemText,
CircularProgress,
ListItemAvatar,
Avatar,
ListItemSecondaryAction,
Tooltip
} from "@material-ui/core";

View File

@ -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()}

View File

@ -3,7 +3,6 @@ import {
withStyles,
Typography,
Avatar,
Divider,
Button,
CircularProgress,
Paper,

View File

@ -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({

View File

@ -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));

437
src/pages/Timeline.tsx Normal file
View File

@ -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));

View File

@ -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 (

View File

@ -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);
}

View File

@ -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];
};

13
src/types/Draft.tsx Normal file
View File

@ -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;
};

43
src/utilities/compose.tsx Normal file
View File

@ -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
};
}

View File

@ -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"
);
}
/**

View File

@ -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(