Merge remote-tracking branch 'soapbox/main' into lexical

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2023-09-20 23:49:55 +02:00
1515 changed files with 6883 additions and 14598 deletions

View File

@ -0,0 +1,200 @@
import React, { useState } from 'react';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { createApp } from 'soapbox/actions/apps';
import { obtainOAuthToken } from 'soapbox/actions/oauth';
import { Column, Button, Form, FormActions, FormGroup, Input, Stack, Text, Textarea } from 'soapbox/components/ui';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import { getBaseURL } from 'soapbox/utils/accounts';
const messages = defineMessages({
heading: { id: 'column.app_create', defaultMessage: 'Create app' },
namePlaceholder: { id: 'app_create.name_placeholder', defaultMessage: 'e.g. \'Soapbox\'' },
scopesPlaceholder: { id: 'app_create.scopes_placeholder', defaultMessage: 'e.g. \'read write follow\'' },
});
const BLANK_PARAMS = {
client_name: '',
redirect_uris: 'urn:ietf:wg:oauth:2.0:oob',
scopes: '',
website: '',
};
type Params = typeof BLANK_PARAMS;
const CreateApp: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { account } = useOwnAccount();
const [app, setApp] = useState<Record<string, any> | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setLoading] = useState(false);
const [params, setParams] = useState<Params>(BLANK_PARAMS);
const handleCreateApp = () => {
const baseURL = getBaseURL(account!);
return dispatch(createApp(params, baseURL))
.then(app => {
setApp(app);
return app;
});
};
const handleCreateToken = (app: Record<string, string>) => {
const baseURL = getBaseURL(account!);
const tokenParams = {
client_id: app!.client_id,
client_secret: app!.client_secret,
redirect_uri: params.redirect_uris,
grant_type: 'client_credentials',
scope: params.scopes,
};
return dispatch(obtainOAuthToken(tokenParams, baseURL))
.then(setToken);
};
const handleSubmit = () => {
setLoading(true);
handleCreateApp()
.then(handleCreateToken)
.then(() => {
scrollToTop();
setLoading(false);
}).catch(error => {
console.error(error);
setLoading(false);
});
};
const setParam = (key: string, value: string) => {
setParams({ ...params, [key]: value });
};
const handleParamChange = (key: string): React.ChangeEventHandler<HTMLInputElement> => {
return e => {
setParam(key, e.target.value);
};
};
const resetState = () => {
setApp(null);
setToken(null);
setLoading(false);
setParams(BLANK_PARAMS);
};
const handleReset = () => {
resetState();
scrollToTop();
};
const scrollToTop = (): void => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const renderResults = () => {
return (
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
<Form>
<Stack>
<Text size='lg' weight='medium'>
<FormattedMessage id='app_create.results.explanation_title' defaultMessage='App created successfully' />
</Text>
<Text theme='muted'>
<FormattedMessage
id='app_create.results.explanation_text'
defaultMessage='You created a new app and token! Please copy the credentials somewhere; you will not see them again after navigating away from this page.'
/>
</Text>
</Stack>
<FormGroup labelText={<FormattedMessage id='app_create.results.app_label' defaultMessage='App' />}>
<Textarea
value={JSON.stringify(app, null, 2)}
rows={10}
readOnly
isCodeEditor
/>
</FormGroup>
<FormGroup labelText={<FormattedMessage id='app_create.results.token_label' defaultMessage='OAuth token' />}>
<Textarea
value={JSON.stringify(token, null, 2)}
rows={10}
readOnly
isCodeEditor
/>
</FormGroup>
<FormActions>
<Button theme='primary' type='button' onClick={handleReset}>
<FormattedMessage id='app_create.restart' defaultMessage='Create another' />
</Button>
</FormActions>
</Form>
</Column>
);
};
if (app && token) {
return renderResults();
}
return (
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
<Form onSubmit={handleSubmit}>
<FormGroup labelText={<FormattedMessage id='app_create.name_label' defaultMessage='App name' />}>
<Input
type='text'
placeholder={intl.formatMessage(messages.namePlaceholder)}
onChange={handleParamChange('client_name')}
value={params.client_name}
required
/>
</FormGroup>
<FormGroup labelText={<FormattedMessage id='app_create.website_label' defaultMessage='Website' />}>
<Input
type='text'
placeholder='https://soapbox.pub'
onChange={handleParamChange('website')}
value={params.website}
/>
</FormGroup>
<FormGroup labelText={<FormattedMessage id='app_create.redirect_uri_label' defaultMessage='Redirect URIs' />}>
<Input
type='text'
placeholder='https://example.com'
onChange={handleParamChange('redirect_uris')}
value={params.redirect_uris}
required
/>
</FormGroup>
<FormGroup labelText={<FormattedMessage id='app_create.scopes_label' defaultMessage='Scopes' />}>
<Input
type='text'
placeholder={intl.formatMessage(messages.scopesPlaceholder)}
onChange={handleParamChange('scopes')}
value={params.scopes}
required
/>
</FormGroup>
<FormActions>
<Button theme='primary' type='submit' disabled={isLoading}>
<FormattedMessage id='app_create.submit' defaultMessage='Create app' />
</Button>
</FormActions>
</Form>
</Column>
);
};
export default CreateApp;

View File

@ -0,0 +1,24 @@
import clsx from 'clsx';
import React from 'react';
interface IIndicator {
state?: 'active' | 'pending' | 'error' | 'inactive'
size?: 'sm'
}
/** Indicator dot component. */
const Indicator: React.FC<IIndicator> = ({ state = 'inactive', size = 'sm' }) => {
return (
<div
className={clsx('rounded-full outline-double', {
'w-1.5 h-1.5 shadow-sm': size === 'sm',
'bg-green-500 outline-green-400': state === 'active',
'bg-yellow-500 outline-yellow-400': state === 'pending',
'bg-red-500 outline-red-400': state === 'error',
'bg-neutral-500 outline-neutral-400': state === 'inactive',
})}
/>
);
};
export default Indicator;

View File

@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { changeSettingImmediate } from 'soapbox/actions/settings';
import { Column, Button, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import toast from 'soapbox/toast';
const messages = defineMessages({
heading: { id: 'column.developers', defaultMessage: 'Developers' },
answerLabel: { id: 'developers.challenge.answer_label', defaultMessage: 'Answer' },
answerPlaceholder: { id: 'developers.challenge.answer_placeholder', defaultMessage: 'Your answer' },
success: { id: 'developers.challenge.success', defaultMessage: 'You are now a developer' },
fail: { id: 'developers.challenge.fail', defaultMessage: 'Wrong answer' },
});
const DevelopersChallenge = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const [answer, setAnswer] = useState('');
const handleChangeAnswer = (e: React.ChangeEvent<HTMLInputElement>) => {
setAnswer(e.target.value);
};
const handleSubmit = () => {
if (answer === 'boxsoap') {
dispatch(changeSettingImmediate(['isDeveloper'], true));
toast.success(intl.formatMessage(messages.success));
} else {
toast.error(intl.formatMessage(messages.fail));
}
};
const challenge = `function soapbox() {
return 'soap|box'.split('|').reverse().join('');
}`;
return (
<Column label={intl.formatMessage(messages.heading)}>
<Form onSubmit={handleSubmit}>
<Text>
<FormattedMessage
id='developers.challenge.message'
defaultMessage='What is the result of calling {function}?'
values={{ function: <span className='font-mono'>soapbox()</span> }}
/>
</Text>
<Text tag='pre' family='mono' theme='muted'>
{challenge}
</Text>
<FormGroup
labelText={intl.formatMessage(messages.answerLabel)}
>
<Input
name='answer'
placeholder={intl.formatMessage(messages.answerPlaceholder)}
onChange={handleChangeAnswer}
value={answer}
type='text'
/>
</FormGroup>
<FormActions>
<Button theme='primary' type='submit'>
<FormattedMessage id='developers.challenge.submit' defaultMessage='Become a developer' />
</Button>
</FormActions>
</Form>
</Column>
);
};
export default DevelopersChallenge;

View File

@ -0,0 +1,134 @@
import React from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import { changeSettingImmediate } from 'soapbox/actions/settings';
import { Column, Text } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import { useAppDispatch } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import sourceCode from 'soapbox/utils/code';
const messages = defineMessages({
heading: { id: 'column.developers', defaultMessage: 'Developers' },
leave: { id: 'developers.leave', defaultMessage: 'You have left developers' },
});
interface IDashWidget {
to?: string
onClick?: React.MouseEventHandler<HTMLButtonElement>
children: React.ReactNode
}
const DashWidget: React.FC<IDashWidget> = ({ to, onClick, children }) => {
const className = 'bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-800/75 p-4 rounded flex flex-col items-center justify-center space-y-2';
if (to) {
return <Link className={className} to={to}>{children}</Link>;
} else {
return <button className={className} onClick={onClick}>{children}</button>;
}
};
const Developers: React.FC = () => {
const dispatch = useAppDispatch();
const history = useHistory();
const intl = useIntl();
const leaveDevelopers = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
dispatch(changeSettingImmediate(['isDeveloper'], false));
toast.success(intl.formatMessage(messages.leave));
history.push('/');
};
const showToast = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
toast.success('Hello world!', {
action: () => alert('hi'),
actionLabel: 'Click me',
});
};
return (
<>
<Column label={intl.formatMessage(messages.heading)}>
<div className='grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3'>
<DashWidget to='/developers/apps/create'>
<SvgIcon src={require('@tabler/icons/apps.svg')} className='text-gray-700 dark:text-gray-600' />
<Text>
<FormattedMessage id='developers.navigation.app_create_label' defaultMessage='Create an app' />
</Text>
</DashWidget>
<DashWidget to='/developers/settings_store'>
<SvgIcon src={require('@tabler/icons/code-plus.svg')} className='text-gray-700 dark:text-gray-600' />
<Text>
<FormattedMessage id='developers.navigation.settings_store_label' defaultMessage='Settings store' />
</Text>
</DashWidget>
<DashWidget to='/developers/timeline'>
<SvgIcon src={require('@tabler/icons/home.svg')} className='text-gray-700 dark:text-gray-600' />
<Text>
<FormattedMessage id='developers.navigation.test_timeline_label' defaultMessage='Test timeline' />
</Text>
</DashWidget>
<DashWidget to='/error'>
<SvgIcon src={require('@tabler/icons/mood-sad.svg')} className='text-gray-700 dark:text-gray-600' />
<Text>
<FormattedMessage id='developers.navigation.intentional_error_label' defaultMessage='Trigger an error' />
</Text>
</DashWidget>
<DashWidget to='/error/network'>
<SvgIcon src={require('@tabler/icons/refresh.svg')} className='text-gray-700 dark:text-gray-600' />
<Text>
<FormattedMessage id='developers.navigation.network_error_label' defaultMessage='Network error' />
</Text>
</DashWidget>
<DashWidget to='/developers/sw'>
<SvgIcon src={require('@tabler/icons/script.svg')} className='text-gray-700 dark:text-gray-600' />
<Text>
<FormattedMessage id='developers.navigation.service_worker_label' defaultMessage='Service Worker' />
</Text>
</DashWidget>
<DashWidget onClick={leaveDevelopers}>
<SvgIcon src={require('@tabler/icons/logout.svg')} className='text-gray-700 dark:text-gray-600' />
<Text>
<FormattedMessage id='developers.navigation.leave_developers_label' defaultMessage='Leave developers' />
</Text>
</DashWidget>
<DashWidget onClick={showToast}>
<SvgIcon src={require('@tabler/icons/urgent.svg')} className='text-gray-700 dark:text-gray-600' />
<Text>
<FormattedMessage id='developers.navigation.show_toast' defaultMessage='Trigger Toast' />
</Text>
</DashWidget>
</div>
</Column>
<div className='p-4'>
<Text align='center' theme='subtle' size='sm'>
{sourceCode.displayName} {sourceCode.version}
</Text>
</div>
</>
);
};
export default Developers;

View File

@ -0,0 +1,15 @@
import React from 'react';
import { getSettings } from 'soapbox/actions/settings';
import { useAppSelector } from 'soapbox/hooks';
import DevelopersChallenge from './developers-challenge';
import DevelopersMenu from './developers-menu';
const Developers: React.FC = () => {
const isDeveloper = useAppSelector((state) => getSettings(state).get('isDeveloper'));
return isDeveloper ? <DevelopersMenu /> : <DevelopersChallenge />;
};
export default Developers;

View File

@ -0,0 +1,140 @@
import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import List, { ListItem } from 'soapbox/components/list';
import { HStack, Text, Column, FormActions, Button, Stack, Icon } from 'soapbox/components/ui';
import { unregisterSW } from 'soapbox/utils/sw';
import Indicator from './components/indicator';
const messages = defineMessages({
heading: { id: 'column.developers.service_worker', defaultMessage: 'Service Worker' },
status: { id: 'sw.status', defaultMessage: 'Status' },
url: { id: 'sw.url', defaultMessage: 'Script URL' },
});
/** Hook that returns the active ServiceWorker registration. */
const useRegistration = () => {
const [isLoading, setLoading] = useState(true);
const [registration, setRegistration] = useState<ServiceWorkerRegistration>();
const isSupported = 'serviceWorker' in navigator;
useEffect(() => {
if (isSupported) {
navigator.serviceWorker.getRegistration()
.then(r => {
setRegistration(r);
setLoading(false);
})
.catch(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
return {
isLoading,
registration,
};
};
interface IServiceWorkerInfo {
}
/** Mini ServiceWorker debugging component. */
const ServiceWorkerInfo: React.FC<IServiceWorkerInfo> = () => {
const intl = useIntl();
const { isLoading, registration } = useRegistration();
const url = registration?.active?.scriptURL;
const getState = () => {
if (registration?.waiting) {
return 'pending';
} else if (registration?.active) {
return 'active';
} else {
return 'inactive';
}
};
const getMessage = () => {
if (isLoading) {
return (
<FormattedMessage
id='sw.state.loading'
defaultMessage='Loading…'
/>
);
} else if (!isLoading && !registration) {
return (
<FormattedMessage
id='sw.state.unavailable'
defaultMessage='Unavailable'
/>
);
} else if (registration?.waiting) {
return (
<FormattedMessage
id='sw.state.waiting'
defaultMessage='Waiting'
/>
);
} else if (registration?.active) {
return (
<FormattedMessage
id='sw.state.active'
defaultMessage='Active'
/>
);
} else {
return (
<FormattedMessage
id='sw.state.unknown'
defaultMessage='Unknown'
/>
);
}
};
const handleRestart = async() => {
await unregisterSW();
window.location.reload();
};
return (
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
<Stack space={4}>
<List>
<ListItem label={intl.formatMessage(messages.status)}>
<HStack alignItems='center' space={2}>
<Indicator state={getState()} />
<Text size='md' theme='muted'>{getMessage()}</Text>
</HStack>
</ListItem>
{url && (
<ListItem label={intl.formatMessage(messages.url)}>
<a href={url} target='_blank' className='flex items-center space-x-1 truncate'>
<span className='truncate'>{url}</span>
<Icon
className='h-4 w-4'
src={require('@tabler/icons/external-link.svg')}
/>
</a>
</ListItem>
)}
</List>
<FormActions>
<Button theme='tertiary' type='button' onClick={handleRestart}>
<FormattedMessage id='sw.restart' defaultMessage='Restart' />
</Button>
</FormActions>
</Stack>
</Column>
);
};
export default ServiceWorkerInfo;

View File

@ -0,0 +1,154 @@
import React, { useState, useEffect } from 'react';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { patchMe } from 'soapbox/actions/me';
import { FE_NAME, SETTINGS_UPDATE, changeSetting } from 'soapbox/actions/settings';
import List, { ListItem } from 'soapbox/components/list';
import {
CardHeader,
CardTitle,
Column,
Button,
Form,
FormActions,
FormGroup,
Textarea,
} from 'soapbox/components/ui';
import SettingToggle from 'soapbox/features/notifications/components/setting-toggle';
import { useAppSelector, useAppDispatch, useSettings } from 'soapbox/hooks';
import toast from 'soapbox/toast';
const isJSONValid = (text: any): boolean => {
try {
JSON.parse(text);
return true;
} catch {
return false;
}
};
const messages = defineMessages({
heading: { id: 'column.settings_store', defaultMessage: 'Settings store' },
advanced: { id: 'developers.settings_store.advanced', defaultMessage: 'Advanced settings' },
hint: { id: 'developers.settings_store.hint', defaultMessage: 'It is possible to directly edit your user settings here. BE CAREFUL! Editing this section can break your account, and you will only be able to recover through the API.' },
});
const SettingsStore: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const settings = useSettings();
const settingsStore = useAppSelector(state => state.settings);
const [rawJSON, setRawJSON] = useState<string>(JSON.stringify(settingsStore, null, 2));
const [jsonValid, setJsonValid] = useState(true);
const [isLoading, setLoading] = useState(false);
const handleEditJSON: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
const rawJSON = target.value;
setRawJSON(rawJSON);
setJsonValid(isJSONValid(rawJSON));
};
const onToggleChange = (key: string[], checked: boolean) => {
dispatch(changeSetting(key, checked, { showAlert: true }));
};
const handleSubmit: React.FormEventHandler = e => {
const settings = JSON.parse(rawJSON);
setLoading(true);
dispatch(patchMe({
pleroma_settings_store: {
[FE_NAME]: settings,
},
})).then(response => {
dispatch({ type: SETTINGS_UPDATE, settings });
setLoading(false);
}).catch(error => {
toast.showAlertForError(error);
setLoading(false);
});
};
useEffect(() => {
setRawJSON(JSON.stringify(settingsStore, null, 2));
setJsonValid(true);
}, [settingsStore]);
return (
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
<Form onSubmit={handleSubmit}>
<FormGroup
hintText={intl.formatMessage(messages.hint)}
errors={jsonValid ? [] : ['is invalid']}
>
<Textarea
value={rawJSON}
onChange={handleEditJSON}
disabled={isLoading}
rows={12}
isCodeEditor
/>
</FormGroup>
<FormActions>
<Button theme='primary' type='submit' disabled={!jsonValid || isLoading}>
<FormattedMessage id='soapbox_config.save' defaultMessage='Save' />
</Button>
</FormActions>
</Form>
<CardHeader className='mb-4'>
<CardTitle title={intl.formatMessage(messages.advanced)} />
</CardHeader>
<List>
<ListItem
label={<FormattedMessage id='preferences.fields.demo_label' defaultMessage='Demo mode' />}
hint={<FormattedMessage id='preferences.fields.demo_hint' defaultMessage='Use the default Soapbox logo and color scheme. Useful for taking screenshots.' />}
>
<SettingToggle settings={settings} settingPath={['demo']} onChange={onToggleChange} />
</ListItem>
<ListItem label={<FormattedMessage id='preferences.notifications.advanced' defaultMessage='Show all notification categories' />}>
<SettingToggle settings={settings} settingPath={['notifications', 'quickFilter', 'advanced']} onChange={onToggleChange} />
</ListItem>
<ListItem label={<FormattedMessage id='preferences.fields.unfollow_modal_label' defaultMessage='Show confirmation dialog before unfollowing someone' />}>
<SettingToggle settings={settings} settingPath={['unfollowModal']} onChange={onToggleChange} />
</ListItem>
<ListItem label={<FormattedMessage id='preferences.fields.missing_description_modal_label' defaultMessage='Show confirmation dialog before sending a post without media descriptions' />}>
<SettingToggle settings={settings} settingPath={['missingDescriptionModal']} onChange={onToggleChange} />
</ListItem>
<ListItem label={<FormattedMessage id='preferences.fields.reduce_motion_label' defaultMessage='Reduce motion in animations' />}>
<SettingToggle settings={settings} settingPath={['reduceMotion']} onChange={onToggleChange} />
</ListItem>
<ListItem label={<FormattedMessage id='preferences.fields.underline_links_label' defaultMessage='Always underline links in posts' />}>
<SettingToggle settings={settings} settingPath={['underlineLinks']} onChange={onToggleChange} />
</ListItem>
<ListItem label={<FormattedMessage id='preferences.fields.system_font_label' defaultMessage="Use system's default font" />}>
<SettingToggle settings={settings} settingPath={['systemFont']} onChange={onToggleChange} />
</ListItem>
<ListItem
label={<FormattedMessage id='preferences.fields.demetricator_label' defaultMessage='Use Demetricator' />}
hint={<FormattedMessage id='preferences.hints.demetricator' defaultMessage='Decrease social media anxiety by hiding all numbers from the site.' />}
>
<SettingToggle settings={settings} settingPath={['demetricator']} onChange={onToggleChange} />
</ListItem>
<ListItem
label={<FormattedMessage id='preferences.fields.wysiwyg_label' defaultMessage='Use WYSIWYG editor' />}
>
<SettingToggle settings={settings} settingPath={['wysiwyg']} onChange={onToggleChange} />
</ListItem>
</List>
</Column>
);
};
export default SettingsStore;