Merge remote-tracking branch 'origin/develop' into customize-redirect-from-root-when-not-logged-in-settings-input

This commit is contained in:
Alex Gleason 2023-01-11 18:33:32 -06:00
commit 1e07c03479
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
286 changed files with 3429 additions and 14434 deletions

View File

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

View File

@ -3,6 +3,9 @@ image: node:18
variables:
NODE_ENV: test
default:
interruptible: true
cache: &cache
key:
files:
@ -15,6 +18,7 @@ stages:
- deps
- test
- deploy
- release
deps:
stage: deps
@ -25,7 +29,6 @@ deps:
cache:
<<: *cache
policy: push
interruptible: true
danger:
stage: test
@ -33,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
@ -47,7 +52,6 @@ lint-js:
- "**/*.tsx"
- ".eslintignore"
- ".eslintrc.js"
interruptible: true
lint-sass:
stage: test
@ -57,7 +61,6 @@ lint-sass:
- "**/*.scss"
- "**/*.css"
- ".stylelintrc.json"
interruptible: true
jest:
stage: test
@ -80,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
@ -110,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
@ -135,7 +129,6 @@ review:
script:
- npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub
allow_failure: true
interruptible: true
pages:
stage: deploy
@ -149,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
@ -166,9 +158,17 @@ 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
release:
stage: release
rules:
- if: $CI_COMMIT_TAG
script:
- npx ts-node ./scripts/do-release.ts
interruptible: false
include:
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
- template: Security/License-Scanning.gitlab-ci.yml

View File

@ -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"] }],
"font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free"] }],
"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"
}
}

View File

@ -1 +1 @@
nodejs 18.2.0
nodejs 18.13.0

View File

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

12
.vscode/settings.json vendored
View File

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

View File

@ -8,12 +8,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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.
- Status Info: moved context (repost, pinned) to improve UX.
- Posts: remove file icon from empty link previews.
### 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.
- Modals: close modal when navigating to a different page.
- Modals: fix "View context" button in media modal.
- Posts: let unauthenticated users to translate posts if allowed by backend.
- Chats: fix jumpy scrollbar.
## [3.0.0] - 2022-12-25

View File

@ -1,17 +0,0 @@
import loadPolyfills from './soapbox/load-polyfills';
// Load iframe event listener
require('./soapbox/iframe');
// @ts-ignore
require.context('./assets/images/', true);
// Load stylesheet
require('react-datepicker/dist/react-datepicker.css');
require('./styles/application.scss');
loadPolyfills().then(() => {
require('./soapbox/main').default();
}).catch(e => {
console.error(e);
});

View File

@ -1,94 +0,0 @@
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
with Reserved Font Name OpenDyslexic.
Copyright (c) 12/2012 - 2019
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

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

View File

@ -10,10 +10,10 @@ import {
} from './importer';
import type { AxiosError, CancelToken } from 'axios';
import type { History } from 'history';
import type { Map as ImmutableMap } from 'immutable';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Status } from 'soapbox/types/entities';
import type { History } from 'soapbox/types/history';
const ACCOUNT_CREATE_REQUEST = 'ACCOUNT_CREATE_REQUEST';
const ACCOUNT_CREATE_SUCCESS = 'ACCOUNT_CREATE_SUCCESS';

View File

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

View File

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

View File

@ -6,8 +6,8 @@ import { getFeatures } from 'soapbox/utils/features';
import api, { getLinks } from '../api';
import type { History } from 'history';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { History } from 'soapbox/types/history';
const CHATS_FETCH_REQUEST = 'CHATS_FETCH_REQUEST';
const CHATS_FETCH_SUCCESS = 'CHATS_FETCH_SUCCESS';

View File

@ -19,11 +19,11 @@ import { openModal, closeModal } from './modals';
import { getSettings } from './settings';
import { createStatus } from './statuses';
import type { History } from 'history';
import type { Emoji } from 'soapbox/components/autosuggest-emoji';
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities';
import type { History } from 'soapbox/types/history';
const { CancelToken, isCancel } = axios;

View File

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

View File

@ -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',
} : {};

View File

@ -107,7 +107,10 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record<
// Desktop notifications
try {
if (showAlert && !filtered) {
// eslint-disable-next-line compat/compat
const isNotificationsEnabled = window.Notification?.permission === 'granted';
if (showAlert && !filtered && isNotificationsEnabled) {
const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : '');

View File

@ -47,7 +47,6 @@ const defaultSettings = ImmutableMap({
autoloadMore: true,
systemFont: false,
dyslexicFont: false,
demetricator: false,
isDeveloper: false,

View File

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

View File

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

View File

@ -1,50 +0,0 @@
'use strict';
import 'intl';
import 'intl/locale-data/jsonp/en';
import 'es6-symbol/implement';
// @ts-ignore: No types
import includes from 'array-includes';
// @ts-ignore: No types
import isNaN from 'is-nan';
import assign from 'object-assign';
// @ts-ignore: No types
import values from 'object.values';
import { decode as decodeBase64 } from './utils/base64';
if (!Array.prototype.includes) {
includes.shim();
}
if (!Object.assign) {
Object.assign = assign;
}
if (!Object.values) {
values.shim();
}
if (!Number.isNaN) {
Number.isNaN = isNaN;
}
if (!HTMLCanvasElement.prototype.toBlob) {
const BASE64_MARKER = ';base64,';
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
value(callback: any, type = 'image/png', quality: any) {
const dataURL = this.toDataURL(type, quality);
let data;
if (dataURL.includes(BASE64_MARKER)) {
const [, base64] = dataURL.split(BASE64_MARKER);
data = decodeBase64(base64);
} else {
[, data] = dataURL.split(',');
}
callback(new Blob([data], { type }));
},
});
}

View File

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

View File

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

View File

@ -15,14 +15,17 @@ import type { Account as AccountEntity } from 'soapbox/types/entities';
interface IInstanceFavicon {
account: AccountEntity,
disabled?: boolean,
}
const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account }) => {
const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => {
const history = useHistory();
const handleClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
if (disabled) return;
const timelineUrl = `/timeline/${account.domain}`;
if (!(e.ctrlKey || e.metaKey)) {
history.push(timelineUrl);
@ -32,7 +35,11 @@ const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account }) => {
};
return (
<button className='w-4 h-4 flex-none focus:ring-primary-500 focus:ring-2 focus:ring-offset-2' onClick={handleClick}>
<button
className='w-4 h-4 flex-none focus:ring-primary-500 focus:ring-2 focus:ring-offset-2'
onClick={handleClick}
disabled={disabled}
>
<img src={account.favicon} alt='' title={account.domain} className='w-full max-h-full' />
</button>
);
@ -46,7 +53,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',
@ -219,7 +226,7 @@ const Account = ({
<Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text>
{account.favicon && (
<InstanceFavicon account={account} />
<InstanceFavicon account={account} disabled={!withLinkToProfile} />
)}
{(timestamp) ? (

View File

@ -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,7 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
setAccountIds(ImmutableOrderedSet(accountIds));
})
.catch(noOp);
}, 900, { leading: false, trailing: true }), [limit]);
}, 900, { leading: true, trailing: true }), [limit]);
const handleChange: React.ChangeEventHandler<HTMLInputElement> = e => {
refreshCancelToken();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { unblockDomain } from 'soapbox/actions/domain-blocks';
import { useAppDispatch } from 'soapbox/hooks';
import { HStack, IconButton, Text } from './ui';
@ -16,7 +16,7 @@ interface IDomain {
}
const Domain: React.FC<IDomain> = ({ domain }) => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const intl = useIntl();
// const onBlockDomain = () => {

View File

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

View File

@ -1,12 +1,11 @@
import classNames from 'clsx';
import React, { useState, useEffect, useRef } from 'react';
import { usePopper } from 'react-popper';
import { useDispatch } from 'react-redux';
import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts';
import { openModal } from 'soapbox/actions/modals';
import { EmojiSelector } from 'soapbox/components/ui';
import { useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is-mobile';
import { getReactForStatus } from 'soapbox/utils/emoji-reacts';
@ -17,7 +16,7 @@ interface IEmojiButtonWrapper {
/** Provides emoji reaction functionality to the underlying button component */
const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children }): JSX.Element | null => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const ownAccount = useOwnAccount();
const status = useAppSelector(state => state.statuses.get(statusId));
const soapboxConfig = useSoapboxConfig();

View File

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

View File

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

View File

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

View File

@ -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} aria-hidden='true' />
</div>
{text && <span className='icon-button__text'>{text}</span>}
</button>
);
};
export default IconButton;

View File

@ -1,27 +1,28 @@
/**
* Icon: abstract icon class that can render icons from multiple sets.
* Icon: abstact component to render SVG icons.
* @module soapbox/components/icon
* @see soapbox/components/fork_awesome_icon
* @see soapbox/components/svg_icon
*/
import classNames from 'clsx';
import React from 'react';
import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
import ForkAwesomeIcon, { IForkAwesomeIcon } from './fork-awesome-icon';
import SvgIcon, { ISvgIcon } from './svg-icon';
export interface IIcon extends React.HTMLAttributes<HTMLDivElement> {
src: string,
id?: string,
alt?: string,
className?: string,
}
export type IIcon = IForkAwesomeIcon | ISvgIcon;
const Icon: React.FC<IIcon> = (props) => {
if ((props as ISvgIcon).src) {
const { src, ...rest } = (props as ISvgIcon);
return <SvgIcon src={src} {...rest} />;
} else {
const { id, fixedWidth, ...rest } = (props as IForkAwesomeIcon);
return <ForkAwesomeIcon id={id} fixedWidth={fixedWidth} {...rest} />;
}
const Icon: React.FC<IIcon> = ({ src, alt, className, ...rest }) => {
return (
<div
className={classNames('svg-icon', className)}
{...rest}
>
<InlineSVG src={src} title={alt} loader={<></>} />
</div>
);
};
export default Icon;

View File

@ -1,11 +1,11 @@
import classNames from 'clsx';
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useLayoutEffect } from 'react';
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);
@ -532,7 +533,7 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
/>
));
useEffect(() => {
useLayoutEffect(() => {
if (node.current) {
const { offsetWidth } = node.current;

View File

@ -11,7 +11,6 @@ import { useAppDispatch, usePrevious } from 'soapbox/hooks';
import { queryClient } from 'soapbox/queries/client';
import { IPolicy, PolicyKeys } from 'soapbox/queries/policies';
import type { UnregisterCallback } from 'history';
import type { ModalType } from 'soapbox/features/ui/components/modal-root';
import type { ReducerCompose } from 'soapbox/reducers/compose';
import type { ReducerRecord as ReducerComposeEvent } from 'soapbox/reducers/compose-event';
@ -55,7 +54,7 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
const ref = useRef<HTMLDivElement>(null);
const activeElement = useRef<HTMLDivElement | null>(revealed ? document.activeElement as HTMLDivElement | null : null);
const modalHistoryKey = useRef<number>();
const unlistenHistory = useRef<UnregisterCallback>();
const unlistenHistory = useRef<ReturnType<typeof history.listen>>();
const prevChildren = usePrevious(children);
const prevType = usePrevious(type);
@ -152,8 +151,10 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
const handleModalOpen = () => {
modalHistoryKey.current = Date.now();
unlistenHistory.current = history.listen((_, action) => {
if (action === 'POP') {
unlistenHistory.current = history.listen(({ state }, action) => {
if (!(state as any)?.soapboxModalKey) {
onClose();
} else if (action === 'POP') {
handleOnClose();
if (onCancel) onCancel();
@ -165,11 +166,9 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
if (unlistenHistory.current) {
unlistenHistory.current();
}
if (!['FAVOURITES', 'MENTIONS', 'REACTIONS', 'REBLOGS', 'MEDIA'].includes(type)) {
const { state } = history.location;
if (state && (state as any).soapboxModalKey === modalHistoryKey.current) {
history.goBack();
}
const { state } = history.location;
if (state && (state as any).soapboxModalKey === modalHistoryKey.current) {
history.goBack();
}
};
@ -221,7 +220,7 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
ensureHistoryBuffer();
}
});
}, [children]);
if (!visible) {
return (
@ -241,7 +240,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}
/>

View File

@ -24,13 +24,13 @@ type SavedScrollPosition = {
// NOTE: It's crucial to space lists with **padding** instead of margin!
// Pass an `itemClassName` like `pb-3`, NOT a `space-y-3` className
// https://virtuoso.dev/troubleshooting#list-does-not-scroll-to-the-bottom--items-jump-around
const Item: Components<Context>['Item'] = ({ context, ...rest }) => (
const Item: Components<JSX.Element, Context>['Item'] = ({ context, ...rest }) => (
<div className={context?.itemClassName} {...rest} />
);
/** Custom Virtuoso List component for the outer container. */
// Ensure the className winds up here
const List: Components<Context>['List'] = React.forwardRef((props, ref) => {
const List: Components<JSX.Element, Context>['List'] = React.forwardRef((props, ref) => {
const { context, ...rest } = props;
return <div ref={ref} className={context?.listClassName} {...rest} />;
});

View File

@ -2,7 +2,6 @@
import classNames from 'clsx';
import React from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { Link, NavLink } from 'react-router-dom';
import { fetchOwnAccounts, logOut, switchAccount } from 'soapbox/actions/auth';
@ -11,7 +10,7 @@ import { closeSidebar } from 'soapbox/actions/sidebar';
import Account from 'soapbox/components/account';
import { Stack } from 'soapbox/components/ui';
import ProfileStats from 'soapbox/features/ui/components/profile-stats';
import { useAppSelector, useFeatures } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { makeGetAccount, makeGetOtherAccounts } from 'soapbox/selectors';
import { Divider, HStack, Icon, IconButton, Text } from './ui';
@ -81,7 +80,7 @@ const getOtherAccounts = makeGetOtherAccounts();
const SidebarMenu: React.FC = (): JSX.Element | null => {
const intl = useIntl();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const features = useFeatures();
const getAccount = makeGetAccount();

View File

@ -26,7 +26,7 @@ interface IReadMoreButton {
const ReadMoreButton: React.FC<IReadMoreButton> = ({ onClick }) => (
<button className='flex items-center text-gray-900 dark:text-gray-300 border-0 bg-transparent p-0 pt-2 hover:underline active:underline' onClick={onClick}>
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
<Icon className='inline-block h-5 w-5' src={require('@tabler/icons/chevron-right.svg')} fixedWidth />
<Icon className='inline-block h-5 w-5' src={require('@tabler/icons/chevron-right.svg')} />
</button>
);

View File

@ -2,7 +2,7 @@ import classNames from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { HotKeys } from 'react-hotkeys';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { NavLink, useHistory } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
@ -21,9 +21,9 @@ import StatusContent from './status-content';
import StatusMedia from './status-media';
import StatusReplyMentions from './status-reply-mentions';
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
import { Card, HStack, Stack, Text } from './ui';
import StatusInfo from './statuses/status-info';
import { Card, Stack, Text } from './ui';
import type { Map as ImmutableMap } from 'immutable';
import type {
Account as AccountEntity,
Status as StatusEntity,
@ -38,6 +38,7 @@ const messages = defineMessages({
export interface IStatus {
id?: string,
avatarSize?: number,
status: StatusEntity,
onClick?: () => void,
muted?: boolean,
@ -45,7 +46,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,
@ -58,6 +58,8 @@ export interface IStatus {
const Status: React.FC<IStatus> = (props) => {
const {
status,
accountAction,
avatarSize = 42,
focusable = true,
hoverable = true,
onClick,
@ -86,7 +88,7 @@ const Status: React.FC<IStatus> = (props) => {
const [minHeight, setMinHeight] = useState(208);
const actualStatus = getActualStatus(status);
const isReblog = status.reblog && typeof status.reblog === 'object';
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
// Track height changes we know about to compensate scrolling.
@ -203,8 +205,49 @@ const Status: React.FC<IStatus> = (props) => {
firstEmoji?.focus();
};
const renderStatusInfo = () => {
if (isReblog) {
return (
<StatusInfo
avatarSize={avatarSize}
to={`/@${status.getIn(['account', 'acct'])}`}
icon={<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />}
text={
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: (
<bdi className='truncate pr-1 rtl:pl-1'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: String(status.getIn(['account', 'display_name_html'])),
}}
/>
</bdi>
),
}}
/>
}
/>
);
} else if (featured) {
return (
<StatusInfo
avatarSize={avatarSize}
icon={<Icon src={require('@tabler/icons/pinned.svg')} className='text-gray-600 dark:text-gray-400' />}
text={
<Text size='xs' theme='muted' weight='medium'>
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
</Text>
}
/>
);
}
};
if (!status) return null;
let rebloggedByText, reblogElement, reblogElementMobile;
if (hidden) {
return (
@ -230,55 +273,8 @@ const Status: React.FC<IStatus> = (props) => {
);
}
let rebloggedByText;
if (status.reblog && typeof status.reblog === 'object') {
const displayNameHtml = { __html: String(status.getIn(['account', 'display_name_html'])) };
reblogElement = (
<NavLink
to={`/@${status.getIn(['account', 'acct'])}`}
onClick={(event) => event.stopPropagation()}
className='hidden sm:flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 rtl:space-x-reverse hover:underline'
>
<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />
<HStack alignItems='center'>
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: <bdi className='max-w-[100px] truncate pr-1 rtl:px-1'>
<strong className='text-gray-800 dark:text-gray-200' dangerouslySetInnerHTML={displayNameHtml} />
</bdi>,
}}
/>
</HStack>
</NavLink>
);
reblogElementMobile = (
<div className='pb-5 -mt-2 sm:hidden truncate'>
<NavLink
to={`/@${status.getIn(['account', 'acct'])}`}
onClick={(event) => event.stopPropagation()}
className='flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline'
>
<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />
<span>
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: <bdi>
<strong className='text-gray-800 dark:text-gray-200' dangerouslySetInnerHTML={displayNameHtml} />
</bdi>,
}}
/>
</span>
</NavLink>
</div>
);
rebloggedByText = intl.formatMessage(
messages.reblogged_by,
{ name: String(status.getIn(['account', 'acct'])) },
@ -314,8 +310,6 @@ const Status: React.FC<IStatus> = (props) => {
react: handleHotkeyReact,
};
const accountAction = props.accountAction || reblogElement;
const isUnderReview = actualStatus.visibility === 'self';
const isSensitive = actualStatus.hidden;
@ -330,21 +324,9 @@ const Status: React.FC<IStatus> = (props) => {
onClick={handleClick}
role='link'
>
{featured && (
<div className='pt-4 px-4'>
<HStack alignItems='center' space={1}>
<Icon src={require('@tabler/icons/pinned.svg')} className='text-gray-600 dark:text-gray-400' />
<Text size='sm' theme='muted' weight='medium'>
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
</Text>
</HStack>
</div>
)}
<Card
variant={variant}
className={classNames('status__wrapper', `status-${actualStatus.visibility}`, {
className={classNames('status__wrapper space-y-4', `status-${actualStatus.visibility}`, {
'py-6 sm:p-5': variant === 'rounded',
'status-reply': !!status.in_reply_to_id,
muted,
@ -352,21 +334,20 @@ const Status: React.FC<IStatus> = (props) => {
})}
data-id={status.id}
>
{reblogElementMobile}
{renderStatusInfo()}
<div className='mb-4'>
<AccountContainer
key={String(actualStatus.getIn(['account', 'id']))}
id={String(actualStatus.getIn(['account', 'id']))}
timestamp={actualStatus.created_at}
timestampUrl={statusUrl}
action={accountAction}
hideActions={!accountAction}
showEdit={!!actualStatus.edited_at}
showProfileHoverCard={hoverable}
withLinkToProfile={hoverable}
/>
</div>
<AccountContainer
key={String(actualStatus.getIn(['account', 'id']))}
id={String(actualStatus.getIn(['account', 'id']))}
timestamp={actualStatus.created_at}
timestampUrl={statusUrl}
action={accountAction}
hideActions={!accountAction}
showEdit={!!actualStatus.edited_at}
showProfileHoverCard={hoverable}
withLinkToProfile={hoverable}
avatarSize={avatarSize}
/>
<div className='status__content-wrapper'>
<StatusReplyMentions status={actualStatus} hoverable={hoverable} />

View File

@ -0,0 +1,38 @@
import React from 'react';
import { Link } from 'react-router-dom';
interface IStatusInfo {
avatarSize: number
to?: string
icon: React.ReactNode
text: React.ReactNode
}
const StatusInfo = (props: IStatusInfo) => {
const { avatarSize, to, icon, text } = props;
const onClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
event.stopPropagation();
};
const Container = to ? Link : 'div';
const containerProps: any = to ? { onClick, to } : {};
return (
<Container
{...containerProps}
className='flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-3 rtl:space-x-reverse hover:underline'
>
<div
className='flex justify-end'
style={{ width: avatarSize }}
>
{icon}
</div>
{text}
</Container>
);
};
export default StatusInfo;

View File

@ -1,29 +0,0 @@
/**
* SvgIcon: abstact component to render SVG icons.
* @module soapbox/components/svg_icon
* @see soapbox/components/icon
*/
import classNames from 'clsx';
import React from 'react';
import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
export interface ISvgIcon extends React.HTMLAttributes<HTMLDivElement> {
src: string,
id?: string,
alt?: string,
className?: string,
}
const SvgIcon: React.FC<ISvgIcon> = ({ src, alt, className, ...rest }) => {
return (
<div
className={classNames('svg-icon', className)}
{...rest}
>
<InlineSVG src={src} title={alt} loader={<></>} />
</div>
);
};
export default SvgIcon;

View File

@ -4,10 +4,11 @@ import { FormattedMessage, useIntl } from 'react-intl';
import { translateStatus, undoStatusTranslation } from 'soapbox/actions/statuses';
import { useAppDispatch, useAppSelector, useFeatures, useInstance } from 'soapbox/hooks';
import { isLocal } from 'soapbox/utils/accounts';
import { Stack } from './ui';
import type { Status } from 'soapbox/types/entities';
import type { Account, Status } from 'soapbox/types/entities';
interface ITranslateButton {
status: Status,
@ -21,10 +22,13 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
const me = useAppSelector((state) => state.me);
const allowUnauthenticated = instance.pleroma.getIn(['metadata', 'translation', 'allow_unauthenticated'], false);
const allowRemote = instance.pleroma.getIn(['metadata', 'translation', 'allow_remote'], true);
const sourceLanguages = instance.pleroma.getIn(['metadata', 'translation', 'source_languages']) as ImmutableList<string>;
const targetLanguages = instance.pleroma.getIn(['metadata', 'translation', 'target_languages']) as ImmutableList<string>;
const renderTranslate = me && ['public', 'unlisted'].includes(status.visibility) && status.contentHtml.length > 0 && status.language !== null && intl.locale !== status.language;
const renderTranslate = (me || allowUnauthenticated) && (allowRemote || isLocal(status.account as Account)) && ['public', 'unlisted'].includes(status.visibility) && status.contentHtml.length > 0 && status.language !== null && intl.locale !== status.language;
const supportsLanguages = (!sourceLanguages || sourceLanguages.includes(status.language!)) && (!targetLanguages || targetLanguages.includes(intl.locale));

View File

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

View File

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

View File

@ -27,7 +27,7 @@ const FormGroup: React.FC<IFormGroup> = (props) => {
if (React.isValidElement(inputChildren[0])) {
firstChild = React.cloneElement(
inputChildren[0],
{ id: formFieldId, hasError },
{ id: formFieldId },
);
}
const isCheckboxFormGroup = firstChild?.type === Checkbox;

View File

@ -33,8 +33,6 @@ interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxL
value?: string | number,
/** Change event handler for the input. */
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
/** Whether to display the input in red. */
hasError?: boolean,
/** An element to display as prefix to input. Cannot be used with icon. */
prepend?: React.ReactElement,
/** An element to display as suffix to input. Cannot be used with password type. */
@ -48,7 +46,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
(props, ref) => {
const intl = useIntl();
const { type = 'text', icon, className, outerClassName, hasError, append, prepend, theme = 'normal', ...filteredProps } = props;
const { type = 'text', icon, className, outerClassName, append, prepend, theme = 'normal', ...filteredProps } = props;
const [revealed, setRevealed] = React.useState(false);
@ -91,7 +89,6 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal',
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search',
'pr-7 rtl:pl-7 rtl:pr-3': isPassword || append,
'text-red-600 border-red-600': hasError,
'pl-8': typeof icon !== 'undefined',
'pl-16': typeof prepend !== 'undefined',
}, className)}

View File

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

View File

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

View File

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

View File

@ -271,7 +271,6 @@ const SoapboxHead: React.FC<ISoapboxHead> = ({ children }) => {
const bodyClass = classNames('bg-white dark:bg-gray-800 text-base h-full', {
'no-reduce-motion': !settings.get('reduceMotion'),
'underline-links': settings.get('underlineLinks'),
'dyslexic': settings.get('dyslexicFont'),
'demetricator': settings.get('demetricator'),
});

View File

@ -1,9 +1,8 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useHistory, useParams } from 'react-router-dom';
import { toggleMainWindow } from 'soapbox/actions/chats';
import { useOwnAccount, useSettings } from 'soapbox/hooks';
import { useAppDispatch, useOwnAccount, useSettings } from 'soapbox/hooks';
import { IChat, useChat } from 'soapbox/queries/chats';
type WindowState = 'open' | 'minimized';
@ -22,7 +21,7 @@ enum ChatWidgetScreens {
const ChatProvider: React.FC = ({ children }) => {
const history = useHistory();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const settings = useSettings();
const account = useOwnAccount();

View File

@ -1,7 +0,0 @@
'use strict';
import 'intersection-observer';
import 'requestidlecallback';
import objectFitImages from 'object-fit-images';
objectFitImages();

View File

@ -56,7 +56,7 @@ const MediaItem: React.FC<IMediaItem> = ({ attachment, displayWidth, onOpenMedia
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
const height = width;
const status = attachment.get('status');
const title = status.get('spoiler_text') || attachment.get('description');
const title = status.get('spoiler_text') || attachment.get('description');
let thumbnail: React.ReactNode = '';
let icon;

View File

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

View File

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

View File

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

View File

@ -1,12 +1,11 @@
import classNames from 'clsx';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { fetchAliasesSuggestions, clearAliasesSuggestions, changeAliasesSuggestions } from 'soapbox/actions/aliases';
import Icon from 'soapbox/components/icon';
import { Button } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
search: { id: 'aliases.search', defaultMessage: 'Search your old account' },
@ -14,7 +13,7 @@ const messages = defineMessages({
});
const Search: React.FC = () => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const intl = useIntl();
const value = useAppSelector(state => state.aliases.suggestions.value);

View File

@ -1,7 +1,7 @@
import classNames from 'clsx';
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Icon from 'soapbox/components/icon';
@ -397,7 +397,7 @@ const Audio: React.FC<IAudio> = (props) => {
const progress = Math.min((currentTime / getDuration()) * 100, 100);
useEffect(() => {
useLayoutEffect(() => {
if (player.current) {
_setDimensions();
}

View File

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

View File

@ -238,7 +238,6 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
pattern='^[a-zA-Z\d_-]+'
onChange={onUsernameChange}
value={params.get('username', '')}
hasError={usernameUnavailable}
required
/>
</FormGroup>

View File

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

View File

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

View File

@ -1,13 +1,12 @@
import debounce from 'lodash/debounce';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { fetchBlocks, expandBlocks } from 'soapbox/actions/blocks';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Column, Spinner } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import { useAppSelector } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'column.blocks', defaultMessage: 'Blocked users' },
@ -18,7 +17,7 @@ const handleLoadMore = debounce((dispatch) => {
}, 300, { leading: true });
const Blocks: React.FC = () => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const intl = useIntl();
const accountIds = useAppSelector((state) => state.user_lists.blocks.items);

View File

@ -66,6 +66,21 @@ const List: Components['List'] = React.forwardRef((props, ref) => {
return <div ref={ref} {...rest} className='mb-2' />;
});
const Scroller: Components['Scroller'] = React.forwardRef((props, ref) => {
const { style, context, ...rest } = props;
return (
<div
{...rest}
ref={ref}
style={{
...style,
scrollbarGutter: 'stable',
}}
/>
);
});
interface IChatMessageList {
/** Chat the messages are being rendered from. */
chat: IChat,
@ -472,6 +487,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
}}
components={{
List,
Scroller,
Header: () => {
if (hasNextPage || isFetchingNextPage) {
return <Spinner withText={false} />;

View File

@ -6,7 +6,7 @@ import { __stub } from 'soapbox/api';
import { normalizeAccount } from 'soapbox/normalizers';
import { ReducerAccount } from 'soapbox/reducers/accounts';
import { render, screen } from '../../../../../jest/test-helpers';
import { render, screen, waitFor } from '../../../../../jest/test-helpers';
import ChatPage from '../chat-page';
describe('<ChatPage />', () => {
@ -48,7 +48,10 @@ describe('<ChatPage />', () => {
await userEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('chat-page')).toBeInTheDocument();
expect(screen.getByTestId('toast')).toHaveTextContent('Chat Settings updated successfully');
await waitFor(() => {
expect(screen.getByTestId('toast')).toHaveTextContent('Chat Settings updated successfully');
});
});
});
@ -77,7 +80,10 @@ describe('<ChatPage />', () => {
it('renders the Chats', async () => {
render(<ChatPage />, undefined, store);
await userEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('toast')).toHaveTextContent('Chat Settings failed to update.');
await waitFor(() => {
expect(screen.getByTestId('toast')).toHaveTextContent('Chat Settings failed to update.');
});
});
});
});

View File

@ -1,5 +1,5 @@
import classNames from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { matchPath, Route, Switch, useHistory } from 'react-router-dom';
import { Stack } from 'soapbox/components/ui';
@ -44,7 +44,7 @@ const ChatPage: React.FC<IChatPage> = ({ chatId }) => {
setHeight(fullHeight - top + offset);
};
useEffect(() => {
useLayoutEffect(() => {
calculateHeight();
}, [containerRef.current]);

View File

@ -238,7 +238,7 @@ const ChatPageMain = () => {
<div className='h-full overflow-hidden'>
<Chat
className='h-full overflow-hidden'
className='h-full'
chat={chat}
inputRef={inputRef}
/>

View File

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

View File

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

View File

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

View File

@ -1,9 +1,7 @@
import classNames from 'clsx';
import { Map as ImmutableMap } from 'immutable';
import debounce from 'lodash/debounce';
import React, { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
import {
@ -17,7 +15,8 @@ import {
import AutosuggestAccountInput from 'soapbox/components/autosuggest-account-input';
import { Input } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import { useAppSelector } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { AppDispatch, RootState } from 'soapbox/store';
const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
@ -25,7 +24,7 @@ const messages = defineMessages({
});
function redirectToAccount(accountId: string, routerHistory: any) {
return (_dispatch: any, getState: () => ImmutableMap<string, any>) => {
return (_dispatch: AppDispatch, getState: () => RootState) => {
const acct = getState().getIn(['accounts', accountId, 'acct']);
if (acct && routerHistory) {
@ -49,7 +48,7 @@ const Search = (props: ISearch) => {
openInRoute = false,
} = props;
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const history = useHistory();
const intl = useIntl();

View File

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

View File

@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { changeSettingImmediate } from 'soapbox/actions/settings';
import { Column, Button, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import toast from 'soapbox/toast';
const messages = defineMessages({
@ -15,7 +15,7 @@ const messages = defineMessages({
});
const DevelopersChallenge = () => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const intl = useIntl();
const [answer, setAnswer] = useState('');

View File

@ -1,11 +1,11 @@
import React from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { Link, useHistory } from 'react-router-dom';
import { changeSettingImmediate } from 'soapbox/actions/settings';
import { Column, Text } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import { useAppDispatch } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import sourceCode from 'soapbox/utils/code';
@ -31,7 +31,7 @@ const DashWidget: React.FC<IDashWidget> = ({ to, onClick, children }) => {
};
const Developers: React.FC = () => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const history = useHistory();
const intl = useIntl();

View File

@ -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);
@ -134,12 +134,6 @@ const SettingsStore: React.FC = () => {
<SettingToggle settings={settings} settingPath={['systemFont']} onChange={onToggleChange} />
</ListItem>
<div className='dyslexic'>
<ListItem label={<FormattedMessage id='preferences.fields.dyslexic_font_label' defaultMessage='Dyslexic mode' />}>
<SettingToggle settings={settings} settingPath={['dyslexicFont']} onChange={onToggleChange} />
</ListItem>
</div>
<ListItem
label={<FormattedMessage id='preferences.fields.demetricator_label' defaultMessage='Use Demetricator' />}
hint={<FormattedMessage id='preferences.hints.demetricator' defaultMessage='Decrease social media anxiety by hiding all numbers from the site.' />}

View File

@ -1,13 +1,12 @@
import classNames from 'clsx';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { fetchDirectory, expandDirectory } from 'soapbox/actions/directory';
import LoadMore from 'soapbox/components/load-more';
import { Column, RadioButton, Stack, Text } from 'soapbox/components/ui';
import { useAppSelector, useFeatures, useInstance } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector, useFeatures, useInstance } from 'soapbox/hooks';
import AccountCard from './components/account-card';
@ -21,7 +20,7 @@ const messages = defineMessages({
const Directory = () => {
const intl = useIntl();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const { search } = useLocation();
const params = new URLSearchParams(search);
const instance = useInstance();

View File

@ -1,13 +1,12 @@
import debounce from 'lodash/debounce';
import React from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { fetchDomainBlocks, expandDomainBlocks } from 'soapbox/actions/domain-blocks';
import Domain from 'soapbox/components/domain';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Column, Spinner } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'column.domain_blocks', defaultMessage: 'Hidden domains' },
@ -19,7 +18,7 @@ const handleLoadMore = debounce((dispatch) => {
}, 300, { leading: true });
const DomainBlocks: React.FC = () => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const intl = useIntl();
const domains = useAppSelector((state) => state.domain_lists.blocks.items);

View File

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

View File

@ -78,6 +78,9 @@ describe('<FeedCarousel />', () => {
expect(screen.getAllByTestId('carousel-item-avatar')[0]).toHaveClass('ring-primary-600');
});
// HACK: wait for state change
await new Promise((r) => setTimeout(r, 0));
// Marked as seen, not selected
await userEvent.click(screen.getAllByTestId('carousel-item-avatar')[0]);
await waitFor(() => {

View File

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

View File

@ -1,14 +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 { useAppSelector } from 'soapbox/hooks';
import Account from 'soapbox/components/account';
import { Button, HStack } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
const messages = defineMessages({
@ -22,7 +18,7 @@ interface IAccountAuthorize {
const AccountAuthorize: React.FC<IAccountAuthorize> = ({ id }) => {
const intl = useIntl();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const getAccount = useCallback(makeGetAccount(), []);
@ -38,24 +34,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>
);
};

View File

@ -1,12 +1,11 @@
import debounce from 'lodash/debounce';
import React from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { fetchFollowRequests, expandFollowRequests } from 'soapbox/actions/accounts';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Column, Spinner } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import AccountAuthorize from './components/account-authorize';
@ -19,7 +18,7 @@ const handleLoadMore = debounce((dispatch) => {
}, 300, { leading: true });
const FollowRequests: React.FC = () => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const intl = useIntl();
const accountIds = useAppSelector((state) => state.user_lists.follow_requests.items);

View File

@ -37,7 +37,7 @@ const List: React.FC<IList> = ({ listId }) => {
return (
<div className='flex items-center gap-1.5 px-2 py-4 text-black dark:text-white'>
<Icon src={require('@tabler/icons/list.svg')} fixedWidth />
<Icon src={require('@tabler/icons/list.svg')} />
<span className='flex-grow'>
{list.title}
</span>

View File

@ -1,10 +1,9 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { changeListEditorTitle, submitListEditor } from 'soapbox/actions/lists';
import { Button, Form, HStack, Input } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' },
@ -13,7 +12,7 @@ const messages = defineMessages({
});
const NewListForm: React.FC = () => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const intl = useIntl();
const value = useAppSelector((state) => state.listEditor.get('title'));

View File

@ -1,6 +1,5 @@
import React, { useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { Link } from 'react-router-dom';
import { createSelector } from 'reselect';
@ -9,7 +8,7 @@ import { openModal } from 'soapbox/actions/modals';
import Icon from 'soapbox/components/icon';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Column, IconButton, Spinner } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import NewListForm from './components/new-list-form';
@ -35,7 +34,7 @@ const getOrderedLists = createSelector([(state: RootState) => state.lists], list
});
const Lists: React.FC = () => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const intl = useIntl();
const lists = useAppSelector((state) => getOrderedLists(state));
@ -85,7 +84,7 @@ const Lists: React.FC = () => {
>
{lists.map((list: any) => (
<Link key={list.id} to={`/list/${list.id}`} className='flex items-center gap-1.5 p-2 text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg'>
<Icon src={require('@tabler/icons/list.svg')} fixedWidth />
<Icon src={require('@tabler/icons/list.svg')} />
<span className='flex-grow'>
{list.title}
</span>

View File

@ -1,13 +1,12 @@
import debounce from 'lodash/debounce';
import React from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { fetchMutes, expandMutes } from 'soapbox/actions/mutes';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Column, Spinner } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import { useAppSelector } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'column.mutes', defaultMessage: 'Muted users' },
@ -18,7 +17,7 @@ const handleLoadMore = debounce((dispatch) => {
}, 300, { leading: true });
const Mutes: React.FC = () => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const intl = useIntl();
const accountIds = useAppSelector((state) => state.user_lists.mutes.items);

View File

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

View File

@ -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,
@ -151,6 +151,8 @@ const buildMessage = (
});
};
const avatarSize = 48;
interface INotificaton {
hidden?: boolean,
notification: NotificationEntity,
@ -290,7 +292,7 @@ const Notification: React.FC<INotificaton> = (props) => {
<AccountContainer
id={account.id}
hidden={hidden}
avatarSize={48}
avatarSize={avatarSize}
/>
) : null;
case 'follow_request':
@ -298,7 +300,7 @@ const Notification: React.FC<INotificaton> = (props) => {
<AccountContainer
id={account.id}
hidden={hidden}
avatarSize={48}
avatarSize={avatarSize}
actionType='follow_request'
/>
) : null;
@ -307,7 +309,7 @@ const Notification: React.FC<INotificaton> = (props) => {
<AccountContainer
id={notification.target.id}
hidden={hidden}
avatarSize={48}
avatarSize={avatarSize}
/>
) : null;
case 'favourite':
@ -327,6 +329,7 @@ const Notification: React.FC<INotificaton> = (props) => {
hidden={hidden}
onMoveDown={handleMoveDown}
onMoveUp={handleMoveUp}
avatarSize={avatarSize}
/>
) : null;
default:
@ -358,13 +361,18 @@ const Notification: React.FC<INotificaton> = (props) => {
>
<div className='p-4 focusable'>
<div className='mb-2'>
<HStack alignItems='center' space={1.5}>
{renderIcon()}
<HStack alignItems='center' space={3}>
<div
className='flex justify-end'
style={{ flexBasis: avatarSize }}
>
{renderIcon()}
</div>
<div className='truncate'>
<Text
theme='muted'
size='sm'
size='xs'
truncate
data-testid='message'
>

View File

@ -1,12 +1,11 @@
import classNames from 'clsx';
import React from 'react';
import { useDispatch } from 'react-redux';
import ReactSwipeableViews from 'react-swipeable-views';
import { endOnboarding } from 'soapbox/actions/onboarding';
import LandingGradient from 'soapbox/components/landing-gradient';
import { HStack } from 'soapbox/components/ui';
import { useFeatures } from 'soapbox/hooks';
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
import AvatarSelectionStep from './steps/avatar-selection-step';
import BioStep from './steps/bio-step';
@ -17,7 +16,7 @@ import FediverseStep from './steps/fediverse-step';
import SuggestedAccountsStep from './steps/suggested-accounts-step';
const OnboardingWizard = () => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const features = useFeatures();
const [currentStep, setCurrentStep] = React.useState<number>(0);

View File

@ -1,11 +1,10 @@
import classNames from 'clsx';
import React from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { patchMe } from 'soapbox/actions/me';
import { Avatar, Button, Card, CardBody, Icon, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useOwnAccount } from 'soapbox/hooks';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import { isDefaultAvatar } from 'soapbox/utils/accounts';
import resizeImage from 'soapbox/utils/resize-image';
@ -17,7 +16,7 @@ const messages = defineMessages({
});
const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const account = useOwnAccount();
const fileInput = React.useRef<HTMLInputElement>(null);

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