Pull changes from b4
This commit is contained in:
commit
931244cb5a
|
@ -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.
|
|
@ -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).
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
49
src/App.tsx
49
src/App.tsx
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
})
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()
|
||||
) : (
|
||||
|
|
|
@ -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));
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue