Compare commits
27 commits
Author | SHA1 | Date | |
---|---|---|---|
|
491fe19084 | ||
|
8a36343a64 | ||
|
11bd8feee7 | ||
|
65325a63fb | ||
|
2efd4a6a4e | ||
|
bf8ac44011 | ||
|
81844e476c | ||
|
5255fdd31c | ||
|
37bded2cf7 | ||
|
576b247913 | ||
|
9b729bf1b1 | ||
|
56c94f16bf | ||
|
b6c52750a3 | ||
|
88926ac162 | ||
|
4e4757bac5 | ||
|
58709501ca | ||
|
26164479ff | ||
|
923b7a73a2 | ||
|
36202a5cc8 | ||
|
0a6462682a | ||
|
02869b6aed | ||
|
ed237e4d0e | ||
|
cb792539c3 | ||
|
494f853b7c | ||
|
702f82cb04 | ||
|
2358754bb3 | ||
|
076f7534db |
53 changed files with 676 additions and 178 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -6,6 +6,7 @@
|
|||
|
||||
# Ignore bundler config and downloaded libraries.
|
||||
/.bundle
|
||||
distribution
|
||||
/vendor/bundle
|
||||
|
||||
# Ignore the default SQLite database.
|
||||
|
|
30
.woodpecker.yml
Normal file
30
.woodpecker.yml
Normal file
|
@ -0,0 +1,30 @@
|
|||
pipeline:
|
||||
build:
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
- push
|
||||
image: node:16
|
||||
commands:
|
||||
- yarn
|
||||
- TARGET=distribution ./build.sh
|
||||
|
||||
release:
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
- push
|
||||
image: node:16
|
||||
secrets:
|
||||
- SCW_ACCESS_KEY
|
||||
- SCW_SECRET_KEY
|
||||
- SCW_DEFAULT_ORGANIZATION_ID
|
||||
commands:
|
||||
- apt-get update && apt-get install -y rclone wget zip
|
||||
- wget https://github.com/scaleway/scaleway-cli/releases/download/v2.5.1/scaleway-cli_2.5.1_linux_amd64
|
||||
- mv scaleway-cli_2.5.1_linux_amd64 scaleway-cli
|
||||
- chmod +x scaleway-cli
|
||||
- ./scaleway-cli object config install type=rclone
|
||||
- export BUILD_TAG=$${CI_COMMIT_TAG:-"$CI_COMMIT_BRANCH"}
|
||||
- zip mastofe.zip -r distribution
|
||||
- rclone copyto mastofe.zip scaleway:akkoma-updates/frontend/$BUILD_TAG/masto-fe.zip
|
35
README.md
35
README.md
|
@ -1,14 +1,31 @@
|
|||
# Mastodon Glitch Edition
|
||||
# Mastodon Glitch Edition (standalone frontend)
|
||||
|
||||
> Now with automated deploys!
|
||||
This is a very hacky fork of akkoma-masto-fe that adds standalone support (meaning your browser can OAuth against an arbitrary instance). It's currently tested to "work" (login doesn't break, basic functionality works) with Iceshrimp and GoToSocial (and it obviously works with Mastodon).
|
||||
|
||||
[][circleci]
|
||||
[][code_climate]
|
||||
To try this out, go to [masto-fe.iceshrimp.dev](https://masto-fe.iceshrimp.dev), type in your instance domain name (for split domain setups, use the web domain) & press the button.
|
||||
|
||||
[circleci]: https://circleci.com/gh/glitch-soc/mastodon
|
||||
[code_climate]: https://codeclimate.com/github/glitch-soc/mastodon
|
||||
To set this up yourself, clone the repo into e.g. `/home/user/masto-fe-standalone` and run `yarn && yarn build:production` (you might have to use `NODE_OPTIONS=--openssl-legacy-provider` until we've rebased this onto upstream glitch).
|
||||
|
||||
So here's the deal: we all work on this code, and anyone who uses that does so absolutely at their own risk. can you dig it?
|
||||
Then configure nginx for a subdomain like this:
|
||||
|
||||
- You can view documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/).
|
||||
- And contributing guidelines are available [here](CONTRIBUTING.md) and [here](https://glitch-soc.github.io/docs/contributing/).
|
||||
```
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
include sites/example.com/inc/ssl.conf;
|
||||
server_name masto.example.com;
|
||||
|
||||
location / {
|
||||
root /home/user/masto-fe-standalone/public/;
|
||||
index index.html;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And open `https://masto.example.com` in your browser, type in your instance domain, press the button & follow the OAuth flow.
|
||||
|
||||
Should anything break, open `https://masto.example.com/logout.html` or clear local storage manually.
|
||||
|
|
|
@ -874,7 +874,7 @@ export function changePinnedAccountsSuggestions(value) {
|
|||
type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE,
|
||||
value,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function resetPinnedAccountsEditor() {
|
||||
return {
|
||||
|
|
|
@ -18,7 +18,10 @@ const urlBase64ToUint8Array = (base64String) => {
|
|||
return outputArray;
|
||||
};
|
||||
|
||||
const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
|
||||
const getApplicationServerKey = () => {
|
||||
const k = document.querySelector('[name="applicationServerKey"]');
|
||||
return k === null ? '' : k.getAttribute('content');
|
||||
};
|
||||
|
||||
const getRegistration = () => navigator.serviceWorker.ready;
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ export const fetchServer = () => (dispatch, getState) => {
|
|||
dispatch(fetchServerRequest());
|
||||
|
||||
api(getState)
|
||||
.get('/api/v2/instance').then(({ data }) => {
|
||||
.get('/api/v1/instance').then(({ data }) => {
|
||||
if (data.contact.account) dispatch(importFetchedAccount(data.contact.account));
|
||||
dispatch(fetchServerSuccess(data));
|
||||
}).catch(err => dispatch(fetchServerFail(err)));
|
||||
|
|
|
@ -79,10 +79,21 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
|||
}
|
||||
},
|
||||
|
||||
shouldUpdate (timelineId, streamName) {
|
||||
const stream = streamName[0];
|
||||
if (timelineId === 'home' && streamName.startsWith('user')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return timelineId === stream;
|
||||
},
|
||||
|
||||
onReceive (data) {
|
||||
switch(data.event) {
|
||||
case 'update':
|
||||
if ((timelineId === 'home' && data.stream[0].startsWith('user')) || (timelineId === 'community' && data.stream[0].startsWith('public')) || (timelineId === data.stream[0])) {
|
||||
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
|
||||
}
|
||||
break;
|
||||
case 'status.update':
|
||||
dispatch(updateStatus(JSON.parse(data.payload)));
|
||||
|
|
|
@ -3,7 +3,7 @@ import { submitMarkers } from './markers';
|
|||
import api, { getLinks } from 'flavours/glitch/api';
|
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
import compareId from 'flavours/glitch/compare_id';
|
||||
import { me, usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state';
|
||||
import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state';
|
||||
import { toServerSideType } from 'flavours/glitch/utils/filters';
|
||||
|
||||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
|
||||
|
@ -153,7 +153,7 @@ export function fillTimelineGaps(timelineId, path, params = {}, done = noOp) {
|
|||
}
|
||||
|
||||
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
|
||||
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, allow_local_only: !!allowLocalOnly, max_id: maxId, only_media: !!onlyMedia }, done);
|
||||
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia }, done);
|
||||
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
|
||||
export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
|
||||
export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, tagged, max_id: maxId });
|
||||
|
@ -171,7 +171,7 @@ export const expandHashtagTimeline = (hashtag, { maxId, tags, local } =
|
|||
};
|
||||
|
||||
export const fillHomeTimelineGaps = (done = noOp) => fillTimelineGaps('home', '/api/v1/timelines/home', {}, done);
|
||||
export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => fillTimelineGaps(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia, allow_local_only: !!allowLocalOnly }, done);
|
||||
export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => fillTimelineGaps(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia }, done);
|
||||
export const fillCommunityTimelineGaps = ({ onlyMedia } = {}, done = noOp) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }, done);
|
||||
export const fillListTimelineGaps = (id, done = noOp) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}, done);
|
||||
|
||||
|
|
|
@ -51,6 +51,15 @@ const authorizationHeaderFromState = getState => {
|
|||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {() => import('immutable').Map<string,any>} getState
|
||||
* @returns string
|
||||
*/
|
||||
const baseUrlFromState = getState => {
|
||||
const baseUrl = getState && getState().getIn(['meta', 'base_url'], '');
|
||||
return `${baseUrl}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {() => import('immutable').Map} getState
|
||||
* @returns {import('axios').AxiosInstance}
|
||||
|
@ -62,6 +71,8 @@ export default function api(getState) {
|
|||
...authorizationHeaderFromState(getState),
|
||||
},
|
||||
|
||||
baseURL: baseUrlFromState(getState),
|
||||
|
||||
transformResponse: [
|
||||
function (data) {
|
||||
try {
|
||||
|
|
|
@ -117,7 +117,7 @@ export default class ErrorBoundary extends React.PureComponent {
|
|||
<FormattedMessage
|
||||
id='web_app_crash.change_your_settings'
|
||||
defaultMessage='Change your {settings}'
|
||||
values={{ settings: <a href={preferencesLink}><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></a> }}
|
||||
values={{ settings: <span><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></span> }}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
|
|
|
@ -55,8 +55,9 @@ export const ImmutableHashtag = ({ hashtag }) => (
|
|||
name={hashtag.get('name')}
|
||||
href={hashtag.get('url')}
|
||||
to={`/tags/${hashtag.get('name')}`}
|
||||
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
|
||||
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
|
||||
people={0}
|
||||
uses={0}
|
||||
history={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ const messages = defineMessages({
|
|||
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
||||
private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
|
||||
direct: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
|
||||
local: { id: 'privacy.local.short', defaultMessage: 'Local users only' },
|
||||
});
|
||||
|
||||
class VisibilityIcon extends ImmutablePureComponent {
|
||||
|
@ -28,6 +29,7 @@ class VisibilityIcon extends ImmutablePureComponent {
|
|||
unlisted: 'unlock',
|
||||
private: 'lock',
|
||||
direct: 'envelope',
|
||||
local: 'lock',
|
||||
}[visibility];
|
||||
|
||||
const label = intl.formatMessage(messages[visibility]);
|
||||
|
|
|
@ -36,7 +36,7 @@ const createIdentityContext = state => ({
|
|||
accountId: state.meta.me,
|
||||
disabledAccountId: state.meta.disabled_account_id,
|
||||
accessToken: state.meta.access_token,
|
||||
permissions: state.role ? state.role.permissions : 0,
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
export default class Mastodon extends React.PureComponent {
|
||||
|
|
|
@ -38,8 +38,6 @@ class ActionBar extends React.PureComponent {
|
|||
|
||||
let menu = [];
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
|
||||
menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
|
||||
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
|
||||
|
|
|
@ -21,6 +21,8 @@ import { length } from 'stringz';
|
|||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
|
||||
missingDescriptionConfirm: { id: 'confirmations.missing_media_description.confirm',
|
||||
defaultMessage: 'Send anyway' },
|
||||
missingDescriptionMessage: {
|
||||
id: 'confirmations.missing_media_description.message',
|
||||
defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.',
|
||||
|
@ -311,7 +313,6 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
return (
|
||||
<div className='compose-form'>
|
||||
<WarningContainer />
|
||||
|
||||
<ReplyIndicatorContainer />
|
||||
|
||||
<div className={`spoiler-input ${spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}>
|
||||
|
|
|
@ -130,8 +130,7 @@ class Header extends ImmutablePureComponent {
|
|||
><Icon id='sign-out' /></a>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
export default injectIntl(Header);
|
||||
|
|
|
@ -24,16 +24,10 @@ export default class NavigationBar extends ImmutablePureComponent {
|
|||
</Permalink>
|
||||
|
||||
<div className='navigation-bar__profile'>
|
||||
<div>{this.props.account.get('display_name')}</div>
|
||||
<Permalink className='acct' href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
|
||||
<strong>@{this.props.account.get('acct')}</strong>
|
||||
</Permalink>
|
||||
|
||||
{ profileLink !== undefined && (
|
||||
<a
|
||||
className='edit'
|
||||
href={profileLink}
|
||||
><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='navigation-bar__actions'>
|
||||
|
@ -42,5 +36,4 @@ export default class NavigationBar extends ImmutablePureComponent {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -94,7 +94,6 @@ class Publisher extends ImmutablePureComponent {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(Publisher);
|
||||
|
|
|
@ -136,7 +136,6 @@ class SearchResults extends ImmutablePureComponent {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(SearchResults);
|
||||
|
|
|
@ -39,7 +39,7 @@ const mapStateToProps = state => ({
|
|||
|
||||
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
|
||||
if (needsLockWarning) {
|
||||
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href={profileLink}><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
|
||||
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <span><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></span> }} />} />;
|
||||
}
|
||||
|
||||
if (hashtagWarning) {
|
||||
|
|
|
@ -71,15 +71,8 @@ class LocalSettingsNavigation extends React.PureComponent {
|
|||
/>
|
||||
<LocalSettingsNavigationItem
|
||||
active={index === 5}
|
||||
href={preferencesLink}
|
||||
index={5}
|
||||
icon='cog'
|
||||
title={intl.formatMessage(messages.preferences)}
|
||||
/>
|
||||
<LocalSettingsNavigationItem
|
||||
active={index === 6}
|
||||
className='close'
|
||||
index={6}
|
||||
index={5}
|
||||
onNavigate={onClose}
|
||||
icon='times'
|
||||
title={intl.formatMessage(messages.close)}
|
||||
|
|
|
@ -299,12 +299,12 @@ class LocalSettingsPage extends React.PureComponent {
|
|||
defaultMessage="This setting is now controlled from Mastodon's {settings_page_link}"
|
||||
values={{
|
||||
settings_page_link: (
|
||||
<a href={preferenceLink('user_setting_expand_spoilers')}>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='settings.shared_settings_link'
|
||||
defaultMessage='user preferences'
|
||||
/>
|
||||
</a>
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -78,12 +78,12 @@ class PublicTimeline extends React.PureComponent {
|
|||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props;
|
||||
const { dispatch, onlyMedia, onlyRemote } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
dispatch(expandPublicTimeline({ onlyMedia, onlyRemote, allowLocalOnly }));
|
||||
dispatch(expandPublicTimeline({ onlyMedia, onlyRemote }));
|
||||
if (signedIn) {
|
||||
this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote, allowLocalOnly }));
|
||||
this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote }));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -91,16 +91,16 @@ class PublicTimeline extends React.PureComponent {
|
|||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (prevProps.onlyMedia !== this.props.onlyMedia || prevProps.onlyRemote !== this.props.onlyRemote || prevProps.allowLocalOnly !== this.props.allowLocalOnly) {
|
||||
const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props;
|
||||
const { dispatch, onlyMedia, onlyRemote } = this.props;
|
||||
|
||||
if (this.disconnect) {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
dispatch(expandPublicTimeline({ onlyMedia, onlyRemote, allowLocalOnly }));
|
||||
dispatch(expandPublicTimeline({ onlyMedia, onlyRemote }));
|
||||
|
||||
if (signedIn) {
|
||||
this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote, allowLocalOnly }));
|
||||
this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -117,9 +117,9 @@ class PublicTimeline extends React.PureComponent {
|
|||
};
|
||||
|
||||
handleLoadMore = maxId => {
|
||||
const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props;
|
||||
const { dispatch, onlyMedia, onlyRemote } = this.props;
|
||||
|
||||
dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote, allowLocalOnly }));
|
||||
dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote }));
|
||||
};
|
||||
|
||||
render () {
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { expandPublicTimeline, expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
|
||||
import Masonry from 'react-masonry-infinite';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import DetailedStatusContainer from 'flavours/glitch/features/status/containers/detailed_status_container';
|
||||
import { debounce } from 'lodash';
|
||||
import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
|
||||
|
||||
const mapStateToProps = (state, { local }) => {
|
||||
const timeline = state.getIn(['timelines', local ? 'community' : 'public'], ImmutableMap());
|
||||
|
||||
return {
|
||||
statusIds: timeline.get('items', ImmutableList()),
|
||||
isLoading: timeline.get('isLoading', false),
|
||||
hasMore: timeline.get('hasMore', false),
|
||||
};
|
||||
};
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
class PublicTimeline extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
isLoading: PropTypes.bool.isRequired,
|
||||
hasMore: PropTypes.bool.isRequired,
|
||||
local: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
this._connect();
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.local !== this.props.local) {
|
||||
this._disconnect();
|
||||
this._connect();
|
||||
}
|
||||
}
|
||||
|
||||
_connect () {
|
||||
const { dispatch, local } = this.props;
|
||||
|
||||
dispatch(local ? expandCommunityTimeline() : expandPublicTimeline());
|
||||
}
|
||||
|
||||
handleLoadMore = () => {
|
||||
const { dispatch, statusIds, local } = this.props;
|
||||
const maxId = statusIds.last();
|
||||
|
||||
if (maxId) {
|
||||
dispatch(local ? expandCommunityTimeline({ maxId }) : expandPublicTimeline({ maxId }));
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.masonry = c;
|
||||
}
|
||||
|
||||
handleHeightChange = debounce(() => {
|
||||
if (!this.masonry) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.masonry.forcePack();
|
||||
}, 50)
|
||||
|
||||
render () {
|
||||
const { statusIds, hasMore, isLoading } = this.props;
|
||||
|
||||
const sizes = [
|
||||
{ columns: 1, gutter: 0 },
|
||||
{ mq: '415px', columns: 1, gutter: 10 },
|
||||
{ mq: '640px', columns: 2, gutter: 10 },
|
||||
{ mq: '960px', columns: 3, gutter: 10 },
|
||||
{ mq: '1255px', columns: 3, gutter: 10 },
|
||||
];
|
||||
|
||||
const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined;
|
||||
|
||||
return (
|
||||
<Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}>
|
||||
{statusIds.map(statusId => (
|
||||
<div className='statuses-grid__item' key={statusId}>
|
||||
<DetailedStatusContainer
|
||||
id={statusId}
|
||||
compact
|
||||
measureHeight
|
||||
onHeightChange={this.handleHeightChange}
|
||||
/>
|
||||
</div>
|
||||
)).toArray()}
|
||||
</Masonry>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -65,7 +65,7 @@ class DeprecatedSettingsModal extends React.PureComponent {
|
|||
<ul>
|
||||
{ settings.map((setting_name) => (
|
||||
<li>
|
||||
<a href={preferenceLink(setting_name)}><FormattedMessage {...messages[setting_name]} /></a>
|
||||
<span><FormattedMessage {...messages[setting_name]} /></span>
|
||||
</li>
|
||||
)) }
|
||||
</ul>
|
||||
|
|
|
@ -55,42 +55,12 @@ class LinkFooter extends React.PureComponent {
|
|||
return (
|
||||
<div className='link-footer'>
|
||||
<p>
|
||||
<strong>{domain}</strong>:
|
||||
{' '}
|
||||
<Link to='/about'><FormattedMessage id='footer.about' defaultMessage='About' /></Link>
|
||||
{statusPageUrl && (
|
||||
<>
|
||||
<strong>Masto-FE-standalone</strong>
|
||||
{DividingCircle}
|
||||
<a href={statusPageUrl} target='_blank' rel='noopener'><FormattedMessage id='footer.status' defaultMessage='Status' /></a>
|
||||
</>
|
||||
)}
|
||||
{canInvite && (
|
||||
<>
|
||||
{DividingCircle}
|
||||
<a href='/invites' target='_blank'><FormattedMessage id='footer.invite' defaultMessage='Invite people' /></a>
|
||||
</>
|
||||
)}
|
||||
{canProfileDirectory && (
|
||||
<>
|
||||
{DividingCircle}
|
||||
<Link to='/directory'><FormattedMessage id='footer.directory' defaultMessage='Profiles directory' /></Link>
|
||||
</>
|
||||
)}
|
||||
{DividingCircle}
|
||||
<Link to='/privacy-policy'><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Mastodon</strong>:
|
||||
{' '}
|
||||
<a href='https://joinmastodon.org' target='_blank'><FormattedMessage id='footer.about' defaultMessage='About' /></a>
|
||||
{DividingCircle}
|
||||
<a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='footer.get_app' defaultMessage='Get the app' /></a>
|
||||
<a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='footer.source_code' defaultMessage='Source code' /></a>
|
||||
{DividingCircle}
|
||||
<Link to='/keyboard-shortcuts'><FormattedMessage id='footer.keyboard_shortcuts' defaultMessage='Keyboard shortcuts' /></Link>
|
||||
{DividingCircle}
|
||||
<a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='footer.source_code' defaultMessage='View source code' /></a>
|
||||
{DividingCircle}
|
||||
v{version}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -85,7 +85,6 @@ class NavigationPanel extends React.Component {
|
|||
|
||||
<hr />
|
||||
|
||||
{!!preferencesLink && <ColumnLink transparent href={preferencesLink} icon='cog' text={intl.formatMessage(messages.preferences)} />}
|
||||
<ColumnLink transparent onClick={onOpenSettings} icon='cogs' text={intl.formatMessage(messages.app_settings)} />
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
|
|
@ -144,7 +144,7 @@ export const languages = initialState?.languages;
|
|||
export const statusPageUrl = getMeta('status_page_url');
|
||||
|
||||
// Glitch-soc-specific settings
|
||||
export const maxChars = (initialState && initialState.max_toot_chars) || 500;
|
||||
export const maxChars = (initialState && initialState.char_limit) || 500;
|
||||
export const favouriteModal = getMeta('favourite_modal');
|
||||
export const pollLimits = (initialState && initialState.poll_limits);
|
||||
export const defaultContentType = getMeta('default_content_type');
|
||||
|
|
13
app/javascript/flavours/glitch/reducers/rules.js
Normal file
13
app/javascript/flavours/glitch/reducers/rules.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { RULES_FETCH_SUCCESS } from 'flavours/glitch/actions/rules';
|
||||
import { List as ImmutableList, fromJS } from 'immutable';
|
||||
|
||||
const initialState = ImmutableList();
|
||||
|
||||
export default function rules(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case RULES_FETCH_SUCCESS:
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -88,25 +88,7 @@ const sharedCallbacks = {
|
|||
},
|
||||
|
||||
received (data) {
|
||||
const { stream } = data;
|
||||
|
||||
subscriptions.filter(({ channelName, params }) => {
|
||||
const streamChannelName = stream[0];
|
||||
|
||||
if (stream.length === 1) {
|
||||
return channelName === streamChannelName;
|
||||
}
|
||||
|
||||
const streamIdentifier = stream[1];
|
||||
|
||||
if (['hashtag', 'hashtag:local'].includes(channelName)) {
|
||||
return channelName === streamChannelName && params.tag === streamIdentifier;
|
||||
} else if (channelName === 'list') {
|
||||
return channelName === streamChannelName && params.list === streamIdentifier;
|
||||
}
|
||||
|
||||
return false;
|
||||
}).forEach(subscription => {
|
||||
subscriptions.forEach(subscription => {
|
||||
subscription.onReceive(data);
|
||||
});
|
||||
},
|
||||
|
@ -230,7 +212,9 @@ const createConnection = (streamingAPIBaseURL, accessToken, channelName, { conne
|
|||
channelName = params.shift();
|
||||
|
||||
if (streamingAPIBaseURL.startsWith('ws')) {
|
||||
const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
|
||||
params.push(`access_token=${accessToken}`);
|
||||
|
||||
const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming?stream=user&${params.join('&')}`, accessToken);
|
||||
|
||||
ws.onopen = connected;
|
||||
ws.onmessage = e => received(JSON.parse(e.data));
|
||||
|
|
28
app/javascript/flavours/glitch/util/main.js
Normal file
28
app/javascript/flavours/glitch/util/main.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
import * as registerPushNotifications from 'flavours/glitch/actions/push_notifications';
|
||||
import { setupBrowserNotifications } from 'flavours/glitch/actions/notifications';
|
||||
import { default as Mastodon, store } from 'flavours/glitch/containers/mastodon';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import ready from './ready';
|
||||
|
||||
const perf = require('./performance');
|
||||
|
||||
function main() {
|
||||
perf.start('main()');
|
||||
|
||||
ready(() => {
|
||||
const mountNode = document.getElementById('mastodon');
|
||||
const props = JSON.parse(mountNode.getAttribute('data-props'));
|
||||
|
||||
ReactDOM.render(<Mastodon {...props} />, mountNode);
|
||||
store.dispatch(setupBrowserNotifications());
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// avoid offline in dev mode because it's harder to debug
|
||||
require('offline-plugin/runtime').install();
|
||||
store.dispatch(registerPushNotifications.register());
|
||||
}
|
||||
perf.stop('main()');
|
||||
});
|
||||
}
|
||||
|
||||
export default main;
|
|
@ -1,6 +1,6 @@
|
|||
export const preferencesLink = '/settings/preferences';
|
||||
export const profileLink = '/settings/profile';
|
||||
export const signOutLink = '/auth/sign_out';
|
||||
export const preferencesLink = undefined;
|
||||
export const profileLink = undefined;
|
||||
export const signOutLink = '/logout.html';
|
||||
export const privacyPolicyLink = '/privacy-policy';
|
||||
export const accountAdminLink = (id) => `/admin/accounts/${id}`;
|
||||
export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`;
|
||||
|
|
|
@ -25,7 +25,7 @@ export const logOut = () => {
|
|||
submitButton.setAttribute('type', 'submit');
|
||||
form.appendChild(submitButton);
|
||||
|
||||
form.method = 'post';
|
||||
form.method = 'get';
|
||||
form.action = signOutLink;
|
||||
form.style.display = 'none';
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ export const fetchServer = () => (dispatch, getState) => {
|
|||
dispatch(fetchServerRequest());
|
||||
|
||||
api(getState)
|
||||
.get('/api/v2/instance').then(({ data }) => {
|
||||
.get('/api/v1/instance').then(({ data }) => {
|
||||
if (data.contact.account) dispatch(importFetchedAccount(data.contact.account));
|
||||
dispatch(fetchServerSuccess(data));
|
||||
}).catch(err => dispatch(fetchServerFail(err)));
|
||||
|
|
|
@ -51,6 +51,15 @@ const authorizationHeaderFromState = getState => {
|
|||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {() => import('immutable').Map<string,any>} getState
|
||||
* @returns string
|
||||
*/
|
||||
const baseUrlFromState = getState => {
|
||||
const baseUrl = getState && getState().getIn(['meta', 'base_url'], '');
|
||||
return `${baseUrl}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {() => import('immutable').Map<string,any>} getState
|
||||
* @returns {import('axios').AxiosInstance}
|
||||
|
@ -62,6 +71,8 @@ export default function api(getState) {
|
|||
...authorizationHeaderFromState(getState),
|
||||
},
|
||||
|
||||
baseURL: baseUrlFromState(getState),
|
||||
|
||||
transformResponse: [
|
||||
function (data) {
|
||||
try {
|
||||
|
|
|
@ -32,7 +32,7 @@ const createIdentityContext = state => ({
|
|||
accountId: state.meta.me,
|
||||
disabledAccountId: state.meta.disabled_account_id,
|
||||
accessToken: state.meta.access_token,
|
||||
permissions: state.role ? state.role.permissions : 0,
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
export default class Mastodon extends React.PureComponent {
|
||||
|
|
|
@ -273,7 +273,7 @@
|
|||
"footer.invite": "Invite people",
|
||||
"footer.keyboard_shortcuts": "Keyboard shortcuts",
|
||||
"footer.privacy_policy": "Privacy policy",
|
||||
"footer.source_code": "View source code",
|
||||
"footer.source_code": "Source code",
|
||||
"footer.status": "Status",
|
||||
"generic.saved": "Saved",
|
||||
"getting_started.heading": "Getting started",
|
||||
|
|
|
@ -11,7 +11,7 @@ function openWebCache() {
|
|||
}
|
||||
|
||||
function fetchRoot() {
|
||||
return fetch('/', { credentials: 'include', redirect: 'manual' });
|
||||
return fetch('/web', { credentials: 'include', redirect: 'manual' });
|
||||
}
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
@ -58,7 +58,7 @@ registerRoute(
|
|||
// Cause a new version of a registered Service Worker to replace an existing one
|
||||
// that is already installed, and replace the currently active worker on open pages.
|
||||
self.addEventListener('install', function(event) {
|
||||
event.waitUntil(Promise.all([openWebCache(), fetchRoot()]).then(([cache, root]) => cache.put('/', root)));
|
||||
event.waitUntil(Promise.all([openWebCache(), fetchRoot()]).then(([cache, root]) => cache.put('/web', root)));
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function(event) {
|
||||
|
@ -68,20 +68,8 @@ self.addEventListener('activate', function(event) {
|
|||
self.addEventListener('fetch', function(event) {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
if (url.pathname === '/auth/sign_out') {
|
||||
const asyncResponse = fetch(event.request);
|
||||
const asyncCache = openWebCache();
|
||||
|
||||
event.respondWith(asyncResponse.then(response => {
|
||||
if (response.ok || response.type === 'opaqueredirect') {
|
||||
return Promise.all([
|
||||
asyncCache.then(cache => cache.delete('/')),
|
||||
indexedDB.deleteDatabase('mastodon'),
|
||||
]).then(() => response);
|
||||
}
|
||||
|
||||
return response;
|
||||
}));
|
||||
if (url.pathname.startsWith('/web')) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -236,8 +236,10 @@ const createConnection = (streamingAPIBaseURL, accessToken, channelName, { conne
|
|||
channelName = params.shift();
|
||||
|
||||
if (streamingAPIBaseURL.startsWith('ws')) {
|
||||
params.push(`access_token=${accessToken}`);
|
||||
|
||||
// @ts-expect-error
|
||||
const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
|
||||
const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming?${params.join('&')}`, accessToken);
|
||||
|
||||
// @ts-expect-error
|
||||
ws.onopen = connected;
|
||||
|
|
|
@ -24,8 +24,8 @@ export const logOut = () => {
|
|||
submitButton.setAttribute('type', 'submit');
|
||||
form.appendChild(submitButton);
|
||||
|
||||
form.method = 'post';
|
||||
form.action = '/auth/sign_out';
|
||||
form.method = 'get';
|
||||
form.action = '/logout.html';
|
||||
form.style.display = 'none';
|
||||
|
||||
document.body.appendChild(form);
|
||||
|
|
20
build.sh
Executable file
20
build.sh
Executable file
|
@ -0,0 +1,20 @@
|
|||
#!/bin/sh
|
||||
TARGET="${TARGET:-./distribution}" # Where pleroma’s repository is sitting
|
||||
mkdir -p $TARGET/emoji
|
||||
|
||||
die() {
|
||||
echo "Die: $@"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -d "${TARGET}" ] || die "${TARGET} directory is missing, are you sure TARGET is set to a pleroma repository? (Info: TARGET=${TARGET} )"
|
||||
|
||||
yarn install -D || die "Installing dependencies via yarn failed"
|
||||
|
||||
rm -rf public/packs public/assets
|
||||
env -i "PATH=$PATH" npm run build || die "Building the frontend failed"
|
||||
cp public/packs/sw.js "${TARGET}/sw.js" || die "installing sw.js (service-worker) failed"
|
||||
rm -rf "${TARGET}/packs" || die "Removing old assets in priv/static/packs failed"
|
||||
cp -r public/packs "${TARGET}/packs" || die "Copying new assets in priv/static/packs failed"
|
||||
rm -rf "${TARGET}/emoji/*.svg" || die "Removing the old emoji assets failed"
|
||||
cp -r public/emoji/* "${TARGET}/emoji" || die "Installing the new emoji assets failed"
|
|
@ -58,8 +58,7 @@ module.exports = {
|
|||
entry: entries,
|
||||
|
||||
output: {
|
||||
filename: 'js/[name]-[chunkhash].js',
|
||||
chunkFilename: 'js/[name]-[chunkhash].chunk.js',
|
||||
filename: 'js/[name].js',
|
||||
hotUpdateChunkFilename: 'js/[id]-[hash].hot-update.js',
|
||||
hashFunction: 'sha256',
|
||||
path: output.path,
|
||||
|
@ -102,8 +101,7 @@ module.exports = {
|
|||
},
|
||||
),
|
||||
new MiniCssExtractPlugin({
|
||||
filename: 'css/[name]-[contenthash:8].css',
|
||||
chunkFilename: 'css/[name]-[contenthash:8].chunk.css',
|
||||
filename: 'css/[name].css',
|
||||
}),
|
||||
new AssetsManifestPlugin({
|
||||
integrity: true,
|
||||
|
|
18
dev.sh
Executable file
18
dev.sh
Executable file
|
@ -0,0 +1,18 @@
|
|||
#!/bin/sh
|
||||
TARGET="${TARGET:-./distribution}" # Where pleroma’s repository is sitting
|
||||
mkdir -p $TARGET/emoji
|
||||
|
||||
die() {
|
||||
echo "Die: $@"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -d "${TARGET}" ] || die "${TARGET} directory is missing, are you sure TARGET is set to a pleroma repository? (Info: TARGET=${TARGET} )"
|
||||
|
||||
yarn install -D || die "Installing dependencies via yarn failed"
|
||||
|
||||
rm -rf public/packs public/assets
|
||||
env -i "PATH=$PATH" npm run build:development || die "Building the frontend failed"
|
||||
rm -rf "${TARGET}/packs" || die "Removing old assets in priv/static/packs failed"
|
||||
cp -r public/packs "${TARGET}/packs" || die "Copying new assets in priv/static/packs failed"
|
||||
rm -rf "${TARGET}/emoji/*.svg" || die "Removing the old emoji assets failed"
|
|
@ -1,14 +1,17 @@
|
|||
{
|
||||
"name": "@mastodon/mastodon",
|
||||
"description": "mastodon",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "git push --tags",
|
||||
"build:development": "cross-env RAILS_ENV=development NODE_ENV=development ./bin/webpack",
|
||||
"build:production": "cross-env RAILS_ENV=production NODE_ENV=production ./bin/webpack",
|
||||
"build:development": "cross-env NODE_ENV=development webpack --config config/webpack/development.js",
|
||||
"build:production": "cross-env NODE_ENV=production webpack --config config/webpack/production.js",
|
||||
"build": "cross-env NODE_ENV=production webpack --config config/webpack/production.js",
|
||||
"manage:translations": "node ./config/webpack/translationRunner.js",
|
||||
"dev": "cross-env NODE_ENV=development webpack-dev-server --config config/webpack/development.js --progress --color",
|
||||
"start": "node ./streaming/index.js",
|
||||
"test": "${npm_execpath} run test:lint:js && ${npm_execpath} run test:typecheck && ${npm_execpath} run test:jest",
|
||||
"test:lint": "${npm_execpath} run test:lint:js && ${npm_execpath} run test:lint:sass",
|
||||
|
|
8
package.sh
Executable file
8
package.sh
Executable file
|
@ -0,0 +1,8 @@
|
|||
#!/bin/bash
|
||||
|
||||
TARGET="${TARGET:-./distribution}"
|
||||
|
||||
rm -rf "${TARGET}/packs" || die "Removing old assets in priv/static/packs failed"
|
||||
cp -r public/packs "${TARGET}/packs" || die "Copying new assets in priv/static/packs failed"
|
||||
rm -rf "${TARGET}/emoji/*.svg" || die "Removing the old emoji assets failed"
|
||||
cp -r public/emoji/* "${TARGET}/emoji" || die "Installing the new emoji assets failed"
|
101
public/auth.js
Normal file
101
public/auth.js
Normal file
|
@ -0,0 +1,101 @@
|
|||
document.addEventListener("DOMContentLoaded", async function() {
|
||||
await ready();
|
||||
});
|
||||
|
||||
async function ready() {
|
||||
const domain = localStorage.getItem('domain');
|
||||
let accessToken = localStorage.getItem(`access_token`);
|
||||
|
||||
if (domain) document.getElementById('instance').value = domain;
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
|
||||
if (domain && code && !accessToken) await getToken(code, domain).then(res => accessToken = res);
|
||||
if (accessToken) {
|
||||
window.location.href = '/prepare.html';
|
||||
}
|
||||
}
|
||||
|
||||
async function auth() {
|
||||
setMessage('Please wait');
|
||||
const instance = document.getElementById('instance').value;
|
||||
const domain = instance.match(/(?:https?:\/\/)?(.*)/)[1];
|
||||
if (!domain) {
|
||||
setMessage('Invalid instance', false);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('domain', domain);
|
||||
|
||||
// We need to run this every time in cases like Iceshrimp, where the client id/secret aren't reusable (yet) because they contain use-once session information
|
||||
await registerApp(domain);
|
||||
|
||||
authorize(domain);
|
||||
}
|
||||
|
||||
async function registerApp(domain) {
|
||||
setMessage('Registering app');
|
||||
|
||||
const appsUrl = `https://${domain}/api/v1/apps`;
|
||||
const formData = new FormData();
|
||||
formData.append('client_name', 'Masto-FE standalone');
|
||||
formData.append('redirect_uris', document.location.origin + document.location.pathname);
|
||||
formData.append('scopes', 'read write follow push');
|
||||
|
||||
// eslint-disable-next-line promise/catch-or-return
|
||||
await fetch(appsUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(formData),
|
||||
})
|
||||
.then(async res => {
|
||||
const app = await res.json();
|
||||
localStorage.setItem(`client_id`, app.client_id);
|
||||
localStorage.setItem(`client_secret`, app.client_secret);
|
||||
});
|
||||
}
|
||||
|
||||
function authorize(domain) {
|
||||
setMessage('Authorizing');
|
||||
const clientId = localStorage.getItem(`client_id`);
|
||||
document.location.href = `https://${domain}/oauth/authorize?response_type=code&client_id=${clientId}&redirect_uri=${document.location.origin + document.location.pathname}&scope=read+write+follow+push`;
|
||||
}
|
||||
|
||||
async function getToken(code, domain) {
|
||||
setMessage('Getting token');
|
||||
|
||||
const tokenUrl = `https://${domain}/oauth/token`;
|
||||
const clientId = localStorage.getItem(`client_id`);
|
||||
const clientSecret = localStorage.getItem(`client_secret`);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('grant_type', 'authorization_code');
|
||||
formData.append('code', code);
|
||||
formData.append('client_id', clientId);
|
||||
formData.append('client_secret', clientSecret);
|
||||
formData.append('scope', 'read write follow push');
|
||||
formData.append('redirect_uri', document.location.origin + document.location.pathname);
|
||||
|
||||
|
||||
// eslint-disable-next-line promise/catch-or-return
|
||||
return fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(formData),
|
||||
})
|
||||
.then(async res => {
|
||||
const app = await res.json();
|
||||
if (app.access_token) localStorage.setItem(`access_token`, app.access_token);
|
||||
return app.access_token;
|
||||
});
|
||||
}
|
||||
|
||||
function setMessage(message, disabled = true) {
|
||||
document.getElementById('message').textContent = message;
|
||||
document.getElementById('btn').disabled = disabled;
|
||||
}
|
11
public/images/mascot.svg
Normal file
11
public/images/mascot.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 29 KiB |
33
public/index.html
Normal file
33
public/index.html
Normal file
|
@ -0,0 +1,33 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta content='width=device-width, initial-scale=1' name='viewport'>
|
||||
<title>Masto-FE standalone</title>
|
||||
<link rel="manifest" type="applicaton/manifest+json" href="/manifest.json" />
|
||||
|
||||
<meta name="theme-color" content="#282c37" />
|
||||
|
||||
<script crossorigin='anonymous' src="/packs/js/locales.js"></script>
|
||||
<script crossorigin='anonymous' src="/packs/js/locales/glitch/en.js"></script>
|
||||
|
||||
<link rel='preload' as='script' href='/packs/js/flavours/glitch/async/getting_started.js'>
|
||||
<link rel='preload' as='script' href='/packs/js/flavours/glitch/async/compose.js'>
|
||||
<link rel='preload' as='script' href='/packs/js/flavours/glitch/async/home_timeline.js'>
|
||||
<link rel='preload' as='script' href='/packs/js/flavours/glitch/async/notifications.js'>
|
||||
<script id='initial-state' type='application/json'>{}</script>
|
||||
|
||||
<script src="/verify-state.js"></script>
|
||||
<script src="/packs/js/core/common.js"></script>
|
||||
<link rel="stylesheet" media="all" href="/packs/css/core/common.css" />
|
||||
|
||||
<script src="/packs/js/flavours/glitch/common.js"></script>
|
||||
<link rel="stylesheet" media="all" href="/packs/css/flavours/glitch/common.css" />
|
||||
|
||||
<script src="/packs/js/flavours/glitch/home.js"></script>
|
||||
</head>
|
||||
<body class='app-body no-reduce-motion system-font'>
|
||||
<div class='app-holder' data-props='{"locale":"en"}' id='mastodon'>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
13
public/login.html
Normal file
13
public/login.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Login | Masto-FE standalone</title>
|
||||
<script src="/auth.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<input type="text" id="instance" placeholder="yourinstance.tld">
|
||||
<button onclick="auth()" id="btn">Log in</button>
|
||||
<span id="message"></span>
|
||||
</body>
|
||||
</html>
|
14
public/logout.html
Normal file
14
public/logout.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Logout | Masto-FE standalone</title>
|
||||
<script>
|
||||
localStorage.clear();
|
||||
window.location.href = "/login.html";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
Clearing local storage and redirecting back to <a href="/login.html">login</a>...
|
||||
</body>
|
||||
</html>
|
12
public/manifest.json
Normal file
12
public/manifest.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"background_color": "#191b22",
|
||||
"categories": ["social"],
|
||||
"description": "Masto-FE standalone",
|
||||
"display": "standalone",
|
||||
"name": "Masto-FE standalone",
|
||||
"serviceworker": {
|
||||
"src": "/sw.js"
|
||||
},
|
||||
"start_url": "/getting-started",
|
||||
"theme_color": "#282c37"
|
||||
}
|
11
public/prepare.html
Normal file
11
public/prepare.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Login | Masto-FE standalone</title>
|
||||
<script src="/verify-state.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<p>Preparing state object...</p>
|
||||
</body>
|
||||
</html>
|
103
public/verify-state.js
Normal file
103
public/verify-state.js
Normal file
|
@ -0,0 +1,103 @@
|
|||
loadState().then(_ => null);
|
||||
|
||||
async function loadState() {
|
||||
const domain = localStorage.getItem('domain');
|
||||
const access_token = localStorage.getItem('access_token');
|
||||
const storedState = localStorage.getItem('initial_state');
|
||||
|
||||
if (!domain || !access_token) {
|
||||
window.location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
if (storedState && window.location.pathname !== '/prepare.html') {
|
||||
document.getElementById('initial-state').textContent = storedState;
|
||||
}
|
||||
|
||||
const apiUrl = `https://${domain}/api`;
|
||||
const instance = await fetch(`${apiUrl}/v1/instance`).then(async p => await p.json());
|
||||
const options = {headers: {Authorization: `Bearer ${access_token}`}};
|
||||
const credentials = await fetch(`${apiUrl}/v1/accounts/verify_credentials`, options).then(async p => await p.json());
|
||||
const state = {
|
||||
"accounts": {
|
||||
"plc":{
|
||||
"accepts_direct_messages_from":"everybody",
|
||||
"acct": credentials.acct,
|
||||
"avatar": credentials.avatar,
|
||||
"avatar_static": credentials.avatar_static,
|
||||
"bot": credentials.bot,
|
||||
"created_at": credentials.created_at,
|
||||
"display_name": credentials.display_name,
|
||||
"emojis":[],
|
||||
"fields":[],
|
||||
"follow_requests_count":0,
|
||||
"followers_count": credentials.followers_count,
|
||||
"following_count": credentials.following_count,
|
||||
"fqn":`${credentials.acct}@${domain}`,
|
||||
"header": credentials.header,
|
||||
"header_static": credentials.header_static,
|
||||
"id": credentials.id,
|
||||
"last_status_at": credentials.created_at,
|
||||
"locked": credentials.locked,
|
||||
"note":"",
|
||||
"source": credentials.source,
|
||||
"statuses_count": credentials.statuses_count,
|
||||
"url": credentials.url,
|
||||
"username": credentials.acct
|
||||
}
|
||||
},
|
||||
"char_limit": instance.configuration.statuses.max_characters,
|
||||
"compose": {
|
||||
"allow_content_types": [
|
||||
"text/x.misskeymarkdown"
|
||||
],
|
||||
"default_privacy": credentials.source.privacy,
|
||||
"default_sensitive": credentials.source.sensitive,
|
||||
"me": credentials.id
|
||||
},
|
||||
"media_attachments": {
|
||||
"accept_content_types": instance.configuration.media_attachments.supported_mime_types
|
||||
},
|
||||
"meta": {
|
||||
"access_token": access_token,
|
||||
"admin": "0",
|
||||
"advanced_layout": true,
|
||||
"auto_play_gif": false,
|
||||
"boost_modal": false,
|
||||
"compact_reaction": false,
|
||||
"delete_modal": true,
|
||||
"display_sensitive_media": false,
|
||||
"domain": domain,
|
||||
"enable_reaction": true,
|
||||
"locale": "en",
|
||||
"mascot": "/images/mascot.svg",
|
||||
"max_toot_chars": instance.configuration.statuses.max_characters,
|
||||
"me": credentials.id,
|
||||
"reduce_motion": false,
|
||||
"show_quote_button": true,
|
||||
"base_url": `https://${domain}`,
|
||||
"streaming_api_base_url": `wss://${domain}`,
|
||||
"title": `${instance.title}`,
|
||||
"unfollow_modal": true,
|
||||
"source_url": 'https://iceshrimp.dev/iceshrimp/masto-fe-standalone',
|
||||
"version": instance.version
|
||||
},
|
||||
"poll_limits": {
|
||||
"max_expiration": instance.configuration.polls.max_expiration,
|
||||
"max_option_chars": instance.configuration.polls.max_characters_per_option,
|
||||
"max_options": instance.configuration.polls.max_options,
|
||||
"min_expiration": instance.configuration.polls.min_expiration
|
||||
},
|
||||
"push_subscription": null,
|
||||
"rights": {
|
||||
"admin": false,
|
||||
"delete_others_notice": false
|
||||
},
|
||||
"settings": {}
|
||||
};
|
||||
|
||||
const json = JSON.stringify(state);
|
||||
if (window.location.pathname !== '/prepare.html') document.getElementById('initial-state').textContent = json;
|
||||
localStorage.setItem("initial_state", json);
|
||||
if (window.location.pathname === '/prepare.html') window.location.href = '/';
|
||||
}
|
Loading…
Reference in a new issue