Pull changes from b4

This commit is contained in:
Marquis Kurt 2020-02-16 14:20:50 -05:00
commit 931244cb5a
No known key found for this signature in database
GPG Key ID: 725636D259F5402D
20 changed files with 888 additions and 306 deletions

View File

@ -1,10 +1,24 @@
Hyperspace
Copyright Hyperspace developers 2019
Hyperspace Desktop
Copyright Hyperspace Developers 2020
NON-VIOLENT PUBLIC LICENSE v1
NON-VIOLENT PUBLIC LICENSE v4
Preamble
The Non-Violent Public license is a freedom-respecting sharealike license
for both the author of a work as well as those subject to a work. It aims
to protect the basic rights of human beings from exploitation and the earth
from plunder. It aims to ensure a copyrighted work is forever available
for public use, modification, and redistribution under the same terms so
long as the work is not used for harm. For more information about the NPL
refer to the official webpage
Official Webpage: https://thufie.lain.haus/NPL.html
Terms and Conditions
THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS
NON-VIOLENT PUBLIC LICENSE v1 ("LICENSE"). THE WORK IS PROTECTED BY
NON-VIOLENT PUBLIC LICENSE v4 ("LICENSE"). THE WORK IS PROTECTED BY
COPYRIGHT AND ALL OTHER APPLICABLE LAWS. ANY USE OF THE WORK OTHER THAN
AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. BY
EXERCISING ANY RIGHTS TO THE WORK PROVIDED IN THIS LICENSE, YOU AGREE
@ -38,8 +52,9 @@ AND CONDITIONS OF THIS LICENSE.
timed-relation with a moving image ("synching") will be
considered an Adaptation for the purpose of this License.
c. "Bodily Harm" means any action of one person towards another
in an intentional manner.
c. "Bodily Harm" means any physical hurt or injury to a person that
interferes with the health or comfort of the person and that is more
more than merely transient or trifling in nature.
d. "Collection" means a collection of literary or artistic
works, such as encyclopedias and anthologies, or performances,
@ -61,8 +76,7 @@ AND CONDITIONS OF THIS LICENSE.
f. "Incarceration" means confinement in a jail, prison, or any
other place where individuals of any kind are held against
either their will or the will of their legal guardians by physical
means.
either their will or the will of their legal guardians.
g. "Licensor" means the individual, individuals, entity or
entities that offer(s) the Work under the terms of this License.
@ -134,13 +148,23 @@ AND CONDITIONS OF THIS LICENSE.
through which the Original Author and/or Distributor originally
created, derived, and/or modified it.
o. "Surveilling" means the use of the Work to
overtly or covertly observe persons or their activities.
o. "Surveilling" means the use of the Work to either
overtly or covertly observe and record persons and or their
activities.
p. "Web Service" means the use of a piece of Software to
interpret or modify information that is subsequently and directly
served to users over the Internet.
q. "Discriminate" means the use of a work to differentiate between
humans in a such a way which prioritizes some above others on the
basis of percieved membership within certain groups.
r. "Hate Speech" means communication or any form
of expression which is solely for the purpose of expressing hatred
for some group or advocating a form of Discrimination
(to Discriminate per definition in (q)) between humans.
2. FAIR DEALING RIGHTS
Nothing in this License is intended to reduce, limit, or restrict any
@ -177,7 +201,6 @@ AND CONDITIONS OF THIS LICENSE.
exercise the rights in other media and formats. Subject to
Section 8(g), all rights not expressly granted by Licensor are
hereby reserved.
4. RESTRICTIONS
@ -232,15 +255,15 @@ AND CONDITIONS OF THIS LICENSE.
or tracking individuals for financial gain.
iii. You do not use the Work in an Act of War.
iv. You do not use the Work for the purpose of supporting
an Act of War.
or profiting from an Act of War.
v. You do not use the Work for the purpose of Incarceration.
vi. You do not use the Work for the purpose of extracting
oil, gas, or coal.
vii. You do not use the Work for the purpose of
expediting, coordinating, or facilitating paid work
undertaken by individuals under the age of 12 years.
viii. You do not use the Work to either discriminate or
spread hate speech on the basis of sex, sexual orientation,
viii. You do not use the Work to either Discriminate or
spread Hate Speech on the basis of sex, sexual orientation,
gender identity, race, age, disability, color, national origin,
religion, or lower economic status.
@ -419,4 +442,4 @@ AND CONDITIONS OF THIS LICENSE.
additional rights not granted under this License, such
additional rights are deemed to be included in the License; this
License is not intended to restrict the license of any rights
under applicable law.
under applicable law.

View File

@ -9,7 +9,7 @@
[![Matrix room](https://img.shields.io/matrix/hypermasto:matrix.org.svg)](https://matrix.to/#/#hypermasto:matrix.org)
[![Discord server](https://img.shields.io/discord/554108687434907660.svg?color=blueviolet&label=discord)](https://discord.gg/c69AXwk)
![Build Status](https://github.com/hyperspacedev/hyperspace/workflows/Node%20CI/badge.svg) [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/hyperspacedev/hyperspace?include_prereleases)](https://github.com/hyperspacedev/hyperspace/releases) <!-- [![iTunes App Store](https://img.shields.io/itunes/v/1454139710?label=Mac%20App%20Store&logo=apple&logoColor=white)](https://apps.apple.com/us/app/hyperspace/id1454139710?mt=12)--> [![Hyperspace](https://snapcraft.io/hyperspace/badge.svg)](https://snapcraft.io/hyperspace)
![Build Status](https://github.com/hyperspacedev/hyperspace/workflows/Node%20CI/badge.svg) [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/hyperspacedev/hyperspace?include_prereleases)](https://github.com/hyperspacedev/hyperspace/releases) [![License: NPLv4+](https://img.shields.io/badge/license-NPLv4%2B-blue.svg)](LICENSE.txt) [![Hyperspace](https://snapcraft.io/hyperspace/badge.svg)](https://snapcraft.io/hyperspace)
Hyperspace is the fluffiest client for Mastodon and other fediverse networks written in TypeScript and React. Hyperspace offers a fun, clean, fast, and responsive design that scales beautifully across devices and enhances the fediverse experience.
@ -127,12 +127,12 @@ You'll also want to modify the `notarize.js` file to change the details from the
## Licensing and Credits
Hyperspace is licensed under the [Non-violent Public License](LICENSE), a permissive license under the conditions that you do not use this for any unethical purposes and to file patent claims. Please read what your rights are as a Hyperspace user/developer in the license for more information.
Hyperspace is licensed under the [Non-violent Public License v4+](LICENSE.txt), a permissive license under the conditions that you do not use this for any unethical purposes and to file patent claims. Please read what your rights are as a Hyperspace user/developer in the license for more information.
Hyperspace has been made possible by the React, TypeScript, Megalodon, and Material-UI projects as well our [Patrons](patreon.md) and our contributors on GitHub.
## Contribute
Contrubition guidelines are available in the [contributing file](.github/contributing.md) and when you make an issue/pull request. Additionally, you can access our [Code of Conduct](.github/code_of_conduct.md).
Contribution guidelines are available in the [contributing file](.github/contributing.md) and when you make an issue/pull request. Additionally, you can access our [Code of Conduct](.github/code_of_conduct.md).
If you want to aid the project in other ways, consider supporting the project on [Patreon](https://patreon.com/hyperspacedev).

View File

@ -1,7 +1,7 @@
{
"name": "hyperspace",
"productName": "Hyperspace Desktop",
"version": "1.1.0-beta3",
"version": "1.1.0-beta4",
"description": "A beautiful, fluffy client for the fediverse",
"author": "Marquis Kurt <hyperspacedev@marquiskurt.net>",
"repository": "https://github.com/hyperspacedev/hyperspace.git",

View File

@ -20,7 +20,7 @@
"account": "774314"
},
"license": {
"name": "Non-violent Public License",
"name": "Non-violent Public License v4+",
"url": "https://thufie.lain.haus/NPL.html"
},
"repository": "https://github.com/hyperspacedev/hyperspace"

View File

@ -8,9 +8,7 @@ import AboutPage from "./pages/About";
import Settings from "./pages/Settings";
import { getUserDefaultBool, getUserDefaultTheme } from "./utilities/settings";
import ProfilePage from "./pages/ProfilePage";
import HomePage from "./pages/Home";
import LocalPage from "./pages/Local";
import PublicPage from "./pages/Public";
import TimelinePage from "./pages/Timeline";
import Conversation from "./pages/Conversation";
import NotificationsPage from "./pages/Notifications";
import SearchPage from "./pages/Search";
@ -98,10 +96,47 @@ class App extends Component<any, IAppState> {
<Route path="/welcome" component={WelcomePage} />
<div>
{this.state.showLayout ? <AppLayout /> : null}
<PrivateRoute exact path="/" component={HomePage} />
<PrivateRoute path="/home" component={HomePage} />
<PrivateRoute path="/local" component={LocalPage} />
<PrivateRoute path="/public" component={PublicPage} />
<PrivateRoute
exact
path="/"
render={(props: any) => (
<TimelinePage
{...props}
stream="/streaming/user"
timeline="/timelines/home"
/>
)}
/>
<PrivateRoute
path="/home"
render={(props: any) => (
<TimelinePage
{...props}
stream="/streaming/user"
timeline="/timelines/home"
/>
)}
/>
<PrivateRoute
path="/local"
render={(props: any) => (
<TimelinePage
{...props}
stream="/streaming/public/local"
timeline="/timelines/public?local=true"
/>
)}
/>
<PrivateRoute
path="/public"
render={(props: any) => (
<TimelinePage
{...props}
stream="/streaming/public"
timeline="/timelines/public"
/>
)}
/>
<PrivateRoute path="/messages" component={MessagesPage} />
<PrivateRoute
path="/notifications"

View File

@ -81,9 +81,11 @@ class AttachmentComponent extends Component<
return <AudioPlayer src={slide.url} id={slide.id} />;
case "gifv":
return (
<img
<video
autoPlay
loop
src={slide.url}
alt={slide.description ? slide.description : ""}
title={slide.description ? slide.description : ""}
className={classes.mediaObject}
/>
);

View File

@ -396,37 +396,60 @@ export class Post extends React.Component<any, IPostState> {
getReblogAuthors(post: Status) {
const { classes } = this.props;
if (post.reblog) {
let author = post.reblog.account;
let emojis = author.emojis;
emojis.concat(post.account.emojis);
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 {
let author = post.account;
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>
</>
)
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);
}
console.log(post);
return (
<>
<span
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>
{reblogger ? (
<>
<AutorenewIcon
fontSize="small"
className={classes.postReblogIcon}
/>
<span
dangerouslySetInnerHTML={{
__html: emojifyString(
reblogger.display_name ||
reblogger.username,
emojis,
classes.postAuthorEmoji
)
}}
></span>
</>
) : null}
</>
);
}
getMentions(mention: [Mention]) {
@ -513,86 +536,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 toggleFavorite(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 toggleReblog(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);
}
}
@ -669,7 +669,11 @@ export class Post extends React.Component<any, IPostState> {
</IconButton>
</Tooltip>
}
title={<Typography>{this.getReblogAuthors(post)}</Typography>}
title={
<Typography>
{this.getReblogAuthors(post)}
</Typography>
}
subheader={moment(post.created_at).format(
"MMMM Do YYYY [at] h:mm A"
)}
@ -707,30 +711,20 @@ export class Post extends React.Component<any, IPostState> {
</Typography>
<Tooltip title="Favorite">
<IconButton
onClick={() => this.toggleFavorited(post)}
onClick={() => this.toggleFavorite(post)}
>
<FavoriteIcon
className={
post.reblog
? post.reblog.favourited
? classes.postDidAction
: ""
: post.favourited
post.favourited
? classes.postDidAction
: ""
}
/>
</IconButton>
</Tooltip>
<Typography>
{post.reblog
? post.reblog.favourites_count
: post.favourites_count}
</Typography>
<Typography>{post.favourites_count}</Typography>
<Tooltip title="Boost">
<IconButton
onClick={() => this.toggleReblogged(post)}
>
<IconButton onClick={() => this.toggleReblog(post)}>
<AutorenewIcon
className={
post.reblog

View File

@ -78,14 +78,14 @@ export const ProfileRoute = (rest: any, component: Component) => (
export const PrivateRoute = (props: IPrivateRouteProps) => {
const { component, render, ...rest } = props;
const redir = (comp: any) =>
userLoggedIn() ? comp : <Redirect to="/welcome" />;
return (
<Route
{...rest}
render={(compProps: any) =>
userLoggedIn() ? (
React.createElement(component, compProps)
) : (
<Redirect to="/welcome" />
redir(
React.createElement(render ? render : component, compProps)
)
}
/>
@ -93,5 +93,6 @@ export const PrivateRoute = (props: IPrivateRouteProps) => {
};
interface IPrivateRouteProps extends RouteProps {
component: any;
component?: any;
render?: any;
}

8
src/interfaces/utils.tsx Normal file
View File

@ -0,0 +1,8 @@
/**
* A Generic dictionary with the value of a specific type.
*
* Keys _must_ be strings.
*/
export interface Dictionary<T> {
[Key: string]: T;
}

View File

@ -626,9 +626,15 @@ class Composer extends Component<any, IComposerState> {
: null
})
// If we succeed, send a success message and go back.
// 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();
})

View File

@ -28,6 +28,11 @@ interface IHomePageState {
isMasonryLayout?: boolean;
}
/**
* The base class for the home timeline.
* @deprecated Use TimelinePage with the props `timeline="/timelines/home"`
* and `stream="/streaming/user"`.
*/
class HomePage extends Component<any, IHomePageState> {
client: Mastodon;
streamListener: StreamListener;

View File

@ -28,6 +28,11 @@ interface ILocalPageState {
isMasonryLayout?: boolean;
}
/**
* The base class for the local timeline.
* @deprecated Use TimelinePage with the props `timeline="/timelines/public?local=true"`
* and `stream="/streaming/public/local"`.
*/
class LocalPage extends Component<any, ILocalPageState> {
client: Mastodon;
streamListener: StreamListener;

View File

@ -17,7 +17,9 @@ import {
DialogContent,
DialogContentText,
DialogActions,
Tooltip
Tooltip,
Menu,
MenuItem
} from "@material-ui/core";
import AssignmentIndIcon from "@material-ui/icons/AssignmentInd";
@ -25,16 +27,22 @@ import PersonIcon from "@material-ui/icons/Person";
import PersonAddIcon from "@material-ui/icons/PersonAdd";
import DeleteIcon from "@material-ui/icons/Delete";
import { styles } from "./PageLayout.styles";
import { LinkableIconButton, LinkableAvatar } from "../interfaces/overrides";
import {
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.
@ -69,6 +77,11 @@ interface INotificationsPageState {
* Whether the delete confirmation dialog should be open.
*/
deleteDialogOpen: boolean;
/**
* Whether the menu should be open on smaller devices.
*/
mobileMenuOpen: Dictionary<boolean>;
}
/**
@ -101,7 +114,8 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
// Initialize the state.
this.state = {
viewIsLoading: true,
deleteDialogOpen: false
deleteDialogOpen: false,
mobileMenuOpen: {}
};
}
@ -114,10 +128,17 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
.get("/notifications")
.then((resp: any) => {
let notifications: [Notification] = resp.data;
let notifMenus: Dictionary<boolean> = {};
notifications.forEach((notif: Notification) => {
notifMenus[notif.id] = false;
});
this.setState({
notifications,
viewIsLoading: false,
viewDidLoad: true
viewDidLoad: true,
mobileMenuOpen: notifMenus
});
})
.catch((err: Error) => {
@ -160,6 +181,12 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
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.
*
@ -306,6 +333,108 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
}
/>
<ListItemSecondaryAction>
{this.getActions(notif)}
</ListItemSecondaryAction>
</ListItem>
);
}
/**
* Follow an account from a notification if already not followed.
* @param acct The account to follow, if possible
*/
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
.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);
});
}
// 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"
});
});
}
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.followMember(notif.account)}
>
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">
@ -363,54 +492,10 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
<DeleteIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
</div>
</>
);
}
/**
* Follow an account from a notification if already not followed.
* @param acct The account to follow, if possible
*/
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
.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);
});
}
// 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.

View File

@ -335,5 +335,9 @@ export const styles = (theme: Theme) =>
"my-masonry-grid_column": {
// non-standard name fixes react-masonry-css bug :shrug:
padding: 5
},
noTopPaddingMargin: {
marginTop: 0,
paddingTop: 0
}
});

View File

@ -25,6 +25,8 @@ import Post from "../components/Post";
import { withSnackbar } from "notistack";
import { LinkableIconButton } from "../interfaces/overrides";
import { emojifyString } from "../utilities/emojis";
import Masonry from "react-masonry-css";
import { getUserDefaultBool } from "..//utilities/settings";
import AccountEditIcon from "mdi-material-ui/AccountEdit";
import PersonAddIcon from "@material-ui/icons/PersonAdd";
@ -44,6 +46,7 @@ interface IProfilePageState {
viewDidError?: boolean;
viewDidErrorCode?: string;
blockDialogOpen: boolean;
isMasonryLayout?: boolean;
}
class ProfilePage extends Component<any, IProfilePageState> {
@ -59,7 +62,8 @@ class ProfilePage extends Component<any, IProfilePageState> {
this.state = {
viewIsLoading: true,
blockDialogOpen: false
blockDialogOpen: false,
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
};
}
@ -305,8 +309,36 @@ class ProfilePage extends Component<any, IProfilePageState> {
}
}
renderPosts(posts: Status[]) {
const { classes } = this.props;
const postComponents = posts.map((post: Status) => {
return <Post key={post.id} post={post} client={this.client} />;
});
if (this.state.isMasonryLayout) {
return (
<Masonry
className={classes.masonryGrid}
columnClassName={classes["my-masonry-grid_column"]}
breakpointCols={{
default: 4,
2000: 3,
1400: 2,
1050: 1
}}
>
{postComponents}
</Masonry>
);
} else {
return <div>{postComponents}</div>;
}
}
render() {
const { classes } = this.props;
const containerClasses = `${classes.pageContentLayoutConstraints} ${
this.state.isMasonryLayout ? classes.pageLayoutMasonry : ""
}`;
return (
<div className={classes.pageLayoutMinimalConstraints}>
<div className={classes.pageHeroBackground}>
@ -464,7 +496,7 @@ class ProfilePage extends Component<any, IProfilePageState> {
</div>
</div>
</div>
<div className={classes.pageContentLayoutConstraints}>
<div className={containerClasses}>
{this.state.viewDidError ? (
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
@ -482,15 +514,7 @@ class ProfilePage extends Component<any, IProfilePageState> {
)}
{this.state.posts ? (
<div>
{this.state.posts.map((post: Status) => {
return (
<Post
key={post.id}
post={post}
client={this.client}
/>
);
})}
{this.renderPosts(this.state.posts)}
<br />
{this.state.viewDidLoad &&
!this.state.viewDidError ? (

View File

@ -28,6 +28,11 @@ interface IPublicPageState {
isMasonryLayout?: boolean;
}
/**
* The base class for the public timeline.
* @deprecated Use TimelinePage with the props `timeline="/timelines/public"`
* and `stream="/streaming/public"`.
*/
class PublicPage extends Component<any, IPublicPageState> {
client: Mastodon;
streamListener: StreamListener;

View File

@ -26,6 +26,8 @@ import { withSnackbar } from "notistack";
import Post from "../components/Post";
import { Status } from "../types/Status";
import { Account } from "../types/Account";
import Masonry from "react-masonry-css";
import { getUserDefaultBool } from "../utilities/settings";
interface ISearchPageState {
query: string[] | string;
@ -36,6 +38,7 @@ interface ISearchPageState {
viewDidLoad?: boolean;
viewDidError?: boolean;
viewDidErrorCode?: string;
isMasonryLayout: boolean;
}
class SearchPage extends Component<any, ISearchPageState> {
@ -54,7 +57,8 @@ class SearchPage extends Component<any, ISearchPageState> {
this.state = {
viewIsLoading: true,
query: searchParams.query,
type: searchParams.type
type: searchParams.type,
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
};
if (searchParams.type === "tag") {
@ -195,7 +199,7 @@ class SearchPage extends Component<any, ISearchPageState> {
showAllAccountsFromQuery() {
const { classes } = this.props;
return (
<div>
<div className={classes.pageLayoutConstraints}>
<ListSubheader>Accounts</ListSubheader>
{this.state.results &&
@ -260,22 +264,44 @@ class SearchPage extends Component<any, ISearchPageState> {
);
}
renderPosts(posts: Status[]) {
const { classes } = this.props;
const postComponents = posts.map((post: Status) => {
return <Post key={post.id} post={post} client={this.client} />;
});
if (this.state.isMasonryLayout) {
return (
<Masonry
className={classes.masonryGrid}
columnClassName={classes["my-masonry-grid_column"]}
breakpointCols={{
default: 4,
2000: 3,
1400: 2,
1050: 1
}}
>
{postComponents}
</Masonry>
);
} else {
return <div>{postComponents}</div>;
}
}
showAllPostsFromQuery() {
const { classes } = this.props;
const containerClasses = `${classes.pageLayoutConstraints} ${
this.state.isMasonryLayout
? classes.pageLayoutMasonry + " " + classes.noTopPaddingMargin
: ""
}`;
return (
<div>
<div className={containerClasses}>
<ListSubheader>Posts</ListSubheader>
{this.state.results ? (
this.state.results.statuses.length > 0 ? (
this.state.results.statuses.map((post: Status) => {
return (
<Post
key={post.id}
post={post}
client={this.client}
/>
);
})
this.renderPosts(this.state.results.statuses)
) : (
<Typography
variant="caption"
@ -291,20 +317,15 @@ class SearchPage extends Component<any, ISearchPageState> {
showAllPostsWithTag() {
const { classes } = this.props;
const containerClasses = `${classes.pageLayoutMaxConstraints} ${
this.state.isMasonryLayout ? classes.pageLayoutMasonry : ""
}`;
return (
<div>
<div className={containerClasses}>
<ListSubheader>Tagged posts</ListSubheader>
{this.state.tagResults ? (
this.state.tagResults.length > 0 ? (
this.state.tagResults.map((post: Status) => {
return (
<Post
key={post.id}
post={post}
client={this.client}
/>
);
})
this.renderPosts(this.state.tagResults)
) : (
<Typography
variant="caption"
@ -321,7 +342,7 @@ class SearchPage extends Component<any, ISearchPageState> {
render() {
const { classes } = this.props;
return (
<div className={classes.pageLayoutConstraints}>
<div>
{this.state.type && this.state.type === "tag" ? (
this.showAllPostsWithTag()
) : (

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

@ -0,0 +1,385 @@
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;
}
/**
* 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")
};
// 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);
}
/**
* 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: 40 })
// 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", () => {});
}
/**
* Halt the stream listener when unmounting the component.
*/
componentWillUnmount() {
this.streamListener.stop();
}
/**
* 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: 20
})
// 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"
});
});
}
}
/**
* 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}
>
<Post
key={post.id}
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

@ -650,70 +650,49 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
loginData
);
// Re-read the config file. It's possible the state doesn't take
// effect immediately.
getConfig().then((result: any) => {
if (result !== undefined) {
let config: Config = result;
let redirectUrl =
`https://${window.location.host}` || undefined;
// Is this an emergency login? Don't pass a redirect
// URI
if (
this.state.emergencyMode ||
clientLoginSession.authUrl.includes(
"urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob"
)
) {
redirectUrl = undefined;
}
// Is this the desktop app? Change the redirect URI
else if (window.location.protocol === "hyperspace:") {
redirectUrl = "hyperspace://hyperspace/app/";
}
// Otherwise, read the config
else if (
config.location !== "dynamic" &&
config.location !== "desktop" &&
!inDisallowedDomains(config.location)
) {
redirectUrl = config.location;
}
// Fetch the access token
Mastodon.fetchAccessToken(
clientLoginSession.clientId,
clientLoginSession.clientSecret,
code,
localStorage.getItem("baseurl") as string,
redirectUrl
)
// If we succeeded, store the access token and redirect to the
// main view.
.then((tokenData: any) => {
localStorage.setItem(
"access_token",
tokenData.access_token
);
this.redirectToApp();
})
// Otherwise, present an error
.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);
});
});
}
}

View File

@ -44,18 +44,18 @@ export function createHyperspaceApp(
/**
* Gets the appropriate redirect address.
* @param type The address or configuration to use
* @param url The address or configuration to use
*/
export function getRedirectAddress(
type: "desktop" | "dynamic" | string
url: "desktop" | "dynamic" | string
): string {
switch (type) {
switch (url) {
case "desktop":
return "hyperspace://hyperspace/app/";
case "dynamic":
return `https://${window.location.host}`;
default:
return type;
return url;
}
}