Compare commits

...

27 commits
glitch ... akko

Author SHA1 Message Date
Laura Hausmann
491fe19084
Fix local and public timeline links 2023-10-12 18:04:51 +02:00
Laura Hausmann
8a36343a64
Fix preload crossorigin 2023-10-12 17:48:29 +02:00
Laura Hausmann
11bd8feee7
Preload correct js files 2023-10-12 17:45:20 +02:00
Laura Hausmann
65325a63fb
Update README 2023-10-09 22:48:03 +02:00
Laura Hausmann
2efd4a6a4e
Add access_token to websocket connect 2023-10-09 22:37:08 +02:00
Laura Hausmann
bf8ac44011
Move state generation to page load 2023-10-09 20:13:07 +02:00
Laura Hausmann
81844e476c
Auth improvements 2023-10-09 19:31:31 +02:00
Laura Hausmann
5255fdd31c
Actually disable button on click 2023-10-09 19:29:04 +02:00
Laura Hausmann
37bded2cf7
Reformat link footer data 2023-10-09 19:23:18 +02:00
Laura Hausmann
576b247913
Add display name to sidebar 2023-10-09 19:12:14 +02:00
Laura Hausmann
9b729bf1b1
Remove/disable user profile edit links 2023-10-09 19:06:32 +02:00
Laura Hausmann
56c94f16bf
Remove/disable user preferences links 2023-10-09 19:02:26 +02:00
Laura Hausmann
b6c52750a3
remove noop code 2023-10-09 18:51:23 +02:00
Laura Hausmann
88926ac162
Update README 2023-10-09 18:38:57 +02:00
Laura Hausmann
4e4757bac5
logout button 2023-10-09 18:26:53 +02:00
Laura Hausmann
58709501ca
add mascot 2023-10-09 18:22:33 +02:00
Laura Hausmann
26164479ff
don't send query 2023-10-09 18:18:34 +02:00
Laura Hausmann
923b7a73a2
attempt 1 2023-10-09 18:09:18 +02:00
Laura Hausmann
36202a5cc8
Add baseUrl state property 2023-10-09 15:45:56 +02:00
FloatingGhost
0a6462682a char limit 2023-04-14 16:20:35 +01:00
FloatingGhost
02869b6aed fix streaming for some streams 2023-04-14 16:14:27 +01:00
FloatingGhost
ed237e4d0e Update akkoma branch 2023-04-14 16:02:11 +01:00
FloatingGhost
cb792539c3 Merge remote-tracking branch 'glitch-soc/main' into akkoma 2023-04-14 15:19:19 +01:00
FloatingGhost
494f853b7c fix build script 2022-12-08 15:10:35 +00:00
FloatingGhost
702f82cb04 match fedibird's SW 2022-12-08 14:59:06 +00:00
FloatingGhost
2358754bb3 don't update all timelines 2022-12-08 14:50:38 +00:00
FloatingGhost
076f7534db Akkoma patches
don't replace my history

re-add CI

lint stuff

upload to $build-tag

change SW path

use default SW

fix notifications streams

use akkoma hashtag schema

add local intl
2022-12-08 14:32:45 +00:00
53 changed files with 676 additions and 178 deletions

1
.gitignore vendored
View file

@ -6,6 +6,7 @@
# Ignore bundler config and downloaded libraries.
/.bundle
distribution
/vendor/bundle
# Ignore the default SQLite database.

30
.woodpecker.yml Normal file
View 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

View file

@ -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).
[![Build Status](https://img.shields.io/circleci/project/github/glitch-soc/mastodon.svg)][circleci]
[![Code Climate](https://img.shields.io/codeclimate/maintainability/glitch-soc/mastodon.svg)][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.

View file

@ -874,7 +874,7 @@ export function changePinnedAccountsSuggestions(value) {
type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE,
value,
};
}
};
export function resetPinnedAccountsEditor() {
return {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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={[]}
/>
);

View file

@ -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]);

View file

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

View file

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

View file

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

View file

@ -130,8 +130,7 @@ class Header extends ImmutablePureComponent {
><Icon id='sign-out' /></a>
</nav>
);
}
};
}
export default injectIntl(Header);

View file

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

View file

@ -94,7 +94,6 @@ class Publisher extends ImmutablePureComponent {
</div>
);
}
}
export default injectIntl(Publisher);

View file

@ -136,7 +136,6 @@ class SearchResults extends ImmutablePureComponent {
</div>
);
}
}
export default injectIntl(SearchResults);

View file

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

View file

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

View file

@ -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>
),
}}
/>

View file

@ -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 () {

View file

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

View file

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

View file

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

View file

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

View file

@ -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');

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

View file

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

View 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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -0,0 +1,20 @@
#!/bin/sh
TARGET="${TARGET:-./distribution}" # Where pleromas 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"

View file

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

@ -0,0 +1,18 @@
#!/bin/sh
TARGET="${TARGET:-./distribution}" # Where pleromas 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"

View file

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

33
public/index.html Normal file
View 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='{&quot;locale&quot;:&quot;en&quot;}' id='mastodon'>
</div>
</body>
</html>

13
public/login.html Normal file
View 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
View 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
View 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
View 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
View 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 = '/';
}