diff --git a/.eslintignore b/.eslintignore index 0c17e6907..1ab6f8d8c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,4 +5,4 @@ /tmp/** /coverage/** /custom/** -!.eslintrc.js +!.eslintrc.cjs diff --git a/.eslintrc.js b/.eslintrc.cjs similarity index 95% rename from .eslintrc.js rename to .eslintrc.cjs index 0ecb15a5b..e829eaa1f 100644 --- a/.eslintrc.js +++ b/.eslintrc.cjs @@ -18,7 +18,7 @@ module.exports = { ATTACHMENT_HOST: false, }, - parser: 'babel-eslint', + parser: '@babel/eslint-parser', plugins: [ 'react', @@ -43,7 +43,7 @@ module.exports = { react: { version: 'detect', }, - 'import/extensions': ['.js', '.jsx', '.ts', '.tsx'], + 'import/extensions': ['.js', '.jsx', '.cjs', '.mjs', '.ts', '.tsx'], 'import/ignore': [ 'node_modules', '\\.(css|scss|json)$', @@ -54,12 +54,12 @@ module.exports = { }, }, polyfills: [ - 'es:all', - 'fetch', - 'IntersectionObserver', - 'Promise', - 'URL', - 'URLSearchParams', + 'es:all', // core-js + 'IntersectionObserver', // npm:intersection-observer + 'Promise', // core-js + 'ResizeObserver', // npm:resize-observer-polyfill + 'URL', // core-js + 'URLSearchParams', // core-js ], }, diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index cdf22dd2e..36a3084ea 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: @@ -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 @@ -43,11 +48,12 @@ lint-js: changes: - "**/*.js" - "**/*.jsx" + - "**/*.cjs" + - "**/*.mjs" - "**/*.ts" - "**/*.tsx" - ".eslintignore" - - ".eslintrc.js" - interruptible: true + - ".eslintrc.cjs" lint-sass: stage: test @@ -57,7 +63,6 @@ lint-sass: - "**/*.scss" - "**/*.css" - ".stylelintrc.json" - interruptible: true jest: stage: test @@ -69,7 +74,7 @@ jest: - "app/soapbox/**/*" - "webpack/**/*" - "custom/**/*" - - "jest.config.js" + - "jest.config.cjs" - "package.json" - "yarn.lock" - ".gitlab-ci.yml" @@ -80,27 +85,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 +118,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 +131,6 @@ review: script: - npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub allow_failure: true - interruptible: true pages: stage: deploy @@ -149,15 +144,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.23 services: - - docker:20.10.17-dind + - docker:20.10.23-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,6 +160,17 @@ docker: - docker build -t $CI_REGISTRY_IMAGE . - docker push $CI_REGISTRY_IMAGE only: - refs: - - develop - interruptible: true \ No newline at end of file + 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: Security/License-Scanning.gitlab-ci.yml \ No newline at end of file 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/.lintstagedrc.json b/.lintstagedrc.json index 5dcd5926a..97bad7f28 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,5 +1,8 @@ { "*.js": "eslint --cache", + "*.cjs": "eslint --cache", + "*.mjs": "eslint --cache", "*.ts": "eslint --cache", + "*.tsx": "eslint --cache", "app/styles/**/*.scss": "stylelint" } diff --git a/.stylelintrc.json b/.stylelintrc.json index c8a71a164..1a610d5eb 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"] }], + "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" } } diff --git a/.tool-versions b/.tool-versions index f0c37ee48..efc600fbd 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs 18.2.0 +nodejs 18.13.0 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..1b3f69961 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,21 @@ { + "css.validate": false, "editor.insertSpaces": true, "editor.tabSize": 2, "files.associations": { "*.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" + } + ], + "scss.validate": false } diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a3507f2..a675c568c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,67 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +### Added +- Admin: redirect the homepage to any URL. +- Compatibility: added compatibility with Friendica. +- Posts: bot badge on statuses from bot accounts. +- Compatibility: improved browser support for older browsers. +- Events: allow to repost events in event menu. +- Groups: Initial support for groups. + +### Changed +- Chats: improved display of media attachments. +- ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away. + +### Fixed +- Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load. +- Chats: don't display "copy" button for messages without text. +- Posts: don't have to click the play button twice for embedded videos. +- index.html: remove `referrer` meta tag so it doesn't conflict with backend's `Referrer-Policy` header. +- Modals: fix media modal automatically switching to video. + +### Removed +- Admin: single user mode. Now the homepage can be redirected to any URL. + +## [3.1.0] - 2023-01-13 + +### 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`. +- Backups: restored Pleroma backups functionality. +- Export: restored "Export data" to CSV. + +### 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. +- Settings: moved "Import data" under settings. +- Composer: add more descriptive discard confirmation message. + +### 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. +- Editing: don't display edited posts as pending posts. +- 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. +- Composer: fix alignment of icon in submit button. +- Login: add a border around QR codes. +- Composer: don't display action button in reply indicator. + +## [3.0.0] - 2022-12-25 + ### Added - Editing: ability to edit posts and view edit history (on Rebased, Pleroma, and Mastodon). - Events: ability to create, view, and comment on Events (on Rebased). @@ -20,6 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Theme: auto-detect system theme by default. - Profile: remove a specific user from your followers (on Rebased, Mastodon). - Suggestions: ability to view all suggested profiles. +- Feeds: display suggested accounts in Home feed (optional by admin). - Compatibility: added compatibility with Truth Social, Fedibird, Pixelfed, Akkoma, and Glitch. - Developers: added Test feed, Service Worker debugger, and Network Error preview. - Reports: display server rules in reports. Let users select rule violations when submitting a report. @@ -27,6 +89,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Admin: custom badges. Admins can add non-federating badges to any user's profile (on Rebased, Pleroma). - Admin: consolidated user dropdown actions (verify/suggest/etc) into a unified "Moderate User" modal. - i18n: updated translations for Italian, Polish, Arabic, Hebrew, and German. +- Toast: added the ability to dismiss toast notifications. ### Changed - UI: the whole UI has been overhauled both inside and out. 97% of the codebase has been rewritten to TypeScript, and a new component library has been introduced with Tailwind CSS. @@ -37,12 +100,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Posts: move instance favicon beside username instead of post timestamp. - Posts: changed the behavior of content warnings. CWs and sensitive media are unified into one design. - Posts: redesigned interaction counters to use text instead of icons. +- Posts: letterbox images taller than 1:1. - Profile: overhauled user profiles to be consistent with the rest of the UI. - Composer: move emoji button alongside other composer buttons, add numerical counter. - Birthdays: move today's birthdays out of notifications into right sidebar. - Performance: improve scrolling/navigation between feeds by using a virtual window library. - Admin: reorganize UI into 3-column layout. - Admin: include external link to frontend repo for the running commit. +- Toast: redesigned toast notifications. ### Removed - Theme: Halloween theme. diff --git a/README.md b/README.md index 677995a86..2504de278 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ One disadvantage of this approach is that it does not help the software spread. © Alex Gleason & other Soapbox contributors © Eugen Rochko & other Mastodon contributors +© Trump Media & Technology Group © Gab AI, Inc. Soapbox is free software: you can redistribute it and/or modify diff --git a/app/application.ts b/app/application.ts deleted file mode 100644 index 9610b4f9a..000000000 --- a/app/application.ts +++ /dev/null @@ -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); -}); diff --git a/app/assets/fonts/OpenDyslexic/LICENSE b/app/assets/fonts/OpenDyslexic/LICENSE deleted file mode 100644 index bb867823f..000000000 --- a/app/assets/fonts/OpenDyslexic/LICENSE +++ /dev/null @@ -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. diff --git a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 b/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 deleted file mode 100644 index aa7bcdea9..000000000 Binary files a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 and /dev/null differ diff --git a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold.woff2 b/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold.woff2 deleted file mode 100644 index 2f04ad119..000000000 Binary files a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold.woff2 and /dev/null differ diff --git a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Italic.woff2 b/app/assets/fonts/OpenDyslexic/OpenDyslexic-Italic.woff2 deleted file mode 100644 index 00c19082d..000000000 Binary files a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Italic.woff2 and /dev/null differ diff --git a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Regular.woff2 b/app/assets/fonts/OpenDyslexic/OpenDyslexic-Regular.woff2 deleted file mode 100644 index 47e26d82a..000000000 Binary files a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Regular.woff2 and /dev/null differ diff --git a/app/index.ejs b/app/index.ejs index b0d109e1f..e7c9b7330 100644 --- a/app/index.ejs +++ b/app/index.ejs @@ -5,7 +5,6 @@ - <%= snippets %> diff --git a/app/soapbox/__tests__/toast.test.tsx b/app/soapbox/__tests__/toast.test.tsx new file mode 100644 index 000000000..4c38755e2 --- /dev/null +++ b/app/soapbox/__tests__/toast.test.tsx @@ -0,0 +1,166 @@ +import { render } from '@testing-library/react'; +import { AxiosError } from 'axios'; +import React from 'react'; +import { IntlProvider } from 'react-intl'; + +import { act, screen } from 'soapbox/jest/test-helpers'; + +function renderApp() { + const { Toaster } = require('react-hot-toast'); + const toast = require('../toast').default; + + return { + toast, + ...render( + + , + , + ), + }; +} + +beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + (console.error as any).mockClear(); +}); + +afterAll(() => { + (console.error as any).mockRestore(); +}); + +describe('toasts', () =>{ + it('renders successfully', async() => { + const { toast } = renderApp(); + + act(() => { + toast.success('hello'); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent('hello'); + }); + + describe('actionable button', () => { + it('renders the button', async() => { + const { toast } = renderApp(); + + act(() => { + toast.success('hello', { action: () => null, actionLabel: 'click me' }); + }); + + expect(screen.getByTestId('toast-action')).toHaveTextContent('click me'); + }); + + it('does not render the button', async() => { + const { toast } = renderApp(); + + act(() => { + toast.success('hello'); + }); + + expect(screen.queryAllByTestId('toast-action')).toHaveLength(0); + }); + }); + + describe('showAlertForError()', () => { + const buildError = (message: string, status: number) => new AxiosError(message, String(status), undefined, null, { + data: { + error: message, + }, + statusText: String(status), + status, + headers: {}, + config: {}, + }); + + describe('with a 502 status code', () => { + it('renders the correct message', async() => { + const message = 'The server is down'; + const error = buildError(message, 502); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent('The server is down'); + }); + }); + + describe('with a 404 status code', () => { + it('renders the correct message', async() => { + const error = buildError('', 404); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.queryAllByTestId('toast')).toHaveLength(0); + }); + }); + + describe('with a 410 status code', () => { + it('renders the correct message', async() => { + const error = buildError('', 410); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.queryAllByTestId('toast')).toHaveLength(0); + }); + }); + + describe('with an accepted status code', () => { + describe('with a message from the server', () => { + it('renders the correct message', async() => { + const message = 'custom message'; + const error = buildError(message, 200); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent(message); + }); + }); + + describe('without a message from the server', () => { + it('renders the correct message', async() => { + const message = 'The request has been accepted for processing'; + const error = buildError(message, 202); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent(message); + }); + }); + }); + + describe('without a response', () => { + it('renders the default message', async() => { + const error = new AxiosError(); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent('An unexpected error occurred.'); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/actions/__tests__/alerts.test.ts b/app/soapbox/actions/__tests__/alerts.test.ts deleted file mode 100644 index 5f1f9f4d6..000000000 --- a/app/soapbox/actions/__tests__/alerts.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { AxiosError } from 'axios'; - -import { mockStore, rootState } from 'soapbox/jest/test-helpers'; - -import { dismissAlert, showAlert, showAlertForError } from '../alerts'; - -const buildError = (message: string, status: number) => new AxiosError(message, String(status), undefined, null, { - data: { - error: message, - }, - statusText: String(status), - status, - headers: {}, - config: {}, -}); - -let store: ReturnType; - -beforeEach(() => { - const state = rootState; - store = mockStore(state); -}); - -describe('dismissAlert()', () => { - it('dispatches the proper actions', async() => { - const alert = 'hello world'; - const expectedActions = [ - { type: 'ALERT_DISMISS', alert }, - ]; - await store.dispatch(dismissAlert(alert as any)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); -}); - -describe('showAlert()', () => { - it('dispatches the proper actions', async() => { - const title = 'title'; - const message = 'msg'; - const severity = 'info'; - const expectedActions = [ - { type: 'ALERT_SHOW', title, message, severity }, - ]; - await store.dispatch(showAlert(title, message, severity)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); -}); - -describe('showAlert()', () => { - describe('with a 502 status code', () => { - it('dispatches the proper actions', async() => { - const message = 'The server is down'; - const error = buildError(message, 502); - - const expectedActions = [ - { type: 'ALERT_SHOW', title: '', message, severity: 'error' }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('with a 404 status code', () => { - it('dispatches the proper actions', async() => { - const error = buildError('', 404); - - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual([]); - }); - }); - - describe('with a 410 status code', () => { - it('dispatches the proper actions', async() => { - const error = buildError('', 410); - - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual([]); - }); - }); - - describe('with an accepted status code', () => { - describe('with a message from the server', () => { - it('dispatches the proper actions', async() => { - const message = 'custom message'; - const error = buildError(message, 200); - - const expectedActions = [ - { type: 'ALERT_SHOW', title: '', message, severity: 'error' }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('without a message from the server', () => { - it('dispatches the proper actions', async() => { - const message = 'The request has been accepted for processing'; - const error = buildError(message, 202); - - const expectedActions = [ - { type: 'ALERT_SHOW', title: '', message, severity: 'error' }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - }); - - describe('without a response', () => { - it('dispatches the proper actions', async() => { - const error = new AxiosError(); - - const expectedActions = [ - { - type: 'ALERT_SHOW', - title: { - defaultMessage: 'Oops!', - id: 'alert.unexpected.title', - }, - message: { - defaultMessage: 'An unexpected error occurred.', - id: 'alert.unexpected.message', - }, - severity: 'error', - }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); diff --git a/app/soapbox/actions/__tests__/compose.test.ts b/app/soapbox/actions/__tests__/compose.test.ts index 1579d63c9..58f83e537 100644 --- a/app/soapbox/actions/__tests__/compose.test.ts +++ b/app/soapbox/actions/__tests__/compose.test.ts @@ -46,13 +46,6 @@ describe('uploadCompose()', () => { const expectedActions = [ { type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true }, - { - type: 'ALERT_SHOW', - message: 'Image exceeds the current file size limit (10 Bytes)', - actionLabel: undefined, - actionLink: undefined, - severity: 'error', - }, { type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true }, ]; @@ -99,13 +92,6 @@ describe('uploadCompose()', () => { const expectedActions = [ { type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true }, - { - type: 'ALERT_SHOW', - message: 'Video exceeds the current file size limit (10 Bytes)', - actionLabel: undefined, - actionLink: undefined, - severity: 'error', - }, { type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true }, ]; 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/accounts.ts b/app/soapbox/actions/accounts.ts index 14db17275..b4a54201f 100644 --- a/app/soapbox/actions/accounts.ts +++ b/app/soapbox/actions/accounts.ts @@ -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'; 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/alerts.ts b/app/soapbox/actions/alerts.ts deleted file mode 100644 index 8f200563a..000000000 --- a/app/soapbox/actions/alerts.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { defineMessages, MessageDescriptor } from 'react-intl'; - -import { httpErrorMessages } from 'soapbox/utils/errors'; - -import type { SnackbarActionSeverity } from './snackbar'; -import type { AnyAction } from '@reduxjs/toolkit'; -import type { AxiosError } from 'axios'; -import type { NotificationObject } from 'react-notification'; - -const messages = defineMessages({ - unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, - unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, -}); - -export const ALERT_SHOW = 'ALERT_SHOW'; -export const ALERT_DISMISS = 'ALERT_DISMISS'; -export const ALERT_CLEAR = 'ALERT_CLEAR'; - -const noOp = () => { }; - -function dismissAlert(alert: NotificationObject) { - return { - type: ALERT_DISMISS, - alert, - }; -} - -function showAlert( - title: MessageDescriptor | string = messages.unexpectedTitle, - message: MessageDescriptor | string = messages.unexpectedMessage, - severity: SnackbarActionSeverity = 'info', -) { - return { - type: ALERT_SHOW, - title, - message, - severity, - }; -} - -const showAlertForError = (error: AxiosError) => (dispatch: React.Dispatch, _getState: any) => { - if (error?.response) { - const { data, status, statusText } = error.response; - - if (status === 502) { - return dispatch(showAlert('', 'The server is down', 'error')); - } - - if (status === 404 || status === 410) { - // Skip these errors as they are reflected in the UI - return dispatch(noOp as any); - } - - let message: string | undefined = statusText; - - if (data?.error) { - message = data.error; - } - - if (!message) { - message = httpErrorMessages.find((httpError) => httpError.code === status)?.description; - } - - return dispatch(showAlert('', message, 'error')); - } else { - console.error(error); - return dispatch(showAlert(undefined, undefined, 'error')); - } -}; - -export { - dismissAlert, - showAlert, - showAlertForError, -}; diff --git a/app/soapbox/actions/aliases.ts b/app/soapbox/actions/aliases.ts index 8361e31ad..3a5b61163 100644 --- a/app/soapbox/actions/aliases.ts +++ b/app/soapbox/actions/aliases.ts @@ -1,14 +1,13 @@ import { defineMessages } from 'react-intl'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures } from 'soapbox/utils/features'; import api from '../api'; -import { showAlertForError } from './alerts'; import { importFetchedAccounts } from './importer'; import { patchMeSuccess } from './me'; -import snackbar from './snackbar'; import type { AxiosError } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; @@ -80,7 +79,7 @@ const fetchAliasesSuggestions = (q: string) => api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { dispatch(importFetchedAccounts(data)); dispatch(fetchAliasesSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); + }).catch(error => toast.showAlertForError(error)); }; const fetchAliasesSuggestionsReady = (query: string, accounts: APIEntity[]) => ({ @@ -114,7 +113,7 @@ const addToAliases = (account: Account) => api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, account.pleroma.get('ap_id')] }) .then((response => { - dispatch(snackbar.success(messages.createSuccess)); + toast.success(messages.createSuccess); dispatch(addToAliasesSuccess); dispatch(patchMeSuccess(response.data)); })) @@ -129,7 +128,7 @@ const addToAliases = (account: Account) => alias: account.acct, }) .then(() => { - dispatch(snackbar.success(messages.createSuccess)); + toast.success(messages.createSuccess); dispatch(addToAliasesSuccess); dispatch(fetchAliases); }) @@ -165,7 +164,7 @@ const removeFromAliases = (account: string) => api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: alsoKnownAs.filter((id: string) => id !== account) }) .then(response => { - dispatch(snackbar.success(messages.removeSuccess)); + toast.success(messages.removeSuccess); dispatch(removeFromAliasesSuccess); dispatch(patchMeSuccess(response.data)); }) @@ -182,7 +181,7 @@ const removeFromAliases = (account: string) => }, }) .then(response => { - dispatch(snackbar.success(messages.removeSuccess)); + toast.success(messages.removeSuccess); dispatch(removeFromAliasesSuccess); dispatch(fetchAliases); }) diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index 39a6c7c81..06fe848e2 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -14,14 +14,14 @@ import { createApp } from 'soapbox/actions/apps'; import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me'; import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth'; import { startOnboarding } from 'soapbox/actions/onboarding'; -import snackbar from 'soapbox/actions/snackbar'; import { custom } from 'soapbox/custom'; import { queryClient } from 'soapbox/queries/client'; import KVStore from 'soapbox/storage/kv-store'; +import toast from 'soapbox/toast'; import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth'; import sourceCode from 'soapbox/utils/code'; -import { getFeatures } from 'soapbox/utils/features'; import { normalizeUsername } from 'soapbox/utils/input'; +import { getScopes } from 'soapbox/utils/scopes'; import { isStandalone } from 'soapbox/utils/state'; import api, { baseClient } from '../api'; @@ -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'; @@ -51,17 +50,12 @@ const customApp = custom('app'); export const messages = defineMessages({ loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' }, + awaitingApproval: { id: 'auth.awaiting_approval', defaultMessage: 'Your account is awaiting approval' }, invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' }, }); const noOp = () => new Promise(f => f(undefined)); -const getScopes = (state: RootState) => { - const instance = state.instance; - const { scopes } = getFeatures(instance); - return scopes; -}; - const createAppAndToken = () => (dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => @@ -94,11 +88,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 +105,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 +121,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,12 +185,14 @@ 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 if ((error.response?.data as any)?.identifier === 'awaiting_approval') { + toast.error(messages.awaitingApproval); } else { // Return "wrong password" message. - dispatch(snackbar.error(messages.invalidCredentials)); + toast.error(messages.invalidCredentials); } throw error; }); @@ -233,9 +209,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)) @@ -246,7 +222,7 @@ export const logOut = () => dispatch({ type: AUTH_LOGGED_OUT, account, standalone }); - return dispatch(snackbar.success(messages.loggedOut)); + toast.success(messages.loggedOut); }); }; @@ -263,10 +239,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/chats.ts b/app/soapbox/actions/chats.ts index 67b796408..f4ca85abe 100644 --- a/app/soapbox/actions/chats.ts +++ b/app/soapbox/actions/chats.ts @@ -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'; diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index 98c369bb9..75ff84ae5 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -3,16 +3,15 @@ import { List as ImmutableList } from 'immutable'; import throttle from 'lodash/throttle'; import { defineMessages, IntlShape } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; import api from 'soapbox/api'; import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light'; import { tagHistory } from 'soapbox/settings'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures, parseVersion } from 'soapbox/utils/features'; import { formatBytes, getVideoDuration } from 'soapbox/utils/media'; import resizeImage from 'soapbox/utils/resize-image'; -import { showAlert, showAlertForError } from './alerts'; import { useEmoji } from './emojis'; import { importFetchedAccounts } from './importer'; import { uploadMedia, fetchMedia, updateMedia } from './media'; @@ -20,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; @@ -88,13 +87,13 @@ const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; const messages = defineMessages({ exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' }, exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' }, - exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit} seconds)' }, + exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit, plural, one {# second} other {# seconds}})' }, scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' }, success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' }, editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' }, uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, - view: { id: 'snackbar.view', defaultMessage: 'View' }, + view: { id: 'toast.view', defaultMessage: 'View' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, }); @@ -212,7 +211,10 @@ const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, c dispatch(insertIntoTagHistory(composeId, data.tags || [], status)); dispatch(submitComposeSuccess(composeId, { ...data })); - dispatch(snackbar.success(edit ? messages.editSuccess : messages.success, messages.view, `/@${data.account.acct}/posts/${data.id}`)); + toast.success(edit ? messages.editSuccess : messages.success, { + actionLabel: messages.view, + actionLink: `/@${data.account.acct}/posts/${data.id}`, + }); }; const needsDescriptions = (state: RootState, composeId: string) => { @@ -246,7 +248,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false let to = compose.to; if (!validateSchedule(state, composeId)) { - dispatch(snackbar.error(messages.scheduleError)); + toast.error(messages.scheduleError); return; } @@ -332,7 +334,7 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => const mediaCount = media ? media.size : 0; if (files.length + mediaCount > attachmentLimit) { - dispatch(showAlert(undefined, messages.uploadErrorLimit, 'error')); + toast.error(messages.uploadErrorLimit); return; } @@ -348,18 +350,18 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => if (isImage && maxImageSize && (f.size > maxImageSize)) { const limit = formatBytes(maxImageSize); const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); - dispatch(snackbar.error(message)); + toast.error(message); dispatch(uploadComposeFail(composeId, true)); return; } else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) { const limit = formatBytes(maxVideoSize); const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit }); - dispatch(snackbar.error(message)); + toast.error(message); dispatch(uploadComposeFail(composeId, true)); return; } else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) { const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration }); - dispatch(snackbar.error(message)); + toast.error(message); dispatch(uploadComposeFail(composeId, true)); return; } @@ -507,7 +509,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, dispatch(readyComposeSuggestionsAccounts(composeId, token, response.data)); }).catch(error => { if (!isCancel(error)) { - dispatch(showAlertForError(error)); + toast.showAlertForError(error); } }); }, 200, { leading: true, trailing: true }); diff --git a/app/soapbox/actions/consumer-auth.ts b/app/soapbox/actions/consumer-auth.ts index 171941577..72c928dba 100644 --- a/app/soapbox/actions/consumer-auth.ts +++ b/app/soapbox/actions/consumer-auth.ts @@ -3,7 +3,7 @@ import axios from 'axios'; import * as BuildConfig from 'soapbox/build-config'; import { isURL } from 'soapbox/utils/auth'; import sourceCode from 'soapbox/utils/code'; -import { getFeatures } from 'soapbox/utils/features'; +import { getScopes } from 'soapbox/utils/scopes'; import { createApp } from './apps'; @@ -11,8 +11,7 @@ import type { AppDispatch, RootState } from 'soapbox/store'; const createProviderApp = () => { return async(dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const { scopes } = getFeatures(state.instance); + const scopes = getScopes(getState()); const params = { client_name: sourceCode.displayName, @@ -29,8 +28,7 @@ export const prepareRequest = (provider: string) => { return async(dispatch: AppDispatch, getState: () => RootState) => { const baseURL = isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : ''; - const state = getState(); - const { scopes } = getFeatures(state.instance); + const scopes = getScopes(getState()); const app = await dispatch(createProviderApp()); const { client_id, redirect_uri } = app; diff --git a/app/soapbox/actions/events.ts b/app/soapbox/actions/events.ts index 55bf6eec3..d4ec49491 100644 --- a/app/soapbox/actions/events.ts +++ b/app/soapbox/actions/events.ts @@ -1,13 +1,13 @@ import { defineMessages, IntlShape } from 'react-intl'; import api, { getLinks } from 'soapbox/api'; +import toast from 'soapbox/toast'; import { formatBytes } from 'soapbox/utils/media'; import resizeImage from 'soapbox/utils/resize-image'; import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer'; import { fetchMedia, uploadMedia } from './media'; import { closeModal, openModal } from './modals'; -import snackbar from './snackbar'; import { STATUS_FETCH_SOURCE_FAIL, STATUS_FETCH_SOURCE_REQUEST, @@ -91,7 +91,7 @@ const messages = defineMessages({ editSuccess: { id: 'compose_event.edit_success', defaultMessage: 'Your event was edited' }, joinSuccess: { id: 'join_event.success', defaultMessage: 'Joined the event' }, joinRequestSuccess: { id: 'join_event.request_success', defaultMessage: 'Requested to join the event' }, - view: { id: 'snackbar.view', defaultMessage: 'View' }, + view: { id: 'toast.view', defaultMessage: 'View' }, authorized: { id: 'compose_event.participation_requests.authorize_success', defaultMessage: 'User accepted' }, rejected: { id: 'compose_event.participation_requests.reject_success', defaultMessage: 'User rejected' }, }); @@ -163,7 +163,7 @@ const uploadEventBanner = (file: File, intl: IntlShape) => if (maxImageSize && (file.size > maxImageSize)) { const limit = formatBytes(maxImageSize); const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); - dispatch(snackbar.error(message)); + toast.error(message); dispatch(uploadEventBannerFail(true)); return; } @@ -264,7 +264,13 @@ const submitEvent = () => dispatch(closeModal('COMPOSE_EVENT')); dispatch(importFetchedStatus(data)); dispatch(submitEventSuccess(data)); - dispatch(snackbar.success(id ? messages.editSuccess : messages.success, messages.view, `/@${data.account.acct}/events/${data.id}`)); + toast.success( + id ? messages.editSuccess : messages.success, + { + actionLabel: messages.view, + actionLink: `/@${data.account.acct}/events/${data.id}`, + }, + ); }).catch(function(error) { dispatch(submitEventFail(error)); }); @@ -299,11 +305,13 @@ const joinEvent = (id: string, participationMessage?: string) => }).then(({ data }) => { dispatch(importFetchedStatus(data)); dispatch(joinEventSuccess(data)); - dispatch(snackbar.success( + toast.success( data.pleroma.event?.join_state === 'pending' ? messages.joinRequestSuccess : messages.joinSuccess, - messages.view, - `/@${data.account.acct}/events/${data.id}`, - )); + { + actionLabel: messages.view, + actionLink: `/@${data.account.acct}/events/${data.id}`, + }, + ); }).catch(function(error) { dispatch(joinEventFail(error, status, status?.event?.join_state || null)); }); @@ -504,7 +512,7 @@ const authorizeEventParticipationRequest = (id: string, accountId: string) => .post(`/api/v1/pleroma/events/${id}/participation_requests/${accountId}/authorize`) .then(() => { dispatch(authorizeEventParticipationRequestSuccess(id, accountId)); - dispatch(snackbar.success(messages.authorized)); + toast.success(messages.authorized); }) .catch(error => dispatch(authorizeEventParticipationRequestFail(id, accountId, error))); }; @@ -536,7 +544,7 @@ const rejectEventParticipationRequest = (id: string, accountId: string) => .post(`/api/v1/pleroma/events/${id}/participation_requests/${accountId}/reject`) .then(() => { dispatch(rejectEventParticipationRequestSuccess(id, accountId)); - dispatch(snackbar.success(messages.rejected)); + toast.success(messages.rejected); }) .catch(error => dispatch(rejectEventParticipationRequestFail(id, accountId, error))); }; diff --git a/app/soapbox/actions/export-data.ts b/app/soapbox/actions/export-data.ts index b558c9e6e..1ddab9103 100644 --- a/app/soapbox/actions/export-data.ts +++ b/app/soapbox/actions/export-data.ts @@ -1,10 +1,9 @@ import { defineMessages } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; import api, { getLinks } from 'soapbox/api'; import { normalizeAccount } from 'soapbox/normalizers'; +import toast from 'soapbox/toast'; -import type { SnackbarAction } from './snackbar'; import type { AxiosResponse } from 'axios'; import type { RootState } from 'soapbox/store'; @@ -37,7 +36,7 @@ type ExportDataActions = { | typeof EXPORT_MUTES_SUCCESS | typeof EXPORT_MUTES_FAIL, error?: any, -} | SnackbarAction +} function fileExport(content: string, fileName: string) { const fileToDownload = document.createElement('a'); @@ -75,7 +74,7 @@ export const exportFollows = () => (dispatch: React.Dispatch, followings.unshift('Account address,Show boosts'); fileExport(followings.join('\n'), 'export_followings.csv'); - dispatch(snackbar.success(messages.followersSuccess)); + toast.success(messages.followersSuccess); dispatch({ type: EXPORT_FOLLOWS_SUCCESS }); }).catch(error => { dispatch({ type: EXPORT_FOLLOWS_FAIL, error }); @@ -90,7 +89,7 @@ export const exportBlocks = () => (dispatch: React.Dispatch, .then((blocks) => { fileExport(blocks.join('\n'), 'export_block.csv'); - dispatch(snackbar.success(messages.blocksSuccess)); + toast.success(messages.blocksSuccess); dispatch({ type: EXPORT_BLOCKS_SUCCESS }); }).catch(error => { dispatch({ type: EXPORT_BLOCKS_FAIL, error }); @@ -105,7 +104,7 @@ export const exportMutes = () => (dispatch: React.Dispatch, g .then((mutes) => { fileExport(mutes.join('\n'), 'export_mutes.csv'); - dispatch(snackbar.success(messages.mutesSuccess)); + toast.success(messages.mutesSuccess); dispatch({ type: EXPORT_MUTES_SUCCESS }); }).catch(error => { dispatch({ type: EXPORT_MUTES_FAIL, error }); diff --git a/app/soapbox/actions/external-auth.ts b/app/soapbox/actions/external-auth.ts index 064e100c9..87840cfe7 100644 --- a/app/soapbox/actions/external-auth.ts +++ b/app/soapbox/actions/external-auth.ts @@ -15,10 +15,11 @@ import sourceCode from 'soapbox/utils/code'; import { getWalletAndSign } from 'soapbox/utils/ethereum'; import { getFeatures } from 'soapbox/utils/features'; import { getQuirks } from 'soapbox/utils/quirks'; +import { getInstanceScopes } from 'soapbox/utils/scopes'; import { baseClient } from '../api'; -import type { AppDispatch } from 'soapbox/store'; +import type { AppDispatch, RootState } from 'soapbox/store'; import type { Instance } from 'soapbox/types/entities'; const fetchExternalInstance = (baseURL?: string) => { @@ -37,25 +38,23 @@ const fetchExternalInstance = (baseURL?: string) => { }; const createExternalApp = (instance: Instance, baseURL?: string) => - (dispatch: AppDispatch) => { + (dispatch: AppDispatch, _getState: () => RootState) => { // Mitra: skip creating the auth app if (getQuirks(instance).noApps) return new Promise(f => f({})); - const { scopes } = getFeatures(instance); - const params = { - client_name: sourceCode.displayName, + client_name: sourceCode.displayName, redirect_uris: `${window.location.origin}/login/external`, - website: sourceCode.homepage, - scopes, + website: sourceCode.homepage, + scopes: getInstanceScopes(instance), }; return dispatch(createApp(params, baseURL)); }; const externalAuthorize = (instance: Instance, baseURL: string) => - (dispatch: AppDispatch) => { - const { scopes } = getFeatures(instance); + (dispatch: AppDispatch, _getState: () => RootState) => { + const scopes = getInstanceScopes(instance); return dispatch(createExternalApp(instance, baseURL)).then((app) => { const { client_id, redirect_uri } = app as Record; @@ -76,7 +75,7 @@ const externalAuthorize = (instance: Instance, baseURL: string) => }; const externalEthereumLogin = (instance: Instance, baseURL?: string) => - (dispatch: AppDispatch) => { + (dispatch: AppDispatch, getState: () => RootState) => { const loginMessage = instance.login_message; return getWalletAndSign(loginMessage).then(({ wallet, signature }) => { @@ -89,7 +88,7 @@ const externalEthereumLogin = (instance: Instance, baseURL?: string) => client_secret: client_secret, password: signature as string, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', - scope: getFeatures(instance).scopes, + scope: getInstanceScopes(instance), }; return dispatch(obtainOAuthToken(params, baseURL)) diff --git a/app/soapbox/actions/filters.ts b/app/soapbox/actions/filters.ts index c0f79c6b8..7e663f88d 100644 --- a/app/soapbox/actions/filters.ts +++ b/app/soapbox/actions/filters.ts @@ -1,6 +1,6 @@ import { defineMessages } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures } from 'soapbox/utils/features'; @@ -66,7 +66,7 @@ const createFilter = (phrase: string, expires_at: string, context: Array expires_at, }).then(response => { dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); - dispatch(snackbar.success(messages.added)); + toast.success(messages.added); }).catch(error => { dispatch({ type: FILTERS_CREATE_FAIL, error }); }); @@ -77,7 +77,7 @@ const deleteFilter = (id: string) => dispatch({ type: FILTERS_DELETE_REQUEST }); return api(getState).delete(`/api/v1/filters/${id}`).then(response => { dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data }); - dispatch(snackbar.success(messages.removed)); + toast.success(messages.removed); }).catch(error => { dispatch({ type: FILTERS_DELETE_FAIL, error }); }); diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts index 3c115b425..5f63a8b51 100644 --- a/app/soapbox/actions/groups.ts +++ b/app/soapbox/actions/groups.ts @@ -1,11 +1,12 @@ import { defineMessages } from 'react-intl'; +import toast from 'soapbox/toast'; + import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedGroups, importFetchedAccounts } from './importer'; import { closeModal, openModal } from './modals'; -import snackbar from './snackbar'; import { deleteFromTimelines } from './timelines'; import type { AxiosError } from 'axios'; @@ -139,7 +140,10 @@ const createGroup = (params: Record, shouldReset?: boolean) => .then(({ data }) => { dispatch(importFetchedGroups([data])); dispatch(createGroupSuccess(data)); - dispatch(snackbar.success(messages.success, messages.view, `/groups/${data.id}`)); + toast.success(messages.success, { + actionLabel: messages.view, + actionLink: `/groups/${data.id}`, + }); if (shouldReset) { dispatch(resetGroupEditor()); @@ -170,7 +174,7 @@ const updateGroup = (id: string, params: Record, shouldReset?: bool .then(({ data }) => { dispatch(importFetchedGroups([data])); dispatch(updateGroupSuccess(data)); - dispatch(snackbar.success(messages.editSuccess)); + toast.success(messages.editSuccess); if (shouldReset) { dispatch(resetGroupEditor()); @@ -316,7 +320,7 @@ const joinGroup = (id: string) => return api(getState).post(`/api/v1/groups/${id}/join`).then(response => { dispatch(joinGroupSuccess(response.data)); - dispatch(snackbar.success(locked ? messages.joinRequestSuccess : messages.joinSuccess)); + toast.success(locked ? messages.joinRequestSuccess : messages.joinSuccess); }).catch(error => { dispatch(joinGroupFail(error, locked)); }); @@ -328,7 +332,7 @@ const leaveGroup = (id: string) => return api(getState).post(`/api/v1/groups/${id}/leave`).then(response => { dispatch(leaveGroupSuccess(response.data)); - dispatch(snackbar.success(messages.leaveSuccess)); + toast.success(messages.leaveSuccess); }).catch(error => { dispatch(leaveGroupFail(error)); }); diff --git a/app/soapbox/actions/import-data.ts b/app/soapbox/actions/import-data.ts index 43de9f85c..90f81e7e7 100644 --- a/app/soapbox/actions/import-data.ts +++ b/app/soapbox/actions/import-data.ts @@ -1,10 +1,9 @@ import { defineMessages } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; +import toast from 'soapbox/toast'; import api from '../api'; -import type { SnackbarAction } from './snackbar'; import type { RootState } from 'soapbox/store'; export const IMPORT_FOLLOWS_REQUEST = 'IMPORT_FOLLOWS_REQUEST'; @@ -31,7 +30,7 @@ type ImportDataActions = { | typeof IMPORT_MUTES_FAIL, error?: any, config?: string -} | SnackbarAction +} const messages = defineMessages({ blocksSuccess: { id: 'import_data.success.blocks', defaultMessage: 'Blocks imported successfully' }, @@ -45,7 +44,7 @@ export const importFollows = (params: FormData) => return api(getState) .post('/api/pleroma/follow_import', params) .then(response => { - dispatch(snackbar.success(messages.followersSuccess)); + toast.success(messages.followersSuccess); dispatch({ type: IMPORT_FOLLOWS_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_FOLLOWS_FAIL, error }); @@ -58,7 +57,7 @@ export const importBlocks = (params: FormData) => return api(getState) .post('/api/pleroma/blocks_import', params) .then(response => { - dispatch(snackbar.success(messages.blocksSuccess)); + toast.success(messages.blocksSuccess); dispatch({ type: IMPORT_BLOCKS_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_BLOCKS_FAIL, error }); @@ -71,7 +70,7 @@ export const importMutes = (params: FormData) => return api(getState) .post('/api/pleroma/mutes_import', params) .then(response => { - dispatch(snackbar.success(messages.mutesSuccess)); + toast.success(messages.mutesSuccess); dispatch({ type: IMPORT_MUTES_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_MUTES_FAIL, error }); 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/interactions.ts b/app/soapbox/actions/interactions.ts index cb23f3dae..9e43d0f40 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -1,6 +1,6 @@ import { defineMessages } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import api from '../api'; @@ -63,7 +63,7 @@ const REMOTE_INTERACTION_FAIL = 'REMOTE_INTERACTION_FAIL'; const messages = defineMessages({ bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' }, bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' }, - view: { id: 'snackbar.view', defaultMessage: 'View' }, + view: { id: 'toast.view', defaultMessage: 'View' }, }); const reblog = (status: StatusEntity) => @@ -222,7 +222,10 @@ const bookmark = (status: StatusEntity) => api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function(response) { dispatch(importFetchedStatus(response.data)); dispatch(bookmarkSuccess(status, response.data)); - dispatch(snackbar.success(messages.bookmarkAdded, messages.view, '/bookmarks')); + toast.success(messages.bookmarkAdded, { + actionLabel: messages.view, + actionLink: '/bookmarks', + }); }).catch(function(error) { dispatch(bookmarkFail(status, error)); }); @@ -235,7 +238,7 @@ const unbookmark = (status: StatusEntity) => api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unbookmarkSuccess(status, response.data)); - dispatch(snackbar.success(messages.bookmarkRemoved)); + toast.success(messages.bookmarkRemoved); }).catch(error => { dispatch(unbookmarkFail(status, error)); }); diff --git a/app/soapbox/actions/lists.ts b/app/soapbox/actions/lists.ts index bf7dba8ba..216fae669 100644 --- a/app/soapbox/actions/lists.ts +++ b/app/soapbox/actions/lists.ts @@ -1,8 +1,8 @@ +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import api from '../api'; -import { showAlertForError } from './alerts'; import { importFetchedAccounts } from './importer'; import type { AxiosError } from 'axios'; @@ -265,7 +265,7 @@ const fetchListSuggestions = (q: string) => (dispatch: AppDispatch, getState: () api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { dispatch(importFetchedAccounts(data)); dispatch(fetchListSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); + }).catch(error => toast.showAlertForError(error)); }; const fetchListSuggestionsReady = (query: string, accounts: APIEntity[]) => ({ 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/moderation.tsx b/app/soapbox/actions/moderation.tsx index 1791500b5..5b0a4a5f2 100644 --- a/app/soapbox/actions/moderation.tsx +++ b/app/soapbox/actions/moderation.tsx @@ -4,10 +4,10 @@ import { defineMessages, IntlShape } from 'react-intl'; import { fetchAccountByUsername } from 'soapbox/actions/accounts'; import { deactivateUsers, deleteUsers, deleteStatus, toggleStatusSensitivity } from 'soapbox/actions/admin'; import { openModal } from 'soapbox/actions/modals'; -import snackbar from 'soapbox/actions/snackbar'; import OutlineBox from 'soapbox/components/outline-box'; import { Stack, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account-container'; +import toast from 'soapbox/toast'; import { isLocal } from 'soapbox/utils/accounts'; import type { AppDispatch, RootState } from 'soapbox/store'; @@ -65,7 +65,7 @@ const deactivateUserModal = (intl: IntlShape, accountId: string, afterConfirm = onConfirm: () => { dispatch(deactivateUsers([accountId])).then(() => { const message = intl.formatMessage(messages.userDeactivated, { acct }); - dispatch(snackbar.success(message)); + toast.success(message); afterConfirm(); }).catch(() => {}); }, @@ -105,7 +105,7 @@ const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () = dispatch(deleteUsers([accountId])).then(() => { const message = intl.formatMessage(messages.userDeleted, { acct }); dispatch(fetchAccountByUsername(acct)); - dispatch(snackbar.success(message)); + toast.success(message); afterConfirm(); }).catch(() => {}); }, @@ -147,7 +147,7 @@ const toggleStatusSensitivityModal = (intl: IntlShape, statusId: string, sensiti onConfirm: () => { dispatch(toggleStatusSensitivity(statusId, sensitive)).then(() => { const message = intl.formatMessage(sensitive === false ? messages.statusMarkedSensitive : messages.statusMarkedNotSensitive, { acct }); - dispatch(snackbar.success(message)); + toast.success(message); }).catch(() => {}); afterConfirm(); }, @@ -168,7 +168,7 @@ const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = () onConfirm: () => { dispatch(deleteStatus(statusId)).then(() => { const message = intl.formatMessage(messages.statusDeleted, { acct }); - dispatch(snackbar.success(message)); + toast.success(message); }).catch(() => {}); afterConfirm(); }, diff --git a/app/soapbox/actions/notifications.ts b/app/soapbox/actions/notifications.ts index 6ac655143..7b91b64d8 100644 --- a/app/soapbox/actions/notifications.ts +++ b/app/soapbox/actions/notifications.ts @@ -47,7 +47,7 @@ const MAX_QUEUED_NOTIFICATIONS = 40; defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, - group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, + group: { id: 'notifications.group', defaultMessage: '{count, plural, one {# notification} other {# notifications}}' }, }); const fetchRelatedRelationships = (dispatch: AppDispatch, notifications: APIEntity[]) => { @@ -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 : ''); diff --git a/app/soapbox/actions/scheduled-statuses.ts b/app/soapbox/actions/scheduled-statuses.ts index ddc550105..33e763701 100644 --- a/app/soapbox/actions/scheduled-statuses.ts +++ b/app/soapbox/actions/scheduled-statuses.ts @@ -1,3 +1,5 @@ +import { getFeatures } from 'soapbox/utils/features'; + import api, { getLinks } from '../api'; import type { AxiosError } from 'axios'; @@ -18,10 +20,17 @@ const SCHEDULED_STATUS_CANCEL_FAIL = 'SCHEDULED_STATUS_CANCEL_FAIL'; const fetchScheduledStatuses = () => (dispatch: AppDispatch, getState: () => RootState) => { - if (getState().status_lists.get('scheduled_statuses')?.isLoading) { + const state = getState(); + + if (state.status_lists.get('scheduled_statuses')?.isLoading) { return; } + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.scheduledStatuses) return; + dispatch(fetchScheduledStatusesRequest()); api(getState).get('/api/v1/scheduled_statuses').then(response => { diff --git a/app/soapbox/actions/security.ts b/app/soapbox/actions/security.ts index 196e54dcb..4448104b7 100644 --- a/app/soapbox/actions/security.ts +++ b/app/soapbox/actions/security.ts @@ -4,7 +4,7 @@ * @see module:soapbox/actions/auth */ -import snackbar from 'soapbox/actions/snackbar'; +import toast from 'soapbox/toast'; import { getLoggedInAccount } from 'soapbox/utils/auth'; import { parseVersion, TRUTHSOCIAL } from 'soapbox/utils/features'; import { normalizeUsername } from 'soapbox/utils/input'; @@ -50,7 +50,7 @@ const MOVE_ACCOUNT_FAIL = 'MOVE_ACCOUNT_FAIL'; const fetchOAuthTokens = () => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: FETCH_TOKENS_REQUEST }); - return api(getState).get('/api/oauth_tokens.json').then(({ data: tokens }) => { + return api(getState).get('/api/oauth_tokens').then(({ data: tokens }) => { dispatch({ type: FETCH_TOKENS_SUCCESS, tokens }); }).catch(() => { dispatch({ type: FETCH_TOKENS_FAIL }); @@ -152,7 +152,7 @@ const deleteAccount = (password: string) => if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure dispatch({ type: DELETE_ACCOUNT_SUCCESS, response }); dispatch({ type: AUTH_LOGGED_OUT, account }); - dispatch(snackbar.success(messages.loggedOut)); + toast.success(messages.loggedOut); }).catch(error => { dispatch({ type: DELETE_ACCOUNT_FAIL, error, skipAlert: true }); throw error; diff --git a/app/soapbox/actions/settings.ts b/app/soapbox/actions/settings.ts index 91b4545c9..5f4a600d2 100644 --- a/app/soapbox/actions/settings.ts +++ b/app/soapbox/actions/settings.ts @@ -4,11 +4,9 @@ import { createSelector } from 'reselect'; import { v4 as uuid } from 'uuid'; import { patchMe } from 'soapbox/actions/me'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; -import { showAlertForError } from './alerts'; -import snackbar from './snackbar'; - import type { AppDispatch, RootState } from 'soapbox/store'; const SETTING_CHANGE = 'SETTING_CHANGE'; @@ -49,7 +47,6 @@ const defaultSettings = ImmutableMap({ autoloadMore: true, systemFont: false, - dyslexicFont: false, demetricator: false, isDeveloper: false, @@ -224,10 +221,10 @@ const saveSettingsImmediate = (opts?: SettingOpts) => dispatch({ type: SETTING_SAVE }); if (opts?.showAlert) { - dispatch(snackbar.success(messages.saveSuccess)); + toast.success(messages.saveSuccess); } }).catch(error => { - dispatch(showAlertForError(error)); + toast.showAlertForError(error); }); }; diff --git a/app/soapbox/actions/snackbar.ts b/app/soapbox/actions/snackbar.ts deleted file mode 100644 index 57d23b64b..000000000 --- a/app/soapbox/actions/snackbar.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ALERT_SHOW } from './alerts'; - -import type { MessageDescriptor } from 'react-intl'; - -export type SnackbarActionSeverity = 'info' | 'success' | 'error'; - -type SnackbarMessage = string | MessageDescriptor; - -export type SnackbarAction = { - type: typeof ALERT_SHOW, - message: SnackbarMessage, - actionLabel?: SnackbarMessage, - actionLink?: string, - action?: () => void, - severity: SnackbarActionSeverity, -}; - -type SnackbarOpts = { - actionLabel?: SnackbarMessage, - actionLink?: string, - action?: () => void, - dismissAfter?: number | false, -}; - -export const show = ( - severity: SnackbarActionSeverity, - message: SnackbarMessage, - opts?: SnackbarOpts, -): SnackbarAction => ({ - type: ALERT_SHOW, - message, - severity, - ...opts, -}); - -export const info = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) => - show('info', message, { actionLabel, actionLink }); - -export const success = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) => - show('success', message, { actionLabel, actionLink }); - -export const error = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) => - show('error', message, { actionLabel, actionLink }); - -export default { - info, - success, - error, - show, -}; 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/actions/trending-statuses.ts b/app/soapbox/actions/trending-statuses.ts index 435fcf6df..7ccab27ab 100644 --- a/app/soapbox/actions/trending-statuses.ts +++ b/app/soapbox/actions/trending-statuses.ts @@ -17,6 +17,8 @@ const fetchTrendingStatuses = () => const instance = state.instance; const features = getFeatures(instance); + if (!features.trendingStatuses && !features.trendingTruths) return; + dispatch({ type: TRENDING_STATUSES_FETCH_REQUEST }); return api(getState).get(features.trendingTruths ? '/api/v1/truth/trending/truths' : '/api/v1/trends/statuses').then(({ data: statuses }) => { dispatch(importFetchedStatuses(statuses)); diff --git a/app/soapbox/actions/verification.ts b/app/soapbox/actions/verification.ts index ce3d27009..d038a79a9 100644 --- a/app/soapbox/actions/verification.ts +++ b/app/soapbox/actions/verification.ts @@ -32,13 +32,14 @@ export type Challenge = 'age' | 'sms' | 'email' type Challenges = { email?: 0 | 1, - sms?: number, - age?: number, + sms?: 0 | 1, + age?: 0 | 1, } type Verification = { token?: string, challenges?: Challenges, + challengeTypes?: Array<'age' | 'sms' | 'email'> }; /** @@ -83,6 +84,18 @@ const fetchStoredChallenges = () => { } }; +/** + * Fetch and return the state of the verification challenge types. + */ +const fetchStoredChallengeTypes = () => { + try { + const verification: Verification | null = fetchStoredVerification(); + return verification!.challengeTypes; + } catch { + return null; + } +}; + /** * Update the verification object in local storage. * @@ -131,7 +144,10 @@ function saveChallenges(challenges: Array<'age' | 'sms' | 'email'>) { } } - updateStorage({ challenges: currentChallenges }); + updateStorage({ + challenges: currentChallenges, + challengeTypes: challenges, + }); } /** @@ -267,13 +283,29 @@ const confirmEmailVerification = (emailToken: string) => return api(getState).post('/api/v1/pepe/verify_email/confirm', { token: emailToken }, { headers: { Authorization: `Bearer ${token}` }, }) - .then(() => { - finishChallenge(EMAIL); - dispatchNextChallenge(dispatch); + .then((response) => { + updateStorageFromEmailConfirmation(dispatch, response.data.token); }) .finally(() => dispatch({ type: SET_LOADING, value: false })); }; +const updateStorageFromEmailConfirmation = (dispatch: AppDispatch, token: string) => { + const challengeTypes = fetchStoredChallengeTypes(); + if (!challengeTypes) { + return; + } + + const indexOfEmail = challengeTypes.indexOf('email'); + const challenges: Challenges = {}; + challengeTypes?.forEach((challengeType, idx) => { + const value = idx <= indexOfEmail ? 1 : 0; + challenges[challengeType] = value; + }); + + updateStorage({ token, challengeTypes, challenges }); + dispatchNextChallenge(dispatch); +}; + const postEmailVerification = () => (dispatch: AppDispatch) => { finishChallenge(EMAIL); 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/base-polyfills.ts b/app/soapbox/base-polyfills.ts deleted file mode 100644 index a6e92bb3c..000000000 --- a/app/soapbox/base-polyfills.ts +++ /dev/null @@ -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 })); - }, - }); -} 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 0fd843d6b..79bc03050 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { FormattedMessage } from 'react-intl'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper'; @@ -9,6 +9,7 @@ import { useAppSelector, useOnScreen } from 'soapbox/hooks'; import { getAcct } from 'soapbox/utils/accounts'; import { displayFqn } from 'soapbox/utils/state'; +import Badge from './badge'; import RelativeTimestamp from './relative-timestamp'; import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui'; @@ -17,14 +18,21 @@ import type { Account as AccountEntity } from 'soapbox/types/entities'; interface IInstanceFavicon { account: AccountEntity, + disabled?: boolean, } -const InstanceFavicon: React.FC = ({ account }) => { +const messages = defineMessages({ + bot: { id: 'account.badges.bot', defaultMessage: 'Bot' }, +}); + +const InstanceFavicon: React.FC = ({ 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); @@ -34,7 +42,11 @@ const InstanceFavicon: React.FC = ({ account }) => { }; return ( - ); @@ -42,13 +54,19 @@ const InstanceFavicon: React.FC = ({ account }) => { interface IProfilePopper { condition: boolean, - wrapper: (children: any) => React.ReactElement + wrapper: (children: React.ReactNode) => React.ReactNode + children: React.ReactNode } -const ProfilePopper: React.FC = ({ condition, wrapper, children }): any => - condition ? wrapper(children) : children; +const ProfilePopper: React.FC = ({ condition, wrapper, children }) => { + return ( + <> + {condition ? wrapper(children) : children} + + ); +}; -interface IAccount { +export interface IAccount { account: AccountEntity, action?: React.ReactElement, actionAlignment?: 'center' | 'top', @@ -142,6 +160,8 @@ const Account = ({ return null; }; + const intl = useIntl(); + React.useEffect(() => { const style: React.CSSProperties = {}; const actionWidth = actionRef.current?.clientWidth || 0; @@ -214,16 +234,18 @@ const Account = ({ /> {account.verified && } + + {account.bot && } - @{username} + @{username} {account.favicon && ( - + )} {(timestamp) ? ( 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 35460131a..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; @@ -268,7 +268,8 @@ export default class AutosuggestInput extends ImmutablePureComponent = ({ 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 809d69153..9736e5950 100644 --- a/app/soapbox/components/autosuggest-textarea.tsx +++ b/app/soapbox/components/autosuggest-textarea.tsx @@ -30,6 +30,7 @@ interface IAutosuggesteTextarea { onFocus: () => void, onBlur?: () => void, condensed?: boolean, + children: React.ReactNode, } class AutosuggestTextarea extends ImmutablePureComponent { @@ -64,7 +65,7 @@ class AutosuggestTextarea extends ImmutablePureComponent } this.props.onChange(e); - } + }; onKeyDown: React.KeyboardEventHandler = (e) => { const { suggestions, disabled } = this.props; @@ -122,7 +123,7 @@ class AutosuggestTextarea extends ImmutablePureComponent } this.props.onKeyDown(e); - } + }; onBlur = () => { this.setState({ suggestionsHidden: true, focused: false }); @@ -130,7 +131,7 @@ class AutosuggestTextarea extends ImmutablePureComponent if (this.props.onBlur) { this.props.onBlur(); } - } + }; onFocus = () => { this.setState({ focused: true }); @@ -138,14 +139,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 @@ -156,7 +157,8 @@ class AutosuggestTextarea extends ImmutablePureComponent if (lastTokenUpdated && !valueUpdated) { return false; } else { - return super.shouldComponentUpdate!(nextProps, nextState, undefined); + // https://stackoverflow.com/a/35962835 + return super.shouldComponentUpdate!.bind(this)(nextProps, nextState, undefined); } } @@ -169,14 +171,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 +210,7 @@ class AutosuggestTextarea extends ImmutablePureComponent {inner} ); - } + }; setPortalPosition() { if (!this.textarea) { @@ -229,7 +231,8 @@ class AutosuggestTextarea extends ImmutablePureComponent const { suggestionsHidden } = this.state; const style = { direction: 'ltr', minRows: 10 }; - if (isRtl(value)) { + // TODO: convert to functional component and use `useLocale()` hook instead of checking placeholder text. + if (isRtl(value) || (!value && placeholder && isRtl(placeholder))) { style.direction = 'rtl'; } 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/birthday-input.tsx b/app/soapbox/components/birthday-input.tsx index 9da717e0d..b8e4d11ad 100644 --- a/app/soapbox/components/birthday-input.tsx +++ b/app/soapbox/components/birthday-input.tsx @@ -70,7 +70,7 @@ const BirthdayInput: React.FC = ({ value, onChange, required })
= ({ value, onChange, required }) /> {intl.formatDate(date, { month: 'long' })} = ({ value, onChange, required })
= ({ value, onChange, required }) /> {intl.formatDate(date, { year: 'numeric' })} = ({ account, children, withSuffix = true, withDate = false }) => { diff --git a/app/soapbox/components/domain.tsx b/app/soapbox/components/domain.tsx index 191dc3872..117b94120 100644 --- a/app/soapbox/components/domain.tsx +++ b/app/soapbox/components/domain.tsx @@ -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 = ({ domain }) => { - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const intl = useIntl(); // const onBlockDomain = () => { 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-button-wrapper.tsx b/app/soapbox/components/emoji-button-wrapper.tsx index cc528b194..f0d287aa6 100644 --- a/app/soapbox/components/emoji-button-wrapper.tsx +++ b/app/soapbox/components/emoji-button-wrapper.tsx @@ -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 = ({ 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(); 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..87f6d749d 100644 --- a/app/soapbox/components/error-boundary.tsx +++ b/app/soapbox/components/error-boundary.tsx @@ -26,7 +26,9 @@ const mapStateToProps = (state: RootState) => { }; }; -type Props = ReturnType; +interface Props extends ReturnType { + children: React.ReactNode +} type State = { hasError: boolean, @@ -42,7 +44,7 @@ class ErrorBoundary extends React.PureComponent { error: undefined, componentStack: undefined, browser: undefined, - } + }; textarea: HTMLTextAreaElement | null = null; @@ -71,7 +73,7 @@ class ErrorBoundary extends React.PureComponent { setTextareaRef: React.RefCallback = c => { this.textarea = c; - } + }; handleCopy: React.MouseEventHandler = () => { if (!this.textarea) return; @@ -80,12 +82,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 +98,7 @@ class ErrorBoundary extends React.PureComponent { e.preventDefault(); unregisterSw().then(goHome).catch(goHome); } - } + }; render() { const { browser, hasError } = this.state; @@ -213,4 +215,4 @@ class ErrorBoundary extends React.PureComponent { } -export default connect(mapStateToProps)(ErrorBoundary as any); +export default connect(mapStateToProps)(ErrorBoundary); diff --git a/app/soapbox/components/helmet.tsx b/app/soapbox/components/helmet.tsx index 6fa678a04..c7eef876a 100644 --- a/app/soapbox/components/helmet.tsx +++ b/app/soapbox/components/helmet.tsx @@ -15,7 +15,11 @@ const getNotifTotals = (state: RootState): number => { return notifications + reports + approvals; }; -const Helmet: React.FC = ({ children }) => { +interface IHelmet { + children: React.ReactNode +} + +const Helmet: React.FC = ({ children }) => { const instance = useInstance(); const { unreadChatsCount } = useStatContext(); const unreadCount = useAppSelector((state) => getNotifTotals(state) + unreadChatsCount); diff --git a/app/soapbox/components/hover-ref-wrapper.tsx b/app/soapbox/components/hover-ref-wrapper.tsx index bcde02720..e1cb9d5f5 100644 --- a/app/soapbox/components/hover-ref-wrapper.tsx +++ b/app/soapbox/components/hover-ref-wrapper.tsx @@ -18,6 +18,7 @@ interface IHoverRefWrapper { accountId: string, inline?: boolean, className?: string, + children: React.ReactNode, } /** Makes a profile hover card appear when the wrapped element is hovered. */ diff --git a/app/soapbox/components/hover-status-wrapper.tsx b/app/soapbox/components/hover-status-wrapper.tsx index 6f23658a0..558428954 100644 --- a/app/soapbox/components/hover-status-wrapper.tsx +++ b/app/soapbox/components/hover-status-wrapper.tsx @@ -17,6 +17,7 @@ interface IHoverStatusWrapper { statusId: any, inline: boolean, className?: string, + children: React.ReactNode, } /** Makes a status hover card appear when the wrapped element is hovered. */ 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..9927ae8df --- /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/icon.tsx b/app/soapbox/components/icon.tsx index f03f40580..8e875ed0e 100644 --- a/app/soapbox/components/icon.tsx +++ b/app/soapbox/components/icon.tsx @@ -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 { + src: string, + id?: string, + alt?: string, + className?: string, +} -export type IIcon = IForkAwesomeIcon | ISvgIcon; - -const Icon: React.FC = (props) => { - if ((props as ISvgIcon).src) { - const { src, ...rest } = (props as ISvgIcon); - - return ; - } else { - const { id, fixedWidth, ...rest } = (props as IForkAwesomeIcon); - - return ; - } +const Icon: React.FC = ({ src, alt, className, ...rest }) => { + return ( +
+ } /> +
+ ); }; export default Icon; diff --git a/app/soapbox/components/list.tsx b/app/soapbox/components/list.tsx index 510cbecb8..b135fdf36 100644 --- a/app/soapbox/components/list.tsx +++ b/app/soapbox/components/list.tsx @@ -7,7 +7,11 @@ import { SelectDropdown } from '../features/forms'; import Icon from './icon'; import { HStack, Select } from './ui'; -const List: React.FC = ({ children }) => ( +interface IList { + children: React.ReactNode +} + +const List: React.FC = ({ children }) => (
{children}
); @@ -17,6 +21,7 @@ interface IListItem { onClick?(): void, onSelect?(): void isSelected?: boolean + children?: React.ReactNode } const ListItem: React.FC = ({ label, hint, children, onClick, onSelect, isSelected }) => { @@ -70,7 +75,7 @@ const ListItem: React.FC = ({ label, hint, children, onClick, onSelec {children} - + ) : null} diff --git a/app/soapbox/components/media-gallery.tsx b/app/soapbox/components/media-gallery.tsx index cd7cd2605..9b9d1fb7d 100644 --- a/app/soapbox/components/media-gallery.tsx +++ b/app/soapbox/components/media-gallery.tsx @@ -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 = ({ }) => { 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) => { /> )); - useEffect(() => { + useLayoutEffect(() => { if (node.current) { const { offsetWidth } = node.current; diff --git a/app/soapbox/components/modal-root.tsx b/app/soapbox/components/modal-root.tsx index a43d2d638..8abc27eac 100644 --- a/app/soapbox/components/modal-root.tsx +++ b/app/soapbox/components/modal-root.tsx @@ -11,13 +11,12 @@ 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'; const messages = defineMessages({ - confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, + confirm: { id: 'confirmations.cancel.confirm', defaultMessage: 'Discard' }, cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' }, }); @@ -43,6 +42,7 @@ interface IModalRoot { onCancel?: () => void, onClose: (type?: ModalType) => void, type: ModalType, + children: React.ReactNode, } const ModalRoot: React.FC = ({ children, onCancel, onClose, type }) => { @@ -55,7 +55,7 @@ const ModalRoot: React.FC = ({ children, onCancel, onClose, type }) const ref = useRef(null); const activeElement = useRef(revealed ? document.activeElement as HTMLDivElement | null : null); const modalHistoryKey = useRef(); - const unlistenHistory = useRef(); + const unlistenHistory = useRef>(); const prevChildren = usePrevious(children); const prevType = usePrevious(type); @@ -80,10 +80,10 @@ const ModalRoot: React.FC = ({ children, onCancel, onClose, type }) icon: require('@tabler/icons/trash.svg'), heading: isEditing ? - : , + : , message: isEditing ? - : , + : , confirm: intl.formatMessage(messages.confirm), onConfirm: () => { dispatch(closeModal('COMPOSE')); @@ -129,10 +129,10 @@ const ModalRoot: React.FC = ({ children, onCancel, onClose, type }) }); }; - const handleKeyDown = useCallback((e) => { + const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Tab') { const focusable = Array.from(ref.current!.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none'); - const index = focusable.indexOf(e.target); + const index = focusable.indexOf(e.target as Element); let element; @@ -152,8 +152,10 @@ const ModalRoot: React.FC = ({ 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 +167,9 @@ const ModalRoot: React.FC = ({ 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 +221,7 @@ const ModalRoot: React.FC = ({ children, onCancel, onClose, type }) ensureHistoryBuffer(); } - }); + }, [children]); if (!visible) { return ( @@ -241,17 +241,16 @@ const ModalRoot: React.FC = ({ children, onCancel, onClose, type })