sforkowany z mirror/soapbox
Merge remote-tracking branch 'origin/develop' into release-ci
commit
039433988f
|
@ -18,7 +18,7 @@ module.exports = {
|
|||
ATTACHMENT_HOST: false,
|
||||
},
|
||||
|
||||
parser: 'babel-eslint',
|
||||
parser: '@babel/eslint-parser',
|
||||
|
||||
plugins: [
|
||||
'react',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
## Summary
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
|
||||
## Screenshots (if appropriate):
|
||||
| Before | After |
|
||||
| ------ | ----- |
|
||||
| | |
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
nodejs 18.2.0
|
||||
nodejs 18.12.1
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"stylelint.vscode-stylelint",
|
||||
"wix.vscode-import-cost"
|
||||
"wix.vscode-import-cost",
|
||||
"redhat.vscode-yaml"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -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
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
} : {};
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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],
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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
|
||||
});
|
|
@ -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',
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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));
|
|
@ -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;
|
|
@ -2,6 +2,3 @@
|
|||
|
||||
import 'intersection-observer';
|
||||
import 'requestidlecallback';
|
||||
import objectFitImages from 'object-fit-images';
|
||||
|
||||
objectFitImages();
|
||||
|
|
|
@ -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 }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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));
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 />', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
||||
|
|
|
@ -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' />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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%' },
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -28,6 +28,7 @@ const BirthdaysModal = ({ onClose }: IBirthdaysModal) => {
|
|||
<ScrollableList
|
||||
scrollKey='birthdays'
|
||||
emptyMessage={emptyMessage}
|
||||
className='max-w-full'
|
||||
itemClassName='pb-3'
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 =>
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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 }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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([
|
||||
|
|
Plik diff jest za duży
Load Diff
|
@ -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": "You’ve scored the latest version of {siteTitle}! Take a moment to review the exciting new things we’ve 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 user’s 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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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', '');
|
||||
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
|
||||
|
|
|
@ -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)) {
|
|
@ -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:
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
Ładowanie…
Reference in New Issue