Merge remote-tracking branch 'origin/develop' into release-ci

vite
Alex Gleason 2023-01-08 16:32:35 -06:00
commit 039433988f
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
144 zmienionych plików z 2541 dodań i 11866 usunięć

Wyświetl plik

@ -18,7 +18,7 @@ module.exports = {
ATTACHMENT_HOST: false,
},
parser: 'babel-eslint',
parser: '@babel/eslint-parser',
plugins: [
'react',

Wyświetl plik

@ -3,6 +3,9 @@ image: node:18
variables:
NODE_ENV: test
default:
interruptible: true
cache: &cache
key:
files:
@ -26,7 +29,6 @@ deps:
cache:
<<: *cache
policy: push
interruptible: true
danger:
stage: test
@ -34,8 +36,10 @@ danger:
# https://github.com/danger/danger-js/issues/1029#issuecomment-998915436
- export CI_MERGE_REQUEST_IID=${CI_OPEN_MERGE_REQUESTS#*!}
- npx danger ci
except:
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
allow_failure: true
interruptible: true
lint-js:
stage: test
@ -48,7 +52,6 @@ lint-js:
- "**/*.tsx"
- ".eslintignore"
- ".eslintrc.js"
interruptible: true
lint-sass:
stage: test
@ -58,7 +61,6 @@ lint-sass:
- "**/*.scss"
- "**/*.css"
- ".stylelintrc.json"
interruptible: true
jest:
stage: test
@ -81,27 +83,30 @@ jest:
coverage_report:
coverage_format: cobertura
path: .coverage/cobertura-coverage.xml
interruptible: true
nginx-test:
stage: test
image: nginx:latest
before_script: cp installation/mastodon.conf /etc/nginx/conf.d/default.conf
before_script:
- cp installation/mastodon.conf /etc/nginx/conf.d/default.conf
script: nginx -t
only:
changes:
- "installation/mastodon.conf"
interruptible: true
build-production:
stage: test
script: yarn build
script:
- yarn build
- yarn manage:translations en
# Fail if files got changed.
# https://stackoverflow.com/a/9066385
- git diff --quiet
variables:
NODE_ENV: production
artifacts:
paths:
- static
interruptible: true
docs-deploy:
stage: deploy
@ -111,22 +116,10 @@ docs-deploy:
script:
- curl -X POST -F"token=$CI_JOB_TOKEN" -F'ref=master' https://gitlab.com/api/v4/projects/15685485/trigger/pipeline
only:
refs:
- develop
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
changes:
- "docs/**/*"
interruptible: true
# Supposed to fail when translations are outdated, instead always passes
#
# i18n:
# stage: build
# script: yarn manage:translations
# variables:
# NODE_ENV: development
# before_script:
# - yarn
# - yarn build
review:
stage: deploy
@ -136,7 +129,6 @@ review:
script:
- npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub
allow_failure: true
interruptible: true
pages:
stage: deploy
@ -150,15 +142,14 @@ pages:
paths:
- public
only:
refs:
- develop
interruptible: true
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
docker:
stage: deploy
image: docker:20.10.17
image: docker:20.10.22
services:
- docker:20.10.17-dind
- docker:20.10.22-dind
tags:
- dind
# https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df
@ -167,9 +158,8 @@ docker:
- docker build -t $CI_REGISTRY_IMAGE .
- docker push $CI_REGISTRY_IMAGE
only:
refs:
- develop
interruptible: true
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
# https://docs.gitlab.com/ee/user/project/releases/release_cicd_examples.html#create-release-metadata-in-a-custom-script
prepare-release:
@ -181,6 +171,7 @@ prepare-release:
artifacts:
reports:
dotenv: variables.env
interruptible: false
release:
stage: release
@ -199,4 +190,9 @@ release:
links:
- name: Build
url: 'https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/$CI_COMMIT_TAG/download?job=build-production'
link_type: package
link_type: package
interruptible: false
include:
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
- template: Security/License-Scanning.gitlab-ci.yml

Wyświetl plik

@ -0,0 +1,8 @@
## Summary
<!-- Describe your changes in detail -->
## Screenshots (if appropriate):
| Before | After |
| ------ | ----- |
| | |

Wyświetl plik

@ -1,16 +1,22 @@
{
"extends": ["stylelint-config-standard"],
"ignoreFiles": ["app/styles/reset.scss"],
"plugins": ["stylelint-scss"],
"extends": ["stylelint-config-standard-scss"],
"rules": {
"alpha-value-notation": null,
"at-rule-no-unknown": null,
"at-rule-empty-line-before": ["always", { "ignore": ["after-comment", "first-nested", "inside-block", "blockless-after-same-name-blockless", "blockless-after-blockless"] }],
"color-function-notation": null,
"custom-property-pattern": null,
"declaration-block-no-redundant-longhand-properties": null,
"declaration-colon-newline-after": null,
"declaration-empty-line-before": "never",
"font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free", "OpenDyslexic", "soapbox"] }],
"max-line-length": null,
"no-descending-specificity": null,
"no-duplicate-selectors": null,
"scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["/tailwind/", "layer"]}],
"no-invalid-position-at-import-rule": null
"no-invalid-position-at-import-rule": null,
"scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["tailwind", "apply", "layer", "config"]}],
"scss/operator-no-unspaced": null,
"selector-class-pattern": null,
"string-quotes": "single"
}
}

Wyświetl plik

@ -1 +1 @@
nodejs 18.2.0
nodejs 18.12.1

Wyświetl plik

@ -3,6 +3,7 @@
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"stylelint.vscode-stylelint",
"wix.vscode-import-cost"
"wix.vscode-import-cost",
"redhat.vscode-yaml"
]
}

12
.vscode/settings.json vendored
Wyświetl plik

@ -5,5 +5,15 @@
"*.conf.template": "properties"
},
"files.eol": "\n",
"files.insertFinalNewline": false
"files.insertFinalNewline": false,
"json.schemas": [
{
"fileMatch": [".lintstagedrc.json"],
"url": "https://json.schemastore.org/lintstagedrc.schema.json"
},
{
"fileMatch": ["renovate.json"],
"url": "https://docs.renovatebot.com/renovate-schema.json"
}
]
}

Wyświetl plik

@ -7,11 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Compatibility: rudimentary support for Takahē.
- UI: added backdrop blur behind modals.
- Admin: let admins configure media preview for attachment thumbnails.
- Login: accept `?server` param in external login, eg `fe.soapbox.pub/login/external?server=gleasonator.com`.
### Changed
- Posts: letterbox images to 19:6 again.
### Fixed
- Layout: use accent color for "floating action button" (mobile compose button).
- ServiceWorker: don't serve favicon, robots.txt, and others from ServiceWorker.
- Datepicker: correctly default to the current year.
- Scheduled posts: fix page crashing on deleting a scheduled post.
- Events: don't crash when searching for a location.
- Search: fixes an abort error when using the navbar search component.
- Posts: fix monospace font in Markdown code blocks.
- Modals: fix action buttons overflow
- Editing: don't insert edited posts to the top of the feed.
## [3.0.0] - 2022-12-25

Wyświetl plik

@ -2,7 +2,9 @@ import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { AccountRecord } from 'soapbox/normalizers';
import { AuthUserRecord, ReducerRecord } from '../../reducers/auth';
import {
fetchMe, patchMe,
} from '../me';
@ -38,18 +40,18 @@ describe('fetchMe()', () => {
beforeEach(() => {
const state = rootState
.set('auth', ImmutableMap({
.set('auth', ReducerRecord({
me: accountUrl,
users: ImmutableMap({
[accountUrl]: ImmutableMap({
[accountUrl]: AuthUserRecord({
'access_token': token,
}),
}),
}))
.set('accounts', ImmutableMap({
[accountUrl]: {
[accountUrl]: AccountRecord({
url: accountUrl,
},
}),
}) as any);
store = mockStore(state);
});
@ -112,4 +114,4 @@ describe('patchMe()', () => {
expect(actions).toEqual(expectedActions);
});
});
});
});

Wyświetl plik

@ -77,6 +77,16 @@ const ADMIN_USERS_UNSUGGEST_REQUEST = 'ADMIN_USERS_UNSUGGEST_REQUEST';
const ADMIN_USERS_UNSUGGEST_SUCCESS = 'ADMIN_USERS_UNSUGGEST_SUCCESS';
const ADMIN_USERS_UNSUGGEST_FAIL = 'ADMIN_USERS_UNSUGGEST_FAIL';
const ADMIN_USER_INDEX_EXPAND_FAIL = 'ADMIN_USER_INDEX_EXPAND_FAIL';
const ADMIN_USER_INDEX_EXPAND_REQUEST = 'ADMIN_USER_INDEX_EXPAND_REQUEST';
const ADMIN_USER_INDEX_EXPAND_SUCCESS = 'ADMIN_USER_INDEX_EXPAND_SUCCESS';
const ADMIN_USER_INDEX_FETCH_FAIL = 'ADMIN_USER_INDEX_FETCH_FAIL';
const ADMIN_USER_INDEX_FETCH_REQUEST = 'ADMIN_USER_INDEX_FETCH_REQUEST';
const ADMIN_USER_INDEX_FETCH_SUCCESS = 'ADMIN_USER_INDEX_FETCH_SUCCESS';
const ADMIN_USER_INDEX_QUERY_SET = 'ADMIN_USER_INDEX_QUERY_SET';
const nicknamesFromIds = (getState: () => RootState, ids: string[]) => ids.map(id => getState().accounts.get(id)!.acct);
const fetchConfig = () =>
@ -544,6 +554,50 @@ const unsuggestUsers = (accountIds: string[]) =>
});
};
const setUserIndexQuery = (query: string) => ({ type: ADMIN_USER_INDEX_QUERY_SET, query });
const fetchUserIndex = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const { filters, page, query, pageSize, isLoading } = getState().admin_user_index;
if (isLoading) return;
dispatch({ type: ADMIN_USER_INDEX_FETCH_REQUEST });
dispatch(fetchUsers(filters.toJS() as string[], page + 1, query, pageSize))
.then((data: any) => {
if (data.error) {
dispatch({ type: ADMIN_USER_INDEX_FETCH_FAIL });
} else {
const { users, count, next } = (data);
dispatch({ type: ADMIN_USER_INDEX_FETCH_SUCCESS, users, count, next });
}
}).catch(() => {
dispatch({ type: ADMIN_USER_INDEX_FETCH_FAIL });
});
};
const expandUserIndex = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const { filters, page, query, pageSize, isLoading, next, loaded } = getState().admin_user_index;
if (!loaded || isLoading) return;
dispatch({ type: ADMIN_USER_INDEX_EXPAND_REQUEST });
dispatch(fetchUsers(filters.toJS() as string[], page + 1, query, pageSize, next))
.then((data: any) => {
if (data.error) {
dispatch({ type: ADMIN_USER_INDEX_EXPAND_FAIL });
} else {
const { users, count, next } = (data);
dispatch({ type: ADMIN_USER_INDEX_EXPAND_SUCCESS, users, count, next });
}
}).catch(() => {
dispatch({ type: ADMIN_USER_INDEX_EXPAND_FAIL });
});
};
export {
ADMIN_CONFIG_FETCH_REQUEST,
ADMIN_CONFIG_FETCH_SUCCESS,
@ -596,6 +650,13 @@ export {
ADMIN_USERS_UNSUGGEST_REQUEST,
ADMIN_USERS_UNSUGGEST_SUCCESS,
ADMIN_USERS_UNSUGGEST_FAIL,
ADMIN_USER_INDEX_EXPAND_FAIL,
ADMIN_USER_INDEX_EXPAND_REQUEST,
ADMIN_USER_INDEX_EXPAND_SUCCESS,
ADMIN_USER_INDEX_FETCH_FAIL,
ADMIN_USER_INDEX_FETCH_REQUEST,
ADMIN_USER_INDEX_FETCH_SUCCESS,
ADMIN_USER_INDEX_QUERY_SET,
fetchConfig,
updateConfig,
updateSoapboxConfig,
@ -622,4 +683,7 @@ export {
setRole,
suggestUsers,
unsuggestUsers,
setUserIndexQuery,
fetchUserIndex,
expandUserIndex,
};

Wyświetl plik

@ -29,7 +29,6 @@ import api, { baseClient } from '../api';
import { importFetchedAccount } from './importer';
import type { AxiosError } from 'axios';
import type { Map as ImmutableMap } from 'immutable';
import type { AppDispatch, RootState } from 'soapbox/store';
export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT';
@ -94,11 +93,11 @@ const createAuthApp = () =>
const createAppToken = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const app = getState().auth.get('app');
const app = getState().auth.app;
const params = {
client_id: app.get('client_id'),
client_secret: app.get('client_secret'),
client_id: app.client_id!,
client_secret: app.client_secret!,
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
grant_type: 'client_credentials',
scope: getScopes(getState()),
@ -111,11 +110,11 @@ const createAppToken = () =>
const createUserToken = (username: string, password: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const app = getState().auth.get('app');
const app = getState().auth.app;
const params = {
client_id: app.get('client_id'),
client_secret: app.get('client_secret'),
client_id: app.client_id!,
client_secret: app.client_secret!,
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
grant_type: 'password',
username: username,
@ -127,32 +126,12 @@ const createUserToken = (username: string, password: string) =>
.then((token: Record<string, string | number>) => dispatch(authLoggedIn(token)));
};
export const refreshUserToken = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const refreshToken = getState().auth.getIn(['user', 'refresh_token']);
const app = getState().auth.get('app');
if (!refreshToken) return dispatch(noOp);
const params = {
client_id: app.get('client_id'),
client_secret: app.get('client_secret'),
refresh_token: refreshToken,
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
grant_type: 'refresh_token',
scope: getScopes(getState()),
};
return dispatch(obtainOAuthToken(params))
.then((token: Record<string, string | number>) => dispatch(authLoggedIn(token)));
};
export const otpVerify = (code: string, mfa_token: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const app = getState().auth.get('app');
const app = getState().auth.app;
return api(getState, 'app').post('/oauth/mfa/challenge', {
client_id: app.get('client_id'),
client_secret: app.get('client_secret'),
client_id: app.client_id,
client_secret: app.client_secret,
mfa_token: mfa_token,
code: code,
challenge_type: 'totp',
@ -211,7 +190,7 @@ export const logIn = (username: string, password: string) =>
(dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => {
return dispatch(createUserToken(normalizeUsername(username), password));
}).catch((error: AxiosError) => {
if ((error.response?.data as any).error === 'mfa_required') {
if ((error.response?.data as any)?.error === 'mfa_required') {
// If MFA is required, throw the error and handle it in the component.
throw error;
} else {
@ -233,9 +212,9 @@ export const logOut = () =>
if (!account) return dispatch(noOp);
const params = {
client_id: state.auth.getIn(['app', 'client_id']),
client_secret: state.auth.getIn(['app', 'client_secret']),
token: state.auth.getIn(['users', account.url, 'access_token']),
client_id: state.auth.app.client_id!,
client_secret: state.auth.app.client_secret!,
token: state.auth.users.get(account.url)!.access_token,
};
return dispatch(revokeOAuthToken(params))
@ -263,10 +242,10 @@ export const switchAccount = (accountId: string, background = false) =>
export const fetchOwnAccounts = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
return state.auth.get('users').forEach((user: ImmutableMap<string, string>) => {
const account = state.accounts.get(user.get('id'));
return state.auth.users.forEach((user) => {
const account = state.accounts.get(user.id);
if (!account) {
dispatch(verifyCredentials(user.get('access_token')!, user.get('url')));
dispatch(verifyCredentials(user.access_token, user.url));
}
});
};

Wyświetl plik

@ -10,12 +10,12 @@ import api from '../api';
const getMeUrl = (state: RootState) => {
const me = state.me;
return state.accounts.getIn([me, 'url']);
return state.accounts.get(me)?.url;
};
/** Figure out the appropriate instance to fetch depending on the state */
export const getHost = (state: RootState) => {
const accountUrl = getMeUrl(state) || getAuthUserUrl(state);
const accountUrl = getMeUrl(state) || getAuthUserUrl(state) as string;
try {
return new URL(accountUrl).host;

Wyświetl plik

@ -6,7 +6,7 @@ import api from '../api';
import { loadCredentials } from './auth';
import { importFetchedAccount } from './importer';
import type { AxiosError, AxiosRequestHeaders } from 'axios';
import type { AxiosError, RawAxiosRequestHeaders } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
@ -30,8 +30,8 @@ const getMeUrl = (state: RootState) => {
const getMeToken = (state: RootState) => {
// Fallback for upgrading IDs to URLs
const accountUrl = getMeUrl(state) || state.auth.get('me');
return state.auth.getIn(['users', accountUrl, 'access_token']);
const accountUrl = getMeUrl(state) || state.auth.me;
return state.auth.users.get(accountUrl!)?.access_token;
};
const fetchMe = () =>
@ -46,7 +46,7 @@ const fetchMe = () =>
}
dispatch(fetchMeRequest());
return dispatch(loadCredentials(token, accountUrl))
return dispatch(loadCredentials(token, accountUrl!))
.catch(error => dispatch(fetchMeFail(error)));
};
@ -66,7 +66,7 @@ const patchMe = (params: Record<string, any>, isFormData = false) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(patchMeRequest());
const headers: AxiosRequestHeaders = isFormData ? {
const headers: RawAxiosRequestHeaders = isFormData ? {
'Content-Type': 'multipart/form-data',
} : {};

Wyświetl plik

@ -68,7 +68,7 @@ const createStatus = (params: Record<string, any>, idempotencyKey: string, statu
}
dispatch(importFetchedStatus(status, idempotencyKey));
dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey });
dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey, editing: !!statusId });
// Poll the backend for the updated card
if (status.expectsCard) {

Wyświetl plik

@ -43,7 +43,7 @@ const maybeParseJSON = (data: string) => {
const getAuthBaseURL = createSelector([
(state: RootState, me: string | false | null) => state.accounts.getIn([me, 'url']),
(state: RootState, _me: string | false | null) => state.auth.get('me'),
(state: RootState, _me: string | false | null) => state.auth.me,
], (accountUrl, authUserUrl) => {
const baseURL = parseBaseURL(accountUrl) || parseBaseURL(authUserUrl);
return baseURL !== window.location.origin ? baseURL : '';
@ -62,7 +62,6 @@ export const baseClient = (accessToken?: string | null, baseURL: string = ''): A
headers: Object.assign(accessToken ? {
'Authorization': `Bearer ${accessToken}`,
} : {}),
transformResponse: [maybeParseJSON],
});
};

Wyświetl plik

@ -1,31 +0,0 @@
import React from 'react';
import { normalizeAccount } from 'soapbox/normalizers';
import { render, screen } from '../../jest/test-helpers';
import AvatarOverlay from '../avatar-overlay';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
describe('<AvatarOverlay', () => {
const account = normalizeAccount({
username: 'alice',
acct: 'alice',
display_name: 'Alice',
avatar: '/animated/alice.gif',
avatar_static: '/static/alice.jpg',
}) as ReducerAccount;
const friend = normalizeAccount({
username: 'eve',
acct: 'eve@blackhat.lair',
display_name: 'Evelyn',
avatar: '/animated/eve.gif',
avatar_static: '/static/eve.jpg',
}) as ReducerAccount;
it('renders a overlay avatar', () => {
render(<AvatarOverlay account={account} friend={friend} />);
expect(screen.queryAllByRole('img')).toHaveLength(2);
});
});

Wyświetl plik

@ -1,38 +0,0 @@
import React from 'react';
import { normalizeAccount } from 'soapbox/normalizers';
import { render, screen } from '../../jest/test-helpers';
import Avatar from '../avatar';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
describe('<Avatar />', () => {
const account = normalizeAccount({
username: 'alice',
acct: 'alice',
display_name: 'Alice',
avatar: '/animated/alice.gif',
avatar_static: '/static/alice.jpg',
}) as ReducerAccount;
const size = 100;
// describe('Autoplay', () => {
// it('renders an animated avatar', () => {
// render(<Avatar account={account} animate size={size} />);
// expect(screen.getByRole('img').getAttribute('src')).toBe(account.get('avatar'));
// });
// });
describe('Still', () => {
it('renders a still avatar', () => {
render(<Avatar account={account} size={size} />);
expect(screen.getByRole('img').getAttribute('src')).toBe(account.get('avatar'));
});
});
// TODO add autoplay test if possible
});

Wyświetl plik

@ -46,7 +46,7 @@ interface IProfilePopper {
const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children }): any =>
condition ? wrapper(children) : children;
interface IAccount {
export interface IAccount {
account: AccountEntity,
action?: React.ReactElement,
actionAlignment?: 'center' | 'top',

Wyświetl plik

@ -44,7 +44,7 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
setAccountIds(ImmutableOrderedSet());
};
const handleAccountSearch = useCallback(throttle(q => {
const handleAccountSearch = useCallback(throttle((q) => {
const params = { q, limit, resolve: false };
dispatch(accountSearch(params, controller.current.signal))
@ -53,7 +53,6 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
setAccountIds(ImmutableOrderedSet(accountIds));
})
.catch(noOp);
}, 900, { leading: true, trailing: true }), [limit]);
const handleChange: React.ChangeEventHandler<HTMLInputElement> = e => {

Wyświetl plik

@ -46,7 +46,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
getFirstIndex = () => {
return this.props.autoSelect ? 0 : -1;
}
};
state = {
suggestionsHidden: true,
@ -76,7 +76,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
if (this.props.onChange) {
this.props.onChange(e);
}
}
};
onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
const { suggestions, menu, disabled } = this.props;
@ -145,15 +145,15 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
if (this.props.onKeyDown) {
this.props.onKeyDown(e);
}
}
};
onBlur = () => {
this.setState({ suggestionsHidden: true, focused: false });
}
};
onFocus = () => {
this.setState({ focused: true });
}
};
onSuggestionClick: React.EventHandler<React.MouseEvent | React.TouchEvent> = (e) => {
const index = Number(e.currentTarget?.getAttribute('data-index'));
@ -161,7 +161,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.input?.focus();
e.preventDefault();
}
};
componentDidUpdate(prevProps: IAutosuggestInput, prevState: any) {
const { suggestions } = this.props;
@ -172,7 +172,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
setInput = (c: HTMLInputElement) => {
this.input = c;
}
};
renderSuggestion = (suggestion: AutoSuggestion, i: number) => {
const { selectedSuggestion } = this.state;
@ -209,21 +209,21 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
{inner}
</div>
);
}
};
handleMenuItemAction = (item: MenuItem | null, e: React.MouseEvent | React.KeyboardEvent) => {
this.onBlur();
if (item?.action) {
item.action(e);
}
}
};
handleMenuItemClick = (item: MenuItem | null): React.MouseEventHandler => {
return e => {
e.preventDefault();
this.handleMenuItemAction(item, e);
};
}
};
renderMenu = () => {
const { menu, suggestions } = this.props;

Wyświetl plik

@ -32,7 +32,7 @@ const AutosuggestLocation: React.FC<IAutosuggestLocation> = ({ id }) => {
<Icon src={ADDRESS_ICONS[location.type] || mapPinIcon} />
<Stack>
<Text>{location.description}</Text>
<Text size='xs' theme='muted'>{[location.street, location.locality, location.country].filter(val => val.trim()).join(' · ')}</Text>
<Text size='xs' theme='muted'>{[location.street, location.locality, location.country].filter(val => val?.trim()).join(' · ')}</Text>
</Stack>
</HStack>
);

Wyświetl plik

@ -64,7 +64,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
}
this.props.onChange(e);
}
};
onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
const { suggestions, disabled } = this.props;
@ -122,7 +122,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
}
this.props.onKeyDown(e);
}
};
onBlur = () => {
this.setState({ suggestionsHidden: true, focused: false });
@ -130,7 +130,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
if (this.props.onBlur) {
this.props.onBlur();
}
}
};
onFocus = () => {
this.setState({ focused: true });
@ -138,14 +138,14 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
if (this.props.onFocus) {
this.props.onFocus();
}
}
};
onSuggestionClick: React.MouseEventHandler<HTMLDivElement> = (e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index') as any);
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.textarea?.focus();
}
};
shouldComponentUpdate(nextProps: IAutosuggesteTextarea, nextState: any) {
// Skip updating when only the lastToken changes so the
@ -169,14 +169,14 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
setTextarea: React.Ref<HTMLTextAreaElement> = (c) => {
this.textarea = c;
}
};
onPaste: React.ClipboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files);
e.preventDefault();
}
}
};
renderSuggestion = (suggestion: string | Emoji, i: number) => {
const { selectedSuggestion } = this.state;
@ -208,7 +208,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
{inner}
</div>
);
}
};
setPortalPosition() {
if (!this.textarea) {

Wyświetl plik

@ -1,19 +0,0 @@
import React from 'react';
import StillImage from 'soapbox/components/still-image';
import type { Account as AccountEntity } from 'soapbox/types/entities';
interface IAvatarOverlay {
account: AccountEntity,
friend: AccountEntity,
}
const AvatarOverlay: React.FC<IAvatarOverlay> = ({ account, friend }) => (
<div className='account__avatar-overlay'>
<StillImage src={account.avatar} className='account__avatar-overlay-base' />
<StillImage src={friend.avatar} className='account__avatar-overlay-overlay' />
</div>
);
export default AvatarOverlay;

Wyświetl plik

@ -1,38 +0,0 @@
import classNames from 'clsx';
import React from 'react';
import StillImage from 'soapbox/components/still-image';
import type { Account } from 'soapbox/types/entities';
interface IAvatar {
account?: Account | null,
size?: number,
className?: string,
}
/**
* Legacy avatar component.
* @see soapbox/components/ui/avatar/avatar.tsx
* @deprecated
*/
const Avatar: React.FC<IAvatar> = ({ account, size, className }) => {
if (!account) return null;
// : TODO : remove inline and change all avatars to be sized using css
const style: React.CSSProperties = !size ? {} : {
width: `${size}px`,
height: `${size}px`,
};
return (
<StillImage
className={classNames('rounded-full overflow-hidden', className)}
style={style}
src={account.avatar}
alt=''
/>
);
};
export default Avatar;

Wyświetl plik

@ -64,7 +64,7 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
if (this.node && !this.node.contains(e.target as Node)) {
this.props.onClose();
}
}
};
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, false);
@ -84,11 +84,11 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
setRef: React.RefCallback<HTMLDivElement> = c => {
this.node = c;
}
};
setFocusRef: React.RefCallback<HTMLAnchorElement> = c => {
this.focusedItem = c;
}
};
handleKeyDown = (e: KeyboardEvent) => {
if (!this.node) return;
@ -127,13 +127,13 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
e.preventDefault();
e.stopPropagation();
}
}
};
handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
}
};
handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
@ -152,7 +152,7 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
e.preventDefault();
action(e);
}
}
};
handleMiddleClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
@ -166,13 +166,13 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
e.preventDefault();
middleClick(e);
}
}
};
handleAuxClick: React.EventHandler<React.MouseEvent> = e => {
if (e.button === 1) {
this.handleMiddleClick(e);
}
}
};
renderItem(option: MenuItem | null, i: number): JSX.Element {
if (option === null) {
@ -303,7 +303,7 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click');
}
}
};
handleClose = () => {
if (this.activeElement && this.activeElement === this.target) {
@ -314,13 +314,13 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
if (this.props.onClose) {
this.props.onClose(this.state.id);
}
}
};
handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
}
};
handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (e) => {
switch (e.key) {
@ -329,7 +329,7 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
this.handleMouseDown(e);
break;
}
}
};
handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (e) => {
switch (e.key) {
@ -340,7 +340,7 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
e.preventDefault();
break;
}
}
};
handleItemClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
@ -358,21 +358,21 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
} else if (to) {
this.props.history?.push(to);
}
}
};
setTargetRef: React.RefCallback<HTMLButtonElement> = c => {
this.target = c;
}
};
findTarget = () => {
return this.target;
}
};
componentWillUnmount = () => {
if (this.state.id === this.props.openDropdownId) {
this.handleClose();
}
}
};
render() {
const { src = require('@tabler/icons/dots.svg'), items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text, children, dropdownMenuStyle } = this.props;

Wyświetl plik

@ -28,7 +28,7 @@ class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
onReact: () => { },
onUnfocus: () => { },
visible: false,
}
};
node?: HTMLDivElement = undefined;
@ -38,7 +38,7 @@ class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
if (focused && (!e.currentTarget || !e.currentTarget.classList.contains('emoji-react-selector__emoji'))) {
onUnfocus();
}
}
};
_selectPreviousEmoji = (i: number): void => {
if (!this.node) return;
@ -85,7 +85,7 @@ class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
onUnfocus();
break;
}
}
};
handleReact = (emoji: string) => (): void => {
const { onReact, focused, onUnfocus } = this.props;
@ -95,7 +95,7 @@ class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
if (focused) {
onUnfocus();
}
}
};
handlers = {
open: () => { },
@ -103,7 +103,7 @@ class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
setRef = (c: HTMLDivElement): void => {
this.node = c;
}
};
render() {
const { visible, focused, allowedEmoji, onReact } = this.props;

Wyświetl plik

@ -42,7 +42,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
error: undefined,
componentStack: undefined,
browser: undefined,
}
};
textarea: HTMLTextAreaElement | null = null;
@ -71,7 +71,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
setTextareaRef: React.RefCallback<HTMLTextAreaElement> = c => {
this.textarea = c;
}
};
handleCopy: React.MouseEventHandler = () => {
if (!this.textarea) return;
@ -80,12 +80,12 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
this.textarea.setSelectionRange(0, 99999);
document.execCommand('copy');
}
};
getErrorText = (): string => {
const { error, componentStack } = this.state;
return error + componentStack;
}
};
clearCookies: React.MouseEventHandler = (e) => {
localStorage.clear();
@ -96,7 +96,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
e.preventDefault();
unregisterSw().then(goHome).catch(goHome);
}
}
};
render() {
const { browser, hasError } = this.state;

Wyświetl plik

@ -1,188 +0,0 @@
import classNames from 'clsx';
import PropTypes from 'prop-types';
import React from 'react';
import spring from 'react-motion/lib/spring';
import Icon from 'soapbox/components/icon';
import emojify from 'soapbox/features/emoji/emoji';
import Motion from '../features/ui/util/optional-motion';
export default class IconButton extends React.PureComponent {
static propTypes = {
className: PropTypes.string,
iconClassName: PropTypes.string,
title: PropTypes.string.isRequired,
icon: PropTypes.string,
src: PropTypes.string,
onClick: PropTypes.func,
onMouseDown: PropTypes.func,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onKeyPress: PropTypes.func,
onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func,
size: PropTypes.number,
active: PropTypes.bool,
pressed: PropTypes.bool,
expanded: PropTypes.bool,
style: PropTypes.object,
activeStyle: PropTypes.object,
disabled: PropTypes.bool,
inverted: PropTypes.bool,
animate: PropTypes.bool,
overlay: PropTypes.bool,
tabIndex: PropTypes.string,
text: PropTypes.string,
emoji: PropTypes.string,
type: PropTypes.string,
};
static defaultProps = {
size: 18,
active: false,
disabled: false,
animate: false,
overlay: false,
tabIndex: '0',
onKeyUp: () => {},
onKeyDown: () => {},
onClick: () => {},
onMouseEnter: () => {},
onMouseLeave: () => {},
type: 'button',
};
handleClick = (e) => {
e.preventDefault();
if (!this.props.disabled) {
this.props.onClick(e);
}
}
handleMouseDown = (e) => {
if (!this.props.disabled && this.props.onMouseDown) {
this.props.onMouseDown(e);
}
}
handleKeyDown = (e) => {
if (!this.props.disabled && this.props.onKeyDown) {
this.props.onKeyDown(e);
}
}
handleKeyUp = (e) => {
if (!this.props.disabled && this.props.onKeyUp) {
this.props.onKeyUp(e);
}
}
handleKeyPress = (e) => {
if (this.props.onKeyPress && !this.props.disabled) {
this.props.onKeyPress(e);
}
}
render() {
const style = {
fontSize: `${this.props.size}px`,
width: `${this.props.size * 1.28571429}px`,
height: `${this.props.size * 1.28571429}px`,
lineHeight: `${this.props.size}px`,
...this.props.style,
...(this.props.active ? this.props.activeStyle : {}),
};
const {
active,
animate,
className,
iconClassName,
disabled,
expanded,
icon,
src,
inverted,
overlay,
pressed,
tabIndex,
title,
text,
emoji,
type,
} = this.props;
const classes = classNames(className, 'icon-button', {
active,
disabled,
inverted,
overlayed: overlay,
});
if (!animate) {
// Perf optimization: avoid unnecessary <Motion> components unless
// we actually need to animate.
return (
<button
aria-label={title}
aria-pressed={pressed}
aria-expanded={expanded}
title={title}
className={classes}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
onKeyUp={this.handleKeyUp}
onKeyPress={this.handleKeyPress}
onMouseEnter={this.props.onMouseEnter}
onMouseLeave={this.props.onMouseLeave}
tabIndex={tabIndex}
disabled={disabled}
type={type}
>
<div style={src ? {} : style}>
{emoji
? <div className='icon-button__emoji' dangerouslySetInnerHTML={{ __html: emojify(emoji) }} aria-hidden='true' />
: <Icon className={iconClassName} id={icon} src={src} fixedWidth aria-hidden='true' />}
</div>
{text && <span className='icon-button__text'>{text}</span>}
</button>
);
}
return (
<Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
{({ rotate }) => (
<button
aria-label={title}
aria-pressed={pressed}
aria-expanded={expanded}
title={title}
className={classes}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
onKeyUp={this.handleKeyUp}
onKeyPress={this.handleKeyPress}
onMouseEnter={this.props.onMouseEnter}
onMouseLeave={this.props.onMouseLeave}
tabIndex={tabIndex}
disabled={disabled}
type={type}
>
<div style={src ? {} : style}>
{emoji
? <div className='icon-button__emoji' style={{ transform: `rotate(${rotate}deg)` }} dangerouslySetInnerHTML={{ __html: emojify(emoji) }} aria-hidden='true' />
: <Icon className={iconClassName} id={icon} src={src} style={{ transform: `rotate(${rotate}deg)` }} fixedWidth aria-hidden='true' />}
</div>
{text && <span className='icon-button__text'>{text}</span>}
</button>
)}
</Motion>
);
}
}

Wyświetl plik

@ -0,0 +1,100 @@
import classNames from 'clsx';
import React from 'react';
import Icon from 'soapbox/components/icon';
interface IIconButton extends Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'disabled' | 'onClick' | 'onKeyDown' | 'onKeyPress' | 'onKeyUp' | 'onMouseDown' | 'onMouseEnter' | 'onMouseLeave' | 'tabIndex' | 'title'> {
active?: boolean
expanded?: boolean
iconClassName?: string
pressed?: boolean
size?: number
src: string
text?: React.ReactNode
}
const IconButton: React.FC<IIconButton> = ({
active,
className,
disabled,
expanded,
iconClassName,
onClick,
onKeyDown,
onKeyUp,
onKeyPress,
onMouseDown,
onMouseEnter,
onMouseLeave,
pressed,
size = 18,
src,
tabIndex = 0,
text,
title,
}) => {
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault();
if (!disabled && onClick) {
onClick(e);
}
};
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (!disabled && onMouseDown) {
onMouseDown(e);
}
};
const handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
if (!disabled && onKeyDown) {
onKeyDown(e);
}
};
const handleKeyUp: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
if (!disabled && onKeyUp) {
onKeyUp(e);
}
};
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
if (onKeyPress && !disabled) {
onKeyPress(e);
}
};
const classes = classNames(className, 'icon-button', {
active,
disabled,
});
return (
<button
aria-label={title}
aria-pressed={pressed}
aria-expanded={expanded}
title={title}
className={classes}
onClick={handleClick}
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onKeyPress={handleKeyPress}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
tabIndex={tabIndex}
disabled={disabled}
type='button'
>
<div>
<Icon className={iconClassName} src={src} fixedWidth aria-hidden='true' />
</div>
{text && <span className='icon-button__text'>{text}</span>}
</button>
);
};
export default IconButton;

Wyświetl plik

@ -5,7 +5,7 @@ import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon';
import StillImage from 'soapbox/components/still-image';
import { MIMETYPE_ICONS } from 'soapbox/components/upload';
import { useSettings } from 'soapbox/hooks';
import { useSettings, useSoapboxConfig } from 'soapbox/hooks';
import { Attachment } from 'soapbox/types/entities';
import { truncateFilename } from 'soapbox/utils/media';
@ -72,6 +72,7 @@ const Item: React.FC<IItem> = ({
}) => {
const settings = useSettings();
const autoPlayGif = settings.get('autoPlayGif') === true;
const { mediaPreview } = useSoapboxConfig();
const handleMouseEnter: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
if (hoverToPlay()) {
@ -171,7 +172,7 @@ const Item: React.FC<IItem> = ({
>
<StillImage
className='w-full h-full'
src={attachment.url}
src={mediaPreview ? attachment.preview_url : attachment.url}
alt={attachment.description}
letterboxed={letterboxed}
showExt
@ -294,7 +295,7 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
const aspectRatio = media.getIn([0, 'meta', 'original', 'aspect']) as number | undefined;
const getHeight = () => {
if (!aspectRatio) return w;
if (!aspectRatio) return w * 9 / 16;
if (isPanoramic(aspectRatio)) return Math.floor(w / maximumAspectRatio);
if (isPortrait(aspectRatio)) return Math.floor(w / minimumAspectRatio);
return Math.floor(w / aspectRatio);

Wyświetl plik

@ -241,7 +241,7 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
<div
role='presentation'
id='modal-overlay'
className='fixed inset-0 bg-gray-500/90 dark:bg-gray-700/90'
className='fixed inset-0 bg-gray-500/90 dark:bg-gray-700/90 backdrop-blur'
onClick={handleOnClose}
/>

Wyświetl plik

@ -1,3 +1,4 @@
/* eslint-disable jsx-a11y/interactive-supports-focus */
import classNames from 'clsx';
import React from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
@ -136,218 +137,234 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
return (
<div
className={classNames('sidebar-menu__root', {
'sidebar-menu__root--visible': sidebarOpen,
})}
aria-expanded={sidebarOpen}
className={
classNames({
'z-[1000]': sidebarOpen,
hidden: !sidebarOpen,
})
}
>
<div
className={classNames({
'fixed inset-0 bg-gray-500/90 dark:bg-gray-700/90 z-1000': true,
'hidden': !sidebarOpen,
})}
className='fixed inset-0 bg-gray-500/90 dark:bg-gray-700/90'
role='button'
onClick={handleClose}
>
<IconButton
title={intl.formatMessage(messages.close)}
onClick={handleClose}
src={require('@tabler/icons/x.svg')}
ref={closeButtonRef}
iconClassName='h-6 w-6'
className='fixed top-5 right-5 text-gray-600 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
/>
</div>
/>
<div className='sidebar-menu'>
<div className='relative overflow-y-scroll overflow-auto h-full w-full'>
<div className='p-4'>
<Stack space={4}>
<Link to={`/@${account.acct}`} onClick={onClose}>
<Account account={account} showProfileHoverCard={false} withLinkToProfile={false} />
</Link>
<ProfileStats
account={account}
onClickHandler={handleClose}
/>
<div className='fixed inset-0 z-[1000] flex'>
<div
className={
classNames({
'flex flex-col flex-1 bg-white dark:bg-primary-900 -translate-x-full rtl:translate-x-full w-full max-w-xs': true,
'!translate-x-0': sidebarOpen,
})
}
>
<IconButton
title={intl.formatMessage(messages.close)}
onClick={handleClose}
src={require('@tabler/icons/x.svg')}
ref={closeButtonRef}
iconClassName='h-6 w-6'
className='absolute top-0 right-0 -mr-11 mt-2 text-gray-600 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
/>
<div className='relative overflow-y-scroll overflow-auto h-full w-full'>
<div className='p-4'>
<Stack space={4}>
<Divider />
<Link to={`/@${account.acct}`} onClick={onClose}>
<Account account={account} showProfileHoverCard={false} withLinkToProfile={false} />
</Link>
<SidebarLink
to={`/@${account.acct}`}
icon={require('@tabler/icons/user.svg')}
text={intl.formatMessage(messages.profile)}
onClick={onClose}
<ProfileStats
account={account}
onClickHandler={handleClose}
/>
{(account.locked || followRequestsCount > 0) && (
<SidebarLink
to='/follow_requests'
icon={require('@tabler/icons/user-plus.svg')}
text={intl.formatMessage(messages.followRequests)}
onClick={onClose}
/>
)}
{features.bookmarks && (
<SidebarLink
to='/bookmarks'
icon={require('@tabler/icons/bookmark.svg')}
text={intl.formatMessage(messages.bookmarks)}
onClick={onClose}
/>
)}
{features.lists && (
<SidebarLink
to='/lists'
icon={require('@tabler/icons/list.svg')}
text={intl.formatMessage(messages.lists)}
onClick={onClose}
/>
)}
{features.events && (
<SidebarLink
to='/events'
icon={require('@tabler/icons/calendar-event.svg')}
text={intl.formatMessage(messages.events)}
onClick={onClose}
/>
)}
{settings.get('isDeveloper') && (
<SidebarLink
to='/developers'
icon={require('@tabler/icons/code.svg')}
text={intl.formatMessage(messages.developers)}
onClick={onClose}
/>
)}
{features.publicTimeline && <>
<Stack space={4}>
<Divider />
<SidebarLink
to='/timeline/local'
icon={features.federating ? require('@tabler/icons/affiliate.svg') : require('@tabler/icons/world.svg')}
text={features.federating ? <FormattedMessage id='tabs_bar.local' defaultMessage='Local' /> : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />}
to={`/@${account.acct}`}
icon={require('@tabler/icons/user.svg')}
text={intl.formatMessage(messages.profile)}
onClick={onClose}
/>
{(account.locked || followRequestsCount > 0) && (
<SidebarLink
to='/follow_requests'
icon={require('@tabler/icons/user-plus.svg')}
text={intl.formatMessage(messages.followRequests)}
onClick={onClose}
/>
)}
{features.bookmarks && (
<SidebarLink
to='/bookmarks'
icon={require('@tabler/icons/bookmark.svg')}
text={intl.formatMessage(messages.bookmarks)}
onClick={onClose}
/>
)}
{features.lists && (
<SidebarLink
to='/lists'
icon={require('@tabler/icons/list.svg')}
text={intl.formatMessage(messages.lists)}
onClick={onClose}
/>
)}
{features.events && (
<SidebarLink
to='/events'
icon={require('@tabler/icons/calendar-event.svg')}
text={intl.formatMessage(messages.events)}
onClick={onClose}
/>
)}
{settings.get('isDeveloper') && (
<SidebarLink
to='/developers'
icon={require('@tabler/icons/code.svg')}
text={intl.formatMessage(messages.developers)}
onClick={onClose}
/>
)}
{features.publicTimeline && <>
<Divider />
<SidebarLink
to='/timeline/local'
icon={features.federating ? require('@tabler/icons/affiliate.svg') : require('@tabler/icons/world.svg')}
text={features.federating ? <FormattedMessage id='tabs_bar.local' defaultMessage='Local' /> : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />}
onClick={onClose}
/>
{features.federating && (
<SidebarLink
to='/timeline/fediverse'
icon={require('@tabler/icons/topology-star-ring-3.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />}
onClick={onClose}
/>
)}
</>}
<Divider />
<SidebarLink
to='/blocks'
icon={require('@tabler/icons/ban.svg')}
text={intl.formatMessage(messages.blocks)}
onClick={onClose}
/>
<SidebarLink
to='/mutes'
icon={require('@tabler/icons/circle-x.svg')}
text={intl.formatMessage(messages.mutes)}
onClick={onClose}
/>
<SidebarLink
to='/settings/preferences'
icon={require('@tabler/icons/settings.svg')}
text={intl.formatMessage(messages.preferences)}
onClick={onClose}
/>
{features.federating && (
<SidebarLink
to='/timeline/fediverse'
icon={require('@tabler/icons/topology-star-ring-3.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />}
to='/domain_blocks'
icon={require('@tabler/icons/ban.svg')}
text={intl.formatMessage(messages.domainBlocks)}
onClick={onClose}
/>
)}
</>}
<Divider />
<SidebarLink
to='/blocks'
icon={require('@tabler/icons/ban.svg')}
text={intl.formatMessage(messages.blocks)}
onClick={onClose}
/>
<SidebarLink
to='/mutes'
icon={require('@tabler/icons/circle-x.svg')}
text={intl.formatMessage(messages.mutes)}
onClick={onClose}
/>
<SidebarLink
to='/settings/preferences'
icon={require('@tabler/icons/settings.svg')}
text={intl.formatMessage(messages.preferences)}
onClick={onClose}
/>
{features.federating && (
<SidebarLink
to='/domain_blocks'
icon={require('@tabler/icons/ban.svg')}
text={intl.formatMessage(messages.domainBlocks)}
onClick={onClose}
/>
)}
{features.filters && (
<SidebarLink
to='/filters'
icon={require('@tabler/icons/filter.svg')}
text={intl.formatMessage(messages.filters)}
onClick={onClose}
/>
)}
{account.admin && (
<SidebarLink
to='/soapbox/config'
icon={require('@tabler/icons/settings.svg')}
text={intl.formatMessage(messages.soapboxConfig)}
onClick={onClose}
/>
)}
{features.import && (
<SidebarLink
to='/settings/import'
icon={require('@tabler/icons/cloud-upload.svg')}
text={intl.formatMessage(messages.importData)}
onClick={onClose}
/>
)}
<Divider />
<SidebarLink
to='/logout'
icon={require('@tabler/icons/logout.svg')}
text={intl.formatMessage(messages.logout)}
onClick={onClickLogOut}
/>
<Divider />
<Stack space={4}>
<button type='button' onClick={handleSwitcherClick} className='py-1'>
<HStack alignItems='center' justifyContent='between'>
<Text tag='span'>
<FormattedMessage id='profile_dropdown.switch_account' defaultMessage='Switch accounts' />
</Text>
<Icon
src={require('@tabler/icons/chevron-down.svg')}
className={classNames('w-4 h-4 text-gray-900 dark:text-gray-100 transition-transform', {
'rotate-180': switcher,
})}
/>
</HStack>
</button>
{switcher && (
<div className='border-t-2 border-gray-100 dark:border-gray-800 border-solid'>
{otherAccounts.map(account => renderAccount(account))}
<NavLink className='flex items-center py-2 space-x-1' to='/login/add' onClick={handleClose}>
<Icon className='text-primary-500 w-4 h-4' src={require('@tabler/icons/plus.svg')} />
<Text size='sm' weight='medium'>{intl.formatMessage(messages.addAccount)}</Text>
</NavLink>
</div>
{features.filters && (
<SidebarLink
to='/filters'
icon={require('@tabler/icons/filter.svg')}
text={intl.formatMessage(messages.filters)}
onClick={onClose}
/>
)}
{account.admin && (
<SidebarLink
to='/soapbox/config'
icon={require('@tabler/icons/settings.svg')}
text={intl.formatMessage(messages.soapboxConfig)}
onClick={onClose}
/>
)}
{features.import && (
<SidebarLink
to='/settings/import'
icon={require('@tabler/icons/cloud-upload.svg')}
text={intl.formatMessage(messages.importData)}
onClick={onClose}
/>
)}
<Divider />
<SidebarLink
to='/logout'
icon={require('@tabler/icons/logout.svg')}
text={intl.formatMessage(messages.logout)}
onClick={onClickLogOut}
/>
<Divider />
<Stack space={4}>
<button type='button' onClick={handleSwitcherClick} className='py-1'>
<HStack alignItems='center' justifyContent='between'>
<Text tag='span'>
<FormattedMessage id='profile_dropdown.switch_account' defaultMessage='Switch accounts' />
</Text>
<Icon
src={require('@tabler/icons/chevron-down.svg')}
className={classNames('w-4 h-4 text-gray-900 dark:text-gray-100 transition-transform', {
'rotate-180': switcher,
})}
/>
</HStack>
</button>
{switcher && (
<div className='border-t-2 border-gray-100 dark:border-gray-800 border-solid'>
{otherAccounts.map(account => renderAccount(account))}
<NavLink className='flex items-center py-2 space-x-1' to='/login/add' onClick={handleClose}>
<Icon className='text-primary-500 w-4 h-4' src={require('@tabler/icons/plus.svg')} />
<Text size='sm' weight='medium'>{intl.formatMessage(messages.addAccount)}</Text>
</NavLink>
</div>
)}
</Stack>
</Stack>
</Stack>
</Stack>
</div>
</div>
</div>
{/* Dummy element to keep Close Icon visible */}
<div
aria-hidden
className='w-14 flex-shrink-0'
onClick={handleClose}
/>
</div>
</div>
);

Wyświetl plik

@ -23,7 +23,6 @@ import StatusReplyMentions from './status-reply-mentions';
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
import { Card, HStack, Stack, Text } from './ui';
import type { Map as ImmutableMap } from 'immutable';
import type {
Account as AccountEntity,
Status as StatusEntity,
@ -45,7 +44,6 @@ export interface IStatus {
unread?: boolean,
onMoveUp?: (statusId: string, featured?: boolean) => void,
onMoveDown?: (statusId: string, featured?: boolean) => void,
group?: ImmutableMap<string, any>,
focusable?: boolean,
featured?: boolean,
hideActionBar?: boolean,

Wyświetl plik

@ -20,7 +20,7 @@ const Datepicker = ({ onChange }: IDatepicker) => {
const [month, setMonth] = useState<number>(new Date().getMonth());
const [day, setDay] = useState<number>(new Date().getDate());
const [year, setYear] = useState<number>(2022);
const [year, setYear] = useState<number>(new Date().getFullYear());
const numberOfDays = useMemo(() => {
return getDaysInMonth(month, year);

Wyświetl plik

@ -3,8 +3,6 @@ import React from 'react';
import { render, screen } from '../../../../jest/test-helpers';
import FormGroup from '../form-group';
jest.mock('uuid', () => jest.requireActual('uuid'));
describe('<FormGroup />', () => {
it('connects the label and input', () => {
render(

Wyświetl plik

@ -11,7 +11,7 @@ const Select = React.forwardRef<HTMLSelectElement, ISelect>((props, ref) => {
return (
<select
ref={ref}
className={`w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-800 focus:outline-none focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-900 dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:focus:ring-primary-500 dark:focus:border-primary-500 sm:text-sm rounded-md disabled:opacity-50 ${className}`}
className={`w-full pl-3 pr-10 py-2 text-base truncate border-gray-300 dark:border-gray-800 focus:outline-none focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-900 dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:focus:ring-primary-500 dark:focus:border-primary-500 sm:text-sm rounded-md disabled:opacity-50 ${className}`}
{...filteredProps}
>
{children}

Wyświetl plik

@ -1,77 +0,0 @@
import React from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import {
followAccount,
unfollowAccount,
blockAccount,
unblockAccount,
muteAccount,
unmuteAccount,
} from '../actions/accounts';
import { openModal } from '../actions/modals';
import { initMuteModal } from '../actions/mutes';
import { getSettings } from '../actions/settings';
import Account from '../components/account';
import { makeGetAccount } from '../selectors';
const messages = defineMessages({
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id),
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onFollow(account) {
dispatch((_, getState) => {
const unfollowModal = getSettings(getState()).get('unfollowModal');
if (account.relationship?.following || account.relationship?.requested) {
if (unfollowModal) {
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/minus.svg'),
heading: <FormattedMessage id='confirmations.unfollow.heading' defaultMessage='Unfollow {name}' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.unfollowConfirm),
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
}));
} else {
dispatch(unfollowAccount(account.get('id')));
}
} else {
dispatch(followAccount(account.get('id')));
}
});
},
onBlock(account) {
if (account.relationship?.blocking) {
dispatch(unblockAccount(account.get('id')));
} else {
dispatch(blockAccount(account.get('id')));
}
},
onMute(account) {
if (account.relationship?.muting) {
dispatch(unmuteAccount(account.get('id')));
} else {
dispatch(initMuteModal(account));
}
},
onMuteNotifications(account, notifications) {
dispatch(muteAccount(account.get('id'), notifications));
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));

Wyświetl plik

@ -0,0 +1,21 @@
import React, { useCallback } from 'react';
import { useAppSelector } from 'soapbox/hooks';
import Account, { IAccount } from '../components/account';
import { makeGetAccount } from '../selectors';
interface IAccountContainer extends Omit<IAccount, 'account'> {
id: string
}
const AccountContainer: React.FC<IAccountContainer> = ({ id, ...props }) => {
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector(state => getAccount(state, id));
return (
<Account account={account!} {...props} />
);
};
export default AccountContainer;

Wyświetl plik

@ -2,6 +2,3 @@
import 'intersection-observer';
import 'requestidlecallback';
import objectFitImages from 'object-fit-images';
objectFitImages();

Wyświetl plik

@ -95,7 +95,7 @@ const AccountGallery = () => {
const media = (attachment.status as Status).media_attachments;
const index = media.findIndex((x) => x.id === attachment.id);
dispatch(openModal('MEDIA', { media, index, status: attachment.status, account: attachment.account }));
dispatch(openModal('MEDIA', { media, index, status: attachment.status }));
}
};

Wyświetl plik

@ -4,9 +4,8 @@ import { Link } from 'react-router-dom';
import { closeReports } from 'soapbox/actions/admin';
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
import Avatar from 'soapbox/components/avatar';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
import { Accordion, Button, Stack, HStack, Text } from 'soapbox/components/ui';
import { Accordion, Avatar, Button, Stack, HStack, Text } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetReport } from 'soapbox/selectors';
@ -86,7 +85,7 @@ const Report: React.FC<IReport> = ({ id }) => {
<HStack space={3} className='p-3' key={report.id}>
<HoverRefWrapper accountId={targetAccount.id} inline>
<Link to={`/@${acct}`} title={acct}>
<Avatar account={targetAccount} size={32} />
<Avatar src={targetAccount.avatar} size={32} className='overflow-hidden' />
</Link>
</HoverRefWrapper>

Wyświetl plik

@ -1,132 +0,0 @@
import { Set as ImmutableSet, OrderedSet as ImmutableOrderedSet, is } from 'immutable';
import debounce from 'lodash/debounce';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl, defineMessages } from 'react-intl';
import { connect } from 'react-redux';
import { fetchUsers } from 'soapbox/actions/admin';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Column } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import { SimpleForm, TextInput } from 'soapbox/features/forms';
const messages = defineMessages({
heading: { id: 'column.admin.users', defaultMessage: 'Users' },
empty: { id: 'admin.user_index.empty', defaultMessage: 'No users found.' },
searchPlaceholder: { id: 'admin.user_index.search_input_placeholder', defaultMessage: 'Who are you looking for?' },
});
class UserIndex extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
};
state = {
isLoading: true,
filters: ImmutableSet(['local', 'active']),
accountIds: ImmutableOrderedSet(),
total: Infinity,
pageSize: 50,
page: 0,
query: '',
nextLink: undefined,
}
clearState = callback => {
this.setState({
isLoading: true,
accountIds: ImmutableOrderedSet(),
page: 0,
}, callback);
}
fetchNextPage = () => {
const { filters, page, query, pageSize, nextLink } = this.state;
const nextPage = page + 1;
this.props.dispatch(fetchUsers(filters, nextPage, query, pageSize, nextLink))
.then(({ users, count, next }) => {
const newIds = users.map(user => user.id);
this.setState({
isLoading: false,
accountIds: this.state.accountIds.union(newIds),
total: count,
page: nextPage,
nextLink: next,
});
})
.catch(() => { });
}
componentDidMount() {
this.fetchNextPage();
}
refresh = () => {
this.clearState(() => {
this.fetchNextPage();
});
}
componentDidUpdate(prevProps, prevState) {
const { filters, query } = this.state;
const filtersChanged = !is(filters, prevState.filters);
const queryChanged = query !== prevState.query;
if (filtersChanged || queryChanged) {
this.refresh();
}
}
handleLoadMore = debounce(() => {
this.fetchNextPage();
}, 2000, { leading: true });
updateQuery = debounce(query => {
this.setState({ query });
}, 900)
handleQueryChange = e => {
this.updateQuery(e.target.value);
};
render() {
const { intl } = this.props;
const { accountIds, isLoading } = this.state;
const hasMore = accountIds.count() < this.state.total && this.state.nextLink !== false;
const showLoading = isLoading && accountIds.isEmpty();
return (
<Column label={intl.formatMessage(messages.heading)}>
<SimpleForm style={{ paddingBottom: 0 }}>
<TextInput
onChange={this.handleQueryChange}
placeholder={intl.formatMessage(messages.searchPlaceholder)}
/>
</SimpleForm>
<ScrollableList
scrollKey='user-index'
hasMore={hasMore}
isLoading={isLoading}
showLoading={showLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={intl.formatMessage(messages.empty)}
className='mt-4'
itemClassName='pb-4'
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} withDate />,
)}
</ScrollableList>
</Column>
);
}
}
export default injectIntl(connect()(UserIndex));

Wyświetl plik

@ -0,0 +1,71 @@
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { expandUserIndex, fetchUserIndex, setUserIndexQuery } from 'soapbox/actions/admin';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Column } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import { SimpleForm, TextInput } from 'soapbox/features/forms';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'column.admin.users', defaultMessage: 'Users' },
empty: { id: 'admin.user_index.empty', defaultMessage: 'No users found.' },
searchPlaceholder: { id: 'admin.user_index.search_input_placeholder', defaultMessage: 'Who are you looking for?' },
});
const UserIndex: React.FC = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { isLoading, items, total, query, next } = useAppSelector((state) => state.admin_user_index);
const handleLoadMore = () => {
dispatch(expandUserIndex());
};
const updateQuery = useCallback(debounce(() => {
dispatch(fetchUserIndex());
}, 900, { leading: true }), []);
const handleQueryChange: React.ChangeEventHandler<HTMLInputElement> = e => {
dispatch(setUserIndexQuery(e.target.value));
};
useEffect(() => {
updateQuery();
}, [query]);
const hasMore = items.count() < total && next !== null;
const showLoading = isLoading && items.isEmpty();
return (
<Column label={intl.formatMessage(messages.heading)}>
<SimpleForm style={{ paddingBottom: 0 }}>
<TextInput
value={query}
onChange={handleQueryChange}
placeholder={intl.formatMessage(messages.searchPlaceholder)}
/>
</SimpleForm>
<ScrollableList
scrollKey='user-index'
hasMore={hasMore}
isLoading={isLoading}
showLoading={showLoading}
onLoadMore={handleLoadMore}
emptyMessage={intl.formatMessage(messages.empty)}
className='mt-4'
itemClassName='pb-4'
>
{items.map(id =>
<AccountContainer key={id} id={id} withDate />,
)}
</ScrollableList>
</Column>
);
};
export default UserIndex;

Wyświetl plik

@ -15,10 +15,10 @@ const hex2rgba = (hex: string, alpha = 1) => {
export default class Visualizer {
tickSize: number
canvas?: HTMLCanvasElement
context?: CanvasRenderingContext2D
analyser?: AnalyserNode
tickSize: number;
canvas?: HTMLCanvasElement;
context?: CanvasRenderingContext2D;
analyser?: AnalyserNode;
constructor(tickSize: number) {
this.tickSize = tickSize;

Wyświetl plik

@ -7,8 +7,6 @@ import { Button, Card, CardBody, CardHeader, CardTitle, Column, Spinner, Stack,
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { Token } from 'soapbox/reducers/security';
import type { Map as ImmutableMap } from 'immutable';
const messages = defineMessages({
header: { id: 'security.headers.tokens', defaultMessage: 'Sessions' },
revoke: { id: 'security.tokens.revoke', defaultMessage: 'Revoke' },
@ -75,9 +73,9 @@ const AuthTokenList: React.FC = () => {
const intl = useIntl();
const tokens = useAppSelector(state => state.security.get('tokens').reverse());
const currentTokenId = useAppSelector(state => {
const currentToken = state.auth.get('tokens').valueSeq().find((token: ImmutableMap<string, any>) => token.get('me') === state.auth.get('me'));
const currentToken = state.auth.tokens.valueSeq().find((token) => token.me === state.auth.me);
return currentToken?.get('id');
return currentToken?.id;
});
useEffect(() => {

Wyświetl plik

@ -1,10 +1,9 @@
import React, { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display-name';
import AccountComponent from 'soapbox/components/account';
import Icon from 'soapbox/components/icon';
import { HStack } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
@ -22,12 +21,6 @@ const Account: React.FC<IAccount> = ({ accountId }) => {
const account = useAppSelector((state) => getAccount(state, accountId));
// useEffect(() => {
// if (accountId && !account) {
// fetchAccount(accountId);
// }
// }, [accountId]);
if (!account) return null;
const birthday = account.birthday;
@ -36,26 +29,20 @@ const Account: React.FC<IAccount> = ({ accountId }) => {
const formattedBirthday = intl.formatDate(birthday, { day: 'numeric', month: 'short', year: 'numeric' });
return (
<div className='account'>
<div className='account__wrapper'>
<Link className='account__display-name' title={account.get('acct')} to={`/@${account.get('acct')}`}>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
</Link>
<div
className='flex items-center gap-0.5'
title={intl.formatMessage(messages.birthday, {
date: formattedBirthday,
})}
>
<Icon src={require('@tabler/icons/ballon.svg')} />
{formattedBirthday}
</div>
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
<div className='w-full'>
<AccountComponent account={account} withRelationship={false} />
</div>
</div>
<div
className='flex items-center gap-0.5'
title={intl.formatMessage(messages.birthday, {
date: formattedBirthday,
})}
>
<Icon src={require('@tabler/icons/ballon.svg')} />
{formattedBirthday}
</div>
</HStack>
);
};

Wyświetl plik

@ -1,15 +1,21 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { CardTitle, HStack, IconButton, Stack } from 'soapbox/components/ui';
import ChatSearch from '../../chat-search/chat-search';
const messages = defineMessages({
title: { id: 'chat.new_message.title', defaultMessage: 'New Message' },
});
interface IChatPageNew {
}
/** New message form to create a chat. */
const ChatPageNew: React.FC<IChatPageNew> = () => {
const intl = useIntl();
const history = useHistory();
return (
@ -22,7 +28,7 @@ const ChatPageNew: React.FC<IChatPageNew> = () => {
onClick={() => history.push('/chats')}
/>
<CardTitle title='New Message' />
<CardTitle title={intl.formatMessage(messages.title)} />
</HStack>
</Stack>
@ -31,4 +37,4 @@ const ChatPageNew: React.FC<IChatPageNew> = () => {
);
};
export default ChatPageNew;
export default ChatPageNew;

Wyświetl plik

@ -5,7 +5,7 @@ import { __stub } from 'soapbox/api';
import { ChatContext } from 'soapbox/contexts/chat-context';
import { StatProvider } from 'soapbox/contexts/stat-context';
import chats from 'soapbox/jest/fixtures/chats.json';
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
import { mockStore, render, rootState, screen, waitFor } from 'soapbox/jest/test-helpers';
import ChatPane from '../chat-pane';
@ -23,7 +23,12 @@ const renderComponentWithChatContext = (store = {}) => render(
describe('<ChatPane />', () => {
describe('when there are no chats', () => {
let store: ReturnType<typeof mockStore>;
beforeEach(() => {
const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.2.0)');
store = mockStore(state);
__stub((mock) => {
mock.onGet('/api/v1/pleroma/chats').reply(200, [], {
link: null,
@ -32,7 +37,7 @@ describe('<ChatPane />', () => {
});
it('renders the blankslate', async () => {
renderComponentWithChatContext();
renderComponentWithChatContext(store);
await waitFor(() => {
expect(screen.getByTestId('chat-pane-blankslate')).toBeInTheDocument();
@ -57,4 +62,4 @@ describe('<ChatPane />', () => {
});
});
});
});
});

Wyświetl plik

@ -1,6 +1,7 @@
import { useMutation } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { Icon, Input, Stack } from 'soapbox/components/ui';
@ -17,11 +18,16 @@ import Blankslate from './blankslate';
import EmptyResultsBlankslate from './empty-results-blankslate';
import Results from './results';
const messages = defineMessages({
placeholder: { id: 'chat_search.placeholder', defaultMessage: 'Type a name' },
});
interface IChatSearch {
isMainPage?: boolean
}
const ChatSearch = (props: IChatSearch) => {
const intl = useIntl();
const { isMainPage = false } = props;
const debounce = useDebounce;
@ -88,7 +94,7 @@ const ChatSearch = (props: IChatSearch) => {
data-testid='search'
type='text'
autoFocus
placeholder='Type a name'
placeholder={intl.formatMessage(messages.placeholder)}
value={value || ''}
onChange={(event) => setValue(event.target.value)}
outerClassName='mt-0'
@ -112,4 +118,4 @@ const ChatSearch = (props: IChatSearch) => {
);
};
export default ChatSearch;
export default ChatSearch;

Wyświetl plik

@ -3,7 +3,7 @@ import React from 'react';
import { __stub } from 'soapbox/api';
import { render, screen } from '../../../../jest/test-helpers';
import { render, screen, waitFor } from '../../../../jest/test-helpers';
import Search from '../search';
describe('<Search />', () => {
@ -22,7 +22,9 @@ describe('<Search />', () => {
await user.type(screen.getByLabelText('Search'), '@jus');
expect(screen.getByLabelText('Search')).toHaveValue('@jus');
expect(screen.getByTestId('account')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByLabelText('Search')).toHaveValue('@jus');
expect(screen.getByTestId('account')).toBeInTheDocument();
});
});
});

Wyświetl plik

@ -75,7 +75,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
const showSearch = useAppSelector((state) => state.search.submitted && !state.search.hidden);
const isModalOpen = useAppSelector((state) => !!(state.modals.size && state.modals.last()!.modalType === 'COMPOSE'));
const maxTootChars = configuration.getIn(['statuses', 'max_characters']) as number;
const scheduledStatusCount = useAppSelector((state) => state.get('scheduled_statuses').size);
const scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size);
const features = useFeatures();
const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt } = compose;

Wyświetl plik

@ -1,36 +0,0 @@
import React from 'react';
interface ITextIconButton {
label: string,
title: string,
active: boolean,
onClick: () => void,
ariaControls: string,
unavailable: boolean,
}
const TextIconButton: React.FC<ITextIconButton> = ({
label,
title,
active,
ariaControls,
unavailable,
onClick,
}) => {
const handleClick: React.MouseEventHandler = (e) => {
e.preventDefault();
onClick();
};
if (unavailable) {
return null;
}
return (
<button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={handleClick} aria-controls={ariaControls}>
{label}
</button>
);
};
export default TextIconButton;

Wyświetl plik

@ -37,7 +37,7 @@ const SettingsStore: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const settings = useSettings();
const settingsStore = useAppSelector(state => state.get('settings'));
const settingsStore = useAppSelector(state => state.settings);
const [rawJSON, setRawJSON] = useState<string>(JSON.stringify(settingsStore, null, 2));
const [jsonValid, setJsonValid] = useState(true);

Wyświetl plik

@ -17,12 +17,14 @@ const messages = defineMessages({
/** Form for logging into a remote instance */
const ExternalLoginForm: React.FC = () => {
const code = new URLSearchParams(window.location.search).get('code');
const query = new URLSearchParams(window.location.search);
const code = query.get('code');
const server = query.get('server');
const intl = useIntl();
const dispatch = useAppDispatch();
const [host, setHost] = useState('');
const [host, setHost] = useState(server || '');
const [isLoading, setLoading] = useState(false);
const handleHostChange: React.ChangeEventHandler<HTMLInputElement> = ({ currentTarget }) => {
@ -44,6 +46,12 @@ const ExternalLoginForm: React.FC = () => {
toast.error(intl.formatMessage(messages.networkFailed));
}
// If the server was invalid, clear it from the URL.
// https://stackoverflow.com/a/40592892
if (server) {
window.history.pushState(null, '', window.location.pathname);
}
setLoading(false);
});
};
@ -54,7 +62,13 @@ const ExternalLoginForm: React.FC = () => {
}
}, [code]);
if (code) {
useEffect(() => {
if (server && !code) {
handleSubmit();
}
}, [server]);
if (code || server) {
return <Spinner />;
}

Wyświetl plik

@ -1,5 +1,5 @@
import classNames from 'clsx';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { replaceHomeTimeline } from 'soapbox/actions/timelines';
@ -11,7 +11,7 @@ import PlaceholderAvatar from '../placeholder/components/placeholder-avatar';
const CarouselItem = React.forwardRef((
{ avatar, seen, onViewed, onPinned }: { avatar: Avatar, seen: boolean, onViewed: (account_id: string) => void, onPinned?: (avatar: null | Avatar) => void },
ref: any,
ref: React.ForwardedRef<HTMLDivElement>,
) => {
const dispatch = useAppDispatch();
@ -40,8 +40,11 @@ const CarouselItem = React.forwardRef((
onPinned(avatar);
}
onViewed(avatar.account_id);
markAsSeen.mutate(avatar.account_id);
if (!seen) {
onViewed(avatar.account_id);
markAsSeen.mutate(avatar.account_id);
}
dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }, () => setLoading(false)));
}
};
@ -51,7 +54,7 @@ const CarouselItem = React.forwardRef((
ref={ref}
aria-disabled={isFetching}
onClick={handleClick}
className='cursor-pointer snap-start py-4'
className='cursor-pointer py-4'
role='filter-feed-by-user'
data-testid='carousel-item'
>
@ -87,6 +90,7 @@ const FeedCarousel = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_ref, setContainerRef, { width }] = useDimensions();
const carouselItemRef = useRef<HTMLDivElement>(null);
const [seenAccountIds, setSeenAccountIds] = useState<string[]>([]);
const [pageSize, setPageSize] = useState<number>(0);
@ -94,13 +98,21 @@ const FeedCarousel = () => {
const [pinnedAvatar, setPinnedAvatar] = useState<Avatar | null>(null);
const avatarsToList = useMemo(() => {
const list = avatars.filter((avatar) => avatar.account_id !== pinnedAvatar?.account_id);
let list: (Avatar | null)[] = avatars.filter((avatar) => avatar.account_id !== pinnedAvatar?.account_id);
// If we have an Avatar pinned, let's create a new array with "null"
// in the first position of each page.
if (pinnedAvatar) {
return [null, ...list];
const index = (currentPage - 1) * pageSize;
list = [
...list.slice(0, index),
null,
...list.slice(index),
];
}
return list;
}, [avatars, pinnedAvatar]);
}, [avatars, pinnedAvatar, currentPage, pageSize]);
const numberOfPages = Math.ceil(avatars.length / pageSize);
const widthPerAvatar = width / (Math.floor(width / 80));
@ -151,23 +163,23 @@ const FeedCarousel = () => {
data-testid='feed-carousel'
>
<HStack alignItems='stretch'>
<div className='z-10 rounded-l-xl bg-white dark:bg-gray-900 w-8 flex self-stretch items-center justify-center'>
<div className='z-10 rounded-l-xl bg-white dark:bg-primary-900 w-8 flex self-stretch items-center justify-center'>
<button
data-testid='prev-page'
onClick={handlePrevPage}
className='h-7 w-7 flex items-center justify-center disabled:opacity-25 transition-opacity duration-500'
className='h-full w-7 flex items-center justify-center disabled:opacity-25 transition-opacity duration-500'
disabled={!hasPrevPage}
>
<Icon src={require('@tabler/icons/chevron-left.svg')} className='text-black dark:text-white h-5 w-5' />
</button>
</div>
<div className='overflow-hidden relative'>
<div className='overflow-hidden relative w-full'>
{pinnedAvatar ? (
<div
className='z-10 flex items-center justify-center absolute left-0 top-0 bottom-0 bg-white dark:bg-primary-900'
style={{
width: widthPerAvatar,
width: widthPerAvatar || 'auto',
}}
>
<CarouselItem
@ -175,6 +187,7 @@ const FeedCarousel = () => {
seen={seenAccountIds?.includes(pinnedAvatar.account_id)}
onViewed={markAsSeen}
onPinned={(avatar) => setPinnedAvatar(avatar)}
ref={carouselItemRef}
/>
</div>
) : null}
@ -189,7 +202,11 @@ const FeedCarousel = () => {
>
{isFetching ? (
new Array(20).fill(0).map((_, idx) => (
<div className='flex flex-shrink-0 justify-center' style={{ width: widthPerAvatar }} key={idx}>
<div
className='flex flex-shrink-0 justify-center'
style={{ width: widthPerAvatar || 'auto' }}
key={idx}
>
<PlaceholderAvatar size={56} withText />
</div>
))
@ -199,11 +216,15 @@ const FeedCarousel = () => {
key={avatar?.account_id || index}
className='flex flex-shrink-0 justify-center'
style={{
width: widthPerAvatar,
width: widthPerAvatar || 'auto',
}}
>
{avatar === null ? (
<Stack className='w-14 snap-start py-4 h-auto' space={3}>
<Stack
className='w-14 py-4 h-auto'
space={3}
style={{ height: carouselItemRef.current?.clientHeight }}
>
<div className='block mx-auto relative w-16 h-16 rounded-full'>
<div className='w-16 h-16' />
</div>
@ -227,11 +248,11 @@ const FeedCarousel = () => {
</HStack>
</div>
<div className='z-10 rounded-r-xl bg-white dark:bg-gray-900 w-8 self-stretch flex items-center justify-center'>
<div className='z-10 rounded-r-xl bg-white dark:bg-primary-900 w-8 self-stretch flex items-center justify-center'>
<button
data-testid='next-page'
onClick={handleNextPage}
className='h-7 w-7 flex items-center justify-center disabled:opacity-25 transition-opacity duration-500'
className='h-full w-7 flex items-center justify-center disabled:opacity-25 transition-opacity duration-500'
disabled={!hasNextPage}
>
<Icon src={require('@tabler/icons/chevron-right.svg')} className='text-black dark:text-white h-5 w-5' />

Wyświetl plik

@ -1,13 +1,10 @@
import React, { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { Link } from 'react-router-dom';
import { authorizeFollowRequest, rejectFollowRequest } from 'soapbox/actions/accounts';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display-name';
import IconButton from 'soapbox/components/icon-button';
import { Text } from 'soapbox/components/ui';
import Account from 'soapbox/components/account';
import { Button, HStack } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
@ -38,24 +35,28 @@ const AccountAuthorize: React.FC<IAccountAuthorize> = ({ id }) => {
if (!account) return null;
const content = { __html: account.note_emojified };
return (
<div className='account-authorize__wrapper'>
<div className='account-authorize'>
<Link to={`/@${account.acct}`}>
<div className='account-authorize__avatar'><Avatar account={account} size={48} /></div>
<DisplayName account={account} />
</Link>
<Text className='account__header__content' dangerouslySetInnerHTML={content} />
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
<div className='w-full'>
<Account account={account} withRelationship={false} />
</div>
<div className='account--panel'>
<div className='account--panel__button'><IconButton title={intl.formatMessage(messages.authorize)} src={require('@tabler/icons/check.svg')} onClick={onAuthorize} /></div>
<div className='account--panel__button'><IconButton title={intl.formatMessage(messages.reject)} src={require('@tabler/icons/x.svg')} onClick={onReject} /></div>
</div>
</div>
<HStack space={2}>
<Button
theme='secondary'
size='sm'
text={intl.formatMessage(messages.authorize)}
icon={require('@tabler/icons/check.svg')}
onClick={onAuthorize}
/>
<Button
theme='danger'
size='sm'
text={intl.formatMessage(messages.reject)}
icon={require('@tabler/icons/x.svg')}
onClick={onReject}
/>
</HStack>
</HStack>
);
};

Wyświetl plik

@ -64,7 +64,7 @@ const NotificationFilterBar = () => {
name: 'pleroma:emoji_reaction',
});
items.push({
text: <Icon src={require('feather-icons/dist/icons/repeat.svg')} />,
text: <Icon src={require('@tabler/icons/repeat.svg')} />,
title: intl.formatMessage(messages.boosts),
action: onClick('reblog'),
name: 'reblog',

Wyświetl plik

@ -17,7 +17,7 @@ import { makeGetNotification } from 'soapbox/selectors';
import { NotificationType, validType } from 'soapbox/utils/notification';
import type { ScrollPosition } from 'soapbox/components/status';
import type { Account, Status as StatusEntity, Notification as NotificationEntity } from 'soapbox/types/entities';
import type { Account as AccountEntity, Status as StatusEntity, Notification as NotificationEntity } from 'soapbox/types/entities';
const notificationForScreenReader = (intl: IntlShape, message: string, timestamp: Date) => {
const output = [message];
@ -27,7 +27,7 @@ const notificationForScreenReader = (intl: IntlShape, message: string, timestamp
return output.join(', ');
};
const buildLink = (account: Account): JSX.Element => (
const buildLink = (account: AccountEntity): JSX.Element => (
<bdi>
<Link
className='text-gray-800 dark:text-gray-200 font-bold hover:underline'
@ -127,7 +127,7 @@ const messages: Record<NotificationType, MessageDescriptor> = defineMessages({
const buildMessage = (
intl: IntlShape,
type: NotificationType,
account: Account,
account: AccountEntity,
totalCount: number | null,
targetName: string,
instanceTitle: string,

Wyświetl plik

@ -1,22 +1,21 @@
import React from 'react';
import { HStack } from 'soapbox/components/ui';
import PlaceholderAvatar from './placeholder-avatar';
import PlaceholderDisplayName from './placeholder-display-name';
/** Fake account to display while data is loading. */
const PlaceholderAccount: React.FC = () => {
return (
<div className='account'>
<div className='account__wrapper'>
<span className='account__display-name'>
<div className='account__avatar-wrapper'>
<PlaceholderAvatar size={36} />
</div>
<PlaceholderDisplayName minLength={3} maxLength={25} />
</span>
</div>
const PlaceholderAccount: React.FC = () => (
<HStack space={3} alignItems='center'>
<div className='flex-shrink-0'>
<PlaceholderAvatar size={42} />
</div>
);
};
<div className='min-w-0 flex-1'>
<PlaceholderDisplayName minLength={3} maxLength={25} />
</div>
</HStack>
);
export default PlaceholderAccount;

Wyświetl plik

@ -29,7 +29,7 @@ const PlaceholderMediaGallery: React.FC<IPlaceholderMediaGallery> = ({ media, de
let itemsDimensions: Record<string, string>[] = [];
if (size === 1) {
style.height = width;
style.height = width! * 9 / 16;
itemsDimensions = [
{ w: '100%', h: '100%' },

Wyświetl plik

@ -1,11 +1,11 @@
import classNames from 'clsx';
import React from 'react';
import Account from 'soapbox/components/account';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import StatusContent from 'soapbox/components/status-content';
import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
import { HStack } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import PollPreview from 'soapbox/features/ui/components/poll-preview';
import { useAppSelector } from 'soapbox/hooks';
@ -20,7 +20,12 @@ interface IScheduledStatus {
}
const ScheduledStatus: React.FC<IScheduledStatus> = ({ statusId, ...other }) => {
const status = useAppSelector((state) => buildStatus(state, state.scheduled_statuses.get(statusId)!)) as StatusEntity;
const status = useAppSelector((state) => {
const scheduledStatus = state.scheduled_statuses.get(statusId);
if (!scheduledStatus) return null;
return buildStatus(state, scheduledStatus);
}) as StatusEntity | null;
if (!status) return null;
@ -31,11 +36,12 @@ const ScheduledStatus: React.FC<IScheduledStatus> = ({ statusId, ...other }) =>
<div className={classNames('status', `status-${status.visibility}`, { 'status-reply': !!status.in_reply_to_id })} data-id={status.id}>
<div className='mb-4'>
<HStack justifyContent='between' alignItems='start'>
<AccountContainer
<Account
key={account.id}
id={account.id}
account={account}
timestamp={status.created_at}
futureTimestamp
hideActions
/>
</HStack>
</div>

Wyświetl plik

@ -50,6 +50,8 @@ const messages = defineMessages({
singleUserModeHint: { id: 'soapbox_config.single_user_mode_hint', defaultMessage: 'Front page will redirect to a given user profile.' },
singleUserModeProfileLabel: { id: 'soapbox_config.single_user_mode_profile_label', defaultMessage: 'Main user handle' },
singleUserModeProfileHint: { id: 'soapbox_config.single_user_mode_profile_hint', defaultMessage: '@handle' },
mediaPreviewLabel: { id: 'soapbox_config.media_preview_label', defaultMessage: 'Prefer preview media for thumbnails' },
mediaPreviewHint: { id: 'soapbox_config.media_preview_hint', defaultMessage: 'Some backends provide an optimized version of media for display in timelines. However, these preview images may be too small without additional configuration.' },
feedInjectionLabel: { id: 'soapbox_config.feed_injection_label', defaultMessage: 'Feed injection' },
feedInjectionHint: { id: 'soapbox_config.feed_injection_hint', defaultMessage: 'Inject the feed with additional content, such as suggested profiles.' },
tileServerLabel: { id: 'soapbox_config.tile_server_label', defaultMessage: 'Map tile server' },
@ -250,6 +252,16 @@ const SoapboxConfig: React.FC = () => {
/>
</ListItem>
<ListItem
label={intl.formatMessage(messages.mediaPreviewLabel)}
hint={intl.formatMessage(messages.mediaPreviewHint)}
>
<Toggle
checked={soapbox.mediaPreview === true}
onChange={handleChange(['mediaPreview'], (e) => e.target.checked)}
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.displayCtaLabel)}>
<Toggle
checked={soapbox.displayCta === true}

Wyświetl plik

@ -111,7 +111,7 @@ const Card: React.FC<ICard> = ({
// Constrain to a sane limit
// https://en.wikipedia.org/wiki/Aspect_ratio_(image)
return Math.min(Math.max(1, ratio), 4);
return Math.min(Math.max(9 / 16, ratio), 4);
};
const interactive = card.type !== 'link';

Wyświetl plik

@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { FormattedDate, FormattedMessage, useIntl } from 'react-intl';
import Account from 'soapbox/components/account';
import Icon from 'soapbox/components/icon';
import StatusContent from 'soapbox/components/status-content';
import StatusMedia from 'soapbox/components/status-media';
@ -8,7 +9,6 @@ import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
import SensitiveContentOverlay from 'soapbox/components/statuses/sensitive-content-overlay';
import TranslateButton from 'soapbox/components/translate-button';
import { HStack, Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container';
import { getActualStatus } from 'soapbox/utils/status';
@ -84,9 +84,9 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
<div className='border-box'>
<div ref={node} className='detailed-actualStatus' tabIndex={-1}>
<div className='mb-4'>
<AccountContainer
<Account
key={account.id}
id={account.id}
account={account}
timestamp={actualStatus.created_at}
avatarSize={42}
hideActions

Wyświetl plik

@ -32,14 +32,14 @@ class Bundle extends React.PureComponent<BundleProps, BundleState> {
onFetch: noop,
onFetchSuccess: noop,
onFetchFail: noop,
}
};
static cache = new Map
static cache = new Map;
state = {
mod: undefined,
forceRender: false,
}
};
componentDidMount() {
this.load(this.props);
@ -91,7 +91,7 @@ class Bundle extends React.PureComponent<BundleProps, BundleState> {
this.setState({ mod: null });
onFetchFail(error);
});
}
};
render() {
const { loading: Loading, error: Error, children, renderDelay } = this.props;

Wyświetl plik

@ -26,7 +26,7 @@ class ImageLoader extends React.PureComponent<IImageLoader> {
loading: true,
error: false,
width: null,
}
};
removers: EventRemover[] = [];
canvas: HTMLCanvasElement | null = null;
@ -87,7 +87,7 @@ class ImageLoader extends React.PureComponent<IImageLoader> {
image.addEventListener('load', handleLoad);
image.src = previewSrc || '';
this.removers.push(removeEventListeners);
})
});
clearPreviewCanvas() {
if (this.canvas && this.canvasContext) {
@ -129,7 +129,7 @@ class ImageLoader extends React.PureComponent<IImageLoader> {
setCanvasRef = (c: HTMLCanvasElement) => {
this.canvas = c;
if (c) this.setState({ width: c.offsetWidth });
}
};
render() {
const { alt, src, width, height, onClick } = this.props;

Wyświetl plik

@ -105,16 +105,16 @@ export default class ModalRoot extends React.PureComponent<IModalRoot> {
renderLoading = (modalId: string) => () => {
return !['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].includes(modalId) ? <ModalLoading /> : null;
}
};
renderError: React.ComponentType<{ onRetry: (props?: BundleProps) => void }> = (props) => {
return <BundleModalError {...props} onClose={this.onClickClose} />;
}
};
onClickClose = (_?: ModalType) => {
const { onClose, type } = this.props;
onClose(type);
}
};
render() {
const { type, props } = this.props;

Wyświetl plik

@ -28,6 +28,7 @@ const BirthdaysModal = ({ onClose }: IBirthdaysModal) => {
<ScrollableList
scrollKey='birthdays'
emptyMessage={emptyMessage}
className='max-w-full'
itemClassName='pb-3'
>
{accountIds.map(id =>

Wyświetl plik

@ -2,7 +2,8 @@ import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { fetchEventParticipations } from 'soapbox/actions/events';
import { Modal, Spinner, Stack } from 'soapbox/components/ui';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Modal, Spinner } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
@ -33,16 +34,19 @@ const EventParticipantsModal: React.FC<IEventParticipantsModal> = ({ onClose, st
if (!accountIds) {
body = <Spinner />;
} else {
const emptyMessage = <FormattedMessage id='empty_column.event_participants' defaultMessage='No one joined this event yet. When someone does, they will show up here.' />;
body = (
<Stack space={3}>
{accountIds.size > 0 ? (
accountIds.map((id) =>
<AccountContainer key={id} id={id} />,
)
) : (
<FormattedMessage id='empty_column.event_participants' defaultMessage='No one joined this event yet. When someone does, they will show up here.' />
<ScrollableList
scrollKey='event_participations'
emptyMessage={emptyMessage}
className='max-w-full'
itemClassName='pb-3'
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} />,
)}
</Stack>
</ScrollableList>
);
}

Wyświetl plik

@ -2,7 +2,8 @@ import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { fetchFavourites } from 'soapbox/actions/interactions';
import { Modal, Spinner, Stack } from 'soapbox/components/ui';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Modal, Spinner } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
@ -33,16 +34,19 @@ const FavouritesModal: React.FC<IFavouritesModal> = ({ onClose, statusId }) => {
if (!accountIds) {
body = <Spinner />;
} else {
const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has liked this post yet. When someone does, they will show up here.' />;
body = (
<Stack space={3}>
{accountIds.size > 0 ? (
accountIds.map((id) =>
<AccountContainer key={id} id={id} />,
)
) : (
<FormattedMessage id='empty_column.favourites' defaultMessage='No one has liked this post yet. When someone does, they will show up here.' />
<ScrollableList
scrollKey='favourites'
emptyMessage={emptyMessage}
className='max-w-full'
itemClassName='pb-3'
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} />,
)}
</Stack>
</ScrollableList>
);
}

Wyświetl plik

@ -13,7 +13,7 @@ import Video from 'soapbox/features/video';
import ImageLoader from '../image-loader';
import type { List as ImmutableList } from 'immutable';
import type { Account, Attachment, Status } from 'soapbox/types/entities';
import type { Attachment, Status } from 'soapbox/types/entities';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -24,7 +24,6 @@ const messages = defineMessages({
interface IMediaModal {
media: ImmutableList<Attachment>,
status?: Status,
account: Account,
index: number,
time?: number,
onClose: () => void,
@ -34,7 +33,6 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
const {
media,
status,
account,
onClose,
time = 0,
} = props;
@ -94,9 +92,9 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
};
const handleStatusClick: React.MouseEventHandler = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
if (status && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
history.push(`/@${account.acct}/posts/${status?.id}`);
history.push(`/@${status.getIn(['account', 'acct'])}/posts/${status?.id}`);
onClose();
}
};
@ -170,7 +168,7 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
const width = (attachment.meta.getIn(['original', 'width']) || undefined) as number | undefined;
const height = (attachment.meta.getIn(['original', 'height']) || undefined) as number | undefined;
const link = (status && account && (
const link = (status && (
<a href={status.url} onClick={handleStatusClick}>
<FormattedMessage id='lightbox.view_context' defaultMessage='View context' />
</a>

Wyświetl plik

@ -41,6 +41,7 @@ const MentionsModal: React.FC<IMentionsModal> = ({ onClose, statusId }) => {
body = (
<ScrollableList
scrollKey='mentions'
className='max-w-full'
itemClassName='pb-3'
>
{accountIds.map(id =>

Wyświetl plik

@ -1,3 +1,4 @@
import classNames from 'clsx';
import { List as ImmutableList } from 'immutable';
import React, { useEffect, useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
@ -84,7 +85,9 @@ const ReactionsModal: React.FC<IReactionsModal> = ({ onClose, statusId, reaction
<ScrollableList
scrollKey='reactions'
emptyMessage={emptyMessage}
className='mt-4'
className={classNames('max-w-full', {
'mt-4': reactions.size > 0,
})}
itemClassName='pb-3'
>
{accounts.map((account) =>

Wyświetl plik

@ -41,6 +41,7 @@ const ReblogsModal: React.FC<IReblogsModal> = ({ onClose, statusId }) => {
<ScrollableList
scrollKey='reblogs'
emptyMessage={emptyMessage}
className='max-w-full'
itemClassName='pb-3'
>
{accountIds.map((id) =>

Wyświetl plik

@ -1,10 +1,10 @@
import classNames from 'clsx';
import React from 'react';
import Account from 'soapbox/components/account';
import StatusContent from 'soapbox/components/status-content';
import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
import { Card, HStack } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder-card';
import PlaceholderMediaGallery from 'soapbox/features/placeholder/components/placeholder-media-gallery';
import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container';
@ -65,9 +65,9 @@ const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, mu
>
<div className='mb-4'>
<HStack justifyContent='between' alignItems='start'>
<AccountContainer
<Account
key={account.id}
id={account.id}
account={account}
timestamp={status.created_at}
hideActions
/>

Wyświetl plik

@ -39,8 +39,8 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
const features = useFeatures();
const intl = useIntl();
const authUsers = useAppSelector((state) => state.auth.get('users'));
const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.get('id'))));
const authUsers = useAppSelector((state) => state.auth.users);
const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.id)!));
const handleLogOut = () => {
dispatch(logOut());

Wyświetl plik

@ -31,7 +31,7 @@ const ProfileMediaPanel: React.FC<IProfileMediaPanel> = ({ account }) => {
const media = attachment.getIn(['status', 'media_attachments']) as ImmutableList<Attachment>;
const index = media.findIndex(x => x.id === attachment.id);
dispatch(openModal('MEDIA', { media, index, status: attachment.status, account: attachment.account }));
dispatch(openModal('MEDIA', { media, index, status: attachment.status }));
}
};

Wyświetl plik

@ -2,9 +2,8 @@ import React from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import Avatar from 'soapbox/components/avatar';
import StillImage from 'soapbox/components/still-image';
import { HStack, Stack, Text } from 'soapbox/components/ui';
import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification-badge';
import { useAppSelector } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
@ -48,10 +47,7 @@ const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain })
title={acct}
className='-mt-12 block'
>
<Avatar
account={account}
className='h-20 w-20 bg-gray-50 ring-2 ring-white'
/>
<Avatar src={account.avatar} className='h-20 w-20 bg-gray-50 ring-2 ring-white overflow-hidden' />
</Link>
{action && (

Wyświetl plik

@ -38,7 +38,9 @@ const WhoToFollowPanel = ({ limit }: IWhoToFollowPanel) => {
title={<FormattedMessage id='who_to_follow.title' defaultMessage='People To Follow' />}
action={
<Link to='/suggestions'>
<Text tag='span' theme='primary' size='sm' className='hover:underline'>View all</Text>
<Text tag='span' theme='primary' size='sm' className='hover:underline'>
<FormattedMessage id='feed_suggestions.view_all' defaultMessage='View all' />
</Text>
</Link>
}
>

Wyświetl plik

@ -31,7 +31,7 @@ class ZoomableImage extends React.PureComponent<IZoomableImage> {
state = {
scale: MIN_SCALE,
}
};
container: HTMLDivElement | null = null;
image: HTMLImageElement | null = null;
@ -54,7 +54,7 @@ class ZoomableImage extends React.PureComponent<IZoomableImage> {
const [p1, p2] = Array.from(e.touches);
this.lastDistance = getDistance(p1, p2);
}
};
handleTouchMove = (e: TouchEvent) => {
if (!this.container) return;
@ -78,7 +78,7 @@ class ZoomableImage extends React.PureComponent<IZoomableImage> {
this.zoom(scale, midpoint);
this.lastDistance = distance;
}
};
zoom(nextScale: number, midpoint: Point) {
if (!this.container) return;
@ -107,15 +107,15 @@ class ZoomableImage extends React.PureComponent<IZoomableImage> {
e.stopPropagation();
const handler = this.props.onClick;
if (handler) handler(e);
}
};
setContainerRef = (c: HTMLDivElement) => {
this.container = c;
}
};
setImageRef = (c: HTMLImageElement) => {
this.image = c;
}
};
render() {
const { alt, src } = this.props;

Wyświetl plik

@ -15,9 +15,6 @@ jest.mock('soapbox/queries/client');
// https://dev.to/andyhaskell/testing-your-indexeddb-code-with-jest-2o17
require('fake-indexeddb/auto');
// Mock external dependencies
jest.mock('uuid', () => ({ v4: jest.fn(() => '1') }));
// Clear toasts after each test.
afterEach(() => {
toast.remove();

Wyświetl plik

@ -28,15 +28,13 @@ function loadPolyfills() {
window.Symbol
);
// Latest version of Firefox and Safari do not have IntersectionObserver.
// Edge does not have requestIdleCallback and object-fit CSS property.
// Older versions of Firefox and Safari do not have IntersectionObserver.
// This avoids shipping them all the polyfills.
const needsExtraPolyfills = !(
window.IntersectionObserver &&
window.IntersectionObserverEntry &&
'isIntersecting' in IntersectionObserverEntry.prototype &&
window.requestIdleCallback &&
'object-fit' in (new Image()).style
window.requestIdleCallback
);
return Promise.all([

Wyświetl plik

@ -110,12 +110,15 @@
"admin.reports.empty_message": "There are no open reports. If a user gets reported, they will show up here.",
"admin.reports.report_closed_message": "Report on @{name} was closed",
"admin.reports.report_title": "Report on {acct}",
"admin.software.backend": "Backend",
"admin.software.frontend": "Frontend",
"admin.statuses.actions.delete_status": "Delete post",
"admin.statuses.actions.mark_status_not_sensitive": "Mark post not sensitive",
"admin.statuses.actions.mark_status_sensitive": "Mark post sensitive",
"admin.statuses.status_deleted_message": "Post by @{acct} was deleted",
"admin.statuses.status_marked_message_not_sensitive": "Post by @{acct} was marked not sensitive",
"admin.statuses.status_marked_message_sensitive": "Post by @{acct} was marked sensitive",
"admin.theme.title": "Theme",
"admin.user_index.empty": "No users found.",
"admin.user_index.search_input_placeholder": "Who are you looking for?",
"admin.users.actions.deactivate_user": "Deactivate @{name}",
@ -147,7 +150,6 @@
"alert.unexpected.links.support": "Support",
"alert.unexpected.message": "Something went wrong.",
"alert.unexpected.return_home": "Return Home",
"alert.unexpected.title": "Oops!",
"aliases.account.add": "Create alias",
"aliases.account_label": "Old account:",
"aliases.aliases_list_delete": "Unlink alias",
@ -186,19 +188,80 @@
"bundle_modal_error.message": "Something went wrong while loading this modal.",
"bundle_modal_error.retry": "Try again",
"card.back.label": "Back",
"chat_box.actions.send": "Send",
"chat_box.input.placeholder": "Send a message…",
"chat_panels.main_window.empty": "No chats found. To start a chat, visit a user's profile.",
"chat_panels.main_window.title": "Chats",
"chat_window.close": "Close chat",
"chat.actions.send": "Send",
"chat.failed_to_send": "Message failed to send.",
"chat.input.placeholder": "Type a message",
"chat.new_message.title": "New Message",
"chat.page_settings.accepting_messages.label": "Allow users to start a new chat with you",
"chat.page_settings.play_sounds.label": "Play a sound when you receive a message",
"chat.page_settings.preferences": "Preferences",
"chat.page_settings.privacy": "Privacy",
"chat.page_settings.submit": "Save",
"chat.page_settings.title": "Message Settings",
"chat.retry": "Retry?",
"chat.welcome.accepting_messages.label": "Allow users to start a new chat with you",
"chat.welcome.notice": "You can change these settings later.",
"chat.welcome.submit": "Save & Continue",
"chat.welcome.subtitle": "Exchange direct messages with other users.",
"chat.welcome.title": "Welcome to {br} Chats!",
"chat_composer.unblock": "Unblock",
"chat_list_item.blocked_you": "This user has blocked you",
"chat_list_item.blocking": "You have blocked this user",
"chat_message_list.blocked": "You blocked this user",
"chat_message_list.blockedBy": "You are blocked by",
"chat_message_list.network_failure.action": "Try again",
"chat_message_list.network_failure.subtitle": "We encountered a network failure.",
"chat_message_list.network_failure.title": "Whoops!",
"chat_message_list_intro.actions.accept": "Accept",
"chat_message_list_intro.actions.leave_chat": "Leave chat",
"chat_message_list_intro.actions.message_lifespan": "Messages older than {day} days are deleted.",
"chat_message_list_intro.actions.report": "Report",
"chat_message_list_intro.intro": "wants to start a chat with you",
"chat_message_list_intro.leave_chat.confirm": "Leave Chat",
"chat_message_list_intro.leave_chat.heading": "Leave Chat",
"chat_message_list_intro.leave_chat.message": "Are you sure you want to leave this chat? Messages will be deleted for you and this chat will be removed from your inbox.",
"chat_search.blankslate.body": "Search for someone to chat with.",
"chat_search.blankslate.title": "Start a chat",
"chat_search.empty_results_blankslate.action": "Message someone",
"chat_search.empty_results_blankslate.body": "Try searching for another name.",
"chat_search.empty_results_blankslate.title": "No matches found",
"chat_search.placeholder": "Type a name",
"chat_search.title": "Messages",
"chat_settings.auto_delete.14days": "14 days",
"chat_settings.auto_delete.2minutes": "2 minutes",
"chat_settings.auto_delete.30days": "30 days",
"chat_settings.auto_delete.7days": "7 days",
"chat_settings.auto_delete.90days": "90 days",
"chat_settings.auto_delete.days": "{day} days",
"chat_settings.auto_delete.hint": "Sent messages will auto-delete after the time period selected",
"chat_settings.auto_delete.label": "Auto-delete messages",
"chat_settings.block.confirm": "Block",
"chat_settings.block.heading": "Block @{acct}",
"chat_settings.block.message": "Blocking will prevent this profile from direct messaging you and viewing your content. You can unblock later.",
"chat_settings.leave.confirm": "Leave Chat",
"chat_settings.leave.heading": "Leave Chat",
"chat_settings.leave.message": "Are you sure you want to leave this chat? Messages will be deleted for you and this chat will be removed from your inbox.",
"chat_settings.options.block_user": "Block @{acct}",
"chat_settings.options.leave_chat": "Leave Chat",
"chat_settings.options.report_user": "Report @{acct}",
"chat_settings.options.unblock_user": "Unblock @{acct}",
"chat_settings.title": "Chat Details",
"chat_settings.unblock.confirm": "Unblock",
"chat_settings.unblock.heading": "Unblock @{acct}",
"chat_settings.unblock.message": "Unblocking will allow this profile to direct message you and view your content.",
"chat_window.auto_delete_label": "Auto-delete after {day} days",
"chat_window.auto_delete_tooltip": "Chat messages are set to auto-delete after {day} days upon sending.",
"chats.actions.copy": "Copy",
"chats.actions.delete": "Delete message",
"chats.actions.deleteForMe": "Delete for me",
"chats.actions.more": "More",
"chats.actions.report": "Report user",
"chats.attachment": "Attachment",
"chats.attachment_image": "Image",
"chats.audio_toggle_off": "Audio notification off",
"chats.audio_toggle_on": "Audio notification on",
"chats.dividers.today": "Today",
"chats.main.blankslate.new_chat": "Message someone",
"chats.main.blankslate.subtitle": "Search for someone to chat with",
"chats.main.blankslate.title": "No messages yet",
"chats.main.blankslate_with_chats.subtitle": "Select from one of your open chats or create a new message.",
"chats.main.blankslate_with_chats.title": "Select a chat",
"chats.search_placeholder": "Start a chat with…",
"column.admin.awaiting_approval": "Awaiting Approval",
"column.admin.dashboard": "Dashboard",
@ -226,6 +289,9 @@
"column.directory": "Browse profiles",
"column.domain_blocks": "Hidden domains",
"column.edit_profile": "Edit profile",
"column.event_map": "Event location",
"column.event_participants": "Event participants",
"column.events": "Events",
"column.export_data": "Export data",
"column.familiar_followers": "People you know following {name}",
"column.favourited_statuses": "Liked posts",
@ -268,15 +334,14 @@
"column.pins": "Pinned posts",
"column.preferences": "Preferences",
"column.public": "Federated timeline",
"column.quotes": "Post quotes",
"column.reactions": "Reactions",
"column.reblogs": "Reposts",
"column.remote": "Federated timeline",
"column.scheduled_statuses": "Scheduled Posts",
"column.search": "Search",
"column.settings_store": "Settings store",
"column.soapbox_config": "Soapbox config",
"column.test": "Test timeline",
"column_back_button.label": "Back",
"column_forbidden.body": "You do not have permission to access this page.",
"column_forbidden.title": "Forbidden",
"common.cancel": "Cancel",
@ -286,7 +351,33 @@
"compose.edit_success": "Your post was edited",
"compose.invalid_schedule": "You must schedule a post at least 5 minutes out.",
"compose.submit_success": "Your post was sent!",
"compose_event.create": "Create",
"compose_event.edit_success": "Your event was edited",
"compose_event.fields.approval_required": "I want to approve participation requests manually",
"compose_event.fields.banner_label": "Event banner",
"compose_event.fields.description_hint": "Markdown syntax is supported",
"compose_event.fields.description_label": "Event description",
"compose_event.fields.description_placeholder": "Description",
"compose_event.fields.end_time_label": "Event end date",
"compose_event.fields.end_time_placeholder": "Event ends on…",
"compose_event.fields.has_end_time": "The event has end date",
"compose_event.fields.location_label": "Event location",
"compose_event.fields.name_label": "Event name",
"compose_event.fields.name_placeholder": "Name",
"compose_event.fields.start_time_label": "Event start date",
"compose_event.fields.start_time_placeholder": "Event begins on…",
"compose_event.participation_requests.authorize": "Authorize",
"compose_event.participation_requests.authorize_success": "User accepted",
"compose_event.participation_requests.reject": "Reject",
"compose_event.participation_requests.reject_success": "User rejected",
"compose_event.reset_location": "Reset location",
"compose_event.submit_success": "Your event was created",
"compose_event.tabs.edit": "Edit details",
"compose_event.tabs.pending": "Manage requests",
"compose_event.update": "Update",
"compose_event.upload_banner": "Upload event banner",
"compose_form.direct_message_warning": "This post will only be sent to the mentioned users.",
"compose_form.event_placeholder": "Post to this event",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
"compose_form.lock_disclaimer.lock": "locked",
@ -342,15 +433,22 @@
"confirmations.cancel_editing.confirm": "Cancel editing",
"confirmations.cancel_editing.heading": "Cancel post editing",
"confirmations.cancel_editing.message": "Are you sure you want to cancel editing this post? All changes will be lost.",
"confirmations.cancel_event_editing.heading": "Cancel event editing",
"confirmations.cancel_event_editing.message": "Are you sure you want to cancel editing this event? All changes will be lost.",
"confirmations.delete.confirm": "Delete",
"confirmations.delete.heading": "Delete post",
"confirmations.delete.message": "Are you sure you want to delete this post?",
"confirmations.delete_event.confirm": "Delete",
"confirmations.delete_event.heading": "Delete event",
"confirmations.delete_event.message": "Are you sure you want to delete this event?",
"confirmations.delete_list.confirm": "Delete",
"confirmations.delete_list.heading": "Delete list",
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
"confirmations.domain_block.confirm": "Hide entire domain",
"confirmations.domain_block.heading": "Block {domain}",
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications.",
"confirmations.leave_event.confirm": "Leave event",
"confirmations.leave_event.message": "If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?",
"confirmations.mute.confirm": "Mute",
"confirmations.mute.heading": "Mute @{name}",
"confirmations.mute.message": "Are you sure you want to mute {name}?",
@ -372,8 +470,6 @@
"confirmations.scheduled_status_delete.heading": "Cancel scheduled post",
"confirmations.scheduled_status_delete.message": "Are you sure you want to cancel this scheduled post?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.heading": "Unfollow {name}",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
"crypto_donate.explanation_box.message": "{siteTitle} accepts cryptocurrency donations. You may send a donation to any of the addresses below. Thank you for your support!",
"crypto_donate.explanation_box.title": "Sending cryptocurrency donations",
"crypto_donate_panel.actions.view": "Click to see {count} {count, plural, one {wallet} other {wallets}}",
@ -400,6 +496,7 @@
"developers.navigation.network_error_label": "Network error",
"developers.navigation.service_worker_label": "Service Worker",
"developers.navigation.settings_store_label": "Settings store",
"developers.navigation.show_toast": "Trigger Toast",
"developers.navigation.test_timeline_label": "Test timeline",
"developers.settings_store.advanced": "Advanced settings",
"developers.settings_store.hint": "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.",
@ -498,6 +595,8 @@
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
"empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
"empty_column.domain_blocks": "There are no hidden domains yet.",
"empty_column.event_participant_requests": "There are no pending event participation requests.",
"empty_column.event_participants": "No one joined this event yet. When someone does, they will show up here.",
"empty_column.favourited_statuses": "You don't have any liked posts yet. When you like one, it will show up here.",
"empty_column.favourites": "No one has liked this post yet. When someone does, they will show up here.",
"empty_column.filters": "You haven't created any muted words yet.",
@ -514,12 +613,36 @@
"empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
"empty_column.notifications_filtered": "You don't have any notifications of this type yet.",
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
"empty_column.quotes": "This post has not been quoted yet.",
"empty_column.remote": "There is nothing here! Manually follow users from {instance} to fill it up.",
"empty_column.scheduled_statuses": "You don't have any scheduled statuses yet. When you add one, it will show up here.",
"empty_column.search.accounts": "There are no people results for \"{term}\"",
"empty_column.search.hashtags": "There are no hashtags results for \"{term}\"",
"empty_column.search.statuses": "There are no posts results for \"{term}\"",
"empty_column.test": "The test timeline is empty.",
"event.banner": "Event banner",
"event.copy": "Copy link to event",
"event.date": "Date",
"event.description": "Description",
"event.discussion.empty": "No one has commented this event yet. When someone does, they will appear here.",
"event.export_ics": "Export to your calendar",
"event.external": "View event on {domain}",
"event.join_state.accept": "Going",
"event.join_state.empty": "Participate",
"event.join_state.pending": "Pending",
"event.join_state.rejected": "Going",
"event.location": "Location",
"event.manage": "Manage",
"event.organized_by": "Organized by {name}",
"event.participants": "{count} {rawCount, plural, one {person} other {people}} going",
"event.show_on_map": "Show on map",
"event.website": "External links",
"event_map.navigate": "Navigate",
"events.create_event": "Create event",
"events.joined_events": "Joined events",
"events.joined_events.empty": "You haven't joined any event yet.",
"events.recent_events": "Recent events",
"events.recent_events.empty": "There are no public events yet.",
"export_data.actions.export": "Export",
"export_data.actions.export_blocks": "Export blocks",
"export_data.actions.export_follows": "Export follows",
@ -600,6 +723,12 @@
"intervals.full.days": "{number, plural, one {# day} other {# days}}",
"intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
"intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
"join_event.hint": "You can tell the organizer why do you want to participate in this event:",
"join_event.join": "Request join",
"join_event.placeholder": "Message to organizer",
"join_event.request_success": "Requested to join the event",
"join_event.success": "Joined the event",
"join_event.title": "Join event",
"keyboard_shortcuts.back": "to navigate back",
"keyboard_shortcuts.blocked": "to open blocked users list",
"keyboard_shortcuts.boost": "to repost",
@ -648,6 +777,7 @@
"lists.search": "Search among people you follow",
"lists.subheading": "Your lists",
"loading_indicator.label": "Loading…",
"location_search.placeholder": "Find an address",
"login.fields.instance_label": "Instance",
"login.fields.instance_placeholder": "example.com",
"login.fields.otp_code_hint": "Enter the two-factor code generated by your phone app or use one of your recovery codes",
@ -696,6 +826,8 @@
"missing_description_modal.text": "You have not entered a description for all attachments. Continue anyway?",
"missing_indicator.label": "Not found",
"missing_indicator.sublabel": "This resource could not be found",
"modals.policy.submit": "Accept & Continue",
"modals.policy.updateTitle": "Youve scored the latest version of {siteTitle}! Take a moment to review the exciting new things weve been working on.",
"moderation_overlay.contact": "Contact",
"moderation_overlay.hide": "Hide content",
"moderation_overlay.show": "Show Content",
@ -722,8 +854,10 @@
"navigation_bar.compose": "Compose a post",
"navigation_bar.compose_direct": "Direct message",
"navigation_bar.compose_edit": "Edit post",
"navigation_bar.compose_event": "Manage event",
"navigation_bar.compose_quote": "Quote post",
"navigation_bar.compose_reply": "Reply to post",
"navigation_bar.create_event": "Create new event",
"navigation_bar.domain_blocks": "Domain blocks",
"navigation_bar.favourites": "Likes",
"navigation_bar.filters": "Filters",
@ -746,6 +880,9 @@
"notification.others": " + {count} {count, plural, one {other} other {others}}",
"notification.pleroma:chat_mention": "{name} sent you a message",
"notification.pleroma:emoji_reaction": "{name} reacted to your post",
"notification.pleroma:event_reminder": "An event you are participating in starts soon",
"notification.pleroma:participation_accepted": "You were accepted to join the event",
"notification.pleroma:participation_request": "{name} wants to join your event",
"notification.poll": "A poll you have voted in has ended",
"notification.reblog": "{name} reposted your post",
"notification.status": "{name} just posted",
@ -819,6 +956,8 @@
"preferences.fields.content_type_label": "Default post format",
"preferences.fields.delete_modal_label": "Show confirmation dialog before deleting a post",
"preferences.fields.demetricator_label": "Use Demetricator",
"preferences.fields.demo_hint": "Use the default Soapbox logo and color scheme. Useful for taking screenshots.",
"preferences.fields.demo_label": "Demo mode",
"preferences.fields.display_media.default": "Hide posts marked as sensitive",
"preferences.fields.display_media.hide_all": "Always hide posts",
"preferences.fields.display_media.show_all": "Always show posts",
@ -834,7 +973,6 @@
"preferences.fields.underline_links_label": "Always underline links in posts",
"preferences.fields.unfollow_modal_label": "Show confirmation dialog before unfollowing someone",
"preferences.hints.demetricator": "Decrease social media anxiety by hiding all numbers from the site.",
"preferences.hints.feed": "In your home feed",
"preferences.notifications.advanced": "Show all notification categories",
"preferences.options.content_type_markdown": "Markdown",
"preferences.options.content_type_plaintext": "Plain text",
@ -907,6 +1045,8 @@
"remote_instance.unpin_host": "Unpin {host}",
"remote_interaction.account_placeholder": "Enter your username@domain you want to act from",
"remote_interaction.divider": "or",
"remote_interaction.event_join": "Proceed to join",
"remote_interaction.event_join_title": "Join an event remotely",
"remote_interaction.favourite": "Proceed to like",
"remote_interaction.favourite_title": "Like a post remotely",
"remote_interaction.follow": "Proceed to follow",
@ -928,6 +1068,8 @@
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",
"report.chatMessage.context": "When reporting a users message, the five messages before and five messages after the one selected will be passed along to our moderation team for context.",
"report.chatMessage.title": "Report message",
"report.confirmation.content": "If we find that this account is violating the {link} we will take further action on the matter.",
"report.confirmation.title": "Thanks for submitting your report.",
"report.done": "Done",
@ -989,6 +1131,7 @@
"settings.configure_mfa": "Configure MFA",
"settings.delete_account": "Delete Account",
"settings.edit_profile": "Edit Profile",
"settings.messages.label": "Allow users to start a new chat with you",
"settings.other": "Other options",
"settings.preferences": "Preferences",
"settings.profile": "Profile",
@ -1016,7 +1159,6 @@
"sms_verification.sent.body": "We sent you a 6-digit code via SMS. Enter it below.",
"sms_verification.sent.header": "Verification code",
"sms_verification.success": "A verification code has been sent to your phone number.",
"toast.view": "View",
"soapbox_config.authenticated_profile_hint": "Users must be logged-in to view replies and media on user profiles.",
"soapbox_config.authenticated_profile_label": "Profiles require authentication",
"soapbox_config.copyright_footer.meta_fields.label_placeholder": "Copyright footer",
@ -1029,9 +1171,8 @@
"soapbox_config.display_fqn_label": "Display domain (eg @user@domain) for local accounts.",
"soapbox_config.feed_injection_hint": "Inject the feed with additional content, such as suggested profiles.",
"soapbox_config.feed_injection_label": "Feed injection",
"soapbox_config.fields.accent_color_label": "Accent color",
"soapbox_config.fields.brand_color_label": "Brand color",
"soapbox_config.fields.crypto_addresses_label": "Cryptocurrency addresses",
"soapbox_config.fields.edit_theme_label": "Edit theme",
"soapbox_config.fields.home_footer_fields_label": "Home footer items",
"soapbox_config.fields.logo_label": "Logo",
"soapbox_config.fields.promo_panel_fields_label": "Promo panel items",
@ -1039,6 +1180,7 @@
"soapbox_config.greentext_label": "Enable greentext support",
"soapbox_config.headings.advanced": "Advanced",
"soapbox_config.headings.cryptocurrency": "Cryptocurrency",
"soapbox_config.headings.events": "Events",
"soapbox_config.headings.navigation": "Navigation",
"soapbox_config.headings.options": "Options",
"soapbox_config.headings.theme": "Theme",
@ -1049,6 +1191,8 @@
"soapbox_config.hints.promo_panel_icons.link": "Soapbox Icons List",
"soapbox_config.home_footer.meta_fields.label_placeholder": "Label",
"soapbox_config.home_footer.meta_fields.url_placeholder": "URL",
"soapbox_config.media_preview_hint": "Some backends provide an optimized version of media for display in timelines. However, these preview images may be too small without additional configuration.",
"soapbox_config.media_preview_label": "Prefer preview media for thumbnails",
"soapbox_config.promo_panel.meta_fields.icon_placeholder": "Icon",
"soapbox_config.promo_panel.meta_fields.label_placeholder": "Label",
"soapbox_config.promo_panel.meta_fields.url_placeholder": "URL",
@ -1060,6 +1204,8 @@
"soapbox_config.single_user_mode_label": "Single user mode",
"soapbox_config.single_user_mode_profile_hint": "@handle",
"soapbox_config.single_user_mode_profile_label": "Main user handle",
"soapbox_config.tile_server_attribution_label": "Map tiles attribution",
"soapbox_config.tile_server_label": "Map tile server",
"soapbox_config.verified_can_edit_name_label": "Allow verified users to edit their own display name.",
"sponsored.info.message": "{siteTitle} displays ads to help fund our service.",
"sponsored.info.title": "Why am I seeing this ad?",
@ -1081,6 +1227,7 @@
"status.favourite": "Like",
"status.filtered": "Filtered",
"status.interactions.favourites": "{count, plural, one {Like} other {Likes}}",
"status.interactions.quotes": "{count, plural, one {Quote} other {Quotes}}",
"status.interactions.reblogs": "{count, plural, one {Repost} other {Reposts}}",
"status.load_more": "Load more",
"status.mention": "Mention @{name}",
@ -1139,7 +1286,6 @@
"sw.update_text": "An update is available.",
"sw.url": "Script URL",
"tabs_bar.all": "All",
"tabs_bar.chats": "Chats",
"tabs_bar.dashboard": "Dashboard",
"tabs_bar.fediverse": "Fediverse",
"tabs_bar.home": "Home",
@ -1149,6 +1295,13 @@
"tabs_bar.profile": "Profile",
"tabs_bar.search": "Search",
"tabs_bar.settings": "Settings",
"theme_editor.Reset": "Reset",
"theme_editor.export": "Export theme",
"theme_editor.import": "Import theme",
"theme_editor.import_success": "Theme was successfully imported!",
"theme_editor.restore": "Restore default theme",
"theme_editor.save": "Save theme",
"theme_editor.saved": "Theme updated!",
"theme_toggle.dark": "Dark",
"theme_toggle.light": "Light",
"theme_toggle.system": "System",
@ -1161,10 +1314,10 @@
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"toast.view": "View",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
"trends.title": "Trends",
"trendsPanel.viewAll": "View all",
"ui.beforeunload": "Your draft will be lost if you leave.",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Drag & drop to upload",

Wyświetl plik

@ -21,12 +21,13 @@ const shouldShowError = ({ type, skipAlert, error }: AnyAction): boolean => {
};
/** Middleware to display Redux errors to the user. */
export default function errorsMiddleware(): ThunkMiddleware {
return () => next => action => {
const errorsMiddleware = (): ThunkMiddleware =>
() => next => action => {
if (shouldShowError(action)) {
toast.showAlertForError(action.error);
}
return next(action);
};
}
export default errorsMiddleware;

Wyświetl plik

@ -112,7 +112,7 @@ const fixAkkoma = (instance: ImmutableMap<string, any>) => {
}
};
/** Set Takahe version to a Pleroma-like string */
/** Set Takahē version to a Pleroma-like string */
const fixTakahe = (instance: ImmutableMap<string, any>) => {
const version: string = instance.get('version', '');

Wyświetl plik

@ -115,6 +115,11 @@ export const SoapboxConfigRecord = ImmutableRecord({
feedInjection: true,
tileServer: '',
tileServerAttribution: '',
/**
* Whether to use the preview URL for media thumbnails.
* On some platforms this can be too blurry without additional configuration.
*/
mediaPreview: false,
}, 'SoapboxConfig');
type SoapboxConfigMap = ImmutableMap<string, any>;

Wyświetl plik

@ -178,7 +178,8 @@ describe('useChats', () => {
describe('with a successful request', () => {
beforeEach(() => {
store = mockStore(rootState);
const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.2.0)');
store = mockStore(state);
__stub((mock) => {
mock.onGet('/api/v1/pleroma/chats')
@ -378,4 +379,4 @@ describe('useChatActions', () => {
expect((nextQueryData as any).message_expiration).toBe(1200);
});
});
});
});

Wyświetl plik

@ -157,6 +157,7 @@ const useChats = (search?: string) => {
const queryInfo = useInfiniteQuery(ChatKeys.chatSearch(search), ({ pageParam }) => getChats(pageParam), {
keepPreviousData: true,
enabled: features.chats,
getNextPageParam: (config) => {
if (config.hasMore) {
return { link: config.link };

Wyświetl plik

@ -10,17 +10,18 @@ import {
} from 'soapbox/actions/auth';
import { ME_FETCH_SKIP } from 'soapbox/actions/me';
import { MASTODON_PRELOAD_IMPORT } from 'soapbox/actions/preload';
import { AuthAppRecord, AuthTokenRecord, AuthUserRecord, ReducerRecord } from 'soapbox/reducers/auth';
import reducer from '../auth';
describe('auth reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap({
app: ImmutableMap(),
users: ImmutableMap(),
tokens: ImmutableMap(),
expect(reducer(undefined, {} as any).toJS()).toMatchObject({
app: {},
users: {},
tokens: {},
me: null,
}));
});
});
describe('AUTH_APP_CREATED', () => {
@ -29,9 +30,9 @@ describe('auth reducer', () => {
const action = { type: AUTH_APP_CREATED, app: token };
const result = reducer(undefined, action);
const expected = fromJS(token);
const expected = AuthAppRecord(token);
expect(result.get('app')).toEqual(expected);
expect(result.app).toEqual(expected);
});
});
@ -41,19 +42,19 @@ describe('auth reducer', () => {
const action = { type: AUTH_LOGGED_IN, token };
const result = reducer(undefined, action);
const expected = fromJS({ 'ABCDEFG': token });
const expected = ImmutableMap({ 'ABCDEFG': AuthTokenRecord(token) });
expect(result.get('tokens')).toEqual(expected);
expect(result.tokens).toEqual(expected);
});
it('should merge the token with existing state', () => {
const state = fromJS({
tokens: { 'ABCDEFG': { token_type: 'Bearer', access_token: 'ABCDEFG' } },
const state = ReducerRecord({
tokens: ImmutableMap({ 'ABCDEFG': AuthTokenRecord({ token_type: 'Bearer', access_token: 'ABCDEFG' }) }),
});
const expected = fromJS({
'ABCDEFG': { token_type: 'Bearer', access_token: 'ABCDEFG' },
'HIJKLMN': { token_type: 'Bearer', access_token: 'HIJKLMN' },
const expected = ImmutableMap({
'ABCDEFG': AuthTokenRecord({ token_type: 'Bearer', access_token: 'ABCDEFG' }),
'HIJKLMN': AuthTokenRecord({ token_type: 'Bearer', access_token: 'HIJKLMN' }),
});
const action = {
@ -62,7 +63,7 @@ describe('auth reducer', () => {
};
const result = reducer(state, action);
expect(result.get('tokens')).toEqual(expected);
expect(result.tokens).toEqual(expected);
});
});
@ -73,28 +74,28 @@ describe('auth reducer', () => {
account: fromJS({ url: 'https://gleasonator.com/users/alex' }),
};
const state = fromJS({
users: {
'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' },
'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
},
const state = ReducerRecord({
users: ImmutableMap({
'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }),
'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }),
}),
});
const expected = fromJS({
'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
const expected = ImmutableMap({
'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }),
});
const result = reducer(state, action);
expect(result.get('users')).toEqual(expected);
expect(result.users).toEqual(expected);
});
it('sets `me` to the next available user', () => {
const state = fromJS({
const state = ReducerRecord({
me: 'https://gleasonator.com/users/alex',
users: {
'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' },
'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
},
users: ImmutableMap({
'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }),
'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }),
}),
});
const action = {
@ -103,7 +104,7 @@ describe('auth reducer', () => {
};
const result = reducer(state, action);
expect(result.get('me')).toEqual('https://gleasonator.com/users/benis');
expect(result.me).toEqual('https://gleasonator.com/users/benis');
});
});
@ -115,12 +116,12 @@ describe('auth reducer', () => {
account: { id: '1234', url: 'https://gleasonator.com/users/alex' },
};
const expected = fromJS({
'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' },
const expected = ImmutableMap({
'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }),
});
const result = reducer(undefined, action);
expect(result.get('users')).toEqual(expected);
expect(result.users).toEqual(expected);
});
it('should set the account in the token', () => {
@ -130,21 +131,21 @@ describe('auth reducer', () => {
account: { id: '1234', url: 'https://gleasonator.com/users/alex' },
};
const state = fromJS({
tokens: { 'ABCDEFG': { token_type: 'Bearer', access_token: 'ABCDEFG' } },
const state = ReducerRecord({
tokens: ImmutableMap({ 'ABCDEFG': AuthTokenRecord({ token_type: 'Bearer', access_token: 'ABCDEFG' }) }),
});
const expected = fromJS({
const expected = {
'ABCDEFG': {
token_type: 'Bearer',
access_token: 'ABCDEFG',
account: '1234',
me: 'https://gleasonator.com/users/alex',
},
});
};
const result = reducer(state, action);
expect(result.get('tokens')).toEqual(expected);
expect(result.tokens.toJS()).toMatchObject(expected);
});
it('sets `me` to the account if unset', () => {
@ -155,7 +156,7 @@ describe('auth reducer', () => {
};
const result = reducer(undefined, action);
expect(result.get('me')).toEqual('https://gleasonator.com/users/alex');
expect(result.me).toEqual('https://gleasonator.com/users/alex');
});
it('leaves `me` alone if already set', () => {
@ -165,10 +166,10 @@ describe('auth reducer', () => {
account: { id: '1234', url: 'https://gleasonator.com/users/alex' },
};
const state = fromJS({ me: 'https://gleasonator.com/users/benis' });
const state = ReducerRecord({ me: 'https://gleasonator.com/users/benis' });
const result = reducer(state, action);
expect(result.get('me')).toEqual('https://gleasonator.com/users/benis');
expect(result.me).toEqual('https://gleasonator.com/users/benis');
});
it('deletes mismatched users', () => {
@ -178,21 +179,21 @@ describe('auth reducer', () => {
account: { id: '1234', url: 'https://gleasonator.com/users/alex' },
};
const state = fromJS({
users: {
'https://gleasonator.com/users/mk': { id: '4567', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/mk' },
'https://gleasonator.com/users/curtis': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/curtis' },
'https://gleasonator.com/users/benis': { id: '5432', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
},
const state = ReducerRecord({
users: ImmutableMap({
'https://gleasonator.com/users/mk': AuthUserRecord({ id: '4567', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/mk' }),
'https://gleasonator.com/users/curtis': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/curtis' }),
'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5432', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }),
}),
});
const expected = fromJS({
'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' },
'https://gleasonator.com/users/benis': { id: '5432', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
const expected = ImmutableMap({
'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }),
'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5432', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }),
});
const result = reducer(state, action);
expect(result.get('users')).toEqual(expected);
expect(result.users).toEqual(expected);
});
it('upgrades from an ID to a URL', () => {
@ -202,18 +203,18 @@ describe('auth reducer', () => {
account: { id: '1234', url: 'https://gleasonator.com/users/alex' },
};
const state = fromJS({
const state = ReducerRecord({
me: '1234',
users: {
'1234': { id: '1234', access_token: 'ABCDEFG' },
'5432': { id: '5432', access_token: 'HIJKLMN' },
},
tokens: {
'ABCDEFG': { access_token: 'ABCDEFG', account: '1234' },
},
users: ImmutableMap({
'1234': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG' }),
'5432': AuthUserRecord({ id: '5432', access_token: 'HIJKLMN' }),
}),
tokens: ImmutableMap({
'ABCDEFG': AuthTokenRecord({ access_token: 'ABCDEFG', account: '1234' }),
}),
});
const expected = fromJS({
const expected = {
me: 'https://gleasonator.com/users/alex',
users: {
'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' },
@ -222,24 +223,24 @@ describe('auth reducer', () => {
tokens: {
'ABCDEFG': { access_token: 'ABCDEFG', account: '1234', me: 'https://gleasonator.com/users/alex' },
},
});
};
const result = reducer(state, action);
expect(result).toEqual(expected);
expect(result.toJS()).toMatchObject(expected);
});
});
describe('VERIFY_CREDENTIALS_FAIL', () => {
it('should delete the failed token if it 403\'d', () => {
const state = fromJS({
tokens: {
'ABCDEFG': { token_type: 'Bearer', access_token: 'ABCDEFG' },
'HIJKLMN': { token_type: 'Bearer', access_token: 'HIJKLMN' },
},
const state = ReducerRecord({
tokens: ImmutableMap({
'ABCDEFG': AuthTokenRecord({ token_type: 'Bearer', access_token: 'ABCDEFG' }),
'HIJKLMN': AuthTokenRecord({ token_type: 'Bearer', access_token: 'HIJKLMN' }),
}),
});
const expected = fromJS({
'HIJKLMN': { token_type: 'Bearer', access_token: 'HIJKLMN' },
const expected = ImmutableMap({
'HIJKLMN': AuthTokenRecord({ token_type: 'Bearer', access_token: 'HIJKLMN' }),
});
const action = {
@ -249,19 +250,19 @@ describe('auth reducer', () => {
};
const result = reducer(state, action);
expect(result.get('tokens')).toEqual(expected);
expect(result.tokens).toEqual(expected);
});
it('should delete any users associated with the failed token', () => {
const state = fromJS({
users: {
'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' },
'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
},
const state = ReducerRecord({
users: ImmutableMap({
'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }),
'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }),
}),
});
const expected = fromJS({
'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
const expected = ImmutableMap({
'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }),
});
const action = {
@ -271,16 +272,16 @@ describe('auth reducer', () => {
};
const result = reducer(state, action);
expect(result.get('users')).toEqual(expected);
expect(result.users).toEqual(expected);
});
it('should reassign `me` to the next in line', () => {
const state = fromJS({
const state = ReducerRecord({
me: 'https://gleasonator.com/users/alex',
users: {
'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' },
'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' },
},
users: ImmutableMap({
'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }),
'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }),
}),
});
const action = {
@ -290,7 +291,7 @@ describe('auth reducer', () => {
};
const result = reducer(state, action);
expect(result.get('me')).toEqual('https://gleasonator.com/users/benis');
expect(result.me).toEqual('https://gleasonator.com/users/benis');
});
});
@ -302,16 +303,16 @@ describe('auth reducer', () => {
};
const result = reducer(undefined, action);
expect(result.get('me')).toEqual('https://gleasonator.com/users/benis');
expect(result.me).toEqual('https://gleasonator.com/users/benis');
});
});
describe('ME_FETCH_SKIP', () => {
it('sets `me` to null', () => {
const state = fromJS({ me: 'https://gleasonator.com/users/alex' });
const state = ReducerRecord({ me: 'https://gleasonator.com/users/alex' });
const action = { type: ME_FETCH_SKIP };
const result = reducer(state, action);
expect(result.get('me')).toEqual(null);
expect(result.me).toEqual(null);
});
});
@ -322,7 +323,7 @@ describe('auth reducer', () => {
data: require('soapbox/__fixtures__/mastodon_initial_state.json'),
};
const expected = fromJS({
const expected = {
me: 'https://mastodon.social/@benis911',
app: {},
users: {
@ -341,10 +342,10 @@ describe('auth reducer', () => {
token_type: 'Bearer',
},
},
});
};
const result = reducer(undefined, action);
expect(result).toEqual(expected);
expect(result.toJS()).toMatchObject(expected);
});
});
});

Wyświetl plik

@ -23,7 +23,7 @@ describe('modal reducer', () => {
});
it('should handle MODAL_CLOSE', () => {
const state = ImmutableList([
const state = ImmutableList<any>([
ImmutableRecord({
modalType: 'type1',
modalProps: { props1: '1' },

Wyświetl plik

@ -0,0 +1,68 @@
import { Set as ImmutableSet, OrderedSet as ImmutableOrderedSet, Record as ImmutableRecord } from 'immutable';
import {
ADMIN_USER_INDEX_EXPAND_FAIL,
ADMIN_USER_INDEX_EXPAND_REQUEST,
ADMIN_USER_INDEX_EXPAND_SUCCESS,
ADMIN_USER_INDEX_FETCH_FAIL,
ADMIN_USER_INDEX_FETCH_REQUEST,
ADMIN_USER_INDEX_FETCH_SUCCESS,
ADMIN_USER_INDEX_QUERY_SET,
} from 'soapbox/actions/admin';
import type { AnyAction } from 'redux';
import type { APIEntity } from 'soapbox/types/entities';
const ReducerRecord = ImmutableRecord({
isLoading: false,
loaded: false,
items: ImmutableOrderedSet<string>(),
filters: ImmutableSet(['local', 'active']),
total: Infinity,
pageSize: 50,
page: -1,
query: '',
next: null as string | null,
});
type State = ReturnType<typeof ReducerRecord>;
export default function admin_user_index(state: State = ReducerRecord(), action: AnyAction): State {
switch (action.type) {
case ADMIN_USER_INDEX_QUERY_SET:
return state.set('query', action.query);
case ADMIN_USER_INDEX_FETCH_REQUEST:
return state
.set('isLoading', true)
.set('loaded', true)
.set('items', ImmutableOrderedSet())
.set('total', action.count)
.set('page', 0)
.set('next', null);
case ADMIN_USER_INDEX_FETCH_SUCCESS:
return state
.set('isLoading', false)
.set('loaded', true)
.set('items', ImmutableOrderedSet(action.users.map((user: APIEntity) => user.id)))
.set('total', action.count)
.set('page', 1)
.set('next', action.next);
case ADMIN_USER_INDEX_FETCH_FAIL:
case ADMIN_USER_INDEX_EXPAND_FAIL:
return state
.set('isLoading', false);
case ADMIN_USER_INDEX_EXPAND_REQUEST:
return state
.set('isLoading', true);
case ADMIN_USER_INDEX_EXPAND_SUCCESS:
return state
.set('isLoading', false)
.set('loaded', true)
.set('items', state.items.union(action.users.map((user: APIEntity) => user.id)))
.set('total', action.count)
.set('page', 1)
.set('next', action.next);
default:
return state;
}
}

Wyświetl plik

@ -54,16 +54,11 @@ export interface ReducerAdminReport extends AdminReportRecord {
statuses: ImmutableList<string | null>,
}
// Umm... based?
// https://itnext.io/typescript-extract-unpack-a-type-from-a-generic-baca7af14e51
type InnerRecord<R> = R extends ImmutableRecord<infer TProps> ? TProps : never;
type InnerState = InnerRecord<State>;
// Lol https://javascript.plainenglish.io/typescript-essentials-conditionally-filter-types-488705bfbf56
type FilterConditionally<Source, Condition> = Pick<Source, {[K in keyof Source]: Source[K] extends Condition ? K : never}[keyof Source]>;
type SetKeys = keyof FilterConditionally<InnerState, ImmutableOrderedSet<string>>;
type SetKeys = keyof FilterConditionally<State, ImmutableOrderedSet<string>>;
type APIReport = { id: string, state: string, statuses: any[] };
type APIUser = { id: string, email: string, nickname: string, registration_reason: string };

Wyświetl plik

@ -1,8 +1,8 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
import trim from 'lodash/trim';
import { MASTODON_PRELOAD_IMPORT } from 'soapbox/actions/preload';
import { FE_SUBDIRECTORY } from 'soapbox/build-config';
import BuildConfig from 'soapbox/build-config';
import KVStore from 'soapbox/storage/kv-store';
import { validId, isURL } from 'soapbox/utils/auth';
@ -17,17 +17,55 @@ import {
} from '../actions/auth';
import { ME_FETCH_SKIP } from '../actions/me';
const defaultState = ImmutableMap({
app: ImmutableMap(),
users: ImmutableMap(),
tokens: ImmutableMap(),
me: null,
import type { AxiosError } from 'axios';
import type { AnyAction } from 'redux';
import type { APIEntity, Account as AccountEntity } from 'soapbox/types/entities';
export const AuthAppRecord = ImmutableRecord({
access_token: null as string | null,
client_id: null as string | null,
client_secret: null as string | null,
id: null as string | null,
name: null as string | null,
redirect_uri: null as string | null,
token_type: null as string | null,
vapid_key: null as string | null,
website: null as string | null,
});
const buildKey = parts => parts.join(':');
export const AuthTokenRecord = ImmutableRecord({
access_token: '',
account: null as string | null,
created_at: 0,
expires_in: null as number | null,
id: null as number | null,
me: null as string | null,
refresh_token: null as string | null,
scope: '',
token_type: '',
});
export const AuthUserRecord = ImmutableRecord({
access_token: '',
id: '',
url: '',
});
export const ReducerRecord = ImmutableRecord({
app: AuthAppRecord(),
tokens: ImmutableMap<string, AuthToken>(),
users: ImmutableMap<string, AuthUser>(),
me: null as string | null,
});
type AuthToken = ReturnType<typeof AuthTokenRecord>;
type AuthUser = ReturnType<typeof AuthUserRecord>;
type State = ReturnType<typeof ReducerRecord>;
const buildKey = (parts: string[]) => parts.join(':');
// For subdirectory support
const NAMESPACE = trim(FE_SUBDIRECTORY, '/') ? `soapbox@${FE_SUBDIRECTORY}` : 'soapbox';
const NAMESPACE = trim(BuildConfig.FE_SUBDIRECTORY, '/') ? `soapbox@${BuildConfig.FE_SUBDIRECTORY}` : 'soapbox';
const STORAGE_KEY = buildKey([NAMESPACE, 'auth']);
const SESSION_KEY = buildKey([NAMESPACE, 'auth', 'me']);
@ -37,35 +75,48 @@ const getSessionUser = () => {
return validId(id) ? id : undefined;
};
const getLocalState = () => {
const state = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
if (!state) return undefined;
return ReducerRecord({
app: AuthAppRecord(state.app),
tokens: ImmutableMap(Object.entries(state.tokens).map(([key, value]) => [key, AuthTokenRecord(value as any)])),
users: ImmutableMap(Object.entries(state.users).map(([key, value]) => [key, AuthUserRecord(value as any)])),
me: state.me,
});
};
const sessionUser = getSessionUser();
export const localState = fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY)));
export const localState = getLocalState(); fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY)!));
// Checks if the user has an ID and access token
const validUser = user => {
const validUser = (user?: AuthUser) => {
try {
return validId(user.get('id')) && validId(user.get('access_token'));
return !!(user && validId(user.id) && validId(user.access_token));
} catch (e) {
return false;
}
};
// Finds the first valid user in the state
const firstValidUser = state => state.get('users', ImmutableMap()).find(validUser);
const firstValidUser = (state: State) => state.users.find(validUser);
// For legacy purposes. IDs get upgraded to URLs further down.
const getUrlOrId = user => {
const getUrlOrId = (user?: AuthUser): string | null => {
try {
const { id, url } = user.toJS();
return url || id;
const { id, url } = user!.toJS();
return (url || id) as string;
} catch {
return null;
}
};
// If `me` doesn't match an existing user, attempt to shift it.
const maybeShiftMe = state => {
const me = state.get('me');
const user = state.getIn(['users', me]);
const maybeShiftMe = (state: State) => {
const me = state.me!;
const user = state.users.get(me);
if (!validUser(user)) {
const nextUser = firstValidUser(state);
@ -76,29 +127,29 @@ const maybeShiftMe = state => {
};
// Set the user from the session or localStorage, whichever is valid first
const setSessionUser = state => state.update('me', null, me => {
const user = ImmutableList([
state.getIn(['users', sessionUser]),
state.getIn(['users', me]),
const setSessionUser = (state: State) => state.update('me', me => {
const user = ImmutableList<AuthUser>([
state.users.get(sessionUser!)!,
state.users.get(me!)!,
]).find(validUser);
return getUrlOrId(user);
});
// Upgrade the initial state
const migrateLegacy = state => {
const migrateLegacy = (state: State) => {
if (localState) return state;
return state.withMutations(state => {
const app = fromJS(JSON.parse(localStorage.getItem('soapbox:auth:app')));
const user = fromJS(JSON.parse(localStorage.getItem('soapbox:auth:user')));
const app = AuthAppRecord(JSON.parse(localStorage.getItem('soapbox:auth:app')!));
const user = fromJS(JSON.parse(localStorage.getItem('soapbox:auth:user')!)) as ImmutableMap<string, any>;
if (!user) return;
state.set('me', '_legacy'); // Placeholder account ID
state.set('app', app);
state.set('tokens', ImmutableMap({
[user.get('access_token')]: user.set('account', '_legacy'),
[user.get('access_token')]: AuthTokenRecord(user.set('account', '_legacy')),
}));
state.set('users', ImmutableMap({
'_legacy': ImmutableMap({
'_legacy': AuthUserRecord({
id: '_legacy',
access_token: user.get('access_token'),
}),
@ -106,26 +157,26 @@ const migrateLegacy = state => {
});
};
const isUpgradingUrlId = state => {
const me = state.get('me');
const user = state.getIn(['users', me]);
const isUpgradingUrlId = (state: State) => {
const me = state.me;
const user = state.users.get(me!);
return validId(me) && user && !isURL(me);
};
// Checks the state and makes it valid
const sanitizeState = state => {
const sanitizeState = (state: State) => {
// Skip sanitation during ID to URL upgrade
if (isUpgradingUrlId(state)) return state;
return state.withMutations(state => {
// Remove invalid users, ensure ID match
state.update('users', ImmutableMap(), users => (
state.update('users', users => (
users.filter((user, url) => (
validUser(user) && user.get('url') === url
))
));
// Remove mismatched tokens
state.update('tokens', ImmutableMap(), tokens => (
state.update('tokens', tokens => (
tokens.filter((token, id) => (
validId(id) && token.get('access_token') === id
))
@ -133,21 +184,21 @@ const sanitizeState = state => {
});
};
const persistAuth = state => localStorage.setItem(STORAGE_KEY, JSON.stringify(state.toJS()));
const persistAuth = (state: State) => localStorage.setItem(STORAGE_KEY, JSON.stringify(state.toJS()));
const persistSession = state => {
const me = state.get('me');
const persistSession = (state: State) => {
const me = state.me;
if (me && typeof me === 'string') {
sessionStorage.setItem(SESSION_KEY, me);
}
};
const persistState = state => {
const persistState = (state: State) => {
persistAuth(state);
persistSession(state);
};
const initialize = state => {
const initialize = (state: State) => {
return state.withMutations(state => {
maybeShiftMe(state);
setSessionUser(state);
@ -157,17 +208,17 @@ const initialize = state => {
});
};
const initialState = initialize(defaultState.merge(localState));
const initialState = initialize(ReducerRecord().merge(localState as any));
const importToken = (state, token) => {
return state.setIn(['tokens', token.access_token], fromJS(token));
const importToken = (state: State, token: APIEntity) => {
return state.setIn(['tokens', token.access_token], AuthTokenRecord(token));
};
// Upgrade the `_legacy` placeholder ID with a real account
const upgradeLegacyId = (state, account) => {
const upgradeLegacyId = (state: State, account: APIEntity) => {
if (localState) return state;
return state.withMutations(state => {
state.update('me', null, me => me === '_legacy' ? account.url : me);
state.update('me', me => me === '_legacy' ? account.url : me);
state.deleteIn(['users', '_legacy']);
});
// TODO: Delete `soapbox:auth:app` and `soapbox:auth:user` localStorage?
@ -176,19 +227,19 @@ const upgradeLegacyId = (state, account) => {
// Users are now stored by their ActivityPub ID instead of their
// primary key to support auth against multiple hosts.
const upgradeNonUrlId = (state, account) => {
const me = state.get('me');
const upgradeNonUrlId = (state: State, account: APIEntity) => {
const me = state.me;
if (isURL(me)) return state;
return state.withMutations(state => {
state.update('me', null, me => me === account.id ? account.url : me);
state.update('me', me => me === account.id ? account.url : me);
state.deleteIn(['users', account.id]);
});
};
// Returns a predicate function for filtering a mismatched user/token
const userMismatch = (token, account) => {
return (user, url) => {
const userMismatch = (token: string, account: APIEntity) => {
return (user: AuthUser, url: string) => {
const sameToken = user.get('access_token') === token;
const differentUrl = url !== account.url || user.get('url') !== account.url;
const differentId = user.get('id') !== account.id;
@ -197,48 +248,48 @@ const userMismatch = (token, account) => {
};
};
const importCredentials = (state, token, account) => {
const importCredentials = (state: State, token: string, account: APIEntity) => {
return state.withMutations(state => {
state.setIn(['users', account.url], ImmutableMap({
state.setIn(['users', account.url], AuthUserRecord({
id: account.id,
access_token: token,
url: account.url,
}));
state.setIn(['tokens', token, 'account'], account.id);
state.setIn(['tokens', token, 'me'], account.url);
state.update('users', ImmutableMap(), users => users.filterNot(userMismatch(token, account)));
state.update('me', null, me => me || account.url);
state.update('users', users => users.filterNot(userMismatch(token, account)));
state.update('me', me => me || account.url);
upgradeLegacyId(state, account);
upgradeNonUrlId(state, account);
});
};
const deleteToken = (state, token) => {
const deleteToken = (state: State, token: string) => {
return state.withMutations(state => {
state.update('tokens', ImmutableMap(), tokens => tokens.delete(token));
state.update('users', ImmutableMap(), users => users.filterNot(user => user.get('access_token') === token));
state.update('tokens', tokens => tokens.delete(token));
state.update('users', users => users.filterNot(user => user.get('access_token') === token));
maybeShiftMe(state);
});
};
const deleteUser = (state, account) => {
const deleteUser = (state: State, account: AccountEntity) => {
const accountUrl = account.get('url');
return state.withMutations(state => {
state.update('users', ImmutableMap(), users => users.delete(accountUrl));
state.update('tokens', ImmutableMap(), tokens => tokens.filterNot(token => token.get('me') === accountUrl));
state.update('users', users => users.delete(accountUrl));
state.update('tokens', tokens => tokens.filterNot(token => token.get('me') === accountUrl));
maybeShiftMe(state);
});
};
const importMastodonPreload = (state, data) => {
const importMastodonPreload = (state: State, data: ImmutableMap<string, any>) => {
return state.withMutations(state => {
const accountId = data.getIn(['meta', 'me']);
const accountUrl = data.getIn(['accounts', accountId, 'url']);
const accessToken = data.getIn(['meta', 'access_token']);
const accountId = data.getIn(['meta', 'me']) as string;
const accountUrl = data.getIn(['accounts', accountId, 'url']) as string;
const accessToken = data.getIn(['meta', 'access_token']) as string;
if (validId(accessToken) && validId(accountId) && isURL(accountUrl)) {
state.setIn(['tokens', accessToken], fromJS({
state.setIn(['tokens', accessToken], AuthTokenRecord({
access_token: accessToken,
account: accountId,
me: accountUrl,
@ -246,7 +297,7 @@ const importMastodonPreload = (state, data) => {
token_type: 'Bearer',
}));
state.setIn(['users', accountUrl], fromJS({
state.setIn(['users', accountUrl], AuthUserRecord({
id: accountId,
access_token: accessToken,
url: accountUrl,
@ -257,11 +308,11 @@ const importMastodonPreload = (state, data) => {
});
};
const persistAuthAccount = account => {
const persistAuthAccount = (account: APIEntity) => {
if (account && account.url) {
const key = `authAccount:${account.url}`;
if (!account.pleroma) account.pleroma = {};
KVStore.getItem(key).then(oldAccount => {
KVStore.getItem(key).then((oldAccount: any) => {
const settings = oldAccount?.pleroma?.settings_store || {};
if (!account.pleroma.settings_store) {
account.pleroma.settings_store = settings;
@ -272,20 +323,20 @@ const persistAuthAccount = account => {
}
};
const deleteForbiddenToken = (state, error, token) => {
if ([401, 403].includes(error.response?.status)) {
const deleteForbiddenToken = (state: State, error: AxiosError, token: string) => {
if ([401, 403].includes(error.response?.status!)) {
return deleteToken(state, token);
} else {
return state;
}
};
const reducer = (state, action) => {
const reducer = (state: State, action: AnyAction) => {
switch (action.type) {
case AUTH_APP_CREATED:
return state.set('app', fromJS(action.app));
return state.set('app', AuthAppRecord(action.app));
case AUTH_APP_AUTHORIZED:
return state.update('app', ImmutableMap(), app => app.merge(fromJS(action.token)));
return state.update('app', app => app.merge(action.token));
case AUTH_LOGGED_IN:
return importToken(state, action.token);
case AUTH_LOGGED_OUT:
@ -300,7 +351,7 @@ const reducer = (state, action) => {
case ME_FETCH_SKIP:
return state.set('me', null);
case MASTODON_PRELOAD_IMPORT:
return importMastodonPreload(state, fromJS(action.data));
return importMastodonPreload(state, fromJS(action.data) as ImmutableMap<string, any>);
default:
return state;
}
@ -309,33 +360,33 @@ const reducer = (state, action) => {
const reload = () => location.replace('/');
// `me` is a user ID string
const validMe = state => {
const me = state.get('me');
const validMe = (state: State) => {
const me = state.me;
return typeof me === 'string' && me !== '_legacy';
};
// `me` has changed from one valid ID to another
const userSwitched = (oldState, state) => {
const me = state.get('me');
const oldMe = oldState.get('me');
const userSwitched = (oldState: State, state: State) => {
const me = state.me;
const oldMe = oldState.me;
const stillValid = validMe(oldState) && validMe(state);
const didChange = oldMe !== me;
const userUpgradedUrl = state.getIn(['users', me, 'id']) === oldMe;
const userUpgradedUrl = state.users.get(me!)?.id === oldMe;
return stillValid && didChange && !userUpgradedUrl;
};
const maybeReload = (oldState, state, action) => {
const maybeReload = (oldState: State, state: State, action: AnyAction) => {
const loggedOutStandalone = action.type === AUTH_LOGGED_OUT && action.standalone;
const switched = userSwitched(oldState, state);
if (switched || loggedOutStandalone) {
reload(state);
reload();
}
};
export default function auth(oldState = initialState, action) {
export default function auth(oldState: State = initialState, action: AnyAction) {
const state = reducer(oldState, action);
if (!state.equals(oldState)) {

Wyświetl plik

@ -455,7 +455,7 @@ export default function compose(state = initialState, action: AnyAction) {
case COMPOSE_POLL_REMOVE:
return updateCompose(state, action.id, compose => compose.set('poll', null));
case COMPOSE_SCHEDULE_ADD:
return updateCompose(state, action.id, compose => compose.set('schedule', new Date()));
return updateCompose(state, action.id, compose => compose.set('schedule', new Date(Date.now() + 10 * 60 * 1000)));
case COMPOSE_SCHEDULE_SET:
return updateCompose(state, action.id, compose => compose.set('schedule', action.date));
case COMPOSE_SCHEDULE_REMOVE:

Wyświetl plik

@ -10,6 +10,7 @@ import accounts_counters from './accounts-counters';
import accounts_meta from './accounts-meta';
import admin from './admin';
import admin_log from './admin-log';
import admin_user_index from './admin-user-index';
import aliases from './aliases';
import announcements from './announcements';
import auth from './auth';
@ -118,6 +119,7 @@ const reducers = {
history,
announcements,
compose_event,
admin_user_index,
};
// Build a default state from all reducers: it has the key and `undefined`

Wyświetl plik

@ -1,4 +1,4 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import { List as ImmutableList, Map as ImmutableMap, fromJS } from 'immutable';
import { PLEROMA_PRELOAD_IMPORT } from 'soapbox/actions/preload';
import KVStore from 'soapbox/storage/kv-store';
@ -11,24 +11,24 @@ import {
SOAPBOX_CONFIG_REQUEST_FAIL,
} from '../actions/soapbox';
const initialState = ImmutableMap();
const initialState = ImmutableMap<string, any>();
const fallbackState = ImmutableMap({
const fallbackState = ImmutableMap<string, any>({
brandColor: '#0482d8', // Azure
});
const updateFromAdmin = (state, configs) => {
const updateFromAdmin = (state: ImmutableMap<string, any>, configs: ImmutableList<ImmutableMap<string, any>>) => {
try {
return ConfigDB.find(configs, ':pleroma', ':frontend_configurations')
return ConfigDB.find(configs, ':pleroma', ':frontend_configurations')!
.get('value')
.find(value => value.getIn(['tuple', 0]) === ':soapbox_fe')
.find((value: ImmutableMap<string, any>) => value.getIn(['tuple', 0]) === ':soapbox_fe')
.getIn(['tuple', 1]);
} catch {
return state;
}
};
const preloadImport = (state, action) => {
const preloadImport = (state: ImmutableMap<string, any>, action: Record<string, any>) => {
const path = '/api/pleroma/frontend_configurations';
const feData = action.data[path];
@ -40,29 +40,29 @@ const preloadImport = (state, action) => {
}
};
const persistSoapboxConfig = (soapboxConfig, host) => {
const persistSoapboxConfig = (soapboxConfig: ImmutableMap<string, any>, host: string) => {
if (host) {
KVStore.setItem(`soapbox_config:${host}`, soapboxConfig.toJS()).catch(console.error);
}
};
const importSoapboxConfig = (state, soapboxConfig, host) => {
const importSoapboxConfig = (state: ImmutableMap<string, any>, soapboxConfig: ImmutableMap<string, any>, host: string) => {
persistSoapboxConfig(soapboxConfig, host);
return soapboxConfig;
};
export default function soapbox(state = initialState, action) {
export default function soapbox(state = initialState, action: Record<string, any>) {
switch (action.type) {
case PLEROMA_PRELOAD_IMPORT:
return preloadImport(state, action);
case SOAPBOX_CONFIG_REMEMBER_SUCCESS:
return fromJS(action.soapboxConfig);
case SOAPBOX_CONFIG_REQUEST_SUCCESS:
return importSoapboxConfig(state, fromJS(action.soapboxConfig), action.host);
return importSoapboxConfig(state, fromJS(action.soapboxConfig) as ImmutableMap<string, any>, action.host);
case SOAPBOX_CONFIG_REQUEST_FAIL:
return fallbackState.mergeDeep(state);
case ADMIN_CONFIG_UPDATE_SUCCESS:
return updateFromAdmin(state, fromJS(action.configs));
return updateFromAdmin(state, fromJS(action.configs) as ImmutableList<ImmutableMap<string, any>>);
default:
return state;
}

Wyświetl plik

@ -12,6 +12,7 @@ import {
STATUS_QUOTES_FETCH_REQUEST,
STATUS_QUOTES_FETCH_SUCCESS,
} from 'soapbox/actions/status-quotes';
import { STATUS_CREATE_SUCCESS } from 'soapbox/actions/statuses';
import {
BOOKMARKED_STATUSES_FETCH_REQUEST,
@ -66,7 +67,7 @@ import {
} from '../actions/scheduled-statuses';
import type { AnyAction } from 'redux';
import type { Status as StatusEntity } from 'soapbox/types/entities';
import type { APIEntity } from 'soapbox/types/entities';
export const StatusListRecord = ImmutableRecord({
next: null as string | null,
@ -77,8 +78,6 @@ export const StatusListRecord = ImmutableRecord({
type State = ImmutableMap<string, StatusList>;
type StatusList = ReturnType<typeof StatusListRecord>;
type Status = string | StatusEntity;
type Statuses = Array<string | StatusEntity>;
const initialState: State = ImmutableMap({
favourites: StatusListRecord(),
@ -89,15 +88,15 @@ const initialState: State = ImmutableMap({
joined_events: StatusListRecord(),
});
const getStatusId = (status: string | StatusEntity) => typeof status === 'string' ? status : status.id;
const getStatusId = (status: string | APIEntity) => typeof status === 'string' ? status : status.id;
const getStatusIds = (statuses: Statuses = []) => (
const getStatusIds = (statuses: APIEntity[] = []) => (
ImmutableOrderedSet(statuses.map(getStatusId))
);
const setLoading = (state: State, listType: string, loading: boolean) => state.setIn([listType, 'isLoading'], loading);
const normalizeList = (state: State, listType: string, statuses: Statuses, next: string | null) => {
const normalizeList = (state: State, listType: string, statuses: APIEntity[], next: string | null) => {
return state.update(listType, StatusListRecord(), listMap => listMap.withMutations(map => {
map.set('next', next);
map.set('loaded', true);
@ -106,7 +105,7 @@ const normalizeList = (state: State, listType: string, statuses: Statuses, next:
}));
};
const appendToList = (state: State, listType: string, statuses: Statuses, next: string | null) => {
const appendToList = (state: State, listType: string, statuses: APIEntity[], next: string | null) => {
const newIds = getStatusIds(statuses);
return state.update(listType, StatusListRecord(), listMap => listMap.withMutations(map => {
@ -116,18 +115,23 @@ const appendToList = (state: State, listType: string, statuses: Statuses, next:
}));
};
const prependOneToList = (state: State, listType: string, status: Status) => {
const prependOneToList = (state: State, listType: string, status: APIEntity) => {
const statusId = getStatusId(status);
return state.updateIn([listType, 'items'], ImmutableOrderedSet(), items => {
return ImmutableOrderedSet([statusId]).union(items as ImmutableOrderedSet<string>);
});
};
const removeOneFromList = (state: State, listType: string, status: Status) => {
const removeOneFromList = (state: State, listType: string, status: APIEntity) => {
const statusId = getStatusId(status);
return state.updateIn([listType, 'items'], ImmutableOrderedSet(), items => (items as ImmutableOrderedSet<string>).delete(statusId));
};
const maybeAppendScheduledStatus = (state: State, status: APIEntity) => {
if (!status.scheduled_at) return state;
return prependOneToList(state, 'scheduled_statuses', getStatusId(status));
};
export default function statusLists(state = initialState, action: AnyAction) {
switch (action.type) {
case FAVOURITED_STATUSES_FETCH_REQUEST:
@ -209,6 +213,8 @@ export default function statusLists(state = initialState, action: AnyAction) {
return setLoading(state, 'joined_events', false);
case JOINED_EVENTS_FETCH_SUCCESS:
return normalizeList(state, 'joined_events', action.statuses, action.next);
case STATUS_CREATE_SUCCESS:
return maybeAppendScheduledStatus(state, action.status);
default:
return state;
}

Wyświetl plik

@ -314,7 +314,7 @@ export default function timelines(state: State = initialState, action: AnyAction
if (action.params.scheduled_at) return state;
return importPendingStatus(state, action.params, action.idempotencyKey);
case STATUS_CREATE_SUCCESS:
if (action.status.scheduled_at) return state;
if (action.status.scheduled_at || action.editing) return state;
return importStatus(state, action.status, action.idempotencyKey);
case TIMELINE_EXPAND_REQUEST:
return setLoading(state, action.timeline, true);

Wyświetl plik

@ -269,16 +269,16 @@ export const makeGetReport = () => {
};
const getAuthUserIds = createSelector([
(state: RootState) => state.auth.get('users', ImmutableMap()),
(state: RootState) => state.auth.users,
], authUsers => {
return authUsers.reduce((ids: ImmutableOrderedSet<string>, authUser: ImmutableMap<string, any>) => {
return authUsers.reduce((ids: ImmutableOrderedSet<string>, authUser) => {
try {
const id = authUser.get('id');
const id = authUser.id;
return validId(id) ? ids.add(id) : ids;
} catch {
return ids;
}
}, ImmutableOrderedSet());
}, ImmutableOrderedSet<string>());
});
export const makeGetOtherAccounts = () => {

Some files were not shown because too many files have changed in this diff Show More