Merge branch 'develop-1.1.0-beta2' into staging
This commit is contained in:
commit
338b9118a6
|
@ -12,8 +12,27 @@ jobs:
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 10.x
|
node-version: 10.x
|
||||||
|
- name: Change desktop field
|
||||||
|
run: |
|
||||||
|
from json import load, dump
|
||||||
|
|
||||||
|
json_dict = {}
|
||||||
|
with open('public/config.json', 'r') as file:
|
||||||
|
json_dict = load(file)
|
||||||
|
|
||||||
|
json_dict["location"] = "desktop"
|
||||||
|
|
||||||
|
with open('public/config.json', 'w+') as out:
|
||||||
|
dump(json_dict, out)
|
||||||
|
shell: python
|
||||||
- name: Install dependencies and build
|
- name: Install dependencies and build
|
||||||
run: |
|
run: |
|
||||||
npm install
|
npm install
|
||||||
npm run build --if-present
|
npm run build --if-present
|
||||||
npm run build-desktop-win
|
npm run build-desktop-win
|
||||||
|
- name: Upload Windows executable
|
||||||
|
uses: actions/upload-artifact@v1
|
||||||
|
if: success()
|
||||||
|
with:
|
||||||
|
name: 'Windows executable (output dir)'
|
||||||
|
path: dist
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "hyperspace",
|
"name": "hyperspace",
|
||||||
"version": "1.1.0-beta1",
|
"version": "1.1.0-beta2",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -16822,6 +16822,11 @@
|
||||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
|
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"react-masonry-css": {
|
||||||
|
"version": "1.0.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-masonry-css/-/react-masonry-css-1.0.14.tgz",
|
||||||
|
"integrity": "sha512-oAPVOCMApTT0HkxZJy84yU1EWaaQNZnJE0DjDMy/L+LxZoJEph4RRXsT9ppPKbFSo/tCzj+cCLwiBHjZmZ2eXA=="
|
||||||
|
},
|
||||||
"react-router": {
|
"react-router": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "hyperspace",
|
"name": "hyperspace",
|
||||||
"productName": "Hyperspace Desktop",
|
"productName": "Hyperspace Desktop",
|
||||||
"version": "1.1.0-beta1",
|
"version": "1.1.0-beta2",
|
||||||
"description": "A beautiful, fluffy client for the fediverse",
|
"description": "A beautiful, fluffy client for the fediverse",
|
||||||
"author": "Marquis Kurt <hyperspacedev@marquiskurt.net>",
|
"author": "Marquis Kurt <hyperspacedev@marquiskurt.net>",
|
||||||
"repository": "https://github.com/hyperspacedev/hyperspace.git",
|
"repository": "https://github.com/hyperspacedev/hyperspace.git",
|
||||||
|
@ -41,7 +41,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-notarize": "^0.1.1",
|
"electron-notarize": "^0.1.1",
|
||||||
"electron-updater": "^4.1.2",
|
"electron-updater": "^4.1.2",
|
||||||
"electron-window-state": "^5.0.3"
|
"electron-window-state": "^5.0.3",
|
||||||
|
"react-masonry-css": "^1.0.14"
|
||||||
},
|
},
|
||||||
"main": "public/electron.js",
|
"main": "public/electron.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -35,7 +35,8 @@ export const styles = (theme: Theme) =>
|
||||||
titleBarText: {
|
titleBarText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
paddingTop: 2,
|
paddingTop: 2,
|
||||||
paddingBottom: 1
|
paddingBottom: 1,
|
||||||
|
color: theme.palette.getContrastText(theme.palette.primary.main)
|
||||||
},
|
},
|
||||||
appBar: {
|
appBar: {
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
|
@ -57,6 +58,10 @@ export const styles = (theme: Theme) =>
|
||||||
display: "none"
|
display: "none"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
appBarBackButton: {
|
||||||
|
marginLeft: -12,
|
||||||
|
marginRight: 20
|
||||||
|
},
|
||||||
appBarTitle: {
|
appBarTitle: {
|
||||||
display: "none",
|
display: "none",
|
||||||
[theme.breakpoints.up("md")]: {
|
[theme.breakpoints.up("md")]: {
|
||||||
|
|
|
@ -44,6 +44,7 @@ import SupervisedUserCircleIcon from "@material-ui/icons/SupervisedUserCircle";
|
||||||
import ExitToAppIcon from "@material-ui/icons/ExitToApp";
|
import ExitToAppIcon from "@material-ui/icons/ExitToApp";
|
||||||
import TrendingUpIcon from "@material-ui/icons/TrendingUp";
|
import TrendingUpIcon from "@material-ui/icons/TrendingUp";
|
||||||
import BuildIcon from "@material-ui/icons/Build";
|
import BuildIcon from "@material-ui/icons/Build";
|
||||||
|
import ArrowBackIcon from "@material-ui/icons/ArrowBack";
|
||||||
|
|
||||||
import { styles } from "./AppLayout.styles";
|
import { styles } from "./AppLayout.styles";
|
||||||
import { MultiAccount, UAccount } from "../../types/Account";
|
import { MultiAccount, UAccount } from "../../types/Account";
|
||||||
|
@ -67,30 +68,81 @@ import {
|
||||||
getAccountRegistry,
|
getAccountRegistry,
|
||||||
removeAccountFromRegistry
|
removeAccountFromRegistry
|
||||||
} from "../../utilities/accounts";
|
} from "../../utilities/accounts";
|
||||||
|
import { isChildView } from "../../utilities/appbar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pre-define state interface for the app layout.
|
||||||
|
*/
|
||||||
interface IAppLayoutState {
|
interface IAppLayoutState {
|
||||||
|
/**
|
||||||
|
* Whether the account menu is open or not.
|
||||||
|
*/
|
||||||
acctMenuOpen: boolean;
|
acctMenuOpen: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the drawer is open (mobile-only).
|
||||||
|
*/
|
||||||
drawerOpenOnMobile: boolean;
|
drawerOpenOnMobile: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current user signed in.
|
||||||
|
*/
|
||||||
currentUser?: UAccount;
|
currentUser?: UAccount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of notifications received.
|
||||||
|
*/
|
||||||
notificationCount: number;
|
notificationCount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the log out dialog is open.
|
||||||
|
*/
|
||||||
logOutOpen: boolean;
|
logOutOpen: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether federation has been enabled in the config.
|
||||||
|
*/
|
||||||
enableFederation?: boolean;
|
enableFederation?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The brand name of the app, if not "Hyperspace".
|
||||||
|
*/
|
||||||
brandName?: string;
|
brandName?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app is in development mode.
|
||||||
|
*/
|
||||||
developerMode?: boolean;
|
developerMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base app layout class. Responsible for the search bar, navigation menus, etc.
|
||||||
|
*/
|
||||||
export class AppLayout extends Component<any, IAppLayoutState> {
|
export class AppLayout extends Component<any, IAppLayoutState> {
|
||||||
|
/**
|
||||||
|
* The Mastodon client to operate with.
|
||||||
|
*/
|
||||||
client: Mastodon;
|
client: Mastodon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stream listener to listen for new streaming events from Mastodon.
|
||||||
|
*/
|
||||||
streamListener: any;
|
streamListener: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct the app layout.
|
||||||
|
* @param props The properties to pass in.
|
||||||
|
*/
|
||||||
constructor(props: any) {
|
constructor(props: any) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
// Create the Mastodon client
|
||||||
this.client = new Mastodon(
|
this.client = new Mastodon(
|
||||||
localStorage.getItem("access_token") as string,
|
localStorage.getItem("access_token") as string,
|
||||||
(localStorage.getItem("baseurl") as string) + "/api/v1"
|
(localStorage.getItem("baseurl") as string) + "/api/v1"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Initialize the state
|
||||||
this.state = {
|
this.state = {
|
||||||
drawerOpenOnMobile: false,
|
drawerOpenOnMobile: false,
|
||||||
acctMenuOpen: false,
|
acctMenuOpen: false,
|
||||||
|
@ -98,14 +150,20 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
||||||
logOutOpen: false
|
logOutOpen: false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bind functions as properties to this class for reference
|
||||||
this.toggleDrawerOnMobile = this.toggleDrawerOnMobile.bind(this);
|
this.toggleDrawerOnMobile = this.toggleDrawerOnMobile.bind(this);
|
||||||
this.toggleAcctMenu = this.toggleAcctMenu.bind(this);
|
this.toggleAcctMenu = this.toggleAcctMenu.bind(this);
|
||||||
this.clearBadge = this.clearBadge.bind(this);
|
this.clearBadge = this.clearBadge.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run post-mount tasks such as getting account data and refreshing the config file.
|
||||||
|
*/
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
// Get the account data.
|
||||||
this.getAccountData();
|
this.getAccountData();
|
||||||
|
|
||||||
|
// Read the config file and then update the state.
|
||||||
getConfig().then((result: any) => {
|
getConfig().then((result: any) => {
|
||||||
if (result !== undefined) {
|
if (result !== undefined) {
|
||||||
let config: Config = result;
|
let config: Config = result;
|
||||||
|
@ -119,18 +177,25 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for notifications.
|
||||||
this.streamNotifications();
|
this.streamNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get updated credentials from Mastodon or pull information from local storage.
|
||||||
|
*/
|
||||||
getAccountData() {
|
getAccountData() {
|
||||||
|
// Try to get updated credentials from Mastodon.
|
||||||
this.client
|
this.client
|
||||||
.get("/accounts/verify_credentials")
|
.get("/accounts/verify_credentials")
|
||||||
.then((resp: any) => {
|
.then((resp: any) => {
|
||||||
|
// Update the account if possible.
|
||||||
let data: UAccount = resp.data;
|
let data: UAccount = resp.data;
|
||||||
this.setState({ currentUser: data });
|
this.setState({ currentUser: data });
|
||||||
sessionStorage.setItem("id", data.id);
|
sessionStorage.setItem("id", data.id);
|
||||||
})
|
})
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
|
// Otherwise, pull from local storage.
|
||||||
this.props.enqueueSnackbar(
|
this.props.enqueueSnackbar(
|
||||||
"Couldn't find profile info: " + err.name
|
"Couldn't find profile info: " + err.name
|
||||||
);
|
);
|
||||||
|
@ -140,9 +205,14 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up a stream listener and listen for notifications.
|
||||||
|
*/
|
||||||
streamNotifications() {
|
streamNotifications() {
|
||||||
|
// Set up the stream listener.
|
||||||
this.streamListener = this.client.stream("/streaming/user");
|
this.streamListener = this.client.stream("/streaming/user");
|
||||||
|
|
||||||
|
// Set the count if the user asked to display the total count.
|
||||||
if (getUserDefaultBool("displayAllOnNotificationBadge")) {
|
if (getUserDefaultBool("displayAllOnNotificationBadge")) {
|
||||||
this.client.get("/notifications").then((resp: any) => {
|
this.client.get("/notifications").then((resp: any) => {
|
||||||
let notifArray = resp.data;
|
let notifArray = resp.data;
|
||||||
|
@ -150,14 +220,17 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Listen for notifications.
|
||||||
this.streamListener.on("notification", (notif: Notification) => {
|
this.streamListener.on("notification", (notif: Notification) => {
|
||||||
const notificationCount = this.state.notificationCount + 1;
|
const notificationCount = this.state.notificationCount + 1;
|
||||||
this.setState({ notificationCount });
|
this.setState({ notificationCount });
|
||||||
|
|
||||||
|
// Update the badge on the desktop.
|
||||||
if (isDesktopApp()) {
|
if (isDesktopApp()) {
|
||||||
getElectronApp().setBadgeCount(notificationCount);
|
getElectronApp().setBadgeCount(notificationCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up a push notification if the window isn't in focus.
|
||||||
if (!document.hasFocus()) {
|
if (!document.hasFocus()) {
|
||||||
let primaryMessage = "";
|
let primaryMessage = "";
|
||||||
let secondaryMessage = "";
|
let secondaryMessage = "";
|
||||||
|
@ -216,25 +289,39 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Respectfully send the notification request.
|
||||||
sendNotificationRequest(primaryMessage, secondaryMessage);
|
sendNotificationRequest(primaryMessage, secondaryMessage);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the account menu.
|
||||||
|
*/
|
||||||
toggleAcctMenu() {
|
toggleAcctMenu() {
|
||||||
this.setState({ acctMenuOpen: !this.state.acctMenuOpen });
|
this.setState({ acctMenuOpen: !this.state.acctMenuOpen });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the app drawer, if on mobile.
|
||||||
|
*/
|
||||||
toggleDrawerOnMobile() {
|
toggleDrawerOnMobile() {
|
||||||
this.setState({
|
this.setState({
|
||||||
drawerOpenOnMobile: !this.state.drawerOpenOnMobile
|
drawerOpenOnMobile: !this.state.drawerOpenOnMobile
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the logout dialog.
|
||||||
|
*/
|
||||||
toggleLogOutDialog() {
|
toggleLogOutDialog() {
|
||||||
this.setState({ logOutOpen: !this.state.logOutOpen });
|
this.setState({ logOutOpen: !this.state.logOutOpen });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a search and redirect to the search page.
|
||||||
|
* @param what The query input from the search box
|
||||||
|
*/
|
||||||
searchForQuery(what: string) {
|
searchForQuery(what: string) {
|
||||||
what = what.replace(/^#/g, "tag:");
|
what = what.replace(/^#/g, "tag:");
|
||||||
console.log(what);
|
console.log(what);
|
||||||
|
@ -243,9 +330,13 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
||||||
: "/#/search?query=" + what;
|
: "/#/search?query=" + what;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear login information, remove the account from the registry, and reload the web page.
|
||||||
|
*/
|
||||||
logOutAndRestart() {
|
logOutAndRestart() {
|
||||||
let loginData = localStorage.getItem("login");
|
let loginData = localStorage.getItem("login");
|
||||||
if (loginData) {
|
if (loginData) {
|
||||||
|
// Remove account from the registry.
|
||||||
let registry = getAccountRegistry();
|
let registry = getAccountRegistry();
|
||||||
|
|
||||||
registry.forEach((registryItem: MultiAccount, index: number) => {
|
registry.forEach((registryItem: MultiAccount, index: number) => {
|
||||||
|
@ -257,15 +348,20 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear some of the local storage fields.
|
||||||
let items = ["login", "account", "baseurl", "access_token"];
|
let items = ["login", "account", "baseurl", "access_token"];
|
||||||
items.forEach(entry => {
|
items.forEach(entry => {
|
||||||
localStorage.removeItem(entry);
|
localStorage.removeItem(entry);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Finally, reload.
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the notifications badge.
|
||||||
|
*/
|
||||||
clearBadge() {
|
clearBadge() {
|
||||||
if (!getUserDefaultBool("displayAllOnNotificationBadge")) {
|
if (!getUserDefaultBool("displayAllOnNotificationBadge")) {
|
||||||
this.setState({ notificationCount: 0 });
|
this.setState({ notificationCount: 0 });
|
||||||
|
@ -276,6 +372,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the title bar.
|
||||||
|
*/
|
||||||
titlebar() {
|
titlebar() {
|
||||||
const { classes } = this.props;
|
const { classes } = this.props;
|
||||||
if (isDarwinApp()) {
|
if (isDarwinApp()) {
|
||||||
|
@ -307,6 +406,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the app drawer. On the desktop, this appears as a sidebar in larger layouts.
|
||||||
|
*/
|
||||||
appDrawer() {
|
appDrawer() {
|
||||||
const { classes } = this.props;
|
const { classes } = this.props;
|
||||||
return (
|
return (
|
||||||
|
@ -476,6 +578,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the entire layout.
|
||||||
|
*/
|
||||||
render() {
|
render() {
|
||||||
const { classes } = this.props;
|
const { classes } = this.props;
|
||||||
return (
|
return (
|
||||||
|
@ -484,6 +589,18 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
||||||
{this.titlebar()}
|
{this.titlebar()}
|
||||||
<AppBar className={classes.appBar} position="static">
|
<AppBar className={classes.appBar} position="static">
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
|
{isDesktopApp() &&
|
||||||
|
isChildView(window.location.hash) ? (
|
||||||
|
<IconButton
|
||||||
|
className={classes.appBarBackButton}
|
||||||
|
color="inherit"
|
||||||
|
aria-label="Go back"
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
>
|
||||||
|
<ArrowBackIcon />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
className={classes.appBarMenuButton}
|
className={classes.appBarMenuButton}
|
||||||
color="inherit"
|
color="inherit"
|
||||||
|
@ -675,7 +792,7 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
||||||
variant="temporary"
|
variant="temporary"
|
||||||
anchor={"left"}
|
anchor={"left"}
|
||||||
open={this.state.drawerOpenOnMobile}
|
open={this.state.drawerOpenOnMobile}
|
||||||
onClose={this.toggleDrawerOnMobile}
|
onClick={this.toggleDrawerOnMobile}
|
||||||
classes={{ paper: classes.drawerPaper }}
|
classes={{ paper: classes.drawerPaper }}
|
||||||
>
|
>
|
||||||
{this.appDrawer()}
|
{this.appDrawer()}
|
||||||
|
|
|
@ -81,6 +81,15 @@ export const styles = (theme: Theme) =>
|
||||||
paddingTop: theme.spacing.unit,
|
paddingTop: theme.spacing.unit,
|
||||||
paddingBottom: theme.spacing.unit
|
paddingBottom: theme.spacing.unit
|
||||||
},
|
},
|
||||||
|
postAuthorAccount: {
|
||||||
|
color: theme.palette.grey[500],
|
||||||
|
marginLeft: theme.spacing.unit * 0.5,
|
||||||
|
},
|
||||||
|
postReblogIcon: {
|
||||||
|
marginBottom: theme.spacing.unit * -0.5,
|
||||||
|
marginLeft: theme.spacing.unit * 0.5,
|
||||||
|
marginRight: theme.spacing.unit * 0.5,
|
||||||
|
},
|
||||||
postAuthorEmoji: {
|
postAuthorEmoji: {
|
||||||
height: theme.typography.fontSize,
|
height: theme.typography.fontSize,
|
||||||
verticalAlign: "middle"
|
verticalAlign: "middle"
|
||||||
|
|
|
@ -398,21 +398,34 @@ export class Post extends React.Component<any, IPostState> {
|
||||||
const { classes } = this.props;
|
const { classes } = this.props;
|
||||||
if (post.reblog) {
|
if (post.reblog) {
|
||||||
let author = post.reblog.account;
|
let author = post.reblog.account;
|
||||||
let origString = `<span>${author.display_name ||
|
|
||||||
author.username} (@${author.acct}) 🔄 ${post.account
|
|
||||||
.display_name || post.account.username}</span>`;
|
|
||||||
let emojis = author.emojis;
|
let emojis = author.emojis;
|
||||||
emojis.concat(post.account.emojis);
|
emojis.concat(post.account.emojis);
|
||||||
return emojifyString(origString, emojis, classes.postAuthorEmoji);
|
return (
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
{emojifyString(author.display_name || author.username, author.emojis, classes.postAuthorEmoji)}
|
||||||
|
</span>
|
||||||
|
<span className={classes.postAuthorAccount}>
|
||||||
|
@{emojifyString(author.acct, author.emojis, classes.postAuthorEmoji)}
|
||||||
|
</span>
|
||||||
|
<AutorenewIcon fontSize='small' className={classes.postReblogIcon} />
|
||||||
|
<span>
|
||||||
|
{emojifyString(post.account.display_name || post.account.username, emojis, classes.postAuthorEmoji)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
let author = post.account;
|
let author = post.account;
|
||||||
let origString = `<span>${author.display_name ||
|
return (
|
||||||
author.username} (@${author.acct})</span>`;
|
<>
|
||||||
return emojifyString(
|
<span>
|
||||||
origString,
|
{emojifyString(author.display_name || author.username, author.emojis, classes.postAuthorEmoji)}
|
||||||
author.emojis,
|
</span>
|
||||||
classes.postAuthorEmoji
|
<span className={classes.postAuthorAccount}>
|
||||||
);
|
@{emojifyString(author.acct, author.emojis, classes.postAuthorEmoji)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -656,13 +669,7 @@ export class Post extends React.Component<any, IPostState> {
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
title={
|
title={<Typography>{this.getReblogAuthors(post)}</Typography>}
|
||||||
<Typography
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: this.getReblogAuthors(post)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
subheader={moment(post.created_at).format(
|
subheader={moment(post.created_at).format(
|
||||||
"MMMM Do YYYY [at] h:mm A"
|
"MMMM Do YYYY [at] h:mm A"
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -45,5 +45,25 @@ export const styles = (theme: Theme) =>
|
||||||
},
|
},
|
||||||
pollWizardFlexGrow: {
|
pollWizardFlexGrow: {
|
||||||
flexGrow: 1
|
flexGrow: 1
|
||||||
|
},
|
||||||
|
draftDisplayArea: {
|
||||||
|
display: "flex",
|
||||||
|
paddingLeft: 8,
|
||||||
|
paddingRight: 8,
|
||||||
|
paddingTop: 4,
|
||||||
|
paddingBottom: 4,
|
||||||
|
borderColor: theme.palette.action.disabledBackground,
|
||||||
|
borderWidth: 0.25,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderRadius: 2,
|
||||||
|
verticalAlign: "middle",
|
||||||
|
marginLeft: 16,
|
||||||
|
marginRight: 16
|
||||||
|
},
|
||||||
|
draftText: {
|
||||||
|
padding: theme.spacing.unit / 2
|
||||||
|
},
|
||||||
|
draftFlexGrow: {
|
||||||
|
flexGrow: 1
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -44,35 +44,106 @@ import {
|
||||||
getConfig,
|
getConfig,
|
||||||
getUserDefaultBool
|
getUserDefaultBool
|
||||||
} from "../utilities/settings";
|
} from "../utilities/settings";
|
||||||
|
import { draftExists, writeDraft, loadDraft } from "../utilities/compose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state for the Composer page.
|
||||||
|
*/
|
||||||
interface IComposerState {
|
interface IComposerState {
|
||||||
|
/**
|
||||||
|
* The current user as an Account.
|
||||||
|
*/
|
||||||
account: UAccount;
|
account: UAccount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The visibility of the post.
|
||||||
|
*/
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether there should be a content warning.
|
||||||
|
*/
|
||||||
sensitive: boolean;
|
sensitive: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The content warning message.
|
||||||
|
*/
|
||||||
sensitiveText?: string;
|
sensitiveText?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the visibility drop-down should be visible.
|
||||||
|
*/
|
||||||
visibilityMenu: boolean;
|
visibilityMenu: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text contents of the post.
|
||||||
|
*/
|
||||||
text: string;
|
text: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remaining amount of characters.
|
||||||
|
*/
|
||||||
remainingChars: number;
|
remainingChars: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional reply ID.
|
||||||
|
*/
|
||||||
reply?: string;
|
reply?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account to reply to, if it exists.
|
||||||
|
*/
|
||||||
acct?: string;
|
acct?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional list of media attachments.
|
||||||
|
*/
|
||||||
attachments?: [Attachment];
|
attachments?: [Attachment];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional poll for the post.
|
||||||
|
*/
|
||||||
poll?: PollWizard;
|
poll?: PollWizard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expiration date of a poll, if it exists.
|
||||||
|
*/
|
||||||
pollExpiresDate?: any;
|
pollExpiresDate?: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the emoji picker should be visible.
|
||||||
|
*/
|
||||||
showEmojis: boolean;
|
showEmojis: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the account's instance is federated.
|
||||||
|
*/
|
||||||
federated: boolean;
|
federated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Compose page contains all of the information to create a UI for post creation.
|
||||||
|
*/
|
||||||
class Composer extends Component<any, IComposerState> {
|
class Composer extends Component<any, IComposerState> {
|
||||||
|
/**
|
||||||
|
* The Mastodon client to work with.
|
||||||
|
*/
|
||||||
client: Mastodon;
|
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) {
|
constructor(props: any) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
// Generate the Mastodon client
|
||||||
this.client = new Mastodon(
|
this.client = new Mastodon(
|
||||||
localStorage.getItem("access_token") as string,
|
localStorage.getItem("access_token") as string,
|
||||||
localStorage.getItem("baseurl") + "/api/v1"
|
localStorage.getItem("baseurl") + "/api/v1"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Set the initial state
|
||||||
this.state = {
|
this.state = {
|
||||||
account: JSON.parse(localStorage.getItem("account") as string),
|
account: JSON.parse(localStorage.getItem("account") as string),
|
||||||
visibility: getUserDefaultVisibility(),
|
visibility: getUserDefaultVisibility(),
|
||||||
|
@ -87,13 +158,21 @@ class Composer extends Component<any, IComposerState> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
componentDidMount() {
|
||||||
|
// Parse the parameters and get the account information if available.
|
||||||
let state = this.getComposerParams(this.props);
|
let state = this.getComposerParams(this.props);
|
||||||
let text = state.acct ? `@${state.acct}: ` : "";
|
let text = state.acct ? `@${state.acct}: ` : "";
|
||||||
this.client.get("/accounts/verify_credentials").then((resp: any) => {
|
this.client.get("/accounts/verify_credentials").then((resp: any) => {
|
||||||
let account: UAccount = resp.data;
|
let account: UAccount = resp.data;
|
||||||
this.setState({ account });
|
this.setState({ account });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get the configuration and load the config values.
|
||||||
getConfig().then((config: any) => {
|
getConfig().then((config: any) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
federated: config.federation.allowPublicPosts,
|
federated: config.federation.allowPublicPosts,
|
||||||
|
@ -107,6 +186,8 @@ class Composer extends Component<any, IComposerState> {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Attach the paste listener to listen for the clipboard and upload media
|
||||||
|
// if possible.
|
||||||
window.addEventListener("paste", (evt: Event) => {
|
window.addEventListener("paste", (evt: Event) => {
|
||||||
let thePasteEvent = evt as ClipboardEvent;
|
let thePasteEvent = evt as ClipboardEvent;
|
||||||
let fileList: File[] = [];
|
let fileList: File[] = [];
|
||||||
|
@ -121,12 +202,21 @@ class Composer extends Component<any, IComposerState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.actuallyUploadMedia(fileList);
|
|
||||||
|
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) {
|
componentWillReceiveProps(props: any) {
|
||||||
let state = this.getComposerParams(props);
|
let state = this.getComposerParams(props);
|
||||||
let text = state.acct ? `@${state.acct}: ` : "";
|
let text = state.acct ? `@${state.acct}: ` : "";
|
||||||
|
@ -141,6 +231,36 @@ class Composer extends Component<any, IComposerState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
checkComposerParams(location?: string): ParsedQuery {
|
||||||
let params = "";
|
let params = "";
|
||||||
if (location !== undefined && typeof location === "string") {
|
if (location !== undefined && typeof location === "string") {
|
||||||
|
@ -151,6 +271,11 @@ class Composer extends Component<any, IComposerState> {
|
||||||
return parseParams(params);
|
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) {
|
getComposerParams(props: any) {
|
||||||
let params = this.checkComposerParams(props.location);
|
let params = this.checkComposerParams(props.location);
|
||||||
let reply: string = "";
|
let reply: string = "";
|
||||||
|
@ -173,6 +298,10 @@ 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) {
|
updateTextFromField(text: string) {
|
||||||
this.setState({
|
this.setState({
|
||||||
text,
|
text,
|
||||||
|
@ -182,20 +311,31 @@ class Composer extends Component<any, IComposerState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the content warning text in the state
|
||||||
|
* @param sensitiveText The text to update the state to
|
||||||
|
*/
|
||||||
updateWarningFromField(sensitiveText: string) {
|
updateWarningFromField(sensitiveText: string) {
|
||||||
this.setState({ sensitiveText });
|
this.setState({ sensitiveText });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the visibility in the state
|
||||||
|
* @param visibility The visibility to update the state to
|
||||||
|
*/
|
||||||
changeVisibility(visibility: Visibility) {
|
changeVisibility(visibility: Visibility) {
|
||||||
this.setState({ 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({
|
filedialog({
|
||||||
multiple: false,
|
multiple: false,
|
||||||
accept: ".jpeg,.jpg,.png,.gif,.webm,.mp4,.mov,.ogg,.wav,.mp3,.flac"
|
accept: ".jpeg,.jpg,.png,.gif,.webm,.mp4,.mov,.ogg,.wav,.mp3,.flac"
|
||||||
})
|
})
|
||||||
.then((media: FileList) => this.actuallyUploadMedia(media))
|
.then((media: FileList) => this.uploadMedia(media))
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
this.props.enqueueSnackbar("Couldn't get media: " + err.name, {
|
this.props.enqueueSnackbar("Couldn't get media: " + err.name, {
|
||||||
variant: "error"
|
variant: "error"
|
||||||
|
@ -204,15 +344,27 @@ class Composer extends Component<any, IComposerState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
actuallyUploadMedia(media: FileList | File[]) {
|
/**
|
||||||
|
* 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();
|
let mediaForm = new FormData();
|
||||||
mediaForm.append("file", media[0]);
|
mediaForm.append("file", media[0]);
|
||||||
|
|
||||||
|
// Let the user know we're uploading the file
|
||||||
this.props.enqueueSnackbar("Uploading media...", {
|
this.props.enqueueSnackbar("Uploading media...", {
|
||||||
persist: true,
|
persist: true,
|
||||||
key: "media-upload"
|
key: "media-upload"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Try to upload the media to the server.
|
||||||
this.client
|
this.client
|
||||||
.post("/media", mediaForm)
|
.post("/media", mediaForm)
|
||||||
|
|
||||||
|
// If we succeed, get the attachments and update the state.
|
||||||
.then((resp: any) => {
|
.then((resp: any) => {
|
||||||
let attachment: Attachment = resp.data;
|
let attachment: Attachment = resp.data;
|
||||||
let attachments = this.state.attachments;
|
let attachments = this.state.attachments;
|
||||||
|
@ -225,6 +377,8 @@ class Composer extends Component<any, IComposerState> {
|
||||||
this.props.closeSnackbar("media-upload");
|
this.props.closeSnackbar("media-upload");
|
||||||
this.props.enqueueSnackbar("Media uploaded.");
|
this.props.enqueueSnackbar("Media uploaded.");
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// If we fail, display an error.
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
this.props.closeSnackbar("media-upload");
|
this.props.closeSnackbar("media-upload");
|
||||||
this.props.enqueueSnackbar(
|
this.props.enqueueSnackbar(
|
||||||
|
@ -234,6 +388,10 @@ class Composer extends Component<any, IComposerState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate through the attachments and grab the attachments' IDs.
|
||||||
|
* @returns A list of IDs as `string[]`
|
||||||
|
*/
|
||||||
getOnlyMediaIds() {
|
getOnlyMediaIds() {
|
||||||
let ids: string[] = [];
|
let ids: string[] = [];
|
||||||
if (this.state.attachments) {
|
if (this.state.attachments) {
|
||||||
|
@ -244,6 +402,10 @@ class Composer extends Component<any, IComposerState> {
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the list of attachments by inserting an attachment.
|
||||||
|
* @param attachment The attachment to insert into the attachments list.
|
||||||
|
*/
|
||||||
fetchAttachmentAfterUpdate(attachment: Attachment) {
|
fetchAttachmentAfterUpdate(attachment: Attachment) {
|
||||||
let attachments = this.state.attachments;
|
let attachments = this.state.attachments;
|
||||||
if (attachments) {
|
if (attachments) {
|
||||||
|
@ -256,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) {
|
deleteMediaAttachment(attachment: Attachment) {
|
||||||
let attachments = this.state.attachments;
|
let attachments = this.state.attachments;
|
||||||
if (attachments) {
|
if (attachments) {
|
||||||
|
@ -269,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) {
|
insertEmoji(e: any) {
|
||||||
if (e.custom) {
|
if (e.custom) {
|
||||||
let text = this.state.text + e.colons;
|
let text = this.state.text + e.colons;
|
||||||
|
@ -285,6 +455,9 @@ class Composer extends Component<any, IComposerState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an empty poll.
|
||||||
|
*/
|
||||||
createPoll() {
|
createPoll() {
|
||||||
if (this.state.poll === undefined) {
|
if (this.state.poll === undefined) {
|
||||||
let expiration = new Date();
|
let expiration = new Date();
|
||||||
|
@ -304,6 +477,9 @@ class Composer extends Component<any, IComposerState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a new poll item into the poll.
|
||||||
|
*/
|
||||||
addPollItem() {
|
addPollItem() {
|
||||||
if (
|
if (
|
||||||
this.state.poll !== undefined &&
|
this.state.poll !== undefined &&
|
||||||
|
@ -326,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) {
|
editPollItem(position: number, newTitle: any) {
|
||||||
if (this.state.poll !== undefined) {
|
if (this.state.poll !== undefined) {
|
||||||
let poll = this.state.poll;
|
let poll = this.state.poll;
|
||||||
|
@ -343,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) {
|
removePollItem(item: string) {
|
||||||
if (
|
if (
|
||||||
this.state.poll !== undefined &&
|
this.state.poll !== undefined &&
|
||||||
|
@ -369,6 +554,10 @@ class Composer extends Component<any, IComposerState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the expiration date of the poll.
|
||||||
|
* @param date The new expiration date
|
||||||
|
*/
|
||||||
setPollExpires(date: string) {
|
setPollExpires(date: string) {
|
||||||
let currentDate = new Date();
|
let currentDate = new Date();
|
||||||
let newDate = new Date(date);
|
let newDate = new Date(date);
|
||||||
|
@ -388,25 +577,38 @@ class Composer extends Component<any, IComposerState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the poll from the post.
|
||||||
|
*/
|
||||||
removePoll() {
|
removePoll() {
|
||||||
this.setState({
|
this.setState({
|
||||||
poll: undefined
|
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) {
|
postViaKeyboard(event: any) {
|
||||||
if ((event.metaKey || event.ctrlKey) && event.keyCode === 13) {
|
if ((event.metaKey || event.ctrlKey) && event.keyCode === 13) {
|
||||||
this.post();
|
this.post();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the post to Mastodon and return to the previous page, if possible.
|
||||||
|
*/
|
||||||
post() {
|
post() {
|
||||||
|
// First, finalize the poll.
|
||||||
let pollOptions: string[] = [];
|
let pollOptions: string[] = [];
|
||||||
if (this.state.poll) {
|
if (this.state.poll) {
|
||||||
this.state.poll.options.forEach((option: PollWizardOption) => {
|
this.state.poll.options.forEach((option: PollWizardOption) => {
|
||||||
pollOptions.push(option.title);
|
pollOptions.push(option.title);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send a post request to Mastodon.
|
||||||
this.client
|
this.client
|
||||||
.post("/statuses", {
|
.post("/statuses", {
|
||||||
status: this.state.text,
|
status: this.state.text,
|
||||||
|
@ -423,28 +625,44 @@ class Composer extends Component<any, IComposerState> {
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// If we succeed, send a success message and go back.
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.props.enqueueSnackbar("Posted!");
|
this.props.enqueueSnackbar("Posted!");
|
||||||
window.history.back();
|
window.history.back();
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Otherwise, show an error message and don't do anything.
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
this.props.enqueueSnackbar("Couldn't post: " + err.name);
|
this.props.enqueueSnackbar("Couldn't post: " + err.name);
|
||||||
console.error(err.message);
|
console.error(err.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the content warning section.
|
||||||
|
*/
|
||||||
toggleSensitive() {
|
toggleSensitive() {
|
||||||
this.setState({ sensitive: !this.state.sensitive });
|
this.setState({ sensitive: !this.state.sensitive });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the visibility drop down menu.
|
||||||
|
*/
|
||||||
toggleVisibilityMenu() {
|
toggleVisibilityMenu() {
|
||||||
this.setState({ visibilityMenu: !this.state.visibilityMenu });
|
this.setState({ visibilityMenu: !this.state.visibilityMenu });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the emoji picker.
|
||||||
|
*/
|
||||||
toggleEmojis() {
|
toggleEmojis() {
|
||||||
this.setState({ showEmojis: !this.state.showEmojis });
|
this.setState({ showEmojis: !this.state.showEmojis });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render all of the components on the page given a set of classes.
|
||||||
|
*/
|
||||||
render() {
|
render() {
|
||||||
const { classes } = this.props;
|
const { classes } = this.props;
|
||||||
|
|
||||||
|
@ -652,7 +870,7 @@ class Composer extends Component<any, IComposerState> {
|
||||||
<Tooltip title="Add photos, videos, or audio">
|
<Tooltip title="Add photos, videos, or audio">
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={this.state.poll !== undefined}
|
disabled={this.state.poll !== undefined}
|
||||||
onClick={() => this.uploadMedia()}
|
onClick={() => this.promptMediaDialog()}
|
||||||
id="compose-media"
|
id="compose-media"
|
||||||
>
|
>
|
||||||
<AttachFileIcon />
|
<AttachFileIcon />
|
||||||
|
@ -738,6 +956,21 @@ class Composer extends Component<any, IComposerState> {
|
||||||
) : null}
|
) : null}
|
||||||
</Menu>
|
</Menu>
|
||||||
</Toolbar>
|
</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>
|
<DialogActions>
|
||||||
<Button color="secondary" onClick={() => this.post()}>
|
<Button color="secondary" onClick={() => this.post()}>
|
||||||
Post
|
Post
|
||||||
|
|
|
@ -14,6 +14,8 @@ import Post from "../components/Post";
|
||||||
import { Status } from "../types/Status";
|
import { Status } from "../types/Status";
|
||||||
import Mastodon, { StreamListener } from "megalodon";
|
import Mastodon, { StreamListener } from "megalodon";
|
||||||
import { withSnackbar } from "notistack";
|
import { withSnackbar } from "notistack";
|
||||||
|
import Masonry from "react-masonry-css";
|
||||||
|
import { getUserDefaultBool } from "../utilities/settings";
|
||||||
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
|
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
|
||||||
|
|
||||||
interface IHomePageState {
|
interface IHomePageState {
|
||||||
|
@ -23,6 +25,7 @@ interface IHomePageState {
|
||||||
viewDidLoad?: boolean;
|
viewDidLoad?: boolean;
|
||||||
viewDidError?: boolean;
|
viewDidError?: boolean;
|
||||||
viewDidErrorCode?: any;
|
viewDidErrorCode?: any;
|
||||||
|
isMasonryLayout?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class HomePage extends Component<any, IHomePageState> {
|
class HomePage extends Component<any, IHomePageState> {
|
||||||
|
@ -34,7 +37,8 @@ class HomePage extends Component<any, IHomePageState> {
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
viewIsLoading: true,
|
viewIsLoading: true,
|
||||||
backlogPosts: null
|
backlogPosts: null,
|
||||||
|
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
|
||||||
};
|
};
|
||||||
|
|
||||||
this.client = new Mastodon(
|
this.client = new Mastodon(
|
||||||
|
@ -154,9 +158,11 @@ class HomePage extends Component<any, IHomePageState> {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { classes } = this.props;
|
const { classes } = this.props;
|
||||||
|
const containerClasses = `${classes.pageLayoutMaxConstraints}${
|
||||||
|
this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : ""
|
||||||
|
}`;
|
||||||
return (
|
return (
|
||||||
<div className={classes.pageLayoutMaxConstraints}>
|
<div className={containerClasses}>
|
||||||
{this.state.backlogPosts ? (
|
{this.state.backlogPosts ? (
|
||||||
<div className={classes.pageTopChipContainer}>
|
<div className={classes.pageTopChipContainer}>
|
||||||
<div className={classes.pageTopChips}>
|
<div className={classes.pageTopChips}>
|
||||||
|
@ -184,6 +190,35 @@ class HomePage extends Component<any, IHomePageState> {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{this.state.posts ? (
|
{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}
|
||||||
|
>
|
||||||
|
<Post
|
||||||
|
key={post.id}
|
||||||
|
post={post}
|
||||||
|
client={this.client}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Masonry>
|
||||||
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{this.state.posts.map((post: Status) => {
|
{this.state.posts.map((post: Status) => {
|
||||||
return (
|
return (
|
||||||
|
@ -194,6 +229,8 @@ class HomePage extends Component<any, IHomePageState> {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<br />
|
<br />
|
||||||
{this.state.viewDidLoad && !this.state.viewDidError ? (
|
{this.state.viewDidLoad && !this.state.viewDidError ? (
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -14,6 +14,8 @@ import Post from "../components/Post";
|
||||||
import { Status } from "../types/Status";
|
import { Status } from "../types/Status";
|
||||||
import Mastodon, { StreamListener } from "megalodon";
|
import Mastodon, { StreamListener } from "megalodon";
|
||||||
import { withSnackbar } from "notistack";
|
import { withSnackbar } from "notistack";
|
||||||
|
import Masonry from "react-masonry-css";
|
||||||
|
import { getUserDefaultBool } from "../utilities/settings";
|
||||||
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
|
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
|
||||||
|
|
||||||
interface ILocalPageState {
|
interface ILocalPageState {
|
||||||
|
@ -23,6 +25,7 @@ interface ILocalPageState {
|
||||||
viewDidLoad?: boolean;
|
viewDidLoad?: boolean;
|
||||||
viewDidError?: boolean;
|
viewDidError?: boolean;
|
||||||
viewDidErrorCode?: any;
|
viewDidErrorCode?: any;
|
||||||
|
isMasonryLayout?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalPage extends Component<any, ILocalPageState> {
|
class LocalPage extends Component<any, ILocalPageState> {
|
||||||
|
@ -34,7 +37,8 @@ class LocalPage extends Component<any, ILocalPageState> {
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
viewIsLoading: true,
|
viewIsLoading: true,
|
||||||
backlogPosts: null
|
backlogPosts: null,
|
||||||
|
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
|
||||||
};
|
};
|
||||||
|
|
||||||
this.client = new Mastodon(
|
this.client = new Mastodon(
|
||||||
|
@ -155,9 +159,12 @@ class LocalPage extends Component<any, ILocalPageState> {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { classes } = this.props;
|
const { classes } = this.props;
|
||||||
|
const containerClasses = `${classes.pageLayoutMaxConstraints}${
|
||||||
|
this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.pageLayoutMaxConstraints}>
|
<div className={containerClasses}>
|
||||||
{this.state.backlogPosts ? (
|
{this.state.backlogPosts ? (
|
||||||
<div className={classes.pageTopChipContainer}>
|
<div className={classes.pageTopChipContainer}>
|
||||||
<div className={classes.pageTopChips}>
|
<div className={classes.pageTopChips}>
|
||||||
|
@ -186,15 +193,50 @@ class LocalPage extends Component<any, ILocalPageState> {
|
||||||
) : null}
|
) : null}
|
||||||
{this.state.posts ? (
|
{this.state.posts ? (
|
||||||
<div>
|
<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) => {
|
{this.state.posts.map((post: Status) => {
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
className={classes.masonryGrid_item}
|
||||||
|
>
|
||||||
<Post
|
<Post
|
||||||
key={post.id}
|
key={post.id}
|
||||||
post={post}
|
post={post}
|
||||||
client={this.client}
|
client={this.client}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</Masonry>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{this.state.posts.map((post: Status) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classes.masonryGrid_item}
|
||||||
|
>
|
||||||
|
<Post
|
||||||
|
key={post.id}
|
||||||
|
post={post}
|
||||||
|
client={this.client}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<br />
|
<br />
|
||||||
{this.state.viewDidLoad && !this.state.viewDidError ? (
|
{this.state.viewDidLoad && !this.state.viewDidError ? (
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -33,35 +33,83 @@ import NotificationsIcon from "@material-ui/icons/Notifications";
|
||||||
import Mastodon from "megalodon";
|
import Mastodon from "megalodon";
|
||||||
import { Notification } from "../types/Notification";
|
import { Notification } from "../types/Notification";
|
||||||
import { Account } from "../types/Account";
|
import { Account } from "../types/Account";
|
||||||
|
import { Relationship } from "../types/Relationship";
|
||||||
import { withSnackbar } from "notistack";
|
import { withSnackbar } from "notistack";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state interface for the notifications page.
|
||||||
|
*/
|
||||||
interface INotificationsPageState {
|
interface INotificationsPageState {
|
||||||
|
/**
|
||||||
|
* The list of notifications, if it exists.
|
||||||
|
*/
|
||||||
notifications?: [Notification];
|
notifications?: [Notification];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the view is still loading.
|
||||||
|
*/
|
||||||
viewIsLoading: boolean;
|
viewIsLoading: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the view has loaded.
|
||||||
|
*/
|
||||||
viewDidLoad?: boolean;
|
viewDidLoad?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the view has loaded but in error.
|
||||||
|
*/
|
||||||
viewDidError?: boolean;
|
viewDidError?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error code for an errored state, if possible.
|
||||||
|
*/
|
||||||
viewDidErrorCode?: string;
|
viewDidErrorCode?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the delete confirmation dialog should be open.
|
||||||
|
*/
|
||||||
deleteDialogOpen: boolean;
|
deleteDialogOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notifications page.
|
||||||
|
*/
|
||||||
class NotificationsPage extends Component<any, INotificationsPageState> {
|
class NotificationsPage extends Component<any, INotificationsPageState> {
|
||||||
|
/**
|
||||||
|
* The Mastodon object to perform notification operations on.
|
||||||
|
*/
|
||||||
client: Mastodon;
|
client: Mastodon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stream listener for tuning in to notifications.
|
||||||
|
*/
|
||||||
streamListener: any;
|
streamListener: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct the notifications page.
|
||||||
|
* @param props The properties to pass in
|
||||||
|
*/
|
||||||
constructor(props: any) {
|
constructor(props: any) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
// Create the Mastodon object.
|
||||||
this.client = new Mastodon(
|
this.client = new Mastodon(
|
||||||
localStorage.getItem("access_token") as string,
|
localStorage.getItem("access_token") as string,
|
||||||
localStorage.getItem("baseurl") + "/api/v1"
|
localStorage.getItem("baseurl") + "/api/v1"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Initialize the state.
|
||||||
this.state = {
|
this.state = {
|
||||||
viewIsLoading: true,
|
viewIsLoading: true,
|
||||||
deleteDialogOpen: false
|
deleteDialogOpen: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform pre-mount tasks.
|
||||||
|
*/
|
||||||
componentWillMount() {
|
componentWillMount() {
|
||||||
|
// Get the list of notifications and update the state.
|
||||||
this.client
|
this.client
|
||||||
.get("/notifications")
|
.get("/notifications")
|
||||||
.then((resp: any) => {
|
.then((resp: any) => {
|
||||||
|
@ -82,10 +130,17 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform post-mount tasks.
|
||||||
|
*/
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
// Start listening for new notifications after fetching.
|
||||||
this.streamNotifications();
|
this.streamNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up a stream listener and keep updating notifications.
|
||||||
|
*/
|
||||||
streamNotifications() {
|
streamNotifications() {
|
||||||
this.streamListener = this.client.stream("/streaming/user");
|
this.streamListener = this.client.stream("/streaming/user");
|
||||||
|
|
||||||
|
@ -98,10 +153,19 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the state of the delete dialog.
|
||||||
|
*/
|
||||||
toggleDeleteDialog() {
|
toggleDeleteDialog() {
|
||||||
this.setState({ deleteDialogOpen: !this.state.deleteDialogOpen });
|
this.setState({ deleteDialogOpen: !this.state.deleteDialogOpen });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
removeHTMLContent(text: string) {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.innerHTML = text;
|
div.innerHTML = text;
|
||||||
|
@ -111,6 +175,10 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
||||||
return innerContent;
|
return innerContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a notification from the server.
|
||||||
|
* @param id The notification's ID
|
||||||
|
*/
|
||||||
removeNotification(id: string) {
|
removeNotification(id: string) {
|
||||||
this.client
|
this.client
|
||||||
.post(`/notifications/${id}/dismiss`)
|
.post(`/notifications/${id}/dismiss`)
|
||||||
|
@ -142,6 +210,9 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge all notifications from the server.
|
||||||
|
*/
|
||||||
removeAllNotifications() {
|
removeAllNotifications() {
|
||||||
this.client
|
this.client
|
||||||
.post("/notifications/clear")
|
.post("/notifications/clear")
|
||||||
|
@ -159,6 +230,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) {
|
createNotification(notif: Notification) {
|
||||||
const { classes } = this.props;
|
const { classes } = this.props;
|
||||||
let primary = "";
|
let primary = "";
|
||||||
|
@ -293,7 +368,20 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow an account from a notification if already not followed.
|
||||||
|
* @param acct The account to follow, if possible
|
||||||
|
*/
|
||||||
followMember(acct: Account) {
|
followMember(acct: Account) {
|
||||||
|
// Get the relationships for this account.
|
||||||
|
this.client
|
||||||
|
.get(`/accounts/relationships`, { id: acct.id })
|
||||||
|
.then((resp: any) => {
|
||||||
|
// Returns a list, so grab only the first item.
|
||||||
|
let relationship: Relationship = resp.data[0];
|
||||||
|
|
||||||
|
// Follow if not following already.
|
||||||
|
if (relationship.following == false) {
|
||||||
this.client
|
this.client
|
||||||
.post(`/accounts/${acct.id}/follow`)
|
.post(`/accounts/${acct.id}/follow`)
|
||||||
.then((resp: any) => {
|
.then((resp: any) => {
|
||||||
|
@ -310,6 +398,23 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Otherwise notify the user.
|
||||||
|
else {
|
||||||
|
this.props.enqueueSnackbar(
|
||||||
|
"You already follow this account."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
this.props.enqueueSnackbar("Couldn't find relationship.", {
|
||||||
|
variant: "error"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the notification page.
|
||||||
|
*/
|
||||||
render() {
|
render() {
|
||||||
const { classes } = this.props;
|
const { classes } = this.props;
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Theme, createStyles } from "@material-ui/core";
|
import { Theme, createStyles, FormHelperText } from "@material-ui/core";
|
||||||
import { isDarwinApp } from "../utilities/desktop";
|
import { isDarwinApp } from "../utilities/desktop";
|
||||||
import { isAppbarExpanded } from "../utilities/appbar";
|
import { isAppbarExpanded } from "../utilities/appbar";
|
||||||
|
|
||||||
|
@ -323,5 +323,17 @@ export const styles = (theme: Theme) =>
|
||||||
display: "block"
|
display: "block"
|
||||||
},
|
},
|
||||||
backgroundColor: theme.palette.primary.main
|
backgroundColor: theme.palette.primary.main
|
||||||
|
},
|
||||||
|
pageLayoutMasonry: {
|
||||||
|
paddingLeft: theme.spacing.unit * 3,
|
||||||
|
paddingRight: theme.spacing.unit * 3
|
||||||
|
},
|
||||||
|
masonryGrid: {
|
||||||
|
display: "flex",
|
||||||
|
width: "auto"
|
||||||
|
},
|
||||||
|
"my-masonry-grid_column": {
|
||||||
|
// non-standard name fixes react-masonry-css bug :shrug:
|
||||||
|
padding: 5
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,6 +14,8 @@ import Post from "../components/Post";
|
||||||
import { Status } from "../types/Status";
|
import { Status } from "../types/Status";
|
||||||
import Mastodon, { StreamListener } from "megalodon";
|
import Mastodon, { StreamListener } from "megalodon";
|
||||||
import { withSnackbar } from "notistack";
|
import { withSnackbar } from "notistack";
|
||||||
|
import Masonry from "react-masonry-css";
|
||||||
|
import { getUserDefaultBool } from "../utilities/settings";
|
||||||
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
|
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
|
||||||
|
|
||||||
interface IPublicPageState {
|
interface IPublicPageState {
|
||||||
|
@ -23,6 +25,7 @@ interface IPublicPageState {
|
||||||
viewDidLoad?: boolean;
|
viewDidLoad?: boolean;
|
||||||
viewDidError?: boolean;
|
viewDidError?: boolean;
|
||||||
viewDidErrorCode?: any;
|
viewDidErrorCode?: any;
|
||||||
|
isMasonryLayout?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class PublicPage extends Component<any, IPublicPageState> {
|
class PublicPage extends Component<any, IPublicPageState> {
|
||||||
|
@ -34,7 +37,8 @@ class PublicPage extends Component<any, IPublicPageState> {
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
viewIsLoading: true,
|
viewIsLoading: true,
|
||||||
backlogPosts: null
|
backlogPosts: null,
|
||||||
|
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
|
||||||
};
|
};
|
||||||
|
|
||||||
this.client = new Mastodon(
|
this.client = new Mastodon(
|
||||||
|
@ -154,9 +158,12 @@ class PublicPage extends Component<any, IPublicPageState> {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { classes } = this.props;
|
const { classes } = this.props;
|
||||||
|
const containerClasses = `${classes.pageLayoutMaxConstraints}${
|
||||||
|
this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.pageLayoutMaxConstraints}>
|
<div className={containerClasses}>
|
||||||
{this.state.backlogPosts ? (
|
{this.state.backlogPosts ? (
|
||||||
<div className={classes.pageTopChipContainer}>
|
<div className={classes.pageTopChipContainer}>
|
||||||
<div className={classes.pageTopChips}>
|
<div className={classes.pageTopChips}>
|
||||||
|
@ -185,15 +192,50 @@ class PublicPage extends Component<any, IPublicPageState> {
|
||||||
) : null}
|
) : null}
|
||||||
{this.state.posts ? (
|
{this.state.posts ? (
|
||||||
<div>
|
<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) => {
|
{this.state.posts.map((post: Status) => {
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
className={classes.masonryGrid_item}
|
||||||
|
>
|
||||||
<Post
|
<Post
|
||||||
key={post.id}
|
key={post.id}
|
||||||
post={post}
|
post={post}
|
||||||
client={this.client}
|
client={this.client}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</Masonry>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{this.state.posts.map((post: Status) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classes.masonryGrid_item}
|
||||||
|
>
|
||||||
|
<Post
|
||||||
|
key={post.id}
|
||||||
|
post={post}
|
||||||
|
client={this.client}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<br />
|
<br />
|
||||||
{this.state.viewDidLoad && !this.state.viewDidError ? (
|
{this.state.viewDidLoad && !this.state.viewDidError ? (
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -64,6 +64,7 @@ import UndoIcon from "@material-ui/icons/Undo";
|
||||||
import DomainDisabledIcon from "@material-ui/icons/DomainDisabled";
|
import DomainDisabledIcon from "@material-ui/icons/DomainDisabled";
|
||||||
import AccountSettingsIcon from "mdi-material-ui/AccountSettings";
|
import AccountSettingsIcon from "mdi-material-ui/AccountSettings";
|
||||||
import AlphabeticalVariantOffIcon from "mdi-material-ui/AlphabeticalVariantOff";
|
import AlphabeticalVariantOffIcon from "mdi-material-ui/AlphabeticalVariantOff";
|
||||||
|
import DashboardIcon from "@material-ui/icons/Dashboard";
|
||||||
|
|
||||||
import { Config } from "../types/Config";
|
import { Config } from "../types/Config";
|
||||||
import { Account } from "../types/Account";
|
import { Account } from "../types/Account";
|
||||||
|
@ -86,6 +87,7 @@ interface ISettingsState {
|
||||||
federated: boolean;
|
federated: boolean;
|
||||||
currentUser?: Account;
|
currentUser?: Account;
|
||||||
imposeCharacterLimit: boolean;
|
imposeCharacterLimit: boolean;
|
||||||
|
masonryLayout?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SettingsPage extends Component<any, ISettingsState> {
|
class SettingsPage extends Component<any, ISettingsState> {
|
||||||
|
@ -117,7 +119,8 @@ class SettingsPage extends Component<any, ISettingsState> {
|
||||||
defaultVisibility: getUserDefaultVisibility() || "public",
|
defaultVisibility: getUserDefaultVisibility() || "public",
|
||||||
brandName: "Hyperspace",
|
brandName: "Hyperspace",
|
||||||
federated: true,
|
federated: true,
|
||||||
imposeCharacterLimit: getUserDefaultBool("imposeCharacterLimit")
|
imposeCharacterLimit: getUserDefaultBool("imposeCharacterLimit"),
|
||||||
|
masonryLayout: getUserDefaultBool("isMasonryLayout")
|
||||||
};
|
};
|
||||||
|
|
||||||
this.toggleDarkMode = this.toggleDarkMode.bind(this);
|
this.toggleDarkMode = this.toggleDarkMode.bind(this);
|
||||||
|
@ -126,6 +129,7 @@ class SettingsPage extends Component<any, ISettingsState> {
|
||||||
this.toggleBadgeCount = this.toggleBadgeCount.bind(this);
|
this.toggleBadgeCount = this.toggleBadgeCount.bind(this);
|
||||||
this.toggleThemeDialog = this.toggleThemeDialog.bind(this);
|
this.toggleThemeDialog = this.toggleThemeDialog.bind(this);
|
||||||
this.toggleVisibilityDialog = this.toggleVisibilityDialog.bind(this);
|
this.toggleVisibilityDialog = this.toggleVisibilityDialog.bind(this);
|
||||||
|
this.toggleMasonryLayout = this.toggleMasonryLayout.bind(this);
|
||||||
this.changeThemeName = this.changeThemeName.bind(this);
|
this.changeThemeName = this.changeThemeName.bind(this);
|
||||||
this.changeTheme = this.changeTheme.bind(this);
|
this.changeTheme = this.changeTheme.bind(this);
|
||||||
this.setVisibility = this.setVisibility.bind(this);
|
this.setVisibility = this.setVisibility.bind(this);
|
||||||
|
@ -241,6 +245,11 @@ class SettingsPage extends Component<any, ISettingsState> {
|
||||||
this.setState({ resetSettingsDialog: !this.state.resetSettingsDialog });
|
this.setState({ resetSettingsDialog: !this.state.resetSettingsDialog });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleMasonryLayout() {
|
||||||
|
this.setState({ masonryLayout: !this.state.masonryLayout });
|
||||||
|
setUserDefaultBool("isMasonryLayout", !this.state.masonryLayout);
|
||||||
|
}
|
||||||
|
|
||||||
changeTheme() {
|
changeTheme() {
|
||||||
setUserDefaultTheme(this.state.selectThemeName);
|
setUserDefaultTheme(this.state.selectThemeName);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
@ -559,7 +568,36 @@ class SettingsPage extends Component<any, ISettingsState> {
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</div>
|
</div>
|
||||||
</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}>
|
<div className={classes.pageContentLayoutConstraints}>
|
||||||
<ListSubheader>Appearance</ListSubheader>
|
<ListSubheader>Appearance</ListSubheader>
|
||||||
<Paper className={classes.pageListConstraints}>
|
<Paper className={classes.pageListConstraints}>
|
||||||
|
@ -621,6 +659,22 @@ class SettingsPage extends Component<any, ISettingsState> {
|
||||||
</Button>
|
</Button>
|
||||||
</ListItemSecondaryAction>
|
</ListItemSecondaryAction>
|
||||||
</ListItem>
|
</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>
|
||||||
</List>
|
</List>
|
||||||
</Paper>
|
</Paper>
|
||||||
<br />
|
<br />
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
|
@ -1,5 +1,14 @@
|
||||||
import { isDarwinApp } from "./desktop";
|
import { isDarwinApp } from "./desktop";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list containing the types of child views.
|
||||||
|
*
|
||||||
|
* This list is used to help determine if a back button is necessary, usually because there
|
||||||
|
* is no defined way of returning to the parent view without using the menu bar or keyboard
|
||||||
|
* shortcut in desktop apps.
|
||||||
|
*/
|
||||||
|
export const childViews = ["#/profile", "#/conversation"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether the title bar is being displayed.
|
* Determine whether the title bar is being displayed.
|
||||||
* This might be useful in cases where styles are dependent on the title bar's visibility, such as heights.
|
* This might be useful in cases where styles are dependent on the title bar's visibility, such as heights.
|
||||||
|
@ -9,3 +18,20 @@ import { isDarwinApp } from "./desktop";
|
||||||
export function isAppbarExpanded(): boolean {
|
export function isAppbarExpanded(): boolean {
|
||||||
return isDarwinApp() || process.env.NODE_ENV === "development";
|
return isDarwinApp() || process.env.NODE_ENV === "development";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether a path is considered a "child view".
|
||||||
|
*
|
||||||
|
* This is often used to determine whether a back button should be rendered or not.
|
||||||
|
* @param path The path of the page, usually its hash
|
||||||
|
* @returns Boolean distating if the view is a child view.
|
||||||
|
*/
|
||||||
|
export function isChildView(path: string): boolean {
|
||||||
|
let protocolMatched = false;
|
||||||
|
childViews.forEach((childViewProtocol: string) => {
|
||||||
|
if (path.startsWith(childViewProtocol)) {
|
||||||
|
protocolMatched = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return protocolMatched;
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
|
@ -101,7 +101,8 @@ export function createUserDefaults() {
|
||||||
clearNotificationsOnRead: false,
|
clearNotificationsOnRead: false,
|
||||||
displayAllOnNotificationBadge: false,
|
displayAllOnNotificationBadge: false,
|
||||||
defaultVisibility: "public",
|
defaultVisibility: "public",
|
||||||
imposeCharacterLimit: true
|
imposeCharacterLimit: true,
|
||||||
|
isMasonryLayout: false
|
||||||
};
|
};
|
||||||
|
|
||||||
let settings = [
|
let settings = [
|
||||||
|
@ -110,7 +111,8 @@ export function createUserDefaults() {
|
||||||
"clearNotificationsOnRead",
|
"clearNotificationsOnRead",
|
||||||
"displayAllOnNotificationBadge",
|
"displayAllOnNotificationBadge",
|
||||||
"defaultVisibility",
|
"defaultVisibility",
|
||||||
"imposeCharacterLimit"
|
"imposeCharacterLimit",
|
||||||
|
"isMasonryLayout"
|
||||||
];
|
];
|
||||||
|
|
||||||
migrateExistingSettings();
|
migrateExistingSettings();
|
||||||
|
|
Loading…
Reference in New Issue