diff --git a/.eslintrc.js b/.eslintrc.js index 0ecb15a5b..7cefd7347 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,7 +18,7 @@ module.exports = { ATTACHMENT_HOST: false, }, - parser: 'babel-eslint', + parser: '@babel/eslint-parser', plugins: [ 'react', diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 61bf382db..a2645807a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,6 +3,9 @@ image: node:18 variables: NODE_ENV: test +default: + interruptible: true + cache: &cache key: files: @@ -26,7 +29,6 @@ deps: cache: <<: *cache policy: push - interruptible: true danger: stage: test @@ -34,8 +36,10 @@ danger: # https://github.com/danger/danger-js/issues/1029#issuecomment-998915436 - export CI_MERGE_REQUEST_IID=${CI_OPEN_MERGE_REQUESTS#*!} - npx danger ci + except: + variables: + - $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME allow_failure: true - interruptible: true lint-js: stage: test @@ -48,7 +52,6 @@ lint-js: - "**/*.tsx" - ".eslintignore" - ".eslintrc.js" - interruptible: true lint-sass: stage: test @@ -58,7 +61,6 @@ lint-sass: - "**/*.scss" - "**/*.css" - ".stylelintrc.json" - interruptible: true jest: stage: test @@ -81,27 +83,30 @@ jest: coverage_report: coverage_format: cobertura path: .coverage/cobertura-coverage.xml - interruptible: true nginx-test: stage: test image: nginx:latest - before_script: cp installation/mastodon.conf /etc/nginx/conf.d/default.conf + before_script: + - cp installation/mastodon.conf /etc/nginx/conf.d/default.conf script: nginx -t only: changes: - "installation/mastodon.conf" - interruptible: true build-production: stage: test - script: yarn build + script: + - yarn build + - yarn manage:translations en + # Fail if files got changed. + # https://stackoverflow.com/a/9066385 + - git diff --quiet variables: NODE_ENV: production artifacts: paths: - static - interruptible: true docs-deploy: stage: deploy @@ -111,22 +116,10 @@ docs-deploy: script: - curl -X POST -F"token=$CI_JOB_TOKEN" -F'ref=master' https://gitlab.com/api/v4/projects/15685485/trigger/pipeline only: - refs: - - develop + variables: + - $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME changes: - "docs/**/*" - interruptible: true - -# Supposed to fail when translations are outdated, instead always passes -# -# i18n: -# stage: build -# script: yarn manage:translations -# variables: -# NODE_ENV: development -# before_script: -# - yarn -# - yarn build review: stage: deploy @@ -136,7 +129,6 @@ review: script: - npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub allow_failure: true - interruptible: true pages: stage: deploy @@ -150,15 +142,14 @@ pages: paths: - public only: - refs: - - develop - interruptible: true + variables: + - $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME docker: stage: deploy - image: docker:20.10.17 + image: docker:20.10.22 services: - - docker:20.10.17-dind + - docker:20.10.22-dind tags: - dind # https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df @@ -167,9 +158,8 @@ docker: - docker build -t $CI_REGISTRY_IMAGE . - docker push $CI_REGISTRY_IMAGE only: - refs: - - develop - interruptible: true + variables: + - $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME # https://docs.gitlab.com/ee/user/project/releases/release_cicd_examples.html#create-release-metadata-in-a-custom-script prepare-release: @@ -181,6 +171,7 @@ prepare-release: artifacts: reports: dotenv: variables.env + interruptible: false release: stage: release @@ -199,4 +190,9 @@ release: links: - name: Build url: 'https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/$CI_COMMIT_TAG/download?job=build-production' - link_type: package \ No newline at end of file + link_type: package + interruptible: false + +include: + - template: Jobs/Dependency-Scanning.gitlab-ci.yml + - template: Security/License-Scanning.gitlab-ci.yml diff --git a/.gitlab/merge_request_templates/BeforeAndAfter.md b/.gitlab/merge_request_templates/BeforeAndAfter.md new file mode 100644 index 000000000..6e457a708 --- /dev/null +++ b/.gitlab/merge_request_templates/BeforeAndAfter.md @@ -0,0 +1,8 @@ +## Summary + + + +## Screenshots (if appropriate): +| Before | After | +| ------ | ----- | +| | | diff --git a/.stylelintrc.json b/.stylelintrc.json index c8a71a164..1223ef3ad 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,16 +1,22 @@ { - "extends": ["stylelint-config-standard"], - "ignoreFiles": ["app/styles/reset.scss"], - "plugins": ["stylelint-scss"], + "extends": ["stylelint-config-standard-scss"], "rules": { + "alpha-value-notation": null, "at-rule-no-unknown": null, "at-rule-empty-line-before": ["always", { "ignore": ["after-comment", "first-nested", "inside-block", "blockless-after-same-name-blockless", "blockless-after-blockless"] }], + "color-function-notation": null, + "custom-property-pattern": null, + "declaration-block-no-redundant-longhand-properties": null, "declaration-colon-newline-after": null, "declaration-empty-line-before": "never", "font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free", "OpenDyslexic", "soapbox"] }], + "max-line-length": null, "no-descending-specificity": null, "no-duplicate-selectors": null, - "scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["/tailwind/", "layer"]}], - "no-invalid-position-at-import-rule": null + "no-invalid-position-at-import-rule": null, + "scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["tailwind", "apply", "layer", "config"]}], + "scss/operator-no-unspaced": null, + "selector-class-pattern": null, + "string-quotes": "single" } } diff --git a/.tool-versions b/.tool-versions index f0c37ee48..5686ee0db 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs 18.2.0 +nodejs 18.12.1 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 57a35ab4f..d1762aa9a 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,6 +3,7 @@ "dbaeumer.vscode-eslint", "bradlc.vscode-tailwindcss", "stylelint.vscode-stylelint", - "wix.vscode-import-cost" + "wix.vscode-import-cost", + "redhat.vscode-yaml" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 4a7155a74..d7ca13345 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" + } + ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 685316297..a7a1288db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Compatibility: rudimentary support for Takahē. +- UI: added backdrop blur behind modals. +- Admin: let admins configure media preview for attachment thumbnails. +- Login: accept `?server` param in external login, eg `fe.soapbox.pub/login/external?server=gleasonator.com`. ### Changed +- Posts: letterbox images to 19:6 again. ### Fixed - Layout: use accent color for "floating action button" (mobile compose button). +- ServiceWorker: don't serve favicon, robots.txt, and others from ServiceWorker. +- Datepicker: correctly default to the current year. +- Scheduled posts: fix page crashing on deleting a scheduled post. +- Events: don't crash when searching for a location. +- Search: fixes an abort error when using the navbar search component. +- Posts: fix monospace font in Markdown code blocks. +- Modals: fix action buttons overflow +- Editing: don't insert edited posts to the top of the feed. ## [3.0.0] - 2022-12-25 diff --git a/app/soapbox/actions/__tests__/me.test.ts b/app/soapbox/actions/__tests__/me.test.ts index c75d128f0..d4dc1d31f 100644 --- a/app/soapbox/actions/__tests__/me.test.ts +++ b/app/soapbox/actions/__tests__/me.test.ts @@ -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); }); }); -}); \ No newline at end of file +}); diff --git a/app/soapbox/actions/admin.ts b/app/soapbox/actions/admin.ts index 3cd5a25ba..e24999aa1 100644 --- a/app/soapbox/actions/admin.ts +++ b/app/soapbox/actions/admin.ts @@ -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, }; diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index 8e7a00d02..436c87ff2 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -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) => 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) => 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) => { - 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)); } }); }; diff --git a/app/soapbox/actions/instance.ts b/app/soapbox/actions/instance.ts index ca1fc3ef5..9738718b0 100644 --- a/app/soapbox/actions/instance.ts +++ b/app/soapbox/actions/instance.ts @@ -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; diff --git a/app/soapbox/actions/me.ts b/app/soapbox/actions/me.ts index 17beae21d..a8b275200 100644 --- a/app/soapbox/actions/me.ts +++ b/app/soapbox/actions/me.ts @@ -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, isFormData = false) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(patchMeRequest()); - const headers: AxiosRequestHeaders = isFormData ? { + const headers: RawAxiosRequestHeaders = isFormData ? { 'Content-Type': 'multipart/form-data', } : {}; diff --git a/app/soapbox/actions/statuses.ts b/app/soapbox/actions/statuses.ts index f9bfca3b0..047d61d71 100644 --- a/app/soapbox/actions/statuses.ts +++ b/app/soapbox/actions/statuses.ts @@ -68,7 +68,7 @@ const createStatus = (params: Record, 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) { diff --git a/app/soapbox/api/index.ts b/app/soapbox/api/index.ts index 97d7d25d7..c7fcb6230 100644 --- a/app/soapbox/api/index.ts +++ b/app/soapbox/api/index.ts @@ -43,7 +43,7 @@ const maybeParseJSON = (data: string) => { const getAuthBaseURL = createSelector([ (state: RootState, me: string | false | null) => state.accounts.getIn([me, 'url']), - (state: RootState, _me: string | false | null) => state.auth.get('me'), + (state: RootState, _me: string | false | null) => state.auth.me, ], (accountUrl, authUserUrl) => { const baseURL = parseBaseURL(accountUrl) || parseBaseURL(authUserUrl); return baseURL !== window.location.origin ? baseURL : ''; @@ -62,7 +62,6 @@ export const baseClient = (accessToken?: string | null, baseURL: string = ''): A headers: Object.assign(accessToken ? { 'Authorization': `Bearer ${accessToken}`, } : {}), - transformResponse: [maybeParseJSON], }); }; diff --git a/app/soapbox/components/__tests__/avatar-overlay.test.tsx b/app/soapbox/components/__tests__/avatar-overlay.test.tsx deleted file mode 100644 index 4e83dd071..000000000 --- a/app/soapbox/components/__tests__/avatar-overlay.test.tsx +++ /dev/null @@ -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(' { - 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(); - expect(screen.queryAllByRole('img')).toHaveLength(2); - }); -}); diff --git a/app/soapbox/components/__tests__/avatar.test.tsx b/app/soapbox/components/__tests__/avatar.test.tsx deleted file mode 100644 index 56f592925..000000000 --- a/app/soapbox/components/__tests__/avatar.test.tsx +++ /dev/null @@ -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('', () => { - 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(); - - // expect(screen.getByRole('img').getAttribute('src')).toBe(account.get('avatar')); - // }); - // }); - - describe('Still', () => { - it('renders a still avatar', () => { - render(); - - expect(screen.getByRole('img').getAttribute('src')).toBe(account.get('avatar')); - }); - }); - - // TODO add autoplay test if possible -}); diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index 322a98119..546d5f3f4 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -46,7 +46,7 @@ interface IProfilePopper { const ProfilePopper: React.FC = ({ condition, wrapper, children }): any => condition ? wrapper(children) : children; -interface IAccount { +export interface IAccount { account: AccountEntity, action?: React.ReactElement, actionAlignment?: 'center' | 'top', diff --git a/app/soapbox/components/autosuggest-account-input.tsx b/app/soapbox/components/autosuggest-account-input.tsx index b2a205e3c..383bf7583 100644 --- a/app/soapbox/components/autosuggest-account-input.tsx +++ b/app/soapbox/components/autosuggest-account-input.tsx @@ -44,7 +44,7 @@ const AutosuggestAccountInput: React.FC = ({ setAccountIds(ImmutableOrderedSet()); }; - const handleAccountSearch = useCallback(throttle(q => { + const handleAccountSearch = useCallback(throttle((q) => { const params = { q, limit, resolve: false }; dispatch(accountSearch(params, controller.current.signal)) @@ -53,7 +53,6 @@ const AutosuggestAccountInput: React.FC = ({ setAccountIds(ImmutableOrderedSet(accountIds)); }) .catch(noOp); - }, 900, { leading: true, trailing: true }), [limit]); const handleChange: React.ChangeEventHandler = e => { diff --git a/app/soapbox/components/autosuggest-input.tsx b/app/soapbox/components/autosuggest-input.tsx index fe82744c1..122890112 100644 --- a/app/soapbox/components/autosuggest-input.tsx +++ b/app/soapbox/components/autosuggest-input.tsx @@ -46,7 +46,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { return this.props.autoSelect ? 0 : -1; - } + }; state = { suggestionsHidden: true, @@ -76,7 +76,7 @@ export default class AutosuggestInput extends ImmutablePureComponent = (e) => { const { suggestions, menu, disabled } = this.props; @@ -145,15 +145,15 @@ export default class AutosuggestInput extends ImmutablePureComponent { this.setState({ suggestionsHidden: true, focused: false }); - } + }; onFocus = () => { this.setState({ focused: true }); - } + }; onSuggestionClick: React.EventHandler = (e) => { const index = Number(e.currentTarget?.getAttribute('data-index')); @@ -161,7 +161,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { this.input = c; - } + }; renderSuggestion = (suggestion: AutoSuggestion, i: number) => { const { selectedSuggestion } = this.state; @@ -209,21 +209,21 @@ export default class AutosuggestInput extends ImmutablePureComponent ); - } + }; 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; diff --git a/app/soapbox/components/autosuggest-location.tsx b/app/soapbox/components/autosuggest-location.tsx index 6b6eee434..fab4493c9 100644 --- a/app/soapbox/components/autosuggest-location.tsx +++ b/app/soapbox/components/autosuggest-location.tsx @@ -32,7 +32,7 @@ const AutosuggestLocation: React.FC = ({ id }) => { {location.description} - {[location.street, location.locality, location.country].filter(val => val.trim()).join(' · ')} + {[location.street, location.locality, location.country].filter(val => val?.trim()).join(' · ')} ); diff --git a/app/soapbox/components/autosuggest-textarea.tsx b/app/soapbox/components/autosuggest-textarea.tsx index b7fceb86d..b1b846c56 100644 --- a/app/soapbox/components/autosuggest-textarea.tsx +++ b/app/soapbox/components/autosuggest-textarea.tsx @@ -64,7 +64,7 @@ class AutosuggestTextarea extends ImmutablePureComponent } this.props.onChange(e); - } + }; onKeyDown: React.KeyboardEventHandler = (e) => { const { suggestions, disabled } = this.props; @@ -122,7 +122,7 @@ class AutosuggestTextarea extends ImmutablePureComponent } this.props.onKeyDown(e); - } + }; onBlur = () => { this.setState({ suggestionsHidden: true, focused: false }); @@ -130,7 +130,7 @@ class AutosuggestTextarea extends ImmutablePureComponent if (this.props.onBlur) { this.props.onBlur(); } - } + }; onFocus = () => { this.setState({ focused: true }); @@ -138,14 +138,14 @@ class AutosuggestTextarea extends ImmutablePureComponent if (this.props.onFocus) { this.props.onFocus(); } - } + }; onSuggestionClick: React.MouseEventHandler = (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 setTextarea: React.Ref = (c) => { this.textarea = c; - } + }; onPaste: React.ClipboardEventHandler = (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 {inner} ); - } + }; setPortalPosition() { if (!this.textarea) { diff --git a/app/soapbox/components/avatar-overlay.tsx b/app/soapbox/components/avatar-overlay.tsx deleted file mode 100644 index a463b35ce..000000000 --- a/app/soapbox/components/avatar-overlay.tsx +++ /dev/null @@ -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 = ({ account, friend }) => ( -
- - -
-); - -export default AvatarOverlay; diff --git a/app/soapbox/components/avatar.tsx b/app/soapbox/components/avatar.tsx deleted file mode 100644 index 4bc5c0774..000000000 --- a/app/soapbox/components/avatar.tsx +++ /dev/null @@ -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 = ({ 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 ( - - ); -}; - -export default Avatar; diff --git a/app/soapbox/components/dropdown-menu.tsx b/app/soapbox/components/dropdown-menu.tsx index 202c43fa8..3cab63aeb 100644 --- a/app/soapbox/components/dropdown-menu.tsx +++ b/app/soapbox/components/dropdown-menu.tsx @@ -64,7 +64,7 @@ class DropdownMenu extends React.PureComponent = c => { this.node = c; - } + }; setFocusRef: React.RefCallback = c => { this.focusedItem = c; - } + }; handleKeyDown = (e: KeyboardEvent) => { if (!this.node) return; @@ -127,13 +127,13 @@ class DropdownMenu extends React.PureComponent = e => { if (e.key === 'Enter' || e.key === ' ') { this.handleClick(e); } - } + }; handleClick: React.EventHandler = e => { const i = Number(e.currentTarget.getAttribute('data-index')); @@ -152,7 +152,7 @@ class DropdownMenu extends React.PureComponent = e => { const i = Number(e.currentTarget.getAttribute('data-index')); @@ -166,13 +166,13 @@ class DropdownMenu extends React.PureComponent = 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 { 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 { if (this.props.onClose) { this.props.onClose(this.state.id); } - } + }; handleMouseDown: React.EventHandler = () => { if (!this.state.open) { this.activeElement = document.activeElement; } - } + }; handleButtonKeyDown: React.EventHandler = (e) => { switch (e.key) { @@ -329,7 +329,7 @@ class Dropdown extends React.PureComponent { this.handleMouseDown(e); break; } - } + }; handleKeyPress: React.EventHandler> = (e) => { switch (e.key) { @@ -340,7 +340,7 @@ class Dropdown extends React.PureComponent { e.preventDefault(); break; } - } + }; handleItemClick: React.EventHandler = e => { const i = Number(e.currentTarget.getAttribute('data-index')); @@ -358,21 +358,21 @@ class Dropdown extends React.PureComponent { } else if (to) { this.props.history?.push(to); } - } + }; setTargetRef: React.RefCallback = 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; diff --git a/app/soapbox/components/emoji-selector.tsx b/app/soapbox/components/emoji-selector.tsx index 43e10d875..be0ca3a51 100644 --- a/app/soapbox/components/emoji-selector.tsx +++ b/app/soapbox/components/emoji-selector.tsx @@ -28,7 +28,7 @@ class EmojiSelector extends ImmutablePureComponent { onReact: () => { }, onUnfocus: () => { }, visible: false, - } + }; node?: HTMLDivElement = undefined; @@ -38,7 +38,7 @@ class EmojiSelector extends ImmutablePureComponent { 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 { onUnfocus(); break; } - } + }; handleReact = (emoji: string) => (): void => { const { onReact, focused, onUnfocus } = this.props; @@ -95,7 +95,7 @@ class EmojiSelector extends ImmutablePureComponent { if (focused) { onUnfocus(); } - } + }; handlers = { open: () => { }, @@ -103,7 +103,7 @@ class EmojiSelector extends ImmutablePureComponent { setRef = (c: HTMLDivElement): void => { this.node = c; - } + }; render() { const { visible, focused, allowedEmoji, onReact } = this.props; diff --git a/app/soapbox/components/error-boundary.tsx b/app/soapbox/components/error-boundary.tsx index 370472cb4..0440de85b 100644 --- a/app/soapbox/components/error-boundary.tsx +++ b/app/soapbox/components/error-boundary.tsx @@ -42,7 +42,7 @@ class ErrorBoundary extends React.PureComponent { error: undefined, componentStack: undefined, browser: undefined, - } + }; textarea: HTMLTextAreaElement | null = null; @@ -71,7 +71,7 @@ class ErrorBoundary extends React.PureComponent { setTextareaRef: React.RefCallback = c => { this.textarea = c; - } + }; handleCopy: React.MouseEventHandler = () => { if (!this.textarea) return; @@ -80,12 +80,12 @@ class ErrorBoundary extends React.PureComponent { 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 { e.preventDefault(); unregisterSw().then(goHome).catch(goHome); } - } + }; render() { const { browser, hasError } = this.state; diff --git a/app/soapbox/components/icon-button.js b/app/soapbox/components/icon-button.js deleted file mode 100644 index bdfe157a6..000000000 --- a/app/soapbox/components/icon-button.js +++ /dev/null @@ -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 components unless - // we actually need to animate. - return ( - - ); - } - - return ( - - {({ rotate }) => ( - - )} - - ); - } - -} diff --git a/app/soapbox/components/icon-button.tsx b/app/soapbox/components/icon-button.tsx new file mode 100644 index 000000000..71f110995 --- /dev/null +++ b/app/soapbox/components/icon-button.tsx @@ -0,0 +1,100 @@ +import classNames from 'clsx'; +import React from 'react'; + +import Icon from 'soapbox/components/icon'; + +interface IIconButton extends Pick, '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 = ({ + active, + className, + disabled, + expanded, + iconClassName, + onClick, + onKeyDown, + onKeyUp, + onKeyPress, + onMouseDown, + onMouseEnter, + onMouseLeave, + pressed, + size = 18, + src, + tabIndex = 0, + text, + title, +}) => { + + const handleClick: React.MouseEventHandler = (e) => { + e.preventDefault(); + + if (!disabled && onClick) { + onClick(e); + } + }; + + const handleMouseDown: React.MouseEventHandler = (e) => { + if (!disabled && onMouseDown) { + onMouseDown(e); + } + }; + + const handleKeyDown: React.KeyboardEventHandler = (e) => { + if (!disabled && onKeyDown) { + onKeyDown(e); + } + }; + + const handleKeyUp: React.KeyboardEventHandler = (e) => { + if (!disabled && onKeyUp) { + onKeyUp(e); + } + }; + + const handleKeyPress: React.KeyboardEventHandler = (e) => { + if (onKeyPress && !disabled) { + onKeyPress(e); + } + }; + + const classes = classNames(className, 'icon-button', { + active, + disabled, + }); + + return ( + + ); +}; + +export default IconButton; diff --git a/app/soapbox/components/media-gallery.tsx b/app/soapbox/components/media-gallery.tsx index 722f5540a..75ff9f798 100644 --- a/app/soapbox/components/media-gallery.tsx +++ b/app/soapbox/components/media-gallery.tsx @@ -5,7 +5,7 @@ import Blurhash from 'soapbox/components/blurhash'; import Icon from 'soapbox/components/icon'; import StillImage from 'soapbox/components/still-image'; import { MIMETYPE_ICONS } from 'soapbox/components/upload'; -import { useSettings } from 'soapbox/hooks'; +import { useSettings, useSoapboxConfig } from 'soapbox/hooks'; import { Attachment } from 'soapbox/types/entities'; import { truncateFilename } from 'soapbox/utils/media'; @@ -72,6 +72,7 @@ const Item: React.FC = ({ }) => { const settings = useSettings(); const autoPlayGif = settings.get('autoPlayGif') === true; + const { mediaPreview } = useSoapboxConfig(); const handleMouseEnter: React.MouseEventHandler = ({ currentTarget: video }) => { if (hoverToPlay()) { @@ -171,7 +172,7 @@ const Item: React.FC = ({ > = (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); diff --git a/app/soapbox/components/modal-root.tsx b/app/soapbox/components/modal-root.tsx index a43d2d638..37fc058b6 100644 --- a/app/soapbox/components/modal-root.tsx +++ b/app/soapbox/components/modal-root.tsx @@ -241,7 +241,7 @@ const ModalRoot: React.FC = ({ children, onCancel, onClose, type })