Merge patchset from akko branch

This commit is contained in:
Laura Hausmann 2023-10-12 20:13:42 +02:00
parent d235afc303
commit 960614ec6f
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
22 changed files with 350 additions and 69 deletions

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

@ -53,6 +53,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}
*/
export default function api(getState) {
@ -62,6 +71,8 @@ export default function api(getState) {
...authorizationHeaderFromState(getState),
},
baseURL: baseUrlFromState(getState),
transformResponse: [
function (data) {
try {

View file

@ -42,8 +42,6 @@ class ActionBar extends 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

@ -27,16 +27,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'>

View file

@ -19,7 +19,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

@ -73,15 +73,8 @@ class LocalSettingsNavigation extends 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

@ -288,12 +288,12 @@ class LocalSettingsPage extends 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

@ -68,7 +68,7 @@ class DeprecatedSettingsModal extends PureComponent {
<ul>
{ settings.map((setting_name) => (
<li key={setting_name}>
<a href={preferenceLink(setting_name)}><FormattedMessage {...messages[setting_name]} /></a>
<span><FormattedMessage {...messages[setting_name]} /></span>
</li>
)) }
</ul>

View file

@ -64,42 +64,12 @@ class LinkFooter extends PureComponent {
return (
<div className='link-footer'>
<p>
<strong>{domain}</strong>:
{' '}
<Link to='/about' target={multiColumn ? '_blank' : undefined}><FormattedMessage id='footer.about' defaultMessage='About' /></Link>
{statusPageUrl && (
<>
{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>
</>
)}
<strong>Masto-FE-standalone</strong>
{DividingCircle}
<Link to='/privacy-policy' target={multiColumn ? '_blank' : undefined}><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

@ -104,7 +104,6 @@ class NavigationPanel extends 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)} />
</>
)}

View file

@ -235,8 +235,9 @@ 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

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

@ -26,7 +26,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

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

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 = '/';
}