Merge branch 'develop-1.1.0-beta4' into HD-44-uneven-masonry-columns

This commit is contained in:
Marquis Kurt 2020-02-17 08:19:51 -05:00
commit cc43d90607
No known key found for this signature in database
GPG Key ID: 725636D259F5402D
16 changed files with 659 additions and 248 deletions

2
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "hyperspace",
"version": "1.1.0-beta2",
"version": "1.1.0-beta4",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

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

@ -324,7 +324,7 @@ export class AppLayout extends Component<any, IAppLayoutState> {
*/
searchForQuery(what: string) {
what = what.replace(/^#/g, "tag:");
console.log(what);
// console.log(what);
window.location.href = isDesktopApp()
? "hyperspace://hyperspace/app/index.html#/search?query=" + what
: "/#/search?query=" + what;

View File

@ -124,7 +124,7 @@ export class Post extends React.Component<any, IPostState> {
})
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't delete post: " + err.name);
console.log(err.message);
console.error(err.message);
});
}
@ -410,6 +410,8 @@ export class Post extends React.Component<any, IPostState> {
emojis.concat(reblogger.emojis);
}
// console.log(post);
return (
<>
<span
@ -539,86 +541,63 @@ export class Post extends React.Component<any, IPostState> {
}
}
/**
* Get the post's URL
* @param post The post to get the URL from
* @returns A string containing the post's URI
*/
getMastodonUrl(post: Status) {
let url = "";
if (post.reblog) {
url = post.reblog.uri;
} else {
url = post.uri;
}
return url;
return post.reblog ? post.reblog.uri : post.uri;
}
toggleFavorited(post: Status) {
let _this = this;
if (post.favourited) {
this.client
.post(`/statuses/${post.id}/unfavourite`)
.then((resp: any) => {
let post: Status = resp.data;
this.setState({ post });
})
.catch((err: Error) => {
_this.props.enqueueSnackbar(
`Couldn't unfavorite post: ${err.name}`,
{
variant: "error"
}
);
console.log(err.message);
});
} else {
this.client
.post(`/statuses/${post.id}/favourite`)
.then((resp: any) => {
let post: Status = resp.data;
this.setState({ post });
})
.catch((err: Error) => {
_this.props.enqueueSnackbar(
`Couldn't favorite post: ${err.name}`,
{
variant: "error"
}
);
console.log(err.message);
});
/**
* Tell server a post has been un/favorited and update post state
* @param post The post to un/favorite
*/
async toggleFavorited(post: Status) {
let action: string = post.favourited ? "unfavourite" : "favourite";
try {
// favorite the original post, not the reblog
let resp: any = await this.client.post(
`/statuses/${post.reblog ? post.reblog.id : post.id}/${action}`
);
// compensate for slow server update
if (action === "unfavourite") {
resp.data.favourites_count -= 1;
// if you unlike both original and reblog before refresh
// and the post has only one favorite:
if (resp.data.favourites_count < 0) {
resp.data.favourites_count = 0;
}
}
this.setState({ post: resp.data as Status });
} catch (e) {
this.props.enqueueSnackbar(`Could not ${action} post: ${e.name}`);
console.error(e.message);
}
}
toggleReblogged(post: Status) {
if (post.reblogged) {
this.client
.post(`/statuses/${post.id}/unreblog`)
.then((resp: any) => {
let post: Status = resp.data;
this.setState({ post });
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't unboost post: ${err.name}`,
{
variant: "error"
}
);
console.log(err.message);
});
} else {
this.client
.post(`/statuses/${post.id}/reblog`)
.then((resp: any) => {
let post: Status = resp.data;
this.setState({ post });
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't boost post: ${err.name}`,
{
variant: "error"
}
);
console.log(err.message);
});
/**
* Tell server a post has been un/reblogged and update post state
* @param post The post to un/reblog
*/
async toggleReblogged(post: Status) {
let action: string =
post.reblogged || post.reblog ? "unreblog" : "reblog";
try {
// modify the original post, not the reblog
let resp: any = await this.client.post(
`/statuses/${post.reblog ? post.reblog.id : post.id}/${action}`
);
// compensate for slow server update
if (action === "unreblog") {
resp.data.reblogs_count -= 1;
}
if (resp.data.reblog) resp.data = resp.data.reblog;
this.setState({ post: resp.data as Status });
} catch (e) {
this.props.enqueueSnackbar(`Could not ${action} post: ${e.name}`);
console.error(e.message);
}
}

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

@ -83,7 +83,7 @@ class ActivityPage extends Component<any, IActivityPageState> {
viewLoading: false,
viewErrored: true
});
console.log(err.message);
console.error(err.message);
});
this.client
@ -101,7 +101,7 @@ class ActivityPage extends Component<any, IActivityPageState> {
viewLoading: false,
viewErrored: true
});
console.log(err.message);
console.error(err.message);
});
}

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

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

@ -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") {
@ -155,7 +159,7 @@ class SearchPage extends Component<any, ISearchPageState> {
viewDidLoad: true,
viewIsLoading: false
});
console.log(this.state.tagResults);
// console.log(this.state.tagResults);
})
.catch((err: Error) => {
this.setState({
@ -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()
) : (

View File

@ -174,6 +174,7 @@ class SettingsPage extends Component<any, ISettingsState> {
getConfig().then((result: any) => {
if (result !== undefined) {
let config: Config = result;
// console.log(!config.federation.allowPublicPosts);
this.setState({
federated: config.federation.allowPublicPosts
});

View File

@ -46,41 +46,157 @@ import { Account, MultiAccount } from "../types/Account";
import AccountCircleIcon from "@material-ui/icons/AccountCircle";
import CloseIcon from "@material-ui/icons/Close";
/**
* Basic props for Welcome page
*/
interface IWelcomeProps extends withSnackbarProps {
classes: any;
}
/**
* Basic state for welcome page
*/
interface IWelcomeState {
/**
* The custom-defined URL to the logo to display
*/
logoUrl?: string;
/**
* The custom-defined URL to the background image to display
*/
backgroundUrl?: string;
/**
* The custom-defined brand name of this app
*/
brandName?: string;
/**
* The custom-defined server address to register to
*/
registerBase?: string;
/**
* Whether this version of Hyperspace has federation
*/
federates?: boolean;
/**
* Whether Hyperspace is ready to get the auth code
*/
proceedToGetCode: boolean;
/**
* The currently "logged-in" user after the first step
*/
user: string;
/**
* Whether the user's input errors
*/
userInputError: boolean;
/**
* The user input error message, if any
*/
userInputErrorMessage: string;
/**
* The app's client ID, if registered
*/
clientId?: string;
/**
* The app's client secret, if registered
*/
clientSecret?: string;
/**
* The authorization URL provided by Mastodon from the
* client ID and secret
*/
authUrl?: string;
/**
* Whether a previous login attempt is present
*/
foundSavedLogin: boolean;
/**
* Whether Hyperspace is in the process of authorizing
*/
authorizing: boolean;
/**
* The custom-defined license for the Hyperspace source code
*/
license?: string;
/**
* The custom-defined URL to the source code of Hyperspace
*/
repo?: string;
/**
* The default address to redirect to. Used in login inits and
* when the authorization code completes.
*/
defaultRedirectAddress: string;
/**
* Whether the redirect address is set to 'dynamic'.
*/
redirectAddressIsDynamic: boolean;
/**
* Whether the authorization dialog for the emergency login is
* open.
*/
openAuthDialog: boolean;
/**
* The authorization code to fetch an access token with
*/
authCode: string;
/**
* Whether the Emergency Mode has been initiated
*/
emergencyMode: boolean;
/**
* The current app version
*/
version: string;
/**
* Whether we are in the process of adding a new account or not
*/
willAddAccount: boolean;
}
/**
* The base class for the Welcome page.
*
* The Welcome page is responsible for handling the registration,
* login, and authorization of accounts into the Hyperspace app.
*/
class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
/**
* The associated Mastodon client to handle logins/authorizations
* with
*/
client: any;
/**
* Construct the state and other components of the Welcome page
* @param props The properties passed onto the page
*/
constructor(props: any) {
super(props);
// Set up our state
this.state = {
proceedToGetCode: false,
user: "",
@ -89,6 +205,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
authorizing: false,
userInputErrorMessage: "",
defaultRedirectAddress: "",
redirectAddressIsDynamic: false,
openAuthDialog: false,
authCode: "",
emergencyMode: false,
@ -96,15 +213,21 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
willAddAccount: false
};
// Read the configuration data and update the state
getConfig()
.then((result: any) => {
if (result !== undefined) {
let config: Config = result;
// Warn if the location is dynamic (unexpected behavior)
if (result.location === "dynamic") {
console.warn(
"Redirect URI is set to dynamic, which may affect how sign-in works for some users. Careful!"
);
}
// Reset to mastodon.social if the location is a disallowed
// domain.
if (
inDisallowedDomains(result.registration.defaultInstance)
) {
@ -113,6 +236,8 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
);
result.registration.defaultInstance = "mastodon.social";
}
// Update the state as per the configuration
this.setState({
logoUrl: config.branding
? result.branding.logo
@ -133,10 +258,13 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
config.location != "dynamic"
? config.location
: `https://${window.location.host}`,
redirectAddressIsDynamic: config.location == "dynamic",
version: config.version
});
}
})
// Print an error if the config wasn't found.
.catch(() => {
console.error(
"config.json is missing. If you want to customize Hyperspace, please include config.json"
@ -144,6 +272,10 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
});
}
/**
* Look for any existing logins and tokens before presenting
* the login page
*/
componentDidMount() {
if (localStorage.getItem("login")) {
this.getSavedSession();
@ -154,18 +286,33 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
}
}
/**
* Update the user field in the state
* @param user The string to update the state to
*/
updateUserInfo(user: string) {
this.setState({ user });
}
/**
* Update the auth code in the state
* @param code The authorization code to update the state to
*/
updateAuthCode(code: string) {
this.setState({ authCode: code });
}
/**
* Toggle the visibility of the authorization dialog
*/
toggleAuthDialog() {
this.setState({ openAuthDialog: !this.state.openAuthDialog });
}
/**
* Determine whether the app is ready to open the authorization
* process.
*/
readyForAuth() {
if (localStorage.getItem("baseurl")) {
return true;
@ -174,11 +321,18 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
}
}
/**
* Clear the current access token and base URL
*/
clear() {
localStorage.removeItem("access_token");
localStorage.removeItem("baseurl");
}
/**
* Get the current saved session from the previous login
* attempt and update the state
*/
getSavedSession() {
let loginData = localStorage.getItem("login");
if (loginData) {
@ -192,6 +346,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
}
}
/**
* Start the emergency login mode.
*/
startEmergencyLogin() {
if (!this.state.emergencyMode) {
this.createEmergencyLogin();
@ -199,6 +356,11 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
this.toggleAuthDialog();
}
/**
* Start the registration process.
* @returns A URL pointing to the signup page of the base as defined
* in the config's `registerBase` field
*/
startRegistration() {
if (this.state.registerBase) {
return "https://" + this.state.registerBase + "/auth/sign_up";
@ -207,15 +369,33 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
}
}
/**
* Watch the keyboard and start the login procedure if the user
* presses the ENTER/RETURN key
* @param event The keyboard event
*/
watchUsernameField(event: any) {
if (event.keyCode === 13) this.startLogin();
}
/**
* Watch the keyboard and start the emergency login auth procedure
* if the user presses the ENTER/RETURN key
* @param event The keyboard event
*/
watchAuthField(event: any) {
if (event.keyCode === 13) this.authorizeEmergencyLogin();
}
/**
* Get the "logged-in" user by reading the username string
* from the first field on the login page.
* @param user The user string to parse
* @returns The base URL of the user
*/
getLoginUser(user: string) {
// Did the user include "@"? They probably are not from the
// server defined in config
if (user.includes("@")) {
if (this.state.federates) {
let newUser = user;
@ -235,7 +415,10 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
: "mastodon.social")
);
}
} else {
}
// Otherwise, treat them as if they're from the server
else {
let newUser = `${user}@${
this.state.registerBase
? this.state.registerBase
@ -251,70 +434,104 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
}
}
/**
* Check the user string for any errors and then create a client with an
* ID and secret to start the authorization process.
*/
startLogin() {
// Check if we have errored
let error = this.checkForErrors();
// If we didn't, create the Hyperspace app to register onto that Mastodon
// server.
if (!error) {
// Define the app's scopes and base URL
const scopes = "read write follow";
const baseurl = this.getLoginUser(this.state.user);
localStorage.setItem("baseurl", baseurl);
// Create the Hyperspace app
createHyperspaceApp(
this.state.brandName ? this.state.brandName : "Hyperspace",
scopes,
baseurl,
getRedirectAddress(this.state.defaultRedirectAddress)
).then((resp: any) => {
let saveSessionForCrashing: SaveClientSession = {
clientId: resp.clientId,
clientSecret: resp.clientSecret,
authUrl: resp.url,
emergency: false
};
localStorage.setItem(
"login",
JSON.stringify(saveSessionForCrashing)
);
this.setState({
clientId: resp.clientId,
clientSecret: resp.clientSecret,
authUrl: resp.url,
proceedToGetCode: true
)
// If we succeeded, create a login attempt for later reference
.then((resp: any) => {
let saveSessionForCrashing: SaveClientSession = {
clientId: resp.clientId,
clientSecret: resp.clientSecret,
authUrl: resp.url,
emergency: false
};
localStorage.setItem(
"login",
JSON.stringify(saveSessionForCrashing)
);
// Finally, update the state
this.setState({
clientId: resp.clientId,
clientSecret: resp.clientSecret,
authUrl: resp.url,
proceedToGetCode: true
});
});
});
} else {
}
}
/**
* Create an emergency mode login. This is usually initiated when the
* "click-to-authorize" method fails and the user needs to copy and paste
* an authorization code manually.
*/
createEmergencyLogin() {
console.log("Creating an emergency login...");
// Set up the scopes and base URL
const scopes = "read write follow";
const baseurl =
localStorage.getItem("baseurl") ||
this.getLoginUser(this.state.user);
// Register the Mastodon app with the Mastodon server
Mastodon.registerApp(
this.state.brandName ? this.state.brandName : "Hyperspace",
{
scopes: scopes
},
baseurl
).then((appData: any) => {
let saveSessionForCrashing: SaveClientSession = {
clientId: appData.clientId,
clientSecret: appData.clientSecret,
authUrl: appData.url,
emergency: true
};
localStorage.setItem(
"login",
JSON.stringify(saveSessionForCrashing)
);
this.setState({
clientId: appData.clientId,
clientSecret: appData.clientSecret,
authUrl: appData.url
)
// If we succeed, create a login attempt for later reference
.then((appData: any) => {
let saveSessionForCrashing: SaveClientSession = {
clientId: appData.clientId,
clientSecret: appData.clientSecret,
authUrl: appData.url,
emergency: true
};
localStorage.setItem(
"login",
JSON.stringify(saveSessionForCrashing)
);
// Finally, update the state
this.setState({
clientId: appData.clientId,
clientSecret: appData.clientSecret,
authUrl: appData.url
});
});
});
}
/**
* Open the URL to redirect to an authorization sequence from an emergency
* login.
*
* Since Hyperspace reads the auth code from the URL, we need to redirect to
* a URL with the code inside to trigger an auth
*/
authorizeEmergencyLogin() {
let redirAddress =
this.state.defaultRedirectAddress === "desktop"
@ -323,6 +540,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
window.location.href = `${redirAddress}/?code=${this.state.authCode}#/`;
}
/**
* Restore a login attempt from a session
*/
resumeLogin() {
let loginData = localStorage.getItem("login");
if (loginData) {
@ -337,10 +557,14 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
}
}
/**
* Check the user input string for any possible errors
*/
checkForErrors(): boolean {
let userInputError = false;
let userInputErrorMessage = "";
// Is the user string blank?
if (this.state.user === "") {
userInputError = true;
userInputErrorMessage = "Username cannot be blank.";
@ -350,6 +574,8 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
if (this.state.user.includes("@")) {
if (this.state.federates && this.state.federates === true) {
let baseUrl = this.state.user.split("@")[1];
// Is the user's domain in the disallowed list?
if (inDisallowedDomains(baseUrl)) {
this.setState({
userInputError: true,
@ -357,6 +583,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
});
return true;
} else {
// Are we unable to ping the server?
axios
.get(
"https://instances.social/api/1.0/instances/show?name=" +
@ -403,56 +630,89 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
}
}
/**
* Read the URL and determine whether or not there's an auth code
* in the URL. If there is, try to authorize and get the access
* token for storage.
*/
checkForToken() {
let location = window.location.href;
// Is there an auth code?
if (location.includes("?code=")) {
let code = parseUrl(location).query.code as string;
this.setState({ authorizing: true });
let loginData = localStorage.getItem("login");
// If there's login data, try to fetch an access token
if (loginData) {
let clientLoginSession: SaveClientSession = JSON.parse(
loginData
);
Mastodon.fetchAccessToken(
clientLoginSession.clientId,
clientLoginSession.clientSecret,
code,
localStorage.getItem("baseurl") as string,
this.state.emergencyMode
? undefined
: clientLoginSession.authUrl.includes(
"urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob"
)
? undefined
: window.location.protocol === "hyperspace:"
? "hyperspace://hyperspace/app/"
: `https://${window.location.host}`
)
.then((tokenData: any) => {
localStorage.setItem(
"access_token",
tokenData.access_token
);
window.location.href =
window.location.protocol === "hyperspace:"
? "hyperspace://hyperspace/app/"
: `https://${window.location.host}/#/`;
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't authorize ${
this.state.brandName
? this.state.brandName
: "Hyperspace"
}: ${err.name}`,
{ variant: "error" }
);
console.error(err.message);
});
getConfig().then((resp: any) => {
if (resp == undefined) {
return;
}
let conf: Config = resp;
let redirectUrl: string | undefined =
this.state.emergencyMode ||
clientLoginSession.authUrl.includes(
"urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob"
)
? undefined
: getRedirectAddress(conf.location);
Mastodon.fetchAccessToken(
clientLoginSession.clientId,
clientLoginSession.clientSecret,
code,
localStorage.getItem("baseurl") as string,
redirectUrl
)
.then((tokenData: any) => {
localStorage.setItem(
"access_token",
tokenData.access_token
);
window.location.href =
window.location.protocol === "hyperspace:"
? "hyperspace://hyperspace/app/"
: this.state.defaultRedirectAddress;
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't authorize ${
this.state.brandName
? this.state.brandName
: "Hyperspace"
}: ${err.name}`,
{ variant: "error" }
);
console.error(err.message);
});
});
}
}
}
/**
* Redirect to the app's main view after a login.
*/
redirectToApp() {
window.location.href =
window.location.protocol === "hyperspace:"
? "hyperspace://hyperspace/app"
: this.state.redirectAddressIsDynamic
? `https://${window.location.host}/#/`
: this.state.defaultRedirectAddress + "/#/";
}
/**
* Render the title bar for macOS
*/
titlebar() {
const { classes } = this.props;
if (isDarwinApp()) {
@ -468,6 +728,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
}
}
/**
* Show the multi-user account panel
*/
showMultiAccount() {
const { classes } = this.props;
return (
@ -481,11 +744,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
<ListItem
onClick={() => {
loginWithAccount(account);
window.location.href =
window.location.protocol ===
"hyperspace:"
? "hyperspace://hyperspace/app/"
: `https://${window.location.host}/#/`;
this.redirectToApp();
}}
button={true}
>
@ -527,6 +786,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
);
}
/**
* Show the main landing panel
*/
showLanding() {
const { classes } = this.props;
return (
@ -610,6 +872,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
);
}
/**
* Show the login auth panel
*/
showLoginAuth() {
const { classes } = this.props;
return (
@ -651,6 +916,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
);
}
/**
* Show the emergency login panel
*/
showAuthDialog() {
const { classes } = this.props;
return (
@ -705,6 +973,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
);
}
/**
* Show the authorizing panel
*/
showAuthorizationLoader() {
const { classes } = this.props;
return (
@ -725,6 +996,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
);
}
/**
* Render the page
*/
render() {
const { classes } = this.props;
return (

View File

@ -74,7 +74,7 @@ class You extends Component<IYouProps, IYouState> {
getAccount() {
let acct = localStorage.getItem("account");
console.log(acct);
// console.log(acct);
if (acct) {
return JSON.parse(acct);
}

View File

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

View File

@ -136,6 +136,15 @@ export function createUserDefaults() {
export async function getConfig(): Promise<Config | undefined> {
try {
const resp = await axios.get("config.json");
let { location } = resp.data;
if (!location.endsWith("/")) {
console.warn(
"Location does not have a backslash, so Hyperspace has added it automatically."
);
resp.data.location = location + "/";
}
return resp.data as Config;
} catch (err) {
console.error(