diff --git a/.eslintignore b/.eslintignore index fe31218da..0c17e6907 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,4 +4,5 @@ /public/** /tmp/** /coverage/** +/custom/** !.eslintrc.js diff --git a/.eslintrc.js b/.eslintrc.js index bce34ca61..164949e65 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,6 +4,7 @@ module.exports = { extends: [ 'eslint:recommended', 'plugin:import/typescript', + 'plugin:compat/recommended', ], env: { @@ -21,6 +22,7 @@ module.exports = { plugins: [ 'react', + 'jsdoc', 'jsx-a11y', 'import', 'promise', @@ -51,6 +53,14 @@ module.exports = { paths: ['app'], }, }, + polyfills: [ + 'es:all', + 'fetch', + 'IntersectionObserver', + 'Promise', + 'URL', + 'URLSearchParams', + ], }, rules: { @@ -65,11 +75,13 @@ module.exports = { ], 'comma-style': ['warn', 'last'], 'space-before-function-paren': ['error', 'never'], + 'space-infix-ops': 'error', 'space-in-parens': ['error', 'never'], - 'consistent-return': 'error', + 'keyword-spacing': 'error', 'dot-notation': 'error', eqeqeq: 'error', indent: ['error', 2, { + SwitchCase: 1, // https://stackoverflow.com/a/53055584/8811886 ignoredNodes: ['TemplateLiteral'], }], 'jsx-quotes': ['error', 'prefer-single'], @@ -254,6 +266,7 @@ module.exports = { alphabetize: { order: 'asc' }, }, ], + '@typescript-eslint/no-duplicate-imports': 'error', 'promise/catch-or-return': 'error', @@ -267,5 +280,23 @@ module.exports = { }, parser: '@typescript-eslint/parser', }, + { + // Only enforce JSDoc comments on UI components for now. + // https://www.npmjs.com/package/eslint-plugin-jsdoc + files: ['app/soapbox/components/ui/**/*'], + rules: { + 'jsdoc/require-jsdoc': ['error', { + publicOnly: true, + require: { + ArrowFunctionExpression: true, + ClassDeclaration: true, + ClassExpression: true, + FunctionDeclaration: true, + FunctionExpression: true, + MethodDefinition: true, + }, + }], + }, + }, ], }; diff --git a/.gitignore b/.gitignore index 138e96d05..92e9362d8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /deploy.sh /.vs/ yarn-error.log* +/junit.xml /static/ /static-test/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0d140029f..90c856e40 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,27 +1,41 @@ -image: node:16 +image: node:18 variables: NODE_ENV: test -cache: +cache: &cache key: files: - yarn.lock paths: - - node_modules + - node_modules/ + policy: pull stages: - - lint + - deps - test - - build - deploy -before_script: - - env - - yarn +deps: + stage: deps + script: yarn install --ignore-scripts + only: + changes: + - yarn.lock + cache: + <<: *cache + policy: push + +danger: + stage: test + script: + # https://github.com/danger/danger-js/issues/1029#issuecomment-998915436 + - export CI_MERGE_REQUEST_IID=${CI_OPEN_MERGE_REQUESTS#*!} + - npx danger ci + allow_failure: true lint-js: - stage: lint + stage: test script: yarn lint:js only: changes: @@ -33,7 +47,7 @@ lint-js: - ".eslintrc.js" lint-sass: - stage: lint + stage: test script: yarn lint:sass only: changes: @@ -43,25 +57,43 @@ lint-sass: jest: stage: test - script: yarn test:coverage + script: yarn test:coverage --runInBand only: changes: - "**/*.js" - "**/*.json" - "app/soapbox/**/*" - "webpack/**/*" + - "custom/**/*" - "jest.config.js" - "package.json" - "yarn.lock" + - ".gitlab-ci.yml" + coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/ + artifacts: + reports: + junit: junit.xml + coverage_report: + coverage_format: cobertura + path: .coverage/cobertura-coverage.xml + +nginx-test: + stage: test + image: nginx:latest + before_script: cp installation/mastodon.conf /etc/nginx/conf.d/default.conf + script: nginx -t + only: + changes: + - "installation/mastodon.conf" build-production: - stage: build + stage: test script: yarn build variables: NODE_ENV: production artifacts: paths: - - static + - static docs-deploy: stage: deploy @@ -87,6 +119,15 @@ docs-deploy: # - yarn # - yarn build +review: + stage: deploy + environment: + name: review/$CI_COMMIT_REF_NAME + url: https://$CI_COMMIT_REF_SLUG.git.soapbox.pub + script: + - npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub + allow_failure: true + pages: stage: deploy before_script: [] diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md new file mode 100644 index 000000000..579c32f66 --- /dev/null +++ b/.gitlab/issue_templates/Bug.md @@ -0,0 +1,7 @@ +### Environment + +* Soapbox version: +* Backend (Mastodon, Pleroma, etc): +* Browser/OS: + +### Bug description diff --git a/.gitlab/merge_request_templates/Default.md b/.gitlab/merge_request_templates/Default.md new file mode 100644 index 000000000..8a6192986 --- /dev/null +++ b/.gitlab/merge_request_templates/Default.md @@ -0,0 +1,5 @@ +## Summary + + + +## Screenshots (if appropriate): diff --git a/.tool-versions b/.tool-versions index 009455657..f0c37ee48 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs 16.14.2 +nodejs 18.2.0 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..527dfacad --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "editorconfig.editorconfig", + "dbaeumer.vscode-eslint", + "bradlc.vscode-tailwindcss", + "stylelint.vscode-stylelint", + "wix.vscode-import-cost" + ] +} diff --git a/.vscode/soapbox.code-snippets b/.vscode/soapbox.code-snippets new file mode 100644 index 000000000..66da1a25b --- /dev/null +++ b/.vscode/soapbox.code-snippets @@ -0,0 +1,58 @@ +{ + // Place your soapbox-fe workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and + // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope + // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is + // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: + // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. + // Placeholders with the same ids are connected. + // Example: + // "Print to console": { + // "scope": "javascript,typescript", + // "prefix": "log", + // "body": [ + // "console.log('$1');", + // "$2" + // ], + // "description": "Log output to console" + // } + "React component": { + "scope": "typescriptreact", + "prefix": ["component", "react component"], + "body": [ + "import React from 'react';", + "", + "interface I${1:Component} {", + "}", + "", + "/** ${1:Component} component. */", + "const ${1:Component}: React.FC = () => {", + " return (", + " <>", + " );", + "};", + "", + "export default ${1:Component};" + ], + "description": "React component" + }, + "React component test": { + "scope": "typescriptreact", + "prefix": ["test", "component test", "react component test"], + "body": [ + "import React from 'react';", + "", + "import { render, screen } from 'soapbox/jest/test-helpers';", + "", + "import ${1:Component} from '${2:..}';", + "", + "describe('<${1:Component} />', () => {", + " it('renders', () => {", + " render(<${1:Component} />);", + "", + " expect(screen.getByTestId('${3:test}')).toBeInTheDocument();", + " });", + "});" + ], + "description": "React component test" + } +} diff --git a/README.md b/README.md index 90bdb903e..1027957d1 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Installing Soapbox FE on an existing Pleroma server is extremely easy. Just ssh into the server and download a .zip of the latest build: ```sh -curl -L https://gitlab.com/soapbox-pub/soapbox-fe/-/jobs/artifacts/v1.3.0/download?job=build-production -o soapbox-fe.zip +curl -L https://gitlab.com/soapbox-pub/soapbox-fe/-/jobs/artifacts/v2.0.0/download?job=build-production -o soapbox-fe.zip ``` Then unpack it into Pleroma's `instance` directory: @@ -31,6 +31,10 @@ It's not necessary to restart the Pleroma service. To remove Soapbox FE and revert to the default pleroma-fe, simply `rm /opt/pleroma/instance/static/index.html` (you can delete other stuff in there too, but be careful not to delete your own HTML files). +## :elephant: Deploy on Mastodon + +See [Installing Soapbox over Mastodon](https://docs.soapbox.pub/frontend/administration/mastodon/). + ## How does it work? Soapbox FE is a [single-page application (SPA)](https://en.wikipedia.org/wiki/Single-page_application) that runs entirely in the browser with JavaScript. @@ -38,7 +42,23 @@ Soapbox FE is a [single-page application (SPA)](https://en.wikipedia.org/wiki/Si It has a single HTML file, `index.html`, responsible only for loading the required JavaScript and CSS. It interacts with the backend through [XMLHttpRequest (XHR)](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest). -It incorporates much of the [Mastodon API](https://docs.joinmastodon.org/methods/) used by Pleroma and Mastodon, but requires many [Pleroma-specific features](https://docs.pleroma.social/backend/development/API/differences_in_mastoapi_responses/) in order to function. +Here is a simplified example with Nginx: + +```nginx +location /api { + proxy_pass http://backend; +} + +location / { + root /opt/soapbox; + try_files $uri index.html; +} +``` + +(See [`mastodon.conf`](https://gitlab.com/soapbox-pub/soapbox-fe/-/blob/develop/installation/mastodon.conf) for a full example.) + +Soapbox incorporates much of the [Mastodon API](https://docs.joinmastodon.org/methods/), [Pleroma API](https://api.pleroma.social/), and more. +It detects features supported by the backend to provide the right experience for the backend. # Running locally @@ -65,8 +85,9 @@ yarn dev It will serve at `http://localhost:3036` by default. -It will proxy requests to the backend for you. -For Pleroma running on `localhost:4000` (the default) no other changes are required, just start a local Pleroma server and it should begin working. +You should see an input box - just enter the domain name of your instance to log in. + +Tip: you can even enter a local instance like `http://localhost:3000`! ### Troubleshooting: `ERROR: NODE_ENV must be set` @@ -79,26 +100,10 @@ cp .env.example .env And ensure that it contains `NODE_ENV=development`. Try again. -## Developing against a live backend +### Troubleshooting: it's not working! -You can also run Soapbox FE locally with a live production server as the backend. - -> **Note:** Whether or not this works depends on your production server. It does not seem to work with Cloudflare or VanwaNet. - -To do so, just copy the env file: - -```sh -cp .env.example .env -``` - -And edit `.env`, setting the configuration like this: - -```sh -BACKEND_URL="https://pleroma.example.com" -PROXY_HTTPS_INSECURE=true -``` - -You will need to restart the local development server for the changes to take effect. +Run `node -V` and compare your Node.js version with the version in [`.tool-versions`](https://gitlab.com/soapbox-pub/soapbox-fe/-/blob/develop/.tool-versions). +If they don't match, try installing [asdf](https://asdf-vm.com/). ## Local Dev Configuration @@ -165,28 +170,26 @@ NODE_ENV=development # Contributing -We welcome contributions to this project. To contribute, first review the [Contributing doc](docs/contributing.md) - -Additional supporting documents include: -* [Soapbox History](docs/history.md) -* [Redux Store Map](docs/history.md) +We welcome contributions to this project. +To contribute, see [Contributing to Soapbox](docs/contributing.md). # Customization -Soapbox supports customization of the user interface, to allow per instance branding and other features. Current customization features include: +Soapbox supports customization of the user interface, to allow per-instance branding and other features. +Some examples include: -* Instance name -* Site logo -* Favicon -* About page -* Terms of Service page -* Privacy Policy page -* Copyright Policy (DMCA) page -* Promo panel list items, e.g. blog site link -* Soapbox extensions, e.g. Patron module -* Default settings, e.g. default theme +- Instance name +- Site logo +- Favicon +- About page +- Terms of Service page +- Privacy Policy page +- Copyright Policy (DMCA) page +- Promo panel list items, e.g. blog site link +- Soapbox extensions, e.g. Patron module +- Default settings, e.g. default theme -Customization details can be found in the [Customization doc](docs/customization.md) +More details can be found in [Customizing Soapbox](docs/customization.md). # License & Credits diff --git a/app/icons/COPYING.md b/app/icons/COPYING.md index bec84979f..5e84c0b5c 100644 --- a/app/icons/COPYING.md +++ b/app/icons/COPYING.md @@ -1,11 +1,6 @@ # Custom icons -- dashboard-filled.svg - Modified from Tabler icons, MIT - fediverse.svg - Modified from Wikipedia, CC0 -- home-squared.svg - Modified from Tabler icons, MIT -- pen-plus.svg - Modified from Tabler icons, MIT - verified.svg - Created by Alex Gleason. CC0 -Tabler: https://tabler-icons.io/ -Feather: https://feathericons.com/ Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg diff --git a/app/icons/alert.svg b/app/icons/alert.svg deleted file mode 100644 index 9ec4beec2..000000000 --- a/app/icons/alert.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/icons/compose.svg b/app/icons/compose.svg deleted file mode 100644 index 9f2190922..000000000 --- a/app/icons/compose.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/icons/dashboard-filled.svg b/app/icons/dashboard-filled.svg deleted file mode 100644 index 69de13b00..000000000 --- a/app/icons/dashboard-filled.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/icons/feed.svg b/app/icons/feed.svg deleted file mode 100644 index 1dd590a51..000000000 --- a/app/icons/feed.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/icons/home-square.svg b/app/icons/home-square.svg deleted file mode 100644 index dd4944597..000000000 --- a/app/icons/home-square.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/icons/pen-plus.svg b/app/icons/pen-plus.svg deleted file mode 100644 index 8f81c48f6..000000000 --- a/app/icons/pen-plus.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/icons/user.svg b/app/icons/user.svg deleted file mode 100644 index 7c92e4f3b..000000000 --- a/app/icons/user.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/images/soapbox-logo-white.svg b/app/images/soapbox-logo-white.svg new file mode 100644 index 000000000..f09e910ea --- /dev/null +++ b/app/images/soapbox-logo-white.svg @@ -0,0 +1 @@ + diff --git a/app/soapbox/__fixtures__/announcements.json b/app/soapbox/__fixtures__/announcements.json new file mode 100644 index 000000000..20e1960d0 --- /dev/null +++ b/app/soapbox/__fixtures__/announcements.json @@ -0,0 +1,44 @@ +[ + { + "id": "1", + "content": "

Updated to Soapbox v3.

", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-06-15T18:47:14.190Z", + "updated_at": "2022-06-15T18:47:18.339Z", + "read": true, + "mentions": [], + "statuses": [], + "tags": [], + "emojis": [], + "reactions": [ + { + "name": "📈", + "count": 476, + "me": true + } + ] + }, + { + "id": "2", + "content": "

Rolled back to Soapbox v2 for now.

", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-07-13T11:11:50.628Z", + "updated_at": "2022-07-13T11:11:50.628Z", + "read": true, + "mentions": [], + "statuses": [], + "tags": [], + "emojis": [], + "reactions": [ + { + "name": "📉", + "count": 420, + "me": false + } + ] + } +] \ No newline at end of file diff --git a/app/soapbox/__fixtures__/blocks.json b/app/soapbox/__fixtures__/blocks.json new file mode 100644 index 000000000..42e8753c5 --- /dev/null +++ b/app/soapbox/__fixtures__/blocks.json @@ -0,0 +1,8 @@ +[ + { + "id": "22", + "username": "twoods", + "acct": "twoods", + "display_name": "Tiger Woods" + } +] diff --git a/app/soapbox/__fixtures__/context_1.json b/app/soapbox/__fixtures__/context_1.json deleted file mode 100644 index 2e37a5502..000000000 --- a/app/soapbox/__fixtures__/context_1.json +++ /dev/null @@ -1,739 +0,0 @@ -{ - "ancestors": [ - { - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "follow_requests_count": 0, - "followers_count": 725, - "following_count": 1211, - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "locked": false, - "note": "Fediverse developer. I come in peace.

#vegan #freeculture #atheist #antiporn #gendercritical.

Boosts ≠ endorsements.", - "pleroma": { - "accepts_chat_messages": true, - "allow_following_move": true, - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "confirmation_pending": false, - "deactivated": false, - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_moderator": false, - "notification_settings": { - "block_from_strangers": false, - "hide_notification_contents": false - }, - "relationship": {}, - "skip_thread_containment": false, - "tags": [], - "unread_conversation_count": 95, - "unread_notifications_count": 0 - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.", - "pleroma": { - "actor_type": "Person", - "discoverable": false, - "no_rich_text": false, - "show_role": true - }, - "privacy": "public", - "sensitive": false - }, - "statuses_count": 9157, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": { - "name": "Web", - "website": null - }, - "bookmarked": false, - "card": null, - "content": "

A

", - "created_at": "2020-09-18T20:07:10.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 0, - "id": "9zIH6kDXA10YqhMKqO", - "in_reply_to_account_id": null, - "in_reply_to_id": null, - "language": null, - "media_attachments": [], - "mentions": [], - "muted": false, - "pinned": false, - "pleroma": { - "content": { - "text/plain": "A" - }, - "conversation_id": 5089485, - "direct_conversation_id": null, - "emoji_reactions": [], - "expires_at": null, - "in_reply_to_account_acct": null, - "local": true, - "parent_visible": false, - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": null, - "reblog": null, - "reblogged": false, - "reblogs_count": 0, - "replies_count": 0, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/objects/9995c074-2ff6-4a01-b596-7ef6971ed5d2", - "url": "https://gleasonator.com/notice/9zIH6kDXA10YqhMKqO", - "visibility": "direct" - }, - { - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "follow_requests_count": 0, - "followers_count": 725, - "following_count": 1211, - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "locked": false, - "note": "Fediverse developer. I come in peace.

#vegan #freeculture #atheist #antiporn #gendercritical.

Boosts ≠ endorsements.", - "pleroma": { - "accepts_chat_messages": true, - "allow_following_move": true, - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "confirmation_pending": false, - "deactivated": false, - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_moderator": false, - "notification_settings": { - "block_from_strangers": false, - "hide_notification_contents": false - }, - "relationship": {}, - "skip_thread_containment": false, - "tags": [], - "unread_conversation_count": 95, - "unread_notifications_count": 0 - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.", - "pleroma": { - "actor_type": "Person", - "discoverable": false, - "no_rich_text": false, - "show_role": true - }, - "privacy": "public", - "sensitive": false - }, - "statuses_count": 9157, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": { - "name": "Web", - "website": null - }, - "bookmarked": false, - "card": null, - "content": "

B

", - "created_at": "2020-09-18T20:07:18.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 0, - "id": "9zIH7PUdhK3Ircg4hM", - "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", - "in_reply_to_id": "9zIH6kDXA10YqhMKqO", - "language": null, - "media_attachments": [], - "mentions": [ - { - "acct": "alex", - "id": "9v5bmRalQvjOy0ECcC", - "url": "https://gleasonator.com/users/alex", - "username": "alex" - } - ], - "muted": false, - "pinned": false, - "pleroma": { - "content": { - "text/plain": "B" - }, - "conversation_id": 5089485, - "direct_conversation_id": null, - "emoji_reactions": [], - "expires_at": null, - "in_reply_to_account_acct": "alex", - "local": true, - "parent_visible": true, - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": null, - "reblog": null, - "reblogged": false, - "reblogs_count": 0, - "replies_count": 0, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/objects/992ca99a-425d-46eb-b094-60412e9fb141", - "url": "https://gleasonator.com/notice/9zIH7PUdhK3Ircg4hM", - "visibility": "direct" - }, - { - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "follow_requests_count": 0, - "followers_count": 725, - "following_count": 1211, - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "locked": false, - "note": "Fediverse developer. I come in peace.

#vegan #freeculture #atheist #antiporn #gendercritical.

Boosts ≠ endorsements.", - "pleroma": { - "accepts_chat_messages": true, - "allow_following_move": true, - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "confirmation_pending": false, - "deactivated": false, - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_moderator": false, - "notification_settings": { - "block_from_strangers": false, - "hide_notification_contents": false - }, - "relationship": {}, - "skip_thread_containment": false, - "tags": [], - "unread_conversation_count": 95, - "unread_notifications_count": 0 - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.", - "pleroma": { - "actor_type": "Person", - "discoverable": false, - "no_rich_text": false, - "show_role": true - }, - "privacy": "public", - "sensitive": false - }, - "statuses_count": 9157, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": { - "name": "Web", - "website": null - }, - "bookmarked": false, - "card": null, - "content": "

C

", - "created_at": "2020-09-18T20:07:22.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 0, - "id": "9zIH7mMGgc1RmJwDLM", - "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", - "in_reply_to_id": "9zIH6kDXA10YqhMKqO", - "language": null, - "media_attachments": [], - "mentions": [ - { - "acct": "alex", - "id": "9v5bmRalQvjOy0ECcC", - "url": "https://gleasonator.com/users/alex", - "username": "alex" - } - ], - "muted": false, - "pinned": false, - "pleroma": { - "content": { - "text/plain": "C" - }, - "conversation_id": 5089485, - "direct_conversation_id": null, - "emoji_reactions": [], - "expires_at": null, - "in_reply_to_account_acct": "alex", - "local": true, - "parent_visible": true, - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": null, - "reblog": null, - "reblogged": false, - "reblogs_count": 0, - "replies_count": 0, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/objects/a2c25ef5-a40e-4098-b07e-b468989ef749", - "url": "https://gleasonator.com/notice/9zIH7mMGgc1RmJwDLM", - "visibility": "direct" - } - ], - "descendants": [ - { - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "follow_requests_count": 0, - "followers_count": 725, - "following_count": 1211, - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "locked": false, - "note": "Fediverse developer. I come in peace.

#vegan #freeculture #atheist #antiporn #gendercritical.

Boosts ≠ endorsements.", - "pleroma": { - "accepts_chat_messages": true, - "allow_following_move": true, - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "confirmation_pending": false, - "deactivated": false, - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_moderator": false, - "notification_settings": { - "block_from_strangers": false, - "hide_notification_contents": false - }, - "relationship": {}, - "skip_thread_containment": false, - "tags": [], - "unread_conversation_count": 95, - "unread_notifications_count": 0 - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.", - "pleroma": { - "actor_type": "Person", - "discoverable": false, - "no_rich_text": false, - "show_role": true - }, - "privacy": "public", - "sensitive": false - }, - "statuses_count": 9157, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": { - "name": "Web", - "website": null - }, - "bookmarked": false, - "card": null, - "content": "

E

", - "created_at": "2020-09-18T20:07:38.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 0, - "id": "9zIH9GTCDWEFSRt2um", - "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", - "in_reply_to_id": "9zIH7PUdhK3Ircg4hM", - "language": null, - "media_attachments": [], - "mentions": [ - { - "acct": "alex", - "id": "9v5bmRalQvjOy0ECcC", - "url": "https://gleasonator.com/users/alex", - "username": "alex" - } - ], - "muted": false, - "pinned": false, - "pleroma": { - "content": { - "text/plain": "E" - }, - "conversation_id": 5089485, - "direct_conversation_id": null, - "emoji_reactions": [], - "expires_at": null, - "in_reply_to_account_acct": "alex", - "local": true, - "parent_visible": true, - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": null, - "reblog": null, - "reblogged": false, - "reblogs_count": 0, - "replies_count": 0, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/objects/a1e45493-2158-4f11-88ca-ba621429dbe5", - "url": "https://gleasonator.com/notice/9zIH9GTCDWEFSRt2um", - "visibility": "direct" - }, - { - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "follow_requests_count": 0, - "followers_count": 725, - "following_count": 1211, - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "locked": false, - "note": "Fediverse developer. I come in peace.

#vegan #freeculture #atheist #antiporn #gendercritical.

Boosts ≠ endorsements.", - "pleroma": { - "accepts_chat_messages": true, - "allow_following_move": true, - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "confirmation_pending": false, - "deactivated": false, - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_moderator": false, - "notification_settings": { - "block_from_strangers": false, - "hide_notification_contents": false - }, - "relationship": {}, - "skip_thread_containment": false, - "tags": [], - "unread_conversation_count": 95, - "unread_notifications_count": 0 - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.", - "pleroma": { - "actor_type": "Person", - "discoverable": false, - "no_rich_text": false, - "show_role": true - }, - "privacy": "public", - "sensitive": false - }, - "statuses_count": 9157, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": { - "name": "Web", - "website": null - }, - "bookmarked": false, - "card": null, - "content": "

F

", - "created_at": "2020-09-18T20:07:42.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 0, - "id": "9zIH9fhaP9atiJoOJc", - "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", - "in_reply_to_id": "9zIH8WYwtnUx4yDzUm", - "language": null, - "media_attachments": [], - "mentions": [ - { - "acct": "alex", - "id": "9v5bmRalQvjOy0ECcC", - "url": "https://gleasonator.com/users/alex", - "username": "alex" - } - ], - "muted": false, - "pinned": false, - "pleroma": { - "content": { - "text/plain": "F" - }, - "conversation_id": 5089485, - "direct_conversation_id": null, - "emoji_reactions": [], - "expires_at": null, - "in_reply_to_account_acct": "alex", - "local": true, - "parent_visible": true, - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": null, - "reblog": null, - "reblogged": false, - "reblogs_count": 0, - "replies_count": 0, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/objects/ee661cf9-35d4-4e84-88ff-13b5950f7556", - "url": "https://gleasonator.com/notice/9zIH9fhaP9atiJoOJc", - "visibility": "direct" - } - ] -} diff --git a/app/soapbox/__fixtures__/context_2.json b/app/soapbox/__fixtures__/context_2.json deleted file mode 100644 index c5cf2a813..000000000 --- a/app/soapbox/__fixtures__/context_2.json +++ /dev/null @@ -1,739 +0,0 @@ -{ - "ancestors": [ - { - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "follow_requests_count": 0, - "followers_count": 725, - "following_count": 1211, - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "locked": false, - "note": "Fediverse developer. I come in peace.

#vegan #freeculture #atheist #antiporn #gendercritical.

Boosts ≠ endorsements.", - "pleroma": { - "accepts_chat_messages": true, - "allow_following_move": true, - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "confirmation_pending": false, - "deactivated": false, - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_moderator": false, - "notification_settings": { - "block_from_strangers": false, - "hide_notification_contents": false - }, - "relationship": {}, - "skip_thread_containment": false, - "tags": [], - "unread_conversation_count": 95, - "unread_notifications_count": 0 - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.", - "pleroma": { - "actor_type": "Person", - "discoverable": false, - "no_rich_text": false, - "show_role": true - }, - "privacy": "public", - "sensitive": false - }, - "statuses_count": 9157, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": { - "name": "Web", - "website": null - }, - "bookmarked": false, - "card": null, - "content": "

A

", - "created_at": "2020-09-18T20:07:10.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 0, - "id": "9zIH6kDXA10YqhMKqO", - "in_reply_to_account_id": null, - "in_reply_to_id": null, - "language": null, - "media_attachments": [], - "mentions": [], - "muted": false, - "pinned": false, - "pleroma": { - "content": { - "text/plain": "A" - }, - "conversation_id": 5089485, - "direct_conversation_id": null, - "emoji_reactions": [], - "expires_at": null, - "in_reply_to_account_acct": null, - "local": true, - "parent_visible": false, - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": null, - "reblog": null, - "reblogged": false, - "reblogs_count": 0, - "replies_count": 0, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/objects/9995c074-2ff6-4a01-b596-7ef6971ed5d2", - "url": "https://gleasonator.com/notice/9zIH6kDXA10YqhMKqO", - "visibility": "direct" - } - ], - "descendants": [ - { - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "follow_requests_count": 0, - "followers_count": 725, - "following_count": 1211, - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "locked": false, - "note": "Fediverse developer. I come in peace.

#vegan #freeculture #atheist #antiporn #gendercritical.

Boosts ≠ endorsements.", - "pleroma": { - "accepts_chat_messages": true, - "allow_following_move": true, - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "confirmation_pending": false, - "deactivated": false, - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_moderator": false, - "notification_settings": { - "block_from_strangers": false, - "hide_notification_contents": false - }, - "relationship": {}, - "skip_thread_containment": false, - "tags": [], - "unread_conversation_count": 95, - "unread_notifications_count": 0 - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.", - "pleroma": { - "actor_type": "Person", - "discoverable": false, - "no_rich_text": false, - "show_role": true - }, - "privacy": "public", - "sensitive": false - }, - "statuses_count": 9157, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": { - "name": "Web", - "website": null - }, - "bookmarked": false, - "card": null, - "content": "

C

", - "created_at": "2020-09-18T20:07:22.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 0, - "id": "9zIH7mMGgc1RmJwDLM", - "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", - "in_reply_to_id": "9zIH6kDXA10YqhMKqO", - "language": null, - "media_attachments": [], - "mentions": [ - { - "acct": "alex", - "id": "9v5bmRalQvjOy0ECcC", - "url": "https://gleasonator.com/users/alex", - "username": "alex" - } - ], - "muted": false, - "pinned": false, - "pleroma": { - "content": { - "text/plain": "C" - }, - "conversation_id": 5089485, - "direct_conversation_id": null, - "emoji_reactions": [], - "expires_at": null, - "in_reply_to_account_acct": "alex", - "local": true, - "parent_visible": true, - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": null, - "reblog": null, - "reblogged": false, - "reblogs_count": 0, - "replies_count": 0, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/objects/a2c25ef5-a40e-4098-b07e-b468989ef749", - "url": "https://gleasonator.com/notice/9zIH7mMGgc1RmJwDLM", - "visibility": "direct" - }, - { - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "follow_requests_count": 0, - "followers_count": 725, - "following_count": 1211, - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "locked": false, - "note": "Fediverse developer. I come in peace.

#vegan #freeculture #atheist #antiporn #gendercritical.

Boosts ≠ endorsements.", - "pleroma": { - "accepts_chat_messages": true, - "allow_following_move": true, - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "confirmation_pending": false, - "deactivated": false, - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_moderator": false, - "notification_settings": { - "block_from_strangers": false, - "hide_notification_contents": false - }, - "relationship": {}, - "skip_thread_containment": false, - "tags": [], - "unread_conversation_count": 95, - "unread_notifications_count": 0 - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.", - "pleroma": { - "actor_type": "Person", - "discoverable": false, - "no_rich_text": false, - "show_role": true - }, - "privacy": "public", - "sensitive": false - }, - "statuses_count": 9157, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": { - "name": "Web", - "website": null - }, - "bookmarked": false, - "card": null, - "content": "

D

", - "created_at": "2020-09-18T20:07:30.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 0, - "id": "9zIH8WYwtnUx4yDzUm", - "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", - "in_reply_to_id": "9zIH7PUdhK3Ircg4hM", - "language": null, - "media_attachments": [], - "mentions": [ - { - "acct": "alex", - "id": "9v5bmRalQvjOy0ECcC", - "url": "https://gleasonator.com/users/alex", - "username": "alex" - } - ], - "muted": false, - "pinned": false, - "pleroma": { - "content": { - "text/plain": "D" - }, - "conversation_id": 5089485, - "direct_conversation_id": null, - "emoji_reactions": [], - "expires_at": null, - "in_reply_to_account_acct": "alex", - "local": true, - "parent_visible": true, - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": null, - "reblog": null, - "reblogged": false, - "reblogs_count": 0, - "replies_count": 0, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/objects/bb423adc-ed86-42d8-942e-84efbe7b1acf", - "url": "https://gleasonator.com/notice/9zIH8WYwtnUx4yDzUm", - "visibility": "direct" - }, - { - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "follow_requests_count": 0, - "followers_count": 725, - "following_count": 1211, - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "locked": false, - "note": "Fediverse developer. I come in peace.

#vegan #freeculture #atheist #antiporn #gendercritical.

Boosts ≠ endorsements.", - "pleroma": { - "accepts_chat_messages": true, - "allow_following_move": true, - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "confirmation_pending": false, - "deactivated": false, - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_moderator": false, - "notification_settings": { - "block_from_strangers": false, - "hide_notification_contents": false - }, - "relationship": {}, - "skip_thread_containment": false, - "tags": [], - "unread_conversation_count": 95, - "unread_notifications_count": 0 - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.", - "pleroma": { - "actor_type": "Person", - "discoverable": false, - "no_rich_text": false, - "show_role": true - }, - "privacy": "public", - "sensitive": false - }, - "statuses_count": 9157, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": { - "name": "Web", - "website": null - }, - "bookmarked": false, - "card": null, - "content": "

E

", - "created_at": "2020-09-18T20:07:38.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 0, - "id": "9zIH9GTCDWEFSRt2um", - "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", - "in_reply_to_id": "9zIH7PUdhK3Ircg4hM", - "language": null, - "media_attachments": [], - "mentions": [ - { - "acct": "alex", - "id": "9v5bmRalQvjOy0ECcC", - "url": "https://gleasonator.com/users/alex", - "username": "alex" - } - ], - "muted": false, - "pinned": false, - "pleroma": { - "content": { - "text/plain": "E" - }, - "conversation_id": 5089485, - "direct_conversation_id": null, - "emoji_reactions": [], - "expires_at": null, - "in_reply_to_account_acct": "alex", - "local": true, - "parent_visible": true, - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": null, - "reblog": null, - "reblogged": false, - "reblogs_count": 0, - "replies_count": 0, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/objects/a1e45493-2158-4f11-88ca-ba621429dbe5", - "url": "https://gleasonator.com/notice/9zIH9GTCDWEFSRt2um", - "visibility": "direct" - }, - { - "account": { - "acct": "alex", - "avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg", - "bot": false, - "created_at": "2020-01-08T01:25:43.000Z", - "display_name": "Alex Gleason", - "emojis": [], - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "follow_requests_count": 0, - "followers_count": 725, - "following_count": 1211, - "header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png", - "id": "9v5bmRalQvjOy0ECcC", - "locked": false, - "note": "Fediverse developer. I come in peace.

#vegan #freeculture #atheist #antiporn #gendercritical.

Boosts ≠ endorsements.", - "pleroma": { - "accepts_chat_messages": true, - "allow_following_move": true, - "ap_id": "https://gleasonator.com/users/alex", - "background_image": null, - "confirmation_pending": false, - "deactivated": false, - "favicon": "https://gleasonator.com/favicon.png", - "hide_favorites": true, - "hide_followers": false, - "hide_followers_count": false, - "hide_follows": false, - "hide_follows_count": false, - "is_admin": true, - "is_moderator": false, - "notification_settings": { - "block_from_strangers": false, - "hide_notification_contents": false - }, - "relationship": {}, - "skip_thread_containment": false, - "tags": [], - "unread_conversation_count": 95, - "unread_notifications_count": 0 - }, - "source": { - "fields": [ - { - "name": "Website", - "value": "https://alexgleason.me" - }, - { - "name": "Pleroma+Soapbox", - "value": "https://soapbox.pub" - }, - { - "name": "Email", - "value": "alex@alexgleason.me" - }, - { - "name": "Gender identity", - "value": "Soyboy" - } - ], - "note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.", - "pleroma": { - "actor_type": "Person", - "discoverable": false, - "no_rich_text": false, - "show_role": true - }, - "privacy": "public", - "sensitive": false - }, - "statuses_count": 9157, - "url": "https://gleasonator.com/users/alex", - "username": "alex" - }, - "application": { - "name": "Web", - "website": null - }, - "bookmarked": false, - "card": null, - "content": "

F

", - "created_at": "2020-09-18T20:07:42.000Z", - "emojis": [], - "favourited": false, - "favourites_count": 0, - "id": "9zIH9fhaP9atiJoOJc", - "in_reply_to_account_id": "9v5bmRalQvjOy0ECcC", - "in_reply_to_id": "9zIH8WYwtnUx4yDzUm", - "language": null, - "media_attachments": [], - "mentions": [ - { - "acct": "alex", - "id": "9v5bmRalQvjOy0ECcC", - "url": "https://gleasonator.com/users/alex", - "username": "alex" - } - ], - "muted": false, - "pinned": false, - "pleroma": { - "content": { - "text/plain": "F" - }, - "conversation_id": 5089485, - "direct_conversation_id": null, - "emoji_reactions": [], - "expires_at": null, - "in_reply_to_account_acct": "alex", - "local": true, - "parent_visible": true, - "spoiler_text": { - "text/plain": "" - }, - "thread_muted": false - }, - "poll": null, - "reblog": null, - "reblogged": false, - "reblogs_count": 0, - "replies_count": 0, - "sensitive": false, - "spoiler_text": "", - "tags": [], - "text": null, - "uri": "https://gleasonator.com/objects/ee661cf9-35d4-4e84-88ff-13b5950f7556", - "url": "https://gleasonator.com/notice/9zIH9fhaP9atiJoOJc", - "visibility": "direct" - } - ] -} diff --git a/app/soapbox/__fixtures__/intlMessages.json b/app/soapbox/__fixtures__/intlMessages.json index 82a489909..54c919e6a 100644 --- a/app/soapbox/__fixtures__/intlMessages.json +++ b/app/soapbox/__fixtures__/intlMessages.json @@ -159,7 +159,7 @@ "empty_column.follow_requests": "You don\"t have any follow requests yet. When you receive one, it will show up here.", "empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.", + "empty_column.home": "Or you can visit {public} to get started and meet other users.", "empty_column.home.local_tab": "the {site_title} tab", "empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.", "empty_column.lists": "You don\"t have any lists yet. When you create one, it will show up here.", @@ -243,7 +243,7 @@ "lists.edit": "Edit list", "lists.edit.submit": "Change title", "lists.new.create": "Add list", - "lists.new.create_title": "Create", + "lists.new.create_title": "Add list", "lists.new.save_title": "Save Title", "lists.new.title_placeholder": "New list title", "lists.search": "Search among people you follow", @@ -397,7 +397,7 @@ "security.update_email.success": "Email successfully updated.", "security.update_password.fail": "Update password failed.", "security.update_password.success": "Password successfully updated.", - "signup_panel.subtitle": "Sign up now to discuss.", + "signup_panel.subtitle": "Sign up now to discuss what's happening.", "signup_panel.title": "New to {site_title}?", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this post in the moderation interface", @@ -637,7 +637,7 @@ "empty_column.follow_requests": "You don\"t have any follow requests yet. When you receive one, it will show up here.", "empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.", + "empty_column.home": "Or you can visit {public} to get started and meet other users.", "empty_column.home.local_tab": "the {site_title} tab", "empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.", "empty_column.lists": "You don\"t have any lists yet. When you create one, it will show up here.", @@ -721,7 +721,7 @@ "lists.edit": "Edit list", "lists.edit.submit": "Change title", "lists.new.create": "Add list", - "lists.new.create_title": "Create", + "lists.new.create_title": "Add list", "lists.new.save_title": "Save Title", "lists.new.title_placeholder": "New list title", "lists.search": "Search among people you follow", @@ -836,6 +836,8 @@ "registration.lead": "With an account on {instance} you\"ll be able to follow people on any server in the fediverse.", "registration.sign_up": "Sign up", "registration.tos": "Terms of Service", + "registration.privacy": "Privacy Policy", + "registration.acceptance": "By registering, you agree to the {terms} and {privacy}.", "registration.reason": "Reason for Joining", "relative_time.days": "{number}d", "relative_time.hours": "{number}h", @@ -876,7 +878,7 @@ "security.update_email.success": "Email successfully updated.", "security.update_password.fail": "Update password failed.", "security.update_password.success": "Password successfully updated.", - "signup_panel.subtitle": "Sign up now to discuss.", + "signup_panel.subtitle": "Sign up now to discuss what's happening.", "signup_panel.title": "New to {site_title}?", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this post in the moderation interface", diff --git a/app/soapbox/__mocks__/api.ts b/app/soapbox/__mocks__/api.ts index 99797009e..060846c94 100644 --- a/app/soapbox/__mocks__/api.ts +++ b/app/soapbox/__mocks__/api.ts @@ -1,11 +1,13 @@ import { jest } from '@jest/globals'; -import { AxiosInstance } from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import LinkHeader from 'http-link-header'; + +import type { AxiosInstance, AxiosResponse } from 'axios'; const api = jest.requireActual('../api') as Record; let mocks: Array = []; -export const __stub = (func: Function) => mocks.push(func); +export const __stub = (func: (mock: MockAdapter) => void) => mocks.push(func); export const __clear = (): Function[] => mocks = []; const setupMock = (axios: AxiosInstance) => { @@ -15,6 +17,10 @@ const setupMock = (axios: AxiosInstance) => { export const staticClient = api.staticClient; +export const getLinks = (response: AxiosResponse): LinkHeader => { + return new LinkHeader(response.headers?.link); +}; + export const baseClient = (...params: any[]) => { const axios = api.baseClient(...params); setupMock(axios); diff --git a/app/soapbox/__tests__/compare_id.test.ts b/app/soapbox/__tests__/compare_id.test.ts new file mode 100644 index 000000000..583b4a1eb --- /dev/null +++ b/app/soapbox/__tests__/compare_id.test.ts @@ -0,0 +1,7 @@ +import compareId from '../compare_id'; + +test('compareId', () => { + expect(compareId('3', '3')).toBe(0); + expect(compareId('10', '1')).toBe(1); + expect(compareId('99', '100')).toBe(-1); +}); diff --git a/app/soapbox/actions/__tests__/about-test.js b/app/soapbox/actions/__tests__/about.test.ts similarity index 100% rename from app/soapbox/actions/__tests__/about-test.js rename to app/soapbox/actions/__tests__/about.test.ts diff --git a/app/soapbox/actions/__tests__/account-notes.test.ts b/app/soapbox/actions/__tests__/account-notes.test.ts new file mode 100644 index 000000000..61e0c20b0 --- /dev/null +++ b/app/soapbox/actions/__tests__/account-notes.test.ts @@ -0,0 +1,110 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { ReducerRecord, EditRecord } from 'soapbox/reducers/account_notes'; + +import { normalizeAccount, normalizeRelationship } from '../../normalizers'; +import { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes'; + +import type { Account } from 'soapbox/types/entities'; + +describe('submitAccountNote()', () => { + let store: ReturnType; + + beforeEach(() => { + const state = rootState + .set('account_notes', ReducerRecord({ edit: EditRecord({ account: '1', comment: 'hello' }) })); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost('/api/v1/accounts/1/note').reply(200, {}); + }); + }); + + it('post the note to the API', async() => { + const expectedActions = [ + { type: 'ACCOUNT_NOTE_SUBMIT_REQUEST' }, + { type: 'MODAL_CLOSE', modalType: undefined }, + { type: 'ACCOUNT_NOTE_SUBMIT_SUCCESS', relationship: {} }, + ]; + await store.dispatch(submitAccountNote()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost('/api/v1/accounts/1/note').networkError(); + }); + }); + + it('should dispatch failed action', async() => { + const expectedActions = [ + { type: 'ACCOUNT_NOTE_SUBMIT_REQUEST' }, + { + type: 'ACCOUNT_NOTE_SUBMIT_FAIL', + error: new Error('Network Error'), + }, + ]; + await store.dispatch(submitAccountNote()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); + +describe('initAccountNoteModal()', () => { + let store: ReturnType; + + beforeEach(() => { + const state = rootState + .set('relationships', ImmutableMap({ '1': normalizeRelationship({ note: 'hello' }) })); + store = mockStore(state); + }); + + it('dispatches the proper actions', async() => { + const account = normalizeAccount({ + id: '1', + acct: 'justin-username', + display_name: 'Justin L', + avatar: 'test.jpg', + verified: true, + }) as Account; + const expectedActions = [ + { type: 'ACCOUNT_NOTE_INIT_MODAL', account, comment: 'hello' }, + { type: 'MODAL_OPEN', modalType: 'ACCOUNT_NOTE' }, + ]; + await store.dispatch(initAccountNoteModal(account)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); +}); + +describe('changeAccountNoteComment()', () => { + let store: ReturnType; + + beforeEach(() => { + const state = rootState; + store = mockStore(state); + }); + + it('dispatches the proper actions', async() => { + const comment = 'hello world'; + const expectedActions = [ + { type: 'ACCOUNT_NOTE_CHANGE_COMMENT', comment }, + ]; + await store.dispatch(changeAccountNoteComment(comment)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); +}); diff --git a/app/soapbox/actions/__tests__/accounts.test.ts b/app/soapbox/actions/__tests__/accounts.test.ts new file mode 100644 index 000000000..0793e36f7 --- /dev/null +++ b/app/soapbox/actions/__tests__/accounts.test.ts @@ -0,0 +1,1638 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { ListRecord, ReducerRecord } from 'soapbox/reducers/user_lists'; + +import { normalizeAccount, normalizeInstance, normalizeRelationship } from '../../normalizers'; +import { + authorizeFollowRequest, + blockAccount, + createAccount, + expandFollowers, + expandFollowing, + expandFollowRequests, + fetchAccount, + fetchAccountByUsername, + fetchFollowers, + fetchFollowing, + fetchFollowRequests, + fetchRelationships, + followAccount, + muteAccount, + removeFromFollowers, + subscribeAccount, + unblockAccount, + unfollowAccount, + unmuteAccount, + unsubscribeAccount, +} from '../accounts'; + +let store: ReturnType; + +describe('createAccount()', () => { + const params = { + email: 'foo@bar.com', + }; + + describe('with a successful API request', () => { + beforeEach(() => { + const state = rootState; + store = mockStore(state); + + __stub((mock) => { + mock.onPost('/api/v1/accounts').reply(200, { token: '123 ' }); + }); + }); + + it('dispatches the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_CREATE_REQUEST', params }, + { + type: 'ACCOUNT_CREATE_SUCCESS', + params, + token: { token: '123 ' }, + }, + ]; + await store.dispatch(createAccount(params)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); + +describe('fetchAccount()', () => { + const id = '123'; + + describe('when the account has "should_refetch" set to false', () => { + beforeEach(() => { + const account = normalizeAccount({ + id, + acct: 'justin-username', + display_name: 'Justin L', + avatar: 'test.jpg', + }); + + const state = rootState + .set('accounts', ImmutableMap({ + [id]: account, + }) as any); + + store = mockStore(state); + + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${id}`).reply(200, account); + }); + }); + + it('should do nothing', async() => { + await store.dispatch(fetchAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('with a successful API request', () => { + const account = require('soapbox/__fixtures__/pleroma-account.json'); + + beforeEach(() => { + const state = rootState; + store = mockStore(state); + + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${id}`).reply(200, account); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_FETCH_REQUEST', id: '123' }, + { type: 'ACCOUNTS_IMPORT', accounts: [account] }, + { + type: 'ACCOUNT_FETCH_SUCCESS', + account, + }, + ]; + + await store.dispatch(fetchAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + const state = rootState; + store = mockStore(state); + + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${id}`).networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_FETCH_REQUEST', id: '123' }, + { + type: 'ACCOUNT_FETCH_FAIL', + id, + error: new Error('Network Error'), + skipAlert: true, + }, + ]; + + await store.dispatch(fetchAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); + +describe('fetchAccountByUsername()', () => { + const id = '123'; + const username = 'tiger'; + let state, account: any; + + beforeEach(() => { + account = normalizeAccount({ + id, + acct: username, + display_name: 'Tiger', + avatar: 'test.jpg', + birthday: undefined, + }); + + state = rootState + .set('accounts', ImmutableMap({ + [id]: account, + })); + + store = mockStore(state); + + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${id}`).reply(200, account); + }); + }); + + describe('when "accountByUsername" feature is enabled', () => { + beforeEach(() => { + const state = rootState + .set('instance', normalizeInstance({ + version: '2.7.2 (compatible; Pleroma 2.4.52-1337-g4779199e.gleasonator+soapbox)', + pleroma: ImmutableMap({ + metadata: ImmutableMap({ + features: [], + }), + }), + })) + .set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${username}`).reply(200, account); + mock.onGet(`/api/v1/accounts/relationships?${[account.id].map(id => `id[]=${id}`).join('&')}`); + }); + }); + + it('should return dispatch the proper actions', async() => { + await store.dispatch(fetchAccountByUsername(username)); + const actions = store.getActions(); + + expect(actions[0]).toEqual({ + type: 'RELATIONSHIPS_FETCH_REQUEST', + ids: ['123'], + skipLoading: true, + }); + expect(actions[1].type).toEqual('ACCOUNTS_IMPORT'); + expect(actions[2].type).toEqual('ACCOUNT_FETCH_SUCCESS'); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${username}`).networkError(); + }); + }); + + it('should return dispatch the proper actions', async() => { + const expectedActions = [ + { + type: 'ACCOUNT_FETCH_FAIL', + id: null, + error: new Error('Network Error'), + skipAlert: true, + }, + { type: 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP', username: 'tiger' }, + ]; + + await store.dispatch(fetchAccountByUsername(username)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); + + describe('when "accountLookup" feature is enabled', () => { + beforeEach(() => { + const state = rootState + .set('instance', normalizeInstance({ + version: '3.4.1 (compatible; TruthSocial 1.0.0)', + pleroma: ImmutableMap({ + metadata: ImmutableMap({ + features: [], + }), + }), + })) + .set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/accounts/lookup').reply(200, account); + }); + }); + + it('should return dispatch the proper actions', async() => { + await store.dispatch(fetchAccountByUsername(username)); + const actions = store.getActions(); + + expect(actions[0]).toEqual({ + type: 'ACCOUNT_LOOKUP_REQUEST', + acct: username, + }); + expect(actions[1].type).toEqual('ACCOUNTS_IMPORT'); + expect(actions[2].type).toEqual('ACCOUNT_LOOKUP_SUCCESS'); + expect(actions[3].type).toEqual('RELATIONSHIPS_FETCH_REQUEST'); + expect(actions[4].type).toEqual('ACCOUNT_FETCH_SUCCESS'); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/accounts/lookup').networkError(); + }); + }); + + it('should return dispatch the proper actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_LOOKUP_REQUEST', acct: 'tiger' }, + { type: 'ACCOUNT_LOOKUP_FAIL' }, + { + type: 'ACCOUNT_FETCH_FAIL', + id: null, + error: new Error('Network Error'), + skipAlert: true, + }, + { type: 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP', username }, + ]; + + await store.dispatch(fetchAccountByUsername(username)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); + + describe('when using the accountSearch function', () => { + beforeEach(() => { + const state = rootState.set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/accounts/search').reply(200, [account]); + }); + }); + + it('should return dispatch the proper actions', async() => { + await store.dispatch(fetchAccountByUsername(username)); + const actions = store.getActions(); + + expect(actions[0]).toEqual({ + type: 'ACCOUNT_SEARCH_REQUEST', + params: { q: username, limit: 5, resolve: true }, + }); + expect(actions[1].type).toEqual('ACCOUNTS_IMPORT'); + expect(actions[2].type).toEqual('ACCOUNT_SEARCH_SUCCESS'); + expect(actions[3]).toEqual({ + type: 'RELATIONSHIPS_FETCH_REQUEST', + ids: [ '123' ], + skipLoading: true, + }); + expect(actions[4].type).toEqual('ACCOUNT_FETCH_SUCCESS'); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/accounts/search').networkError(); + }); + }); + + it('should return dispatch the proper actions', async() => { + const expectedActions = [ + { + type: 'ACCOUNT_SEARCH_REQUEST', + params: { q: username, limit: 5, resolve: true }, + }, + { type: 'ACCOUNT_SEARCH_FAIL', skipAlert: true }, + { + type: 'ACCOUNT_FETCH_FAIL', + id: null, + error: new Error('Network Error'), + skipAlert: true, + }, + { type: 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP', username }, + ]; + + await store.dispatch(fetchAccountByUsername(username)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('followAccount()', () => { + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(followAccount('1')); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + const id = '1'; + + beforeEach(() => { + const state = rootState.set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/accounts/${id}/follow`).reply(200, { success: true }); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { + type: 'ACCOUNT_FOLLOW_REQUEST', + id, + locked: false, + skipLoading: true, + }, + { + type: 'ACCOUNT_FOLLOW_SUCCESS', + relationship: { success: true }, + alreadyFollowing: undefined, + skipLoading: true, + }, + ]; + await store.dispatch(followAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/accounts/${id}/follow`).networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { + type: 'ACCOUNT_FOLLOW_REQUEST', + id, + locked: false, + skipLoading: true, + }, + { + type: 'ACCOUNT_FOLLOW_FAIL', + error: new Error('Network Error'), + locked: false, + skipLoading: true, + }, + ]; + + try { + await store.dispatch(followAccount(id)); + } catch (e) { + const actions = store.getActions(); + expect(actions).toEqual(expectedActions); + expect(e).toEqual(new Error('Network Error')); + } + }); + }); + }); +}); + +describe('unfollowAccount()', () => { + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(unfollowAccount('1')); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + const id = '1'; + + beforeEach(() => { + const state = rootState.set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/accounts/${id}/unfollow`).reply(200, { success: true }); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_UNFOLLOW_REQUEST', id: '1', skipLoading: true }, + { + type: 'ACCOUNT_UNFOLLOW_SUCCESS', + relationship: { success: true }, + statuses: ImmutableMap({}), + skipLoading: true, + }, + ]; + await store.dispatch(unfollowAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/accounts/${id}/unfollow`).networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { + type: 'ACCOUNT_UNFOLLOW_REQUEST', + id, + skipLoading: true, + }, + { + type: 'ACCOUNT_UNFOLLOW_FAIL', + error: new Error('Network Error'), + skipLoading: true, + }, + ]; + await store.dispatch(unfollowAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('blockAccount()', () => { + const id = '1'; + + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(blockAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState.set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/accounts/${id}/block`).reply(200, {}); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_BLOCK_REQUEST', id }, + { + type: 'ACCOUNT_BLOCK_SUCCESS', + relationship: {}, + statuses: ImmutableMap({}), + }, + ]; + await store.dispatch(blockAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/accounts/${id}/block`).networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_BLOCK_REQUEST', id }, + { type: 'ACCOUNT_BLOCK_FAIL', error: new Error('Network Error') }, + ]; + await store.dispatch(blockAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('unblockAccount()', () => { + const id = '1'; + + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(unblockAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState.set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/accounts/${id}/unblock`).reply(200, {}); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_UNBLOCK_REQUEST', id }, + { + type: 'ACCOUNT_UNBLOCK_SUCCESS', + relationship: {}, + }, + ]; + await store.dispatch(unblockAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/accounts/${id}/unblock`).networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_UNBLOCK_REQUEST', id }, + { type: 'ACCOUNT_UNBLOCK_FAIL', error: new Error('Network Error') }, + ]; + await store.dispatch(unblockAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('muteAccount()', () => { + const id = '1'; + + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(unblockAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState.set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/accounts/${id}/mute`).reply(200, {}); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_MUTE_REQUEST', id }, + { + type: 'ACCOUNT_MUTE_SUCCESS', + relationship: {}, + statuses: ImmutableMap({}), + }, + ]; + await store.dispatch(muteAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/accounts/${id}/mute`).networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_MUTE_REQUEST', id }, + { type: 'ACCOUNT_MUTE_FAIL', error: new Error('Network Error') }, + ]; + await store.dispatch(muteAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('unmuteAccount()', () => { + const id = '1'; + + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(unblockAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState.set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/accounts/${id}/unmute`).reply(200, {}); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_UNMUTE_REQUEST', id }, + { + type: 'ACCOUNT_UNMUTE_SUCCESS', + relationship: {}, + }, + ]; + await store.dispatch(unmuteAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/accounts/${id}/unmute`).networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_UNMUTE_REQUEST', id }, + { type: 'ACCOUNT_UNMUTE_FAIL', error: new Error('Network Error') }, + ]; + await store.dispatch(unmuteAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('subscribeAccount()', () => { + const id = '1'; + + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(subscribeAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState.set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/pleroma/accounts/${id}/subscribe`).reply(200, {}); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_SUBSCRIBE_REQUEST', id }, + { + type: 'ACCOUNT_SUBSCRIBE_SUCCESS', + relationship: {}, + }, + ]; + await store.dispatch(subscribeAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/pleroma/accounts/${id}/subscribe`).networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_SUBSCRIBE_REQUEST', id }, + { type: 'ACCOUNT_SUBSCRIBE_FAIL', error: new Error('Network Error') }, + ]; + await store.dispatch(subscribeAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('unsubscribeAccount()', () => { + const id = '1'; + + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(subscribeAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState.set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/pleroma/accounts/${id}/unsubscribe`).reply(200, {}); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_UNSUBSCRIBE_REQUEST', id }, + { + type: 'ACCOUNT_UNSUBSCRIBE_SUCCESS', + relationship: {}, + }, + ]; + await store.dispatch(unsubscribeAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/pleroma/accounts/${id}/unsubscribe`).networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_UNSUBSCRIBE_REQUEST', id }, + { type: 'ACCOUNT_UNSUBSCRIBE_FAIL', error: new Error('Network Error') }, + ]; + await store.dispatch(unsubscribeAccount(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('removeFromFollowers()', () => { + const id = '1'; + + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(removeFromFollowers(id)); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState.set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/accounts/${id}/remove_from_followers`).reply(200, {}); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST', id }, + { + type: 'ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS', + relationship: {}, + }, + ]; + await store.dispatch(removeFromFollowers(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/accounts/${id}/remove_from_followers`).networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST', id }, + { type: 'ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL', id, error: new Error('Network Error') }, + ]; + await store.dispatch(removeFromFollowers(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('fetchFollowers()', () => { + const id = '1'; + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState.set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${id}/followers`).reply(200, [], { + link: `; rel='prev'`, + }); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'FOLLOWERS_FETCH_REQUEST', id }, + { type: 'ACCOUNTS_IMPORT', accounts: [] }, + { + type: 'FOLLOWERS_FETCH_SUCCESS', + id, + accounts: [], + next: null, + }, + ]; + await store.dispatch(fetchFollowers(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${id}/followers`).networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'FOLLOWERS_FETCH_REQUEST', id }, + { type: 'FOLLOWERS_FETCH_FAIL', id, error: new Error('Network Error') }, + ]; + await store.dispatch(fetchFollowers(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('expandFollowers()', () => { + const id = '1'; + + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(expandFollowers(id)); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState + .set('user_lists', ReducerRecord({ + followers: ImmutableMap({ + [id]: ListRecord({ + next: 'next_url', + }), + }), + })) + .set('me', '123'); + store = mockStore(state); + }); + + describe('when the url is null', () => { + beforeEach(() => { + const state = rootState + .set('user_lists', ReducerRecord({ + followers: ImmutableMap({ + [id]: ListRecord({ + next: null, + }), + }), + })) + .set('me', '123'); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(expandFollowers(id)); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('next_url').reply(200, [], { + link: `; rel='prev'`, + }); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'FOLLOWERS_EXPAND_REQUEST', id }, + { type: 'ACCOUNTS_IMPORT', accounts: [] }, + { + type: 'FOLLOWERS_EXPAND_SUCCESS', + id, + accounts: [], + next: null, + }, + ]; + await store.dispatch(expandFollowers(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('next_url').networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'FOLLOWERS_EXPAND_REQUEST', id }, + { type: 'FOLLOWERS_EXPAND_FAIL', id, error: new Error('Network Error') }, + ]; + await store.dispatch(expandFollowers(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('fetchFollowing()', () => { + const id = '1'; + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState.set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${id}/following`).reply(200, [], { + link: `; rel='prev'`, + }); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'FOLLOWING_FETCH_REQUEST', id }, + { type: 'ACCOUNTS_IMPORT', accounts: [] }, + { + type: 'FOLLOWING_FETCH_SUCCESS', + id, + accounts: [], + next: null, + }, + ]; + await store.dispatch(fetchFollowing(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${id}/following`).networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'FOLLOWING_FETCH_REQUEST', id }, + { type: 'FOLLOWING_FETCH_FAIL', id, error: new Error('Network Error') }, + ]; + await store.dispatch(fetchFollowing(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('expandFollowing()', () => { + const id = '1'; + + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(expandFollowing(id)); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState + .set('user_lists', ReducerRecord({ + following: ImmutableMap({ + [id]: ListRecord({ + next: 'next_url', + }), + }), + })) + .set('me', '123'); + store = mockStore(state); + }); + + describe('when the url is null', () => { + beforeEach(() => { + const state = rootState + .set('user_lists', ReducerRecord({ + following: ImmutableMap({ + [id]: ListRecord({ + next: null, + }), + }), + })) + .set('me', '123'); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(expandFollowing(id)); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('next_url').reply(200, [], { + link: `; rel='prev'`, + }); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'FOLLOWING_EXPAND_REQUEST', id }, + { type: 'ACCOUNTS_IMPORT', accounts: [] }, + { + type: 'FOLLOWING_EXPAND_SUCCESS', + id, + accounts: [], + next: null, + }, + ]; + await store.dispatch(expandFollowing(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('next_url').networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'FOLLOWING_EXPAND_REQUEST', id }, + { type: 'FOLLOWING_EXPAND_FAIL', id, error: new Error('Network Error') }, + ]; + await store.dispatch(expandFollowing(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('fetchRelationships()', () => { + const id = '1'; + + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(fetchRelationships([id])); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState + .set('me', '123'); + store = mockStore(state); + }); + + describe('without newAccountIds', () => { + beforeEach(() => { + const state = rootState + .set('relationships', ImmutableMap({ [id]: normalizeRelationship({}) })) + .set('me', '123'); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(fetchRelationships([id])); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + const state = rootState + .set('relationships', ImmutableMap({})) + .set('me', '123'); + store = mockStore(state); + + __stub((mock) => { + mock + .onGet(`/api/v1/accounts/relationships?${[id].map(id => `id[]=${id}`).join('&')}`) + .reply(200, []); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'RELATIONSHIPS_FETCH_REQUEST', ids: [id], skipLoading: true }, + { + type: 'RELATIONSHIPS_FETCH_SUCCESS', + relationships: [], + skipLoading: true, + }, + ]; + await store.dispatch(fetchRelationships([id])); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock + .onGet(`/api/v1/accounts/relationships?${[id].map(id => `id[]=${id}`).join('&')}`) + .networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'RELATIONSHIPS_FETCH_REQUEST', ids: [id], skipLoading: true }, + { type: 'RELATIONSHIPS_FETCH_FAIL', skipLoading: true, error: new Error('Network Error') }, + ]; + await store.dispatch(fetchRelationships([id])); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('fetchFollowRequests()', () => { + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(fetchFollowRequests()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState + .set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + const state = rootState + .set('relationships', ImmutableMap({})) + .set('me', '123'); + store = mockStore(state); + + __stub((mock) => { + mock.onGet('/api/v1/follow_requests').reply(200, [], { + link: '; rel=\'prev\'', + }); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'FOLLOW_REQUESTS_FETCH_REQUEST' }, + { type: 'ACCOUNTS_IMPORT', accounts: [] }, + { + type: 'FOLLOW_REQUESTS_FETCH_SUCCESS', + accounts: [], + next: null, + }, + ]; + await store.dispatch(fetchFollowRequests()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/follow_requests').networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'FOLLOW_REQUESTS_FETCH_REQUEST' }, + { type: 'FOLLOW_REQUESTS_FETCH_FAIL', error: new Error('Network Error') }, + ]; + await store.dispatch(fetchFollowRequests()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('expandFollowRequests()', () => { + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(expandFollowRequests()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState + .set('user_lists', ReducerRecord({ + follow_requests: ListRecord({ + next: 'next_url', + }), + })) + .set('me', '123'); + store = mockStore(state); + }); + + describe('when the url is null', () => { + beforeEach(() => { + const state = rootState + .set('user_lists', ReducerRecord({ + follow_requests: ListRecord({ + next: null, + }), + })) + .set('me', '123'); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(expandFollowRequests()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('next_url').reply(200, [], { + link: '; rel=\'prev\'', + }); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'FOLLOW_REQUESTS_EXPAND_REQUEST' }, + { type: 'ACCOUNTS_IMPORT', accounts: [] }, + { + type: 'FOLLOW_REQUESTS_EXPAND_SUCCESS', + accounts: [], + next: null, + }, + ]; + await store.dispatch(expandFollowRequests()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('next_url').networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'FOLLOW_REQUESTS_EXPAND_REQUEST' }, + { type: 'FOLLOW_REQUESTS_EXPAND_FAIL', error: new Error('Network Error') }, + ]; + await store.dispatch(expandFollowRequests()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('authorizeFollowRequest()', () => { + const id = '1'; + + describe('when logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(authorizeFollowRequest(id)); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('when logged in', () => { + beforeEach(() => { + const state = rootState.set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/follow_requests/${id}/authorize`).reply(200); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'FOLLOW_REQUEST_AUTHORIZE_REQUEST', id }, + { type: 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS', id }, + ]; + await store.dispatch(authorizeFollowRequest(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPost(`/api/v1/follow_requests/${id}/authorize`).networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'FOLLOW_REQUEST_AUTHORIZE_REQUEST', id }, + { type: 'FOLLOW_REQUEST_AUTHORIZE_FAIL', id, error: new Error('Network Error') }, + ]; + await store.dispatch(authorizeFollowRequest(id)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); diff --git a/app/soapbox/actions/__tests__/alerts.test.ts b/app/soapbox/actions/__tests__/alerts.test.ts new file mode 100644 index 000000000..5f1f9f4d6 --- /dev/null +++ b/app/soapbox/actions/__tests__/alerts.test.ts @@ -0,0 +1,146 @@ +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__/announcements.test.ts b/app/soapbox/actions/__tests__/announcements.test.ts new file mode 100644 index 000000000..978311585 --- /dev/null +++ b/app/soapbox/actions/__tests__/announcements.test.ts @@ -0,0 +1,113 @@ +import { List as ImmutableList } from 'immutable'; + +import { fetchAnnouncements, dismissAnnouncement, addReaction, removeReaction } from 'soapbox/actions/announcements'; +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { normalizeAnnouncement, normalizeInstance } from 'soapbox/normalizers'; + +import type { APIEntity } from 'soapbox/types/entities'; + +const announcements = require('soapbox/__fixtures__/announcements.json'); + +describe('fetchAnnouncements()', () => { + describe('with a successful API request', () => { + it('should fetch announcements from the API', async() => { + const state = rootState + .set('instance', normalizeInstance({ version: '3.5.3' })); + const store = mockStore(state); + + __stub((mock) => { + mock.onGet('/api/v1/announcements').reply(200, announcements); + }); + + const expectedActions = [ + { type: 'ANNOUNCEMENTS_FETCH_REQUEST', skipLoading: true }, + { type: 'ANNOUNCEMENTS_FETCH_SUCCESS', announcements, skipLoading: true }, + { type: 'POLLS_IMPORT', polls: [] }, + { type: 'ACCOUNTS_IMPORT', accounts: [] }, + { type: 'STATUSES_IMPORT', statuses: [], expandSpoilers: false }, + ]; + await store.dispatch(fetchAnnouncements()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); + +describe('dismissAnnouncement', () => { + describe('with a successful API request', () => { + it('should mark announcement as dismissed', async() => { + const store = mockStore(rootState); + + __stub((mock) => { + mock.onPost('/api/v1/announcements/1/dismiss').reply(200); + }); + + const expectedActions = [ + { type: 'ANNOUNCEMENTS_DISMISS_REQUEST', id: '1' }, + { type: 'ANNOUNCEMENTS_DISMISS_SUCCESS', id: '1' }, + ]; + await store.dispatch(dismissAnnouncement('1')); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); + +describe('addReaction', () => { + let store: ReturnType; + + beforeEach(() => { + const state = rootState + .setIn(['announcements', 'items'], ImmutableList((announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement)))) + .setIn(['announcements', 'isLoading'], false); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + it('should add reaction to a post', async() => { + __stub((mock) => { + mock.onPut('/api/v1/announcements/2/reactions/📉').reply(200); + }); + + const expectedActions = [ + { type: 'ANNOUNCEMENTS_REACTION_ADD_REQUEST', id: '2', name: '📉', skipLoading: true }, + { type: 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS', id: '2', name: '📉', skipLoading: true }, + ]; + await store.dispatch(addReaction('2', '📉')); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); + +describe('removeReaction', () => { + let store: ReturnType; + + beforeEach(() => { + const state = rootState + .setIn(['announcements', 'items'], ImmutableList((announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement)))) + .setIn(['announcements', 'isLoading'], false); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + it('should remove reaction from a post', async() => { + __stub((mock) => { + mock.onDelete('/api/v1/announcements/2/reactions/📉').reply(200); + }); + + const expectedActions = [ + { type: 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST', id: '2', name: '📉', skipLoading: true }, + { type: 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS', id: '2', name: '📉', skipLoading: true }, + ]; + await store.dispatch(removeReaction('2', '📉')); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); diff --git a/app/soapbox/actions/__tests__/blocks.test.ts b/app/soapbox/actions/__tests__/blocks.test.ts new file mode 100644 index 000000000..8b4c040b3 --- /dev/null +++ b/app/soapbox/actions/__tests__/blocks.test.ts @@ -0,0 +1,183 @@ +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { ListRecord, ReducerRecord as UserListsRecord } from 'soapbox/reducers/user_lists'; + +import { expandBlocks, fetchBlocks } from '../blocks'; + +const account = { + acct: 'twoods', + display_name: 'Tiger Woods', + id: '22', + username: 'twoods', +}; + +describe('fetchBlocks()', () => { + let store: ReturnType; + + describe('if logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(fetchBlocks()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('if logged in', () => { + beforeEach(() => { + const state = rootState.set('me', '1234'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + const blocks = require('soapbox/__fixtures__/blocks.json'); + + __stub((mock) => { + mock.onGet('/api/v1/blocks').reply(200, blocks, { + link: '; rel=\'prev\'', + }); + }); + }); + + it('should fetch blocks from the API', async() => { + const expectedActions = [ + { type: 'BLOCKS_FETCH_REQUEST' }, + { type: 'ACCOUNTS_IMPORT', accounts: [account] }, + { type: 'BLOCKS_FETCH_SUCCESS', accounts: [account], next: null }, + { + type: 'RELATIONSHIPS_FETCH_REQUEST', + ids: ['22'], + skipLoading: true, + }, + ]; + await store.dispatch(fetchBlocks()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/blocks').networkError(); + }); + }); + + it('should dispatch failed action', async() => { + const expectedActions = [ + { type: 'BLOCKS_FETCH_REQUEST' }, + { type: 'BLOCKS_FETCH_FAIL', error: new Error('Network Error') }, + ]; + await store.dispatch(fetchBlocks()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('expandBlocks()', () => { + let store: ReturnType; + + describe('if logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(expandBlocks()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('if logged in', () => { + beforeEach(() => { + const state = rootState.set('me', '1234'); + store = mockStore(state); + }); + + describe('without a url', () => { + beforeEach(() => { + const state = rootState + .set('me', '1234') + .set('user_lists', UserListsRecord({ blocks: ListRecord({ next: null }) })); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(expandBlocks()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('with a url', () => { + beforeEach(() => { + const state = rootState + .set('me', '1234') + .set('user_lists', UserListsRecord({ blocks: ListRecord({ next: 'example' }) })); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + const blocks = require('soapbox/__fixtures__/blocks.json'); + + __stub((mock) => { + mock.onGet('example').reply(200, blocks, { + link: '; rel=\'prev\'', + }); + }); + }); + + it('should fetch blocks from the url', async() => { + const expectedActions = [ + { type: 'BLOCKS_EXPAND_REQUEST' }, + { type: 'ACCOUNTS_IMPORT', accounts: [account] }, + { type: 'BLOCKS_EXPAND_SUCCESS', accounts: [account], next: null }, + { + type: 'RELATIONSHIPS_FETCH_REQUEST', + ids: ['22'], + skipLoading: true, + }, + ]; + await store.dispatch(expandBlocks()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('example').networkError(); + }); + }); + + it('should dispatch failed action', async() => { + const expectedActions = [ + { type: 'BLOCKS_EXPAND_REQUEST' }, + { type: 'BLOCKS_EXPAND_FAIL', error: new Error('Network Error') }, + ]; + await store.dispatch(expandBlocks()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); + }); +}); diff --git a/app/soapbox/actions/__tests__/compose.test.js b/app/soapbox/actions/__tests__/compose.test.ts similarity index 59% rename from app/soapbox/actions/__tests__/compose.test.js rename to app/soapbox/actions/__tests__/compose.test.ts index 5a743544d..88f8c8858 100644 --- a/app/soapbox/actions/__tests__/compose.test.js +++ b/app/soapbox/actions/__tests__/compose.test.ts @@ -1,26 +1,30 @@ -import { mockStore } from 'soapbox/jest/test-helpers'; -import { InstanceRecord } from 'soapbox/normalizers'; -import rootReducer from 'soapbox/reducers'; +import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; -import { uploadCompose } from '../compose'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { InstanceRecord } from 'soapbox/normalizers'; + +import { uploadCompose, submitCompose } from '../compose'; +import { STATUS_CREATE_REQUEST } from '../statuses'; + +import type { IntlShape } from 'react-intl'; describe('uploadCompose()', () => { describe('with images', () => { - let files, store; + let files: FileList, store: ReturnType; beforeEach(() => { const instance = InstanceRecord({ - configuration: { - statuses: { + configuration: ImmutableMap({ + statuses: ImmutableMap({ max_media_attachments: 4, - }, - media_attachments: { + }), + media_attachments: ImmutableMap({ image_size_limit: 10, - }, - }, + }), + }), }); - const state = rootReducer(undefined, {}) + const state = rootState .set('me', '1234') .set('instance', instance); @@ -30,13 +34,13 @@ describe('uploadCompose()', () => { name: 'Image', size: 15, type: 'image/png', - }]; + }] as unknown as FileList; }); it('creates an alert if exceeds max size', async() => { const mockIntl = { formatMessage: jest.fn().mockReturnValue('Image exceeds the current file size limit (10 Bytes)'), - }; + } as unknown as IntlShape; const expectedActions = [ { type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true }, @@ -58,21 +62,21 @@ describe('uploadCompose()', () => { }); describe('with videos', () => { - let files, store; + let files: FileList, store: ReturnType; beforeEach(() => { const instance = InstanceRecord({ - configuration: { - statuses: { + configuration: ImmutableMap({ + statuses: ImmutableMap({ max_media_attachments: 4, - }, - media_attachments: { + }), + media_attachments: ImmutableMap({ video_size_limit: 10, - }, - }, + }), + }), }); - const state = rootReducer(undefined, {}) + const state = rootState .set('me', '1234') .set('instance', instance); @@ -82,13 +86,13 @@ describe('uploadCompose()', () => { name: 'Video', size: 15, type: 'video/mp4', - }]; + }] as unknown as FileList; }); it('creates an alert if exceeds max size', async() => { const mockIntl = { formatMessage: jest.fn().mockReturnValue('Video exceeds the current file size limit (10 Bytes)'), - }; + } as unknown as IntlShape; const expectedActions = [ { type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true }, @@ -109,3 +113,26 @@ describe('uploadCompose()', () => { }); }); }); + +describe('submitCompose()', () => { + it('inserts mentions from text', async() => { + const state = rootState + .set('me', '123') + .setIn(['compose', 'text'], '@alex hello @mkljczk@pl.fediverse.pl @gg@汉语/漢語.com alex@alexgleason.me'); + + const store = mockStore(state); + await store.dispatch(submitCompose()); + const actions = store.getActions(); + + const statusCreateRequest = actions.find(action => action.type === STATUS_CREATE_REQUEST); + const to = statusCreateRequest!.params.to as ImmutableOrderedSet; + + const expected = [ + 'alex', + 'mkljczk@pl.fediverse.pl', + 'gg@汉语/漢語.com', + ]; + + expect(to.toJS()).toEqual(expected); + }); +}); diff --git a/app/soapbox/actions/__tests__/me.test.ts b/app/soapbox/actions/__tests__/me.test.ts new file mode 100644 index 000000000..6d37fc68c --- /dev/null +++ b/app/soapbox/actions/__tests__/me.test.ts @@ -0,0 +1,115 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; + +import { + fetchMe, patchMe, +} from '../me'; + +jest.mock('../../storage/kv_store', () => ({ + __esModule: true, + default: { + getItemOrError: jest.fn().mockReturnValue(Promise.resolve({})), + }, +})); + +let store: ReturnType; + +describe('fetchMe()', () => { + describe('without a token', () => { + beforeEach(() => { + const state = rootState; + store = mockStore(state); + }); + + it('dispatches the correct actions', async() => { + const expectedActions = [{ type: 'ME_FETCH_SKIP' }]; + await store.dispatch(fetchMe()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with a token', () => { + const accountUrl = 'accountUrl'; + const token = '123'; + + beforeEach(() => { + const state = rootState + .set('auth', ImmutableMap({ + me: accountUrl, + users: ImmutableMap({ + [accountUrl]: ImmutableMap({ + 'access_token': token, + }), + }), + })) + .set('accounts', ImmutableMap({ + [accountUrl]: { + url: accountUrl, + }, + }) as any); + store = mockStore(state); + }); + + describe('with a successful API response', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/accounts/verify_credentials').reply(200, {}); + }); + }); + + it('dispatches the correct actions', async() => { + const expectedActions = [ + { type: 'ME_FETCH_REQUEST' }, + { type: 'AUTH_ACCOUNT_REMEMBER_REQUEST', accountUrl }, + { type: 'ACCOUNTS_IMPORT', accounts: [] }, + { + type: 'AUTH_ACCOUNT_REMEMBER_SUCCESS', + account: {}, + accountUrl, + }, + { type: 'VERIFY_CREDENTIALS_REQUEST', token: '123' }, + { type: 'ACCOUNTS_IMPORT', accounts: [] }, + { type: 'VERIFY_CREDENTIALS_SUCCESS', token: '123', account: {} }, + ]; + await store.dispatch(fetchMe()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); + +describe('patchMe()', () => { + beforeEach(() => { + const state = rootState; + store = mockStore(state); + }); + + describe('with a successful API response', () => { + beforeEach(() => { + __stub((mock) => { + mock.onPatch('/api/v1/accounts/update_credentials').reply(200, {}); + }); + }); + + it('dispatches the correct actions', async() => { + const expectedActions = [ + { type: 'ME_PATCH_REQUEST' }, + { type: 'ACCOUNTS_IMPORT', accounts: [] }, + { + type: 'ME_PATCH_SUCCESS', + me: {}, + }, + ]; + await store.dispatch(patchMe({})); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/actions/__tests__/notifications.test.ts b/app/soapbox/actions/__tests__/notifications.test.ts new file mode 100644 index 000000000..2d0dd9356 --- /dev/null +++ b/app/soapbox/actions/__tests__/notifications.test.ts @@ -0,0 +1,38 @@ +import { OrderedMap as ImmutableOrderedMap } from 'immutable'; + +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { normalizeNotification } from 'soapbox/normalizers'; + +import { markReadNotifications } from '../notifications'; + +describe('markReadNotifications()', () => { + it('fires off marker when top notification is newer than lastRead', async() => { + __stub((mock) => mock.onPost('/api/v1/markers').reply(200, {})); + + const items = ImmutableOrderedMap({ + '10': normalizeNotification({ id: '10' }), + }); + + const state = rootState + .set('me', '123') + .setIn(['notifications', 'lastRead'], '9') + .setIn(['notifications', 'items'], items); + + const store = mockStore(state); + + const expectedActions = [{ + type: 'MARKER_SAVE_REQUEST', + marker: { + notifications: { + last_read_id: '10', + }, + }, + }]; + + store.dispatch(markReadNotifications()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); +}); diff --git a/app/soapbox/actions/__tests__/onboarding.test.ts b/app/soapbox/actions/__tests__/onboarding.test.ts index cdd268ed5..f786c7f90 100644 --- a/app/soapbox/actions/__tests__/onboarding.test.ts +++ b/app/soapbox/actions/__tests__/onboarding.test.ts @@ -1,5 +1,4 @@ -import { mockStore, mockWindowProperty } from 'soapbox/jest/test-helpers'; -import rootReducer from 'soapbox/reducers'; +import { mockStore, mockWindowProperty, rootState } from 'soapbox/jest/test-helpers'; import { checkOnboardingStatus, startOnboarding, endOnboarding } from '../onboarding'; @@ -17,7 +16,7 @@ describe('checkOnboarding()', () => { it('does nothing if localStorage item is not set', async() => { mockGetItem = jest.fn().mockReturnValue(null); - const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const state = rootState.setIn(['onboarding', 'needsOnboarding'], false); const store = mockStore(state); await store.dispatch(checkOnboardingStatus()); @@ -30,7 +29,7 @@ describe('checkOnboarding()', () => { it('does nothing if localStorage item is invalid', async() => { mockGetItem = jest.fn().mockReturnValue('invalid'); - const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const state = rootState.setIn(['onboarding', 'needsOnboarding'], false); const store = mockStore(state); await store.dispatch(checkOnboardingStatus()); @@ -43,7 +42,7 @@ describe('checkOnboarding()', () => { it('dispatches the correct action', async() => { mockGetItem = jest.fn().mockReturnValue('1'); - const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const state = rootState.setIn(['onboarding', 'needsOnboarding'], false); const store = mockStore(state); await store.dispatch(checkOnboardingStatus()); @@ -66,7 +65,7 @@ describe('startOnboarding()', () => { }); it('dispatches the correct action', async() => { - const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const state = rootState.setIn(['onboarding', 'needsOnboarding'], false); const store = mockStore(state); await store.dispatch(startOnboarding()); @@ -89,7 +88,7 @@ describe('endOnboarding()', () => { }); it('dispatches the correct action', async() => { - const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const state = rootState.setIn(['onboarding', 'needsOnboarding'], false); const store = mockStore(state); await store.dispatch(endOnboarding()); diff --git a/app/soapbox/actions/__tests__/preload-test.js b/app/soapbox/actions/__tests__/preload.test.ts similarity index 100% rename from app/soapbox/actions/__tests__/preload-test.js rename to app/soapbox/actions/__tests__/preload.test.ts diff --git a/app/soapbox/actions/__tests__/statuses-test.js b/app/soapbox/actions/__tests__/statuses-test.js deleted file mode 100644 index 972330f39..000000000 --- a/app/soapbox/actions/__tests__/statuses-test.js +++ /dev/null @@ -1,27 +0,0 @@ -import { STATUSES_IMPORT } from 'soapbox/actions/importer'; -import { __stub } from 'soapbox/api'; -import { mockStore, rootState } from 'soapbox/jest/test-helpers'; - -import { fetchContext } from '../statuses'; - -describe('fetchContext()', () => { - it('handles Mitra context', done => { - const statuses = require('soapbox/__fixtures__/mitra-context.json'); - - __stub(mock => { - mock.onGet('/api/v1/statuses/017ed505-5926-392f-256a-f86d5075df70/context') - .reply(200, statuses); - }); - - const store = mockStore(rootState); - - store.dispatch(fetchContext('017ed505-5926-392f-256a-f86d5075df70')).then(context => { - const actions = store.getActions(); - - expect(actions[3].type).toEqual(STATUSES_IMPORT); - expect(actions[3].statuses[0].id).toEqual('017ed503-bc96-301a-e871-2c23b30ddd05'); - - done(); - }).catch(console.error); - }); -}); diff --git a/app/soapbox/actions/__tests__/statuses.test.ts b/app/soapbox/actions/__tests__/statuses.test.ts new file mode 100644 index 000000000..18cbc173b --- /dev/null +++ b/app/soapbox/actions/__tests__/statuses.test.ts @@ -0,0 +1,160 @@ +import { fromJS, Map as ImmutableMap } from 'immutable'; + +import { STATUSES_IMPORT } from 'soapbox/actions/importer'; +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { normalizeStatus } from 'soapbox/normalizers/status'; + +import { deleteStatus, fetchContext } from '../statuses'; + +describe('fetchContext()', () => { + it('handles Mitra context', done => { + const statuses = require('soapbox/__fixtures__/mitra-context.json'); + + __stub(mock => { + mock.onGet('/api/v1/statuses/017ed505-5926-392f-256a-f86d5075df70/context') + .reply(200, statuses); + }); + + const store = mockStore(rootState); + + store.dispatch(fetchContext('017ed505-5926-392f-256a-f86d5075df70')).then(() => { + const actions = store.getActions(); + + expect(actions[3].type).toEqual(STATUSES_IMPORT); + expect(actions[3].statuses[0].id).toEqual('017ed503-bc96-301a-e871-2c23b30ddd05'); + + done(); + }).catch(console.error); + }); +}); + +describe('deleteStatus()', () => { + let store: ReturnType; + + describe('if logged out', () => { + beforeEach(() => { + const state = rootState.set('me', null); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(deleteStatus('1')); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('if logged in', () => { + const statusId = 'AHU2RrX0wdcwzCYjFQ'; + const cachedStatus = normalizeStatus({ + id: statusId, + }); + + beforeEach(() => { + const state = rootState + .set('me', '1234') + .set('statuses', fromJS({ + [statusId]: cachedStatus, + }) as any); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + let status: any; + + beforeEach(() => { + status = require('soapbox/__fixtures__/pleroma-status-deleted.json'); + + __stub((mock) => { + mock.onDelete(`/api/v1/statuses/${statusId}`).reply(200, status); + }); + }); + + it('should delete the status from the API', async() => { + const expectedActions = [ + { + type: 'STATUS_DELETE_REQUEST', + params: cachedStatus, + }, + { type: 'STATUS_DELETE_SUCCESS', id: statusId }, + { + type: 'TIMELINE_DELETE', + id: statusId, + accountId: null, + references: ImmutableMap({}), + reblogOf: null, + }, + ]; + await store.dispatch(deleteStatus(statusId)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + + it('should handle redraft', async() => { + const expectedActions = [ + { + type: 'STATUS_DELETE_REQUEST', + params: cachedStatus, + }, + { type: 'STATUS_DELETE_SUCCESS', id: statusId }, + { + type: 'TIMELINE_DELETE', + id: statusId, + accountId: null, + references: ImmutableMap({}), + reblogOf: null, + }, + { + type: 'COMPOSE_SET_STATUS', + status: cachedStatus, + rawText: status.text, + explicitAddressing: false, + spoilerText: '', + contentType: 'text/markdown', + v: { + build: undefined, + compatVersion: '0.0.0', + software: 'Mastodon', + version: '0.0.0', + }, + withRedraft: true, + }, + { type: 'MODAL_OPEN', modalType: 'COMPOSE', modalProps: undefined }, + ]; + await store.dispatch(deleteStatus(statusId, true)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onDelete(`/api/v1/statuses/${statusId}`).networkError(); + }); + }); + + it('should dispatch failed action', async() => { + const expectedActions = [ + { + type: 'STATUS_DELETE_REQUEST', + params: cachedStatus, + }, + { + type: 'STATUS_DELETE_FAIL', + params: cachedStatus, + error: new Error('Network Error'), + }, + ]; + await store.dispatch(deleteStatus(statusId, true)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); diff --git a/app/soapbox/actions/__tests__/suggestions.test.ts b/app/soapbox/actions/__tests__/suggestions.test.ts new file mode 100644 index 000000000..a153208a7 --- /dev/null +++ b/app/soapbox/actions/__tests__/suggestions.test.ts @@ -0,0 +1,109 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { normalizeInstance } from 'soapbox/normalizers'; + +import { + fetchSuggestions, +} from '../suggestions'; + +let store: ReturnType; +let state; + +describe('fetchSuggestions()', () => { + describe('with Truth Social software', () => { + beforeEach(() => { + state = rootState + .set('instance', normalizeInstance({ + version: '3.4.1 (compatible; TruthSocial 1.0.0)', + pleroma: ImmutableMap({ + metadata: ImmutableMap({ + features: [], + }), + }), + })) + .set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + const response = [ + { + account_id: '1', + acct: 'jl', + account_avatar: 'https://example.com/some.jpg', + display_name: 'justin', + note: '

note

', + verified: true, + }, + ]; + + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/suggestions').reply(200, response, { + link: '; rel=\'prev\'', + }); + }); + }); + + it('dispatches the correct actions', async() => { + const expectedActions = [ + { type: 'SUGGESTIONS_V2_FETCH_REQUEST', skipLoading: true }, + { + type: 'ACCOUNTS_IMPORT', accounts: [{ + acct: response[0].acct, + avatar: response[0].account_avatar, + avatar_static: response[0].account_avatar, + id: response[0].account_id, + note: response[0].note, + should_refetch: true, + verified: response[0].verified, + display_name: response[0].display_name, + }], + }, + { + type: 'SUGGESTIONS_TRUTH_FETCH_SUCCESS', + suggestions: response, + next: undefined, + skipLoading: true, + }, + { + type: 'RELATIONSHIPS_FETCH_REQUEST', + skipLoading: true, + ids: [response[0].account_id], + }, + ]; + await store.dispatch(fetchSuggestions()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/suggestions').networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'SUGGESTIONS_V2_FETCH_REQUEST', skipLoading: true }, + { + type: 'SUGGESTIONS_V2_FETCH_FAIL', + error: new Error('Network Error'), + skipLoading: true, + skipAlert: true, + }, + ]; + + await store.dispatch(fetchSuggestions()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); diff --git a/app/soapbox/actions/about.js b/app/soapbox/actions/about.js deleted file mode 100644 index 86be6beb4..000000000 --- a/app/soapbox/actions/about.js +++ /dev/null @@ -1,19 +0,0 @@ -import { staticClient } from '../api'; - -export const FETCH_ABOUT_PAGE_REQUEST = 'FETCH_ABOUT_PAGE_REQUEST'; -export const FETCH_ABOUT_PAGE_SUCCESS = 'FETCH_ABOUT_PAGE_SUCCESS'; -export const FETCH_ABOUT_PAGE_FAIL = 'FETCH_ABOUT_PAGE_FAIL'; - -export function fetchAboutPage(slug = 'index', locale) { - return (dispatch, getState) => { - dispatch({ type: FETCH_ABOUT_PAGE_REQUEST, slug, locale }); - const filename = `${slug}${locale ? `.${locale}` : ''}.html`; - return staticClient.get(`/instance/about/${filename}`).then(({ data: html }) => { - dispatch({ type: FETCH_ABOUT_PAGE_SUCCESS, slug, locale, html }); - return html; - }).catch(error => { - dispatch({ type: FETCH_ABOUT_PAGE_FAIL, slug, locale, error }); - throw error; - }); - }; -} diff --git a/app/soapbox/actions/about.ts b/app/soapbox/actions/about.ts new file mode 100644 index 000000000..07a486fd2 --- /dev/null +++ b/app/soapbox/actions/about.ts @@ -0,0 +1,29 @@ +import { staticClient } from '../api'; + +import type { AnyAction } from 'redux'; + +const FETCH_ABOUT_PAGE_REQUEST = 'FETCH_ABOUT_PAGE_REQUEST'; +const FETCH_ABOUT_PAGE_SUCCESS = 'FETCH_ABOUT_PAGE_SUCCESS'; +const FETCH_ABOUT_PAGE_FAIL = 'FETCH_ABOUT_PAGE_FAIL'; + +const fetchAboutPage = (slug = 'index', locale?: string) => (dispatch: React.Dispatch) => { + dispatch({ type: FETCH_ABOUT_PAGE_REQUEST, slug, locale }); + + const filename = `${slug}${locale ? `.${locale}` : ''}.html`; + return staticClient.get(`/instance/about/${filename}`) + .then(({ data: html }) => { + dispatch({ type: FETCH_ABOUT_PAGE_SUCCESS, slug, locale, html }); + return html; + }) + .catch(error => { + dispatch({ type: FETCH_ABOUT_PAGE_FAIL, slug, locale, error }); + throw error; + }); +}; + +export { + fetchAboutPage, + FETCH_ABOUT_PAGE_REQUEST, + FETCH_ABOUT_PAGE_SUCCESS, + FETCH_ABOUT_PAGE_FAIL, +}; diff --git a/app/soapbox/actions/account-notes.ts b/app/soapbox/actions/account-notes.ts new file mode 100644 index 000000000..33391cff4 --- /dev/null +++ b/app/soapbox/actions/account-notes.ts @@ -0,0 +1,82 @@ +import api from '../api'; + +import { openModal, closeModal } from './modals'; + +import type { AxiosError } from 'axios'; +import type { AnyAction } from 'redux'; +import type { RootState } from 'soapbox/store'; +import type { Account } from 'soapbox/types/entities'; + +const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; +const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS'; +const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL'; + +const ACCOUNT_NOTE_INIT_MODAL = 'ACCOUNT_NOTE_INIT_MODAL'; + +const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT'; + +const submitAccountNote = () => (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch(submitAccountNoteRequest()); + + const id = getState().account_notes.edit.account; + + return api(getState) + .post(`/api/v1/accounts/${id}/note`, { + comment: getState().account_notes.edit.comment, + }) + .then(response => { + dispatch(closeModal()); + dispatch(submitAccountNoteSuccess(response.data)); + }) + .catch(error => dispatch(submitAccountNoteFail(error))); +}; + +function submitAccountNoteRequest() { + return { + type: ACCOUNT_NOTE_SUBMIT_REQUEST, + }; +} + +function submitAccountNoteSuccess(relationship: any) { + return { + type: ACCOUNT_NOTE_SUBMIT_SUCCESS, + relationship, + }; +} + +function submitAccountNoteFail(error: AxiosError) { + return { + type: ACCOUNT_NOTE_SUBMIT_FAIL, + error, + }; +} + +const initAccountNoteModal = (account: Account) => (dispatch: React.Dispatch, getState: () => RootState) => { + const comment = getState().relationships.get(account.id)!.note; + + dispatch({ + type: ACCOUNT_NOTE_INIT_MODAL, + account, + comment, + }); + + dispatch(openModal('ACCOUNT_NOTE')); +}; + +function changeAccountNoteComment(comment: string) { + return { + type: ACCOUNT_NOTE_CHANGE_COMMENT, + comment, + }; +} + +export { + submitAccountNote, + initAccountNoteModal, + changeAccountNoteComment, + ACCOUNT_NOTE_SUBMIT_REQUEST, + ACCOUNT_NOTE_SUBMIT_SUCCESS, + ACCOUNT_NOTE_SUBMIT_FAIL, + ACCOUNT_NOTE_INIT_MODAL, + ACCOUNT_NOTE_CHANGE_COMMENT, +}; diff --git a/app/soapbox/actions/account_notes.js b/app/soapbox/actions/account_notes.js deleted file mode 100644 index d6aeefc49..000000000 --- a/app/soapbox/actions/account_notes.js +++ /dev/null @@ -1,67 +0,0 @@ -import api from '../api'; - -import { openModal, closeModal } from './modals'; - -export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; -export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS'; -export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL'; - -export const ACCOUNT_NOTE_INIT_MODAL = 'ACCOUNT_NOTE_INIT_MODAL'; - -export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT'; - -export function submitAccountNote() { - return (dispatch, getState) => { - dispatch(submitAccountNoteRequest()); - - const id = getState().getIn(['account_notes', 'edit', 'account_id']); - - api(getState).post(`/api/v1/accounts/${id}/note`, { - comment: getState().getIn(['account_notes', 'edit', 'comment']), - }).then(response => { - dispatch(closeModal()); - dispatch(submitAccountNoteSuccess(response.data)); - }).catch(error => dispatch(submitAccountNoteFail(error))); - }; -} - -export function submitAccountNoteRequest() { - return { - type: ACCOUNT_NOTE_SUBMIT_REQUEST, - }; -} - -export function submitAccountNoteSuccess(relationship) { - return { - type: ACCOUNT_NOTE_SUBMIT_SUCCESS, - relationship, - }; -} - -export function submitAccountNoteFail(error) { - return { - type: ACCOUNT_NOTE_SUBMIT_FAIL, - error, - }; -} - -export function initAccountNoteModal(account) { - return (dispatch, getState) => { - const comment = getState().getIn(['relationships', account.get('id'), 'note']); - - dispatch({ - type: ACCOUNT_NOTE_INIT_MODAL, - account, - comment, - }); - - dispatch(openModal('ACCOUNT_NOTE')); - }; -} - -export function changeAccountNoteComment(comment) { - return { - type: ACCOUNT_NOTE_CHANGE_COMMENT, - comment, - }; -} \ No newline at end of file diff --git a/app/soapbox/actions/accounts.js b/app/soapbox/actions/accounts.js deleted file mode 100644 index 1005af838..000000000 --- a/app/soapbox/actions/accounts.js +++ /dev/null @@ -1,1059 +0,0 @@ -import { isLoggedIn } from 'soapbox/utils/auth'; -import { getFeatures } from 'soapbox/utils/features'; - -import api, { getLinks } from '../api'; - -import { - importFetchedAccount, - importFetchedAccounts, - importErrorWhileFetchingAccountByUsername, -} from './importer'; - -export const ACCOUNT_CREATE_REQUEST = 'ACCOUNT_CREATE_REQUEST'; -export const ACCOUNT_CREATE_SUCCESS = 'ACCOUNT_CREATE_SUCCESS'; -export const ACCOUNT_CREATE_FAIL = 'ACCOUNT_CREATE_FAIL'; - -export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; -export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; -export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL'; - -export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST'; -export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS'; -export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL'; - -export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST'; -export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS'; -export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL'; - -export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST'; -export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS'; -export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL'; - -export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; -export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS'; -export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; - -export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; -export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS'; -export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; - -export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; -export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; -export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; - -export const ACCOUNT_SUBSCRIBE_REQUEST = 'ACCOUNT_SUBSCRIBE_REQUEST'; -export const ACCOUNT_SUBSCRIBE_SUCCESS = 'ACCOUNT_SUBSCRIBE_SUCCESS'; -export const ACCOUNT_SUBSCRIBE_FAIL = 'ACCOUNT_SUBSCRIBE_FAIL'; - -export const ACCOUNT_UNSUBSCRIBE_REQUEST = 'ACCOUNT_UNSUBSCRIBE_REQUEST'; -export const ACCOUNT_UNSUBSCRIBE_SUCCESS = 'ACCOUNT_UNSUBSCRIBE_SUCCESS'; -export const ACCOUNT_UNSUBSCRIBE_FAIL = 'ACCOUNT_UNSUBSCRIBE_FAIL'; - -export const ACCOUNT_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST'; -export const ACCOUNT_PIN_SUCCESS = 'ACCOUNT_PIN_SUCCESS'; -export const ACCOUNT_PIN_FAIL = 'ACCOUNT_PIN_FAIL'; - -export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST'; -export const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS'; -export const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL'; - -export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST'; -export const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS'; -export const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL'; - -export const ACCOUNT_SEARCH_REQUEST = 'ACCOUNT_SEARCH_REQUEST'; -export const ACCOUNT_SEARCH_SUCCESS = 'ACCOUNT_SEARCH_SUCCESS'; -export const ACCOUNT_SEARCH_FAIL = 'ACCOUNT_SEARCH_FAIL'; - -export const ACCOUNT_LOOKUP_REQUEST = 'ACCOUNT_LOOKUP_REQUEST'; -export const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS'; -export const ACCOUNT_LOOKUP_FAIL = 'ACCOUNT_LOOKUP_FAIL'; - -export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; -export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS'; -export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL'; - -export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST'; -export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS'; -export const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL'; - -export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST'; -export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS'; -export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL'; - -export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST'; -export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS'; -export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL'; - -export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; -export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS'; -export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; - -export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST'; -export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS'; -export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL'; - -export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST'; -export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS'; -export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL'; - -export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST'; -export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS'; -export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; - -export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; -export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; -export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; - -export const NOTIFICATION_SETTINGS_REQUEST = 'NOTIFICATION_SETTINGS_REQUEST'; -export const NOTIFICATION_SETTINGS_SUCCESS = 'NOTIFICATION_SETTINGS_SUCCESS'; -export const NOTIFICATION_SETTINGS_FAIL = 'NOTIFICATION_SETTINGS_FAIL'; - -export const BIRTHDAY_REMINDERS_FETCH_REQUEST = 'BIRTHDAY_REMINDERS_FETCH_REQUEST'; -export const BIRTHDAY_REMINDERS_FETCH_SUCCESS = 'BIRTHDAY_REMINDERS_FETCH_SUCCESS'; -export const BIRTHDAY_REMINDERS_FETCH_FAIL = 'BIRTHDAY_REMINDERS_FETCH_FAIL'; - -export function createAccount(params) { - return (dispatch, getState) => { - dispatch({ type: ACCOUNT_CREATE_REQUEST, params }); - return api(getState, 'app').post('/api/v1/accounts', params).then(({ data: token }) => { - return dispatch({ type: ACCOUNT_CREATE_SUCCESS, params, token }); - }).catch(error => { - dispatch({ type: ACCOUNT_CREATE_FAIL, error, params }); - throw error; - }); - }; -} - -export function fetchAccount(id) { - return (dispatch, getState) => { - dispatch(fetchRelationships([id])); - - const account = getState().getIn(['accounts', id]); - - if (account && !account.get('should_refetch')) { - return; - } - - dispatch(fetchAccountRequest(id)); - - api(getState).get(`/api/v1/accounts/${id}`).then(response => { - dispatch(importFetchedAccount(response.data)); - dispatch(fetchAccountSuccess(response.data)); - }).catch(error => { - dispatch(fetchAccountFail(id, error)); - }); - }; -} - -export function fetchAccountByUsername(username) { - return (dispatch, getState) => { - const state = getState(); - const account = state.get('accounts').find(account => account.get('acct') === username); - - if (account) { - dispatch(fetchAccount(account.get('id'))); - return; - } - - const instance = state.get('instance'); - const features = getFeatures(instance); - const me = state.get('me'); - - if (features.accountByUsername && (me || !features.accountLookup)) { - api(getState).get(`/api/v1/accounts/${username}`).then(response => { - dispatch(fetchRelationships([response.data.id])); - dispatch(importFetchedAccount(response.data)); - dispatch(fetchAccountSuccess(response.data)); - }).catch(error => { - dispatch(fetchAccountFail(null, error)); - dispatch(importErrorWhileFetchingAccountByUsername(username)); - }); - } else if (features.accountLookup) { - dispatch(accountLookup(username)).then(account => { - dispatch(fetchAccountSuccess(account)); - }).catch(error => { - dispatch(fetchAccountFail(null, error)); - dispatch(importErrorWhileFetchingAccountByUsername(username)); - }); - } else { - dispatch(accountSearch({ - q: username, - limit: 5, - resolve: true, - })).then(accounts => { - const found = accounts.find(a => a.acct === username); - - if (found) { - dispatch(fetchRelationships([found.id])); - dispatch(fetchAccountSuccess(found)); - } else { - throw accounts; - } - }).catch(error => { - dispatch(fetchAccountFail(null, error)); - dispatch(importErrorWhileFetchingAccountByUsername(username)); - }); - } - }; -} - -export function fetchAccountRequest(id) { - return { - type: ACCOUNT_FETCH_REQUEST, - id, - }; -} - -export function fetchAccountSuccess(account) { - return { - type: ACCOUNT_FETCH_SUCCESS, - account, - }; -} - -export function fetchAccountFail(id, error) { - return { - type: ACCOUNT_FETCH_FAIL, - id, - error, - skipAlert: true, - }; -} - -export function followAccount(id, options = { reblogs: true }) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - const alreadyFollowing = getState().getIn(['relationships', id, 'following']); - const locked = getState().getIn(['accounts', id, 'locked'], false); - - dispatch(followAccountRequest(id, locked)); - - api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => { - dispatch(followAccountSuccess(response.data, alreadyFollowing)); - }).catch(error => { - dispatch(followAccountFail(error, locked)); - }); - }; -} - -export function unfollowAccount(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(unfollowAccountRequest(id)); - - api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => { - dispatch(unfollowAccountSuccess(response.data, getState().get('statuses'))); - }).catch(error => { - dispatch(unfollowAccountFail(error)); - }); - }; -} - -export function followAccountRequest(id, locked) { - return { - type: ACCOUNT_FOLLOW_REQUEST, - id, - locked, - skipLoading: true, - }; -} - -export function followAccountSuccess(relationship, alreadyFollowing) { - return { - type: ACCOUNT_FOLLOW_SUCCESS, - relationship, - alreadyFollowing, - skipLoading: true, - }; -} - -export function followAccountFail(error, locked) { - return { - type: ACCOUNT_FOLLOW_FAIL, - error, - locked, - skipLoading: true, - }; -} - -export function unfollowAccountRequest(id) { - return { - type: ACCOUNT_UNFOLLOW_REQUEST, - id, - skipLoading: true, - }; -} - -export function unfollowAccountSuccess(relationship, statuses) { - return { - type: ACCOUNT_UNFOLLOW_SUCCESS, - relationship, - statuses, - skipLoading: true, - }; -} - -export function unfollowAccountFail(error) { - return { - type: ACCOUNT_UNFOLLOW_FAIL, - error, - skipLoading: true, - }; -} - -export function blockAccount(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(blockAccountRequest(id)); - - api(getState).post(`/api/v1/accounts/${id}/block`).then(response => { - // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers - dispatch(blockAccountSuccess(response.data, getState().get('statuses'))); - }).catch(error => { - dispatch(blockAccountFail(id, error)); - }); - }; -} - -export function unblockAccount(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(unblockAccountRequest(id)); - - api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => { - dispatch(unblockAccountSuccess(response.data)); - }).catch(error => { - dispatch(unblockAccountFail(id, error)); - }); - }; -} - -export function blockAccountRequest(id) { - return { - type: ACCOUNT_BLOCK_REQUEST, - id, - }; -} - -export function blockAccountSuccess(relationship, statuses) { - return { - type: ACCOUNT_BLOCK_SUCCESS, - relationship, - statuses, - }; -} - -export function blockAccountFail(error) { - return { - type: ACCOUNT_BLOCK_FAIL, - error, - }; -} - -export function unblockAccountRequest(id) { - return { - type: ACCOUNT_UNBLOCK_REQUEST, - id, - }; -} - -export function unblockAccountSuccess(relationship) { - return { - type: ACCOUNT_UNBLOCK_SUCCESS, - relationship, - }; -} - -export function unblockAccountFail(error) { - return { - type: ACCOUNT_UNBLOCK_FAIL, - error, - }; -} - - -export function muteAccount(id, notifications) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(muteAccountRequest(id)); - - api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { - // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers - dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); - }).catch(error => { - dispatch(muteAccountFail(id, error)); - }); - }; -} - -export function unmuteAccount(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(unmuteAccountRequest(id)); - - api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => { - dispatch(unmuteAccountSuccess(response.data)); - }).catch(error => { - dispatch(unmuteAccountFail(id, error)); - }); - }; -} - -export function muteAccountRequest(id) { - return { - type: ACCOUNT_MUTE_REQUEST, - id, - }; -} - -export function muteAccountSuccess(relationship, statuses) { - return { - type: ACCOUNT_MUTE_SUCCESS, - relationship, - statuses, - }; -} - -export function muteAccountFail(error) { - return { - type: ACCOUNT_MUTE_FAIL, - error, - }; -} - -export function unmuteAccountRequest(id) { - return { - type: ACCOUNT_UNMUTE_REQUEST, - id, - }; -} - -export function unmuteAccountSuccess(relationship) { - return { - type: ACCOUNT_UNMUTE_SUCCESS, - relationship, - }; -} - -export function unmuteAccountFail(error) { - return { - type: ACCOUNT_UNMUTE_FAIL, - error, - }; -} - - -export function subscribeAccount(id, notifications) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(subscribeAccountRequest(id)); - - api(getState).post(`/api/v1/pleroma/accounts/${id}/subscribe`, { notifications }).then(response => { - dispatch(subscribeAccountSuccess(response.data)); - }).catch(error => { - dispatch(subscribeAccountFail(id, error)); - }); - }; -} - -export function unsubscribeAccount(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(unsubscribeAccountRequest(id)); - - api(getState).post(`/api/v1/pleroma/accounts/${id}/unsubscribe`).then(response => { - dispatch(unsubscribeAccountSuccess(response.data)); - }).catch(error => { - dispatch(unsubscribeAccountFail(id, error)); - }); - }; -} - -export function subscribeAccountRequest(id) { - return { - type: ACCOUNT_SUBSCRIBE_REQUEST, - id, - }; -} - -export function subscribeAccountSuccess(relationship) { - return { - type: ACCOUNT_SUBSCRIBE_SUCCESS, - relationship, - }; -} - -export function subscribeAccountFail(error) { - return { - type: ACCOUNT_SUBSCRIBE_FAIL, - error, - }; -} - -export function unsubscribeAccountRequest(id) { - return { - type: ACCOUNT_UNSUBSCRIBE_REQUEST, - id, - }; -} - -export function unsubscribeAccountSuccess(relationship) { - return { - type: ACCOUNT_UNSUBSCRIBE_SUCCESS, - relationship, - }; -} - -export function unsubscribeAccountFail(error) { - return { - type: ACCOUNT_UNSUBSCRIBE_FAIL, - error, - }; -} - -export function fetchFollowers(id) { - return (dispatch, getState) => { - dispatch(fetchFollowersRequest(id)); - - api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - - dispatch(importFetchedAccounts(response.data)); - dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null)); - dispatch(fetchRelationships(response.data.map(item => item.id))); - }).catch(error => { - dispatch(fetchFollowersFail(id, error)); - }); - }; -} - -export function fetchFollowersRequest(id) { - return { - type: FOLLOWERS_FETCH_REQUEST, - id, - }; -} - -export function fetchFollowersSuccess(id, accounts, next) { - return { - type: FOLLOWERS_FETCH_SUCCESS, - id, - accounts, - next, - }; -} - -export function fetchFollowersFail(id, error) { - return { - type: FOLLOWERS_FETCH_FAIL, - id, - error, - }; -} - -export function expandFollowers(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - const url = getState().getIn(['user_lists', 'followers', id, 'next']); - - if (url === null) { - return; - } - - dispatch(expandFollowersRequest(id)); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - - dispatch(importFetchedAccounts(response.data)); - dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null)); - dispatch(fetchRelationships(response.data.map(item => item.id))); - }).catch(error => { - dispatch(expandFollowersFail(id, error)); - }); - }; -} - -export function expandFollowersRequest(id) { - return { - type: FOLLOWERS_EXPAND_REQUEST, - id, - }; -} - -export function expandFollowersSuccess(id, accounts, next) { - return { - type: FOLLOWERS_EXPAND_SUCCESS, - id, - accounts, - next, - }; -} - -export function expandFollowersFail(id, error) { - return { - type: FOLLOWERS_EXPAND_FAIL, - id, - error, - }; -} - -export function fetchFollowing(id) { - return (dispatch, getState) => { - dispatch(fetchFollowingRequest(id)); - - api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - - dispatch(importFetchedAccounts(response.data)); - dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null)); - dispatch(fetchRelationships(response.data.map(item => item.id))); - }).catch(error => { - dispatch(fetchFollowingFail(id, error)); - }); - }; -} - -export function fetchFollowingRequest(id) { - return { - type: FOLLOWING_FETCH_REQUEST, - id, - }; -} - -export function fetchFollowingSuccess(id, accounts, next) { - return { - type: FOLLOWING_FETCH_SUCCESS, - id, - accounts, - next, - }; -} - -export function fetchFollowingFail(id, error) { - return { - type: FOLLOWING_FETCH_FAIL, - id, - error, - }; -} - -export function expandFollowing(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - const url = getState().getIn(['user_lists', 'following', id, 'next']); - - if (url === null) { - return; - } - - dispatch(expandFollowingRequest(id)); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - - dispatch(importFetchedAccounts(response.data)); - dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null)); - dispatch(fetchRelationships(response.data.map(item => item.id))); - }).catch(error => { - dispatch(expandFollowingFail(id, error)); - }); - }; -} - -export function expandFollowingRequest(id) { - return { - type: FOLLOWING_EXPAND_REQUEST, - id, - }; -} - -export function expandFollowingSuccess(id, accounts, next) { - return { - type: FOLLOWING_EXPAND_SUCCESS, - id, - accounts, - next, - }; -} - -export function expandFollowingFail(id, error) { - return { - type: FOLLOWING_EXPAND_FAIL, - id, - error, - }; -} - -export function fetchRelationships(accountIds) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - const loadedRelationships = getState().get('relationships'); - const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null); - - if (newAccountIds.length === 0) { - return; - } - - dispatch(fetchRelationshipsRequest(newAccountIds)); - - api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { - dispatch(fetchRelationshipsSuccess(response.data)); - }).catch(error => { - dispatch(fetchRelationshipsFail(error)); - }); - }; -} - -export function fetchRelationshipsRequest(ids) { - return { - type: RELATIONSHIPS_FETCH_REQUEST, - ids, - skipLoading: true, - }; -} - -export function fetchRelationshipsSuccess(relationships) { - return { - type: RELATIONSHIPS_FETCH_SUCCESS, - relationships, - skipLoading: true, - }; -} - -export function fetchRelationshipsFail(error) { - return { - type: RELATIONSHIPS_FETCH_FAIL, - error, - skipLoading: true, - }; -} - -export function fetchFollowRequests() { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(fetchFollowRequestsRequest()); - - api(getState).get('/api/v1/follow_requests').then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedAccounts(response.data)); - dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)); - }).catch(error => dispatch(fetchFollowRequestsFail(error))); - }; -} - -export function fetchFollowRequestsRequest() { - return { - type: FOLLOW_REQUESTS_FETCH_REQUEST, - }; -} - -export function fetchFollowRequestsSuccess(accounts, next) { - return { - type: FOLLOW_REQUESTS_FETCH_SUCCESS, - accounts, - next, - }; -} - -export function fetchFollowRequestsFail(error) { - return { - type: FOLLOW_REQUESTS_FETCH_FAIL, - error, - }; -} - -export function expandFollowRequests() { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - const url = getState().getIn(['user_lists', 'follow_requests', 'next']); - - if (url === null) { - return; - } - - dispatch(expandFollowRequestsRequest()); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedAccounts(response.data)); - dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)); - }).catch(error => dispatch(expandFollowRequestsFail(error))); - }; -} - -export function expandFollowRequestsRequest() { - return { - type: FOLLOW_REQUESTS_EXPAND_REQUEST, - }; -} - -export function expandFollowRequestsSuccess(accounts, next) { - return { - type: FOLLOW_REQUESTS_EXPAND_SUCCESS, - accounts, - next, - }; -} - -export function expandFollowRequestsFail(error) { - return { - type: FOLLOW_REQUESTS_EXPAND_FAIL, - error, - }; -} - -export function authorizeFollowRequest(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(authorizeFollowRequestRequest(id)); - - api(getState) - .post(`/api/v1/follow_requests/${id}/authorize`) - .then(() => dispatch(authorizeFollowRequestSuccess(id))) - .catch(error => dispatch(authorizeFollowRequestFail(id, error))); - }; -} - -export function authorizeFollowRequestRequest(id) { - return { - type: FOLLOW_REQUEST_AUTHORIZE_REQUEST, - id, - }; -} - -export function authorizeFollowRequestSuccess(id) { - return { - type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS, - id, - }; -} - -export function authorizeFollowRequestFail(id, error) { - return { - type: FOLLOW_REQUEST_AUTHORIZE_FAIL, - id, - error, - }; -} - - -export function rejectFollowRequest(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(rejectFollowRequestRequest(id)); - - api(getState) - .post(`/api/v1/follow_requests/${id}/reject`) - .then(() => dispatch(rejectFollowRequestSuccess(id))) - .catch(error => dispatch(rejectFollowRequestFail(id, error))); - }; -} - -export function rejectFollowRequestRequest(id) { - return { - type: FOLLOW_REQUEST_REJECT_REQUEST, - id, - }; -} - -export function rejectFollowRequestSuccess(id) { - return { - type: FOLLOW_REQUEST_REJECT_SUCCESS, - id, - }; -} - -export function rejectFollowRequestFail(id, error) { - return { - type: FOLLOW_REQUEST_REJECT_FAIL, - id, - error, - }; -} - -export function pinAccount(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(pinAccountRequest(id)); - - api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => { - dispatch(pinAccountSuccess(response.data)); - }).catch(error => { - dispatch(pinAccountFail(error)); - }); - }; -} - -export function unpinAccount(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(unpinAccountRequest(id)); - - api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => { - dispatch(unpinAccountSuccess(response.data)); - }).catch(error => { - dispatch(unpinAccountFail(error)); - }); - }; -} - -export function updateNotificationSettings(params) { - return (dispatch, getState) => { - dispatch({ type: NOTIFICATION_SETTINGS_REQUEST, params }); - return api(getState).put('/api/pleroma/notification_settings', params).then(({ data }) => { - dispatch({ type: NOTIFICATION_SETTINGS_SUCCESS, params, data }); - }).catch(error => { - dispatch({ type: NOTIFICATION_SETTINGS_FAIL, params, error }); - throw error; - }); - }; -} - -export function pinAccountRequest(id) { - return { - type: ACCOUNT_PIN_REQUEST, - id, - }; -} - -export function pinAccountSuccess(relationship) { - return { - type: ACCOUNT_PIN_SUCCESS, - relationship, - }; -} - -export function pinAccountFail(error) { - return { - type: ACCOUNT_PIN_FAIL, - error, - }; -} - -export function unpinAccountRequest(id) { - return { - type: ACCOUNT_UNPIN_REQUEST, - id, - }; -} - -export function unpinAccountSuccess(relationship) { - return { - type: ACCOUNT_UNPIN_SUCCESS, - relationship, - }; -} - -export function unpinAccountFail(error) { - return { - type: ACCOUNT_UNPIN_FAIL, - error, - }; -} - -export function fetchPinnedAccounts(id) { - return (dispatch, getState) => { - dispatch(fetchPinnedAccountsRequest(id)); - - api(getState).get(`/api/v1/pleroma/accounts/${id}/endorsements`).then(response => { - dispatch(importFetchedAccounts(response.data)); - dispatch(fetchPinnedAccountsSuccess(id, response.data, null)); - }).catch(error => { - dispatch(fetchPinnedAccountsFail(id, error)); - }); - }; -} - -export function fetchPinnedAccountsRequest(id) { - return { - type: PINNED_ACCOUNTS_FETCH_REQUEST, - id, - }; -} - -export function fetchPinnedAccountsSuccess(id, accounts, next) { - return { - type: PINNED_ACCOUNTS_FETCH_SUCCESS, - id, - accounts, - next, - }; -} - -export function fetchPinnedAccountsFail(id, error) { - return { - type: PINNED_ACCOUNTS_FETCH_FAIL, - id, - error, - }; -} - -export function accountSearch(params, cancelToken) { - return (dispatch, getState) => { - dispatch({ type: ACCOUNT_SEARCH_REQUEST, params }); - return api(getState).get('/api/v1/accounts/search', { params, cancelToken }).then(({ data: accounts }) => { - dispatch(importFetchedAccounts(accounts)); - dispatch({ type: ACCOUNT_SEARCH_SUCCESS, accounts }); - return accounts; - }).catch(error => { - dispatch({ type: ACCOUNT_SEARCH_FAIL, skipAlert: true }); - throw error; - }); - }; -} - -export function accountLookup(acct, cancelToken) { - return (dispatch, getState) => { - dispatch({ type: ACCOUNT_LOOKUP_REQUEST, acct }); - return api(getState).get('/api/v1/accounts/lookup', { params: { acct }, cancelToken }).then(({ data: account }) => { - if (account && account.id) dispatch(importFetchedAccount(account)); - dispatch({ type: ACCOUNT_LOOKUP_SUCCESS, account }); - return account; - }).catch(error => { - dispatch({ type: ACCOUNT_LOOKUP_FAIL }); - throw error; - }); - }; -} - -export function fetchBirthdayReminders(month, day) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - const me = getState().get('me'); - - dispatch({ type: BIRTHDAY_REMINDERS_FETCH_REQUEST, day, month, id: me }); - - api(getState).get('/api/v1/pleroma/birthdays', { params: { day, month } }).then(response => { - dispatch(importFetchedAccounts(response.data)); - dispatch({ - type: BIRTHDAY_REMINDERS_FETCH_SUCCESS, - accounts: response.data, - day, - month, - id: me, - }); - }).catch(error => { - dispatch({ type: BIRTHDAY_REMINDERS_FETCH_FAIL, day, month, id: me }); - }); - }; -} diff --git a/app/soapbox/actions/accounts.ts b/app/soapbox/actions/accounts.ts new file mode 100644 index 000000000..a18367f1b --- /dev/null +++ b/app/soapbox/actions/accounts.ts @@ -0,0 +1,1144 @@ +import { isLoggedIn } from 'soapbox/utils/auth'; +import { getFeatures } from 'soapbox/utils/features'; + +import api, { getLinks } from '../api'; + +import { + importFetchedAccount, + importFetchedAccounts, + importErrorWhileFetchingAccountByUsername, +} 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'; + +const ACCOUNT_CREATE_REQUEST = 'ACCOUNT_CREATE_REQUEST'; +const ACCOUNT_CREATE_SUCCESS = 'ACCOUNT_CREATE_SUCCESS'; +const ACCOUNT_CREATE_FAIL = 'ACCOUNT_CREATE_FAIL'; + +const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; +const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; +const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL'; + +const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST'; +const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS'; +const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL'; + +const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST'; +const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS'; +const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL'; + +const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST'; +const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS'; +const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL'; + +const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; +const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS'; +const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; + +const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; +const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS'; +const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; + +const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; +const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; +const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; + +const ACCOUNT_SUBSCRIBE_REQUEST = 'ACCOUNT_SUBSCRIBE_REQUEST'; +const ACCOUNT_SUBSCRIBE_SUCCESS = 'ACCOUNT_SUBSCRIBE_SUCCESS'; +const ACCOUNT_SUBSCRIBE_FAIL = 'ACCOUNT_SUBSCRIBE_FAIL'; + +const ACCOUNT_UNSUBSCRIBE_REQUEST = 'ACCOUNT_UNSUBSCRIBE_REQUEST'; +const ACCOUNT_UNSUBSCRIBE_SUCCESS = 'ACCOUNT_UNSUBSCRIBE_SUCCESS'; +const ACCOUNT_UNSUBSCRIBE_FAIL = 'ACCOUNT_UNSUBSCRIBE_FAIL'; + +const ACCOUNT_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST'; +const ACCOUNT_PIN_SUCCESS = 'ACCOUNT_PIN_SUCCESS'; +const ACCOUNT_PIN_FAIL = 'ACCOUNT_PIN_FAIL'; + +const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST'; +const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS'; +const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL'; + +const ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST'; +const ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS'; +const ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL'; + +const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST'; +const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS'; +const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL'; + +const ACCOUNT_SEARCH_REQUEST = 'ACCOUNT_SEARCH_REQUEST'; +const ACCOUNT_SEARCH_SUCCESS = 'ACCOUNT_SEARCH_SUCCESS'; +const ACCOUNT_SEARCH_FAIL = 'ACCOUNT_SEARCH_FAIL'; + +const ACCOUNT_LOOKUP_REQUEST = 'ACCOUNT_LOOKUP_REQUEST'; +const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS'; +const ACCOUNT_LOOKUP_FAIL = 'ACCOUNT_LOOKUP_FAIL'; + +const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; +const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS'; +const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL'; + +const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST'; +const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS'; +const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL'; + +const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST'; +const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS'; +const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL'; + +const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST'; +const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS'; +const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL'; + +const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; +const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS'; +const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; + +const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST'; +const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS'; +const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL'; + +const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST'; +const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS'; +const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL'; + +const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST'; +const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS'; +const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; + +const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; +const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; +const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; + +const NOTIFICATION_SETTINGS_REQUEST = 'NOTIFICATION_SETTINGS_REQUEST'; +const NOTIFICATION_SETTINGS_SUCCESS = 'NOTIFICATION_SETTINGS_SUCCESS'; +const NOTIFICATION_SETTINGS_FAIL = 'NOTIFICATION_SETTINGS_FAIL'; + +const BIRTHDAY_REMINDERS_FETCH_REQUEST = 'BIRTHDAY_REMINDERS_FETCH_REQUEST'; +const BIRTHDAY_REMINDERS_FETCH_SUCCESS = 'BIRTHDAY_REMINDERS_FETCH_SUCCESS'; +const BIRTHDAY_REMINDERS_FETCH_FAIL = 'BIRTHDAY_REMINDERS_FETCH_FAIL'; + +const maybeRedirectLogin = (error: AxiosError, history?: History) => { + // The client is unauthorized - redirect to login. + if (history && error?.response?.status === 401) { + history.push('/login'); + } +}; + +const noOp = () => new Promise(f => f(undefined)); + +const createAccount = (params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ACCOUNT_CREATE_REQUEST, params }); + return api(getState, 'app').post('/api/v1/accounts', params).then(({ data: token }) => { + return dispatch({ type: ACCOUNT_CREATE_SUCCESS, params, token }); + }).catch(error => { + dispatch({ type: ACCOUNT_CREATE_FAIL, error, params }); + throw error; + }); + }; + +const fetchAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchRelationships([id])); + + const account = getState().accounts.get(id); + + if (account && !account.get('should_refetch')) { + return null; + } + + dispatch(fetchAccountRequest(id)); + + return api(getState) + .get(`/api/v1/accounts/${id}`) + .then(response => { + dispatch(importFetchedAccount(response.data)); + dispatch(fetchAccountSuccess(response.data)); + }) + .catch(error => { + dispatch(fetchAccountFail(id, error)); + }); + }; + +const fetchAccountByUsername = (username: string, history?: History) => + (dispatch: AppDispatch, getState: () => RootState) => { + const { instance, me } = getState(); + const features = getFeatures(instance); + + if (features.accountByUsername && (me || !features.accountLookup)) { + return api(getState).get(`/api/v1/accounts/${username}`).then(response => { + dispatch(fetchRelationships([response.data.id])); + dispatch(importFetchedAccount(response.data)); + dispatch(fetchAccountSuccess(response.data)); + }).catch(error => { + dispatch(fetchAccountFail(null, error)); + dispatch(importErrorWhileFetchingAccountByUsername(username)); + }); + } else if (features.accountLookup) { + return dispatch(accountLookup(username)).then(account => { + dispatch(fetchRelationships([account.id])); + dispatch(fetchAccountSuccess(account)); + }).catch(error => { + dispatch(fetchAccountFail(null, error)); + dispatch(importErrorWhileFetchingAccountByUsername(username)); + maybeRedirectLogin(error, history); + }); + } else { + return dispatch(accountSearch({ + q: username, + limit: 5, + resolve: true, + })).then(accounts => { + const found = accounts.find((a: APIEntity) => a.acct === username); + + if (found) { + dispatch(fetchRelationships([found.id])); + dispatch(fetchAccountSuccess(found)); + } else { + throw accounts; + } + }).catch(error => { + dispatch(fetchAccountFail(null, error)); + dispatch(importErrorWhileFetchingAccountByUsername(username)); + }); + } + }; + +const fetchAccountRequest = (id: string) => ({ + type: ACCOUNT_FETCH_REQUEST, + id, +}); + +const fetchAccountSuccess = (account: APIEntity) => ({ + type: ACCOUNT_FETCH_SUCCESS, + account, +}); + +const fetchAccountFail = (id: string | null, error: AxiosError) => ({ + type: ACCOUNT_FETCH_FAIL, + id, + error, + skipAlert: true, +}); + +type FollowAccountOpts = { + reblogs?: boolean, + notify?: boolean +}; + +const followAccount = (id: string, options?: FollowAccountOpts) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + const alreadyFollowing = getState().relationships.get(id)?.following || undefined; + const locked = getState().accounts.get(id)?.locked || false; + + dispatch(followAccountRequest(id, locked)); + + return api(getState) + .post(`/api/v1/accounts/${id}/follow`, options) + .then(response => dispatch(followAccountSuccess(response.data, alreadyFollowing))) + .catch(error => { + dispatch(followAccountFail(error, locked)); + throw error; + }); + }; + +const unfollowAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(unfollowAccountRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/unfollow`) + .then(response => dispatch(unfollowAccountSuccess(response.data, getState().statuses))) + .catch(error => dispatch(unfollowAccountFail(error))); + }; + +const followAccountRequest = (id: string, locked: boolean) => ({ + type: ACCOUNT_FOLLOW_REQUEST, + id, + locked, + skipLoading: true, +}); + +const followAccountSuccess = (relationship: APIEntity, alreadyFollowing?: boolean) => ({ + type: ACCOUNT_FOLLOW_SUCCESS, + relationship, + alreadyFollowing, + skipLoading: true, +}); + +const followAccountFail = (error: AxiosError, locked: boolean) => ({ + type: ACCOUNT_FOLLOW_FAIL, + error, + locked, + skipLoading: true, +}); + +const unfollowAccountRequest = (id: string) => ({ + type: ACCOUNT_UNFOLLOW_REQUEST, + id, + skipLoading: true, +}); + +const unfollowAccountSuccess = (relationship: APIEntity, statuses: ImmutableMap) => ({ + type: ACCOUNT_UNFOLLOW_SUCCESS, + relationship, + statuses, + skipLoading: true, +}); + +const unfollowAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_UNFOLLOW_FAIL, + error, + skipLoading: true, +}); + +const blockAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(blockAccountRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/block`) + .then(response => { + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + return dispatch(blockAccountSuccess(response.data, getState().statuses)); + }).catch(error => dispatch(blockAccountFail(error))); + }; + +const unblockAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(unblockAccountRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/unblock`) + .then(response => dispatch(unblockAccountSuccess(response.data))) + .catch(error => dispatch(unblockAccountFail(error))); + }; + +const blockAccountRequest = (id: string) => ({ + type: ACCOUNT_BLOCK_REQUEST, + id, +}); + +const blockAccountSuccess = (relationship: APIEntity, statuses: ImmutableMap) => ({ + type: ACCOUNT_BLOCK_SUCCESS, + relationship, + statuses, +}); + +const blockAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_BLOCK_FAIL, + error, +}); + +const unblockAccountRequest = (id: string) => ({ + type: ACCOUNT_UNBLOCK_REQUEST, + id, +}); + +const unblockAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_UNBLOCK_SUCCESS, + relationship, +}); + +const unblockAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_UNBLOCK_FAIL, + error, +}); + +const muteAccount = (id: string, notifications?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(muteAccountRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/mute`, { notifications }) + .then(response => { + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + return dispatch(muteAccountSuccess(response.data, getState().statuses)); + }) + .catch(error => dispatch(muteAccountFail(error))); + }; + +const unmuteAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(unmuteAccountRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/unmute`) + .then(response => dispatch(unmuteAccountSuccess(response.data))) + .catch(error => dispatch(unmuteAccountFail(error))); + }; + +const muteAccountRequest = (id: string) => ({ + type: ACCOUNT_MUTE_REQUEST, + id, +}); + +const muteAccountSuccess = (relationship: APIEntity, statuses: ImmutableMap) => ({ + type: ACCOUNT_MUTE_SUCCESS, + relationship, + statuses, +}); + +const muteAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_MUTE_FAIL, + error, +}); + +const unmuteAccountRequest = (id: string) => ({ + type: ACCOUNT_UNMUTE_REQUEST, + id, +}); + +const unmuteAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_UNMUTE_SUCCESS, + relationship, +}); + +const unmuteAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_UNMUTE_FAIL, + error, +}); + +const subscribeAccount = (id: string, notifications?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(subscribeAccountRequest(id)); + + return api(getState) + .post(`/api/v1/pleroma/accounts/${id}/subscribe`, { notifications }) + .then(response => dispatch(subscribeAccountSuccess(response.data))) + .catch(error => dispatch(subscribeAccountFail(error))); + }; + +const unsubscribeAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(unsubscribeAccountRequest(id)); + + return api(getState) + .post(`/api/v1/pleroma/accounts/${id}/unsubscribe`) + .then(response => dispatch(unsubscribeAccountSuccess(response.data))) + .catch(error => dispatch(unsubscribeAccountFail(error))); + }; + +const subscribeAccountRequest = (id: string) => ({ + type: ACCOUNT_SUBSCRIBE_REQUEST, + id, +}); + +const subscribeAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_SUBSCRIBE_SUCCESS, + relationship, +}); + +const subscribeAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_SUBSCRIBE_FAIL, + error, +}); + +const unsubscribeAccountRequest = (id: string) => ({ + type: ACCOUNT_UNSUBSCRIBE_REQUEST, + id, +}); + +const unsubscribeAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_UNSUBSCRIBE_SUCCESS, + relationship, +}); + +const unsubscribeAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_UNSUBSCRIBE_FAIL, + error, +}); + +const removeFromFollowers = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(removeFromFollowersRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/remove_from_followers`) + .then(response => dispatch(removeFromFollowersSuccess(response.data))) + .catch(error => dispatch(removeFromFollowersFail(id, error))); + }; + +const removeFromFollowersRequest = (id: string) => ({ + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST, + id, +}); + +const removeFromFollowersSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS, + relationship, +}); + +const removeFromFollowersFail = (id: string, error: AxiosError) => ({ + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL, + id, + error, +}); + +const fetchFollowers = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchFollowersRequest(id)); + + return api(getState) + .get(`/api/v1/accounts/${id}/followers`) + .then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }) + .catch(error => { + dispatch(fetchFollowersFail(id, error)); + }); + }; + +const fetchFollowersRequest = (id: string) => ({ + type: FOLLOWERS_FETCH_REQUEST, + id, +}); + +const fetchFollowersSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: FOLLOWERS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchFollowersFail = (id: string, error: AxiosError) => ({ + type: FOLLOWERS_FETCH_FAIL, + id, + error, +}); + +const expandFollowers = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + const url = getState().user_lists.followers.get(id)?.next as string; + + if (url === null) { + return null; + } + + dispatch(expandFollowersRequest(id)); + + return api(getState) + .get(url) + .then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }) + .catch(error => { + dispatch(expandFollowersFail(id, error)); + }); + }; + +const expandFollowersRequest = (id: string) => ({ + type: FOLLOWERS_EXPAND_REQUEST, + id, +}); + +const expandFollowersSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: FOLLOWERS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandFollowersFail = (id: string, error: AxiosError) => ({ + type: FOLLOWERS_EXPAND_FAIL, + id, + error, +}); + +const fetchFollowing = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchFollowingRequest(id)); + + return api(getState) + .get(`/api/v1/accounts/${id}/following`) + .then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }) + .catch(error => { + dispatch(fetchFollowingFail(id, error)); + }); + }; + +const fetchFollowingRequest = (id: string) => ({ + type: FOLLOWING_FETCH_REQUEST, + id, +}); + +const fetchFollowingSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: FOLLOWING_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchFollowingFail = (id: string, error: AxiosError) => ({ + type: FOLLOWING_FETCH_FAIL, + id, + error, +}); + +const expandFollowing = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + const url = getState().user_lists.following.get(id)!.next; + + if (url === null) { + return null; + } + + dispatch(expandFollowingRequest(id)); + + return api(getState) + .get(url) + .then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }) + .catch(error => { + dispatch(expandFollowingFail(id, error)); + }); + }; + +const expandFollowingRequest = (id: string) => ({ + type: FOLLOWING_EXPAND_REQUEST, + id, +}); + +const expandFollowingSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: FOLLOWING_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandFollowingFail = (id: string, error: AxiosError) => ({ + type: FOLLOWING_EXPAND_FAIL, + id, + error, +}); + +const fetchRelationships = (accountIds: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + const loadedRelationships = getState().relationships; + const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null); + + if (newAccountIds.length === 0) { + return null; + } + + dispatch(fetchRelationshipsRequest(newAccountIds)); + + return api(getState) + .get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`) + .then(response => dispatch(fetchRelationshipsSuccess(response.data))) + .catch(error => dispatch(fetchRelationshipsFail(error))); + }; + +const fetchRelationshipsRequest = (ids: string[]) => ({ + type: RELATIONSHIPS_FETCH_REQUEST, + ids, + skipLoading: true, +}); + +const fetchRelationshipsSuccess = (relationships: APIEntity[]) => ({ + type: RELATIONSHIPS_FETCH_SUCCESS, + relationships, + skipLoading: true, +}); + +const fetchRelationshipsFail = (error: AxiosError) => ({ + type: RELATIONSHIPS_FETCH_FAIL, + error, + skipLoading: true, +}); + +const fetchFollowRequests = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(fetchFollowRequestsRequest()); + + return api(getState) + .get('/api/v1/follow_requests') + .then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)); + }) + .catch(error => dispatch(fetchFollowRequestsFail(error))); + }; + +const fetchFollowRequestsRequest = () => ({ + type: FOLLOW_REQUESTS_FETCH_REQUEST, +}); + +const fetchFollowRequestsSuccess = (accounts: APIEntity[], next: string | null) => ({ + type: FOLLOW_REQUESTS_FETCH_SUCCESS, + accounts, + next, +}); + +const fetchFollowRequestsFail = (error: AxiosError) => ({ + type: FOLLOW_REQUESTS_FETCH_FAIL, + error, +}); + +const expandFollowRequests = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + const url = getState().user_lists.follow_requests.next; + + if (url === null) { + return null; + } + + dispatch(expandFollowRequestsRequest()); + + return api(getState) + .get(url) + .then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)); + }) + .catch(error => dispatch(expandFollowRequestsFail(error))); + }; + +const expandFollowRequestsRequest = () => ({ + type: FOLLOW_REQUESTS_EXPAND_REQUEST, +}); + +const expandFollowRequestsSuccess = (accounts: APIEntity[], next: string | null) => ({ + type: FOLLOW_REQUESTS_EXPAND_SUCCESS, + accounts, + next, +}); + +const expandFollowRequestsFail = (error: AxiosError) => ({ + type: FOLLOW_REQUESTS_EXPAND_FAIL, + error, +}); + +const authorizeFollowRequest = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(authorizeFollowRequestRequest(id)); + + return api(getState) + .post(`/api/v1/follow_requests/${id}/authorize`) + .then(() => dispatch(authorizeFollowRequestSuccess(id))) + .catch(error => dispatch(authorizeFollowRequestFail(id, error))); + }; + +const authorizeFollowRequestRequest = (id: string) => ({ + type: FOLLOW_REQUEST_AUTHORIZE_REQUEST, + id, +}); + +const authorizeFollowRequestSuccess = (id: string) => ({ + type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + id, +}); + +const authorizeFollowRequestFail = (id: string, error: AxiosError) => ({ + type: FOLLOW_REQUEST_AUTHORIZE_FAIL, + id, + error, +}); + +const rejectFollowRequest = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(rejectFollowRequestRequest(id)); + + api(getState) + .post(`/api/v1/follow_requests/${id}/reject`) + .then(() => dispatch(rejectFollowRequestSuccess(id))) + .catch(error => dispatch(rejectFollowRequestFail(id, error))); + }; + +const rejectFollowRequestRequest = (id: string) => ({ + type: FOLLOW_REQUEST_REJECT_REQUEST, + id, +}); + +const rejectFollowRequestSuccess = (id: string) => ({ + type: FOLLOW_REQUEST_REJECT_SUCCESS, + id, +}); + +const rejectFollowRequestFail = (id: string, error: AxiosError) => ({ + type: FOLLOW_REQUEST_REJECT_FAIL, + id, + error, +}); + +const pinAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return dispatch(noOp); + + dispatch(pinAccountRequest(id)); + + return api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => { + dispatch(pinAccountSuccess(response.data)); + }).catch(error => { + dispatch(pinAccountFail(error)); + }); + }; + +const unpinAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return dispatch(noOp); + + dispatch(unpinAccountRequest(id)); + + return api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => { + dispatch(unpinAccountSuccess(response.data)); + }).catch(error => { + dispatch(unpinAccountFail(error)); + }); + }; + +const updateNotificationSettings = (params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: NOTIFICATION_SETTINGS_REQUEST, params }); + return api(getState).put('/api/pleroma/notification_settings', params).then(({ data }) => { + dispatch({ type: NOTIFICATION_SETTINGS_SUCCESS, params, data }); + }).catch(error => { + dispatch({ type: NOTIFICATION_SETTINGS_FAIL, params, error }); + throw error; + }); + }; + +const pinAccountRequest = (id: string) => ({ + type: ACCOUNT_PIN_REQUEST, + id, +}); + +const pinAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_PIN_SUCCESS, + relationship, +}); + +const pinAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_PIN_FAIL, + error, +}); + +const unpinAccountRequest = (id: string) => ({ + type: ACCOUNT_UNPIN_REQUEST, + id, +}); + +const unpinAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_UNPIN_SUCCESS, + relationship, +}); + +const unpinAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_UNPIN_FAIL, + error, +}); + +const fetchPinnedAccounts = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchPinnedAccountsRequest(id)); + + api(getState).get(`/api/v1/pleroma/accounts/${id}/endorsements`).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchPinnedAccountsSuccess(id, response.data, null)); + }).catch(error => { + dispatch(fetchPinnedAccountsFail(id, error)); + }); + }; + +const fetchPinnedAccountsRequest = (id: string) => ({ + type: PINNED_ACCOUNTS_FETCH_REQUEST, + id, +}); + +const fetchPinnedAccountsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: PINNED_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchPinnedAccountsFail = (id: string, error: AxiosError) => ({ + type: PINNED_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +const accountSearch = (params: Record, signal?: AbortSignal) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ACCOUNT_SEARCH_REQUEST, params }); + return api(getState).get('/api/v1/accounts/search', { params, signal }).then(({ data: accounts }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch({ type: ACCOUNT_SEARCH_SUCCESS, accounts }); + return accounts; + }).catch(error => { + dispatch({ type: ACCOUNT_SEARCH_FAIL, skipAlert: true }); + throw error; + }); + }; + +const accountLookup = (acct: string, cancelToken?: CancelToken) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ACCOUNT_LOOKUP_REQUEST, acct }); + return api(getState).get('/api/v1/accounts/lookup', { params: { acct }, cancelToken }).then(({ data: account }) => { + if (account && account.id) dispatch(importFetchedAccount(account)); + dispatch({ type: ACCOUNT_LOOKUP_SUCCESS, account }); + return account; + }).catch(error => { + dispatch({ type: ACCOUNT_LOOKUP_FAIL }); + throw error; + }); + }; + +const fetchBirthdayReminders = (month: number, day: number) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const me = getState().me; + + dispatch({ type: BIRTHDAY_REMINDERS_FETCH_REQUEST, day, month, id: me }); + + return api(getState).get('/api/v1/pleroma/birthdays', { params: { day, month } }).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch({ + type: BIRTHDAY_REMINDERS_FETCH_SUCCESS, + accounts: response.data, + day, + month, + id: me, + }); + }).catch(error => { + dispatch({ type: BIRTHDAY_REMINDERS_FETCH_FAIL, day, month, id: me }); + }); + }; + +export { + ACCOUNT_CREATE_REQUEST, + ACCOUNT_CREATE_SUCCESS, + ACCOUNT_CREATE_FAIL, + ACCOUNT_FETCH_REQUEST, + ACCOUNT_FETCH_SUCCESS, + ACCOUNT_FETCH_FAIL, + ACCOUNT_FOLLOW_REQUEST, + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_FOLLOW_FAIL, + ACCOUNT_UNFOLLOW_REQUEST, + ACCOUNT_UNFOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_FAIL, + ACCOUNT_BLOCK_REQUEST, + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_BLOCK_FAIL, + ACCOUNT_UNBLOCK_REQUEST, + ACCOUNT_UNBLOCK_SUCCESS, + ACCOUNT_UNBLOCK_FAIL, + ACCOUNT_MUTE_REQUEST, + ACCOUNT_MUTE_SUCCESS, + ACCOUNT_MUTE_FAIL, + ACCOUNT_UNMUTE_REQUEST, + ACCOUNT_UNMUTE_SUCCESS, + ACCOUNT_UNMUTE_FAIL, + ACCOUNT_SUBSCRIBE_REQUEST, + ACCOUNT_SUBSCRIBE_SUCCESS, + ACCOUNT_SUBSCRIBE_FAIL, + ACCOUNT_UNSUBSCRIBE_REQUEST, + ACCOUNT_UNSUBSCRIBE_SUCCESS, + ACCOUNT_UNSUBSCRIBE_FAIL, + ACCOUNT_PIN_REQUEST, + ACCOUNT_PIN_SUCCESS, + ACCOUNT_PIN_FAIL, + ACCOUNT_UNPIN_REQUEST, + ACCOUNT_UNPIN_SUCCESS, + ACCOUNT_UNPIN_FAIL, + ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST, + ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS, + ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL, + PINNED_ACCOUNTS_FETCH_REQUEST, + PINNED_ACCOUNTS_FETCH_SUCCESS, + PINNED_ACCOUNTS_FETCH_FAIL, + ACCOUNT_SEARCH_REQUEST, + ACCOUNT_SEARCH_SUCCESS, + ACCOUNT_SEARCH_FAIL, + ACCOUNT_LOOKUP_REQUEST, + ACCOUNT_LOOKUP_SUCCESS, + ACCOUNT_LOOKUP_FAIL, + FOLLOWERS_FETCH_REQUEST, + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_FETCH_FAIL, + FOLLOWERS_EXPAND_REQUEST, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWERS_EXPAND_FAIL, + FOLLOWING_FETCH_REQUEST, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_FETCH_FAIL, + FOLLOWING_EXPAND_REQUEST, + FOLLOWING_EXPAND_SUCCESS, + FOLLOWING_EXPAND_FAIL, + RELATIONSHIPS_FETCH_REQUEST, + RELATIONSHIPS_FETCH_SUCCESS, + RELATIONSHIPS_FETCH_FAIL, + FOLLOW_REQUESTS_FETCH_REQUEST, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_FETCH_FAIL, + FOLLOW_REQUESTS_EXPAND_REQUEST, + FOLLOW_REQUESTS_EXPAND_SUCCESS, + FOLLOW_REQUESTS_EXPAND_FAIL, + FOLLOW_REQUEST_AUTHORIZE_REQUEST, + FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + FOLLOW_REQUEST_AUTHORIZE_FAIL, + FOLLOW_REQUEST_REJECT_REQUEST, + FOLLOW_REQUEST_REJECT_SUCCESS, + FOLLOW_REQUEST_REJECT_FAIL, + NOTIFICATION_SETTINGS_REQUEST, + NOTIFICATION_SETTINGS_SUCCESS, + NOTIFICATION_SETTINGS_FAIL, + BIRTHDAY_REMINDERS_FETCH_REQUEST, + BIRTHDAY_REMINDERS_FETCH_SUCCESS, + BIRTHDAY_REMINDERS_FETCH_FAIL, + createAccount, + fetchAccount, + fetchAccountByUsername, + fetchAccountRequest, + fetchAccountSuccess, + fetchAccountFail, + followAccount, + unfollowAccount, + followAccountRequest, + followAccountSuccess, + followAccountFail, + unfollowAccountRequest, + unfollowAccountSuccess, + unfollowAccountFail, + blockAccount, + unblockAccount, + blockAccountRequest, + blockAccountSuccess, + blockAccountFail, + unblockAccountRequest, + unblockAccountSuccess, + unblockAccountFail, + muteAccount, + unmuteAccount, + muteAccountRequest, + muteAccountSuccess, + muteAccountFail, + unmuteAccountRequest, + unmuteAccountSuccess, + unmuteAccountFail, + subscribeAccount, + unsubscribeAccount, + subscribeAccountRequest, + subscribeAccountSuccess, + subscribeAccountFail, + unsubscribeAccountRequest, + unsubscribeAccountSuccess, + unsubscribeAccountFail, + removeFromFollowers, + removeFromFollowersRequest, + removeFromFollowersSuccess, + removeFromFollowersFail, + fetchFollowers, + fetchFollowersRequest, + fetchFollowersSuccess, + fetchFollowersFail, + expandFollowers, + expandFollowersRequest, + expandFollowersSuccess, + expandFollowersFail, + fetchFollowing, + fetchFollowingRequest, + fetchFollowingSuccess, + fetchFollowingFail, + expandFollowing, + expandFollowingRequest, + expandFollowingSuccess, + expandFollowingFail, + fetchRelationships, + fetchRelationshipsRequest, + fetchRelationshipsSuccess, + fetchRelationshipsFail, + fetchFollowRequests, + fetchFollowRequestsRequest, + fetchFollowRequestsSuccess, + fetchFollowRequestsFail, + expandFollowRequests, + expandFollowRequestsRequest, + expandFollowRequestsSuccess, + expandFollowRequestsFail, + authorizeFollowRequest, + authorizeFollowRequestRequest, + authorizeFollowRequestSuccess, + authorizeFollowRequestFail, + rejectFollowRequest, + rejectFollowRequestRequest, + rejectFollowRequestSuccess, + rejectFollowRequestFail, + pinAccount, + unpinAccount, + updateNotificationSettings, + pinAccountRequest, + pinAccountSuccess, + pinAccountFail, + unpinAccountRequest, + unpinAccountSuccess, + unpinAccountFail, + fetchPinnedAccounts, + fetchPinnedAccountsRequest, + fetchPinnedAccountsSuccess, + fetchPinnedAccountsFail, + accountSearch, + accountLookup, + fetchBirthdayReminders, +}; diff --git a/app/soapbox/actions/admin.js b/app/soapbox/actions/admin.js deleted file mode 100644 index 3704e114e..000000000 --- a/app/soapbox/actions/admin.js +++ /dev/null @@ -1,370 +0,0 @@ -import { fetchRelationships } from 'soapbox/actions/accounts'; -import { importFetchedAccount, importFetchedStatuses } from 'soapbox/actions/importer'; - -import api from '../api'; - -export const ADMIN_CONFIG_FETCH_REQUEST = 'ADMIN_CONFIG_FETCH_REQUEST'; -export const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS'; -export const ADMIN_CONFIG_FETCH_FAIL = 'ADMIN_CONFIG_FETCH_FAIL'; - -export const ADMIN_CONFIG_UPDATE_REQUEST = 'ADMIN_CONFIG_UPDATE_REQUEST'; -export const ADMIN_CONFIG_UPDATE_SUCCESS = 'ADMIN_CONFIG_UPDATE_SUCCESS'; -export const ADMIN_CONFIG_UPDATE_FAIL = 'ADMIN_CONFIG_UPDATE_FAIL'; - -export const ADMIN_REPORTS_FETCH_REQUEST = 'ADMIN_REPORTS_FETCH_REQUEST'; -export const ADMIN_REPORTS_FETCH_SUCCESS = 'ADMIN_REPORTS_FETCH_SUCCESS'; -export const ADMIN_REPORTS_FETCH_FAIL = 'ADMIN_REPORTS_FETCH_FAIL'; - -export const ADMIN_REPORTS_PATCH_REQUEST = 'ADMIN_REPORTS_PATCH_REQUEST'; -export const ADMIN_REPORTS_PATCH_SUCCESS = 'ADMIN_REPORTS_PATCH_SUCCESS'; -export const ADMIN_REPORTS_PATCH_FAIL = 'ADMIN_REPORTS_PATCH_FAIL'; - -export const ADMIN_USERS_FETCH_REQUEST = 'ADMIN_USERS_FETCH_REQUEST'; -export const ADMIN_USERS_FETCH_SUCCESS = 'ADMIN_USERS_FETCH_SUCCESS'; -export const ADMIN_USERS_FETCH_FAIL = 'ADMIN_USERS_FETCH_FAIL'; - -export const ADMIN_USERS_DELETE_REQUEST = 'ADMIN_USERS_DELETE_REQUEST'; -export const ADMIN_USERS_DELETE_SUCCESS = 'ADMIN_USERS_DELETE_SUCCESS'; -export const ADMIN_USERS_DELETE_FAIL = 'ADMIN_USERS_DELETE_FAIL'; - -export const ADMIN_USERS_APPROVE_REQUEST = 'ADMIN_USERS_APPROVE_REQUEST'; -export const ADMIN_USERS_APPROVE_SUCCESS = 'ADMIN_USERS_APPROVE_SUCCESS'; -export const ADMIN_USERS_APPROVE_FAIL = 'ADMIN_USERS_APPROVE_FAIL'; - -export const ADMIN_USERS_DEACTIVATE_REQUEST = 'ADMIN_USERS_DEACTIVATE_REQUEST'; -export const ADMIN_USERS_DEACTIVATE_SUCCESS = 'ADMIN_USERS_DEACTIVATE_SUCCESS'; -export const ADMIN_USERS_DEACTIVATE_FAIL = 'ADMIN_USERS_DEACTIVATE_FAIL'; - -export const ADMIN_STATUS_DELETE_REQUEST = 'ADMIN_STATUS_DELETE_REQUEST'; -export const ADMIN_STATUS_DELETE_SUCCESS = 'ADMIN_STATUS_DELETE_SUCCESS'; -export const ADMIN_STATUS_DELETE_FAIL = 'ADMIN_STATUS_DELETE_FAIL'; - -export const ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST = 'ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST'; -export const ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS = 'ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS'; -export const ADMIN_STATUS_TOGGLE_SENSITIVITY_FAIL = 'ADMIN_STATUS_TOGGLE_SENSITIVITY_FAIL'; - -export const ADMIN_LOG_FETCH_REQUEST = 'ADMIN_LOG_FETCH_REQUEST'; -export const ADMIN_LOG_FETCH_SUCCESS = 'ADMIN_LOG_FETCH_SUCCESS'; -export const ADMIN_LOG_FETCH_FAIL = 'ADMIN_LOG_FETCH_FAIL'; - -export const ADMIN_USERS_TAG_REQUEST = 'ADMIN_USERS_TAG_REQUEST'; -export const ADMIN_USERS_TAG_SUCCESS = 'ADMIN_USERS_TAG_SUCCESS'; -export const ADMIN_USERS_TAG_FAIL = 'ADMIN_USERS_TAG_FAIL'; - -export const ADMIN_USERS_UNTAG_REQUEST = 'ADMIN_USERS_UNTAG_REQUEST'; -export const ADMIN_USERS_UNTAG_SUCCESS = 'ADMIN_USERS_UNTAG_SUCCESS'; -export const ADMIN_USERS_UNTAG_FAIL = 'ADMIN_USERS_UNTAG_FAIL'; - -export const ADMIN_ADD_PERMISSION_GROUP_REQUEST = 'ADMIN_ADD_PERMISSION_GROUP_REQUEST'; -export const ADMIN_ADD_PERMISSION_GROUP_SUCCESS = 'ADMIN_ADD_PERMISSION_GROUP_SUCCESS'; -export const ADMIN_ADD_PERMISSION_GROUP_FAIL = 'ADMIN_ADD_PERMISSION_GROUP_FAIL'; - -export const ADMIN_REMOVE_PERMISSION_GROUP_REQUEST = 'ADMIN_REMOVE_PERMISSION_GROUP_REQUEST'; -export const ADMIN_REMOVE_PERMISSION_GROUP_SUCCESS = 'ADMIN_REMOVE_PERMISSION_GROUP_SUCCESS'; -export const ADMIN_REMOVE_PERMISSION_GROUP_FAIL = 'ADMIN_REMOVE_PERMISSION_GROUP_FAIL'; - -export const ADMIN_USERS_SUGGEST_REQUEST = 'ADMIN_USERS_SUGGEST_REQUEST'; -export const ADMIN_USERS_SUGGEST_SUCCESS = 'ADMIN_USERS_SUGGEST_SUCCESS'; -export const ADMIN_USERS_SUGGEST_FAIL = 'ADMIN_USERS_SUGGEST_FAIL'; - -export const ADMIN_USERS_UNSUGGEST_REQUEST = 'ADMIN_USERS_UNSUGGEST_REQUEST'; -export const ADMIN_USERS_UNSUGGEST_SUCCESS = 'ADMIN_USERS_UNSUGGEST_SUCCESS'; -export const ADMIN_USERS_UNSUGGEST_FAIL = 'ADMIN_USERS_UNSUGGEST_FAIL'; - -const nicknamesFromIds = (getState, ids) => ids.map(id => getState().getIn(['accounts', id, 'acct'])); - -export function fetchConfig() { - return (dispatch, getState) => { - dispatch({ type: ADMIN_CONFIG_FETCH_REQUEST }); - return api(getState) - .get('/api/pleroma/admin/config') - .then(({ data }) => { - dispatch({ type: ADMIN_CONFIG_FETCH_SUCCESS, configs: data.configs, needsReboot: data.need_reboot }); - }).catch(error => { - dispatch({ type: ADMIN_CONFIG_FETCH_FAIL, error }); - }); - }; -} - -export function updateConfig(configs) { - return (dispatch, getState) => { - dispatch({ type: ADMIN_CONFIG_UPDATE_REQUEST, configs }); - return api(getState) - .post('/api/pleroma/admin/config', { configs }) - .then(({ data }) => { - dispatch({ type: ADMIN_CONFIG_UPDATE_SUCCESS, configs: data.configs, needsReboot: data.need_reboot }); - }).catch(error => { - dispatch({ type: ADMIN_CONFIG_UPDATE_FAIL, error, configs }); - }); - }; -} - -export function fetchReports(params) { - return (dispatch, getState) => { - dispatch({ type: ADMIN_REPORTS_FETCH_REQUEST, params }); - return api(getState) - .get('/api/pleroma/admin/reports', { params }) - .then(({ data: { reports } }) => { - reports.forEach(report => { - dispatch(importFetchedAccount(report.account)); - dispatch(importFetchedAccount(report.actor)); - dispatch(importFetchedStatuses(report.statuses)); - }); - dispatch({ type: ADMIN_REPORTS_FETCH_SUCCESS, reports, params }); - }).catch(error => { - dispatch({ type: ADMIN_REPORTS_FETCH_FAIL, error, params }); - }); - }; -} - -function patchReports(ids, state) { - const reports = ids.map(id => ({ id, state })); - return (dispatch, getState) => { - dispatch({ type: ADMIN_REPORTS_PATCH_REQUEST, reports }); - return api(getState) - .patch('/api/pleroma/admin/reports', { reports }) - .then(() => { - dispatch({ type: ADMIN_REPORTS_PATCH_SUCCESS, reports }); - }).catch(error => { - dispatch({ type: ADMIN_REPORTS_PATCH_FAIL, error, reports }); - }); - }; -} -export function closeReports(ids) { - return patchReports(ids, 'closed'); -} - -export function fetchUsers(filters = [], page = 1, query, pageSize = 50) { - return (dispatch, getState) => { - const params = { filters: filters.join(), page, page_size: pageSize }; - if (query) params.query = query; - - dispatch({ type: ADMIN_USERS_FETCH_REQUEST, filters, page, pageSize }); - return api(getState) - .get('/api/pleroma/admin/users', { params }) - .then(({ data: { users, count, page_size: pageSize } }) => { - dispatch(fetchRelationships(users.map(user => user.id))); - dispatch({ type: ADMIN_USERS_FETCH_SUCCESS, users, count, pageSize, filters, page }); - return { users, count, pageSize }; - }).catch(error => { - dispatch({ type: ADMIN_USERS_FETCH_FAIL, error, filters, page, pageSize }); - }); - }; -} - -export function deactivateUsers(accountIds) { - return (dispatch, getState) => { - const nicknames = nicknamesFromIds(getState, accountIds); - dispatch({ type: ADMIN_USERS_DEACTIVATE_REQUEST, accountIds }); - return api(getState) - .patch('/api/pleroma/admin/users/deactivate', { nicknames }) - .then(({ data: { users } }) => { - dispatch({ type: ADMIN_USERS_DEACTIVATE_SUCCESS, users, accountIds }); - }).catch(error => { - dispatch({ type: ADMIN_USERS_DEACTIVATE_FAIL, error, accountIds }); - }); - }; -} - -export function deleteUsers(accountIds) { - return (dispatch, getState) => { - const nicknames = nicknamesFromIds(getState, accountIds); - dispatch({ type: ADMIN_USERS_DELETE_REQUEST, accountIds }); - return api(getState) - .delete('/api/pleroma/admin/users', { data: { nicknames } }) - .then(({ data: nicknames }) => { - dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames, accountIds }); - }).catch(error => { - dispatch({ type: ADMIN_USERS_DELETE_FAIL, error, accountIds }); - }); - }; -} - -export function approveUsers(accountIds) { - return (dispatch, getState) => { - const nicknames = nicknamesFromIds(getState, accountIds); - dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountIds }); - return api(getState) - .patch('/api/pleroma/admin/users/approve', { nicknames }) - .then(({ data: { users } }) => { - dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users, accountIds }); - }).catch(error => { - dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountIds }); - }); - }; -} - -export function deleteStatus(id) { - return (dispatch, getState) => { - dispatch({ type: ADMIN_STATUS_DELETE_REQUEST, id }); - return api(getState) - .delete(`/api/pleroma/admin/statuses/${id}`) - .then(() => { - dispatch({ type: ADMIN_STATUS_DELETE_SUCCESS, id }); - }).catch(error => { - dispatch({ type: ADMIN_STATUS_DELETE_FAIL, error, id }); - }); - }; -} - -export function toggleStatusSensitivity(id, sensitive) { - return (dispatch, getState) => { - dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST, id }); - return api(getState) - .put(`/api/pleroma/admin/statuses/${id}`, { sensitive: !sensitive }) - .then(() => { - dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS, id }); - }).catch(error => { - dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_FAIL, error, id }); - }); - }; -} - -export function fetchModerationLog(params) { - return (dispatch, getState) => { - dispatch({ type: ADMIN_LOG_FETCH_REQUEST }); - return api(getState) - .get('/api/pleroma/admin/moderation_log', { params }) - .then(({ data }) => { - dispatch({ type: ADMIN_LOG_FETCH_SUCCESS, items: data.items, total: data.total }); - return data; - }).catch(error => { - dispatch({ type: ADMIN_LOG_FETCH_FAIL, error }); - }); - }; -} - -export function tagUsers(accountIds, tags) { - return (dispatch, getState) => { - const nicknames = nicknamesFromIds(getState, accountIds); - dispatch({ type: ADMIN_USERS_TAG_REQUEST, accountIds, tags }); - return api(getState) - .put('/api/v1/pleroma/admin/users/tag', { nicknames, tags }) - .then(() => { - dispatch({ type: ADMIN_USERS_TAG_SUCCESS, accountIds, tags }); - }).catch(error => { - dispatch({ type: ADMIN_USERS_TAG_FAIL, error, accountIds, tags }); - }); - }; -} - -export function untagUsers(accountIds, tags) { - return (dispatch, getState) => { - const nicknames = nicknamesFromIds(getState, accountIds); - dispatch({ type: ADMIN_USERS_UNTAG_REQUEST, accountIds, tags }); - return api(getState) - .delete('/api/v1/pleroma/admin/users/tag', { data: { nicknames, tags } }) - .then(() => { - dispatch({ type: ADMIN_USERS_UNTAG_SUCCESS, accountIds, tags }); - }).catch(error => { - dispatch({ type: ADMIN_USERS_UNTAG_FAIL, error, accountIds, tags }); - }); - }; -} - -export function verifyUser(accountId) { - return (dispatch, getState) => { - return dispatch(tagUsers([accountId], ['verified'])); - }; -} - -export function unverifyUser(accountId) { - return (dispatch, getState) => { - return dispatch(untagUsers([accountId], ['verified'])); - }; -} - -export function setDonor(accountId) { - return (dispatch, getState) => { - return dispatch(tagUsers([accountId], ['donor'])); - }; -} - -export function removeDonor(accountId) { - return (dispatch, getState) => { - return dispatch(untagUsers([accountId], ['donor'])); - }; -} - -export function addPermission(accountIds, permissionGroup) { - return (dispatch, getState) => { - const nicknames = nicknamesFromIds(getState, accountIds); - dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_REQUEST, accountIds, permissionGroup }); - return api(getState) - .post(`/api/v1/pleroma/admin/users/permission_group/${permissionGroup}`, { nicknames }) - .then(({ data }) => { - dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_SUCCESS, accountIds, permissionGroup, data }); - }).catch(error => { - dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_FAIL, error, accountIds, permissionGroup }); - }); - }; -} - -export function removePermission(accountIds, permissionGroup) { - return (dispatch, getState) => { - const nicknames = nicknamesFromIds(getState, accountIds); - dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_REQUEST, accountIds, permissionGroup }); - return api(getState) - .delete(`/api/v1/pleroma/admin/users/permission_group/${permissionGroup}`, { data: { nicknames } }) - .then(({ data }) => { - dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_SUCCESS, accountIds, permissionGroup, data }); - }).catch(error => { - dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_FAIL, error, accountIds, permissionGroup }); - }); - }; -} - -export function promoteToAdmin(accountId) { - return (dispatch, getState) => { - return Promise.all([ - dispatch(addPermission([accountId], 'admin')), - dispatch(removePermission([accountId], 'moderator')), - ]); - }; -} - -export function promoteToModerator(accountId) { - return (dispatch, getState) => { - return Promise.all([ - dispatch(removePermission([accountId], 'admin')), - dispatch(addPermission([accountId], 'moderator')), - ]); - }; -} - -export function demoteToUser(accountId) { - return (dispatch, getState) => { - return Promise.all([ - dispatch(removePermission([accountId], 'admin')), - dispatch(removePermission([accountId], 'moderator')), - ]); - }; -} - -export function suggestUsers(accountIds) { - return (dispatch, getState) => { - const nicknames = nicknamesFromIds(getState, accountIds); - dispatch({ type: ADMIN_USERS_SUGGEST_REQUEST, accountIds }); - return api(getState) - .patch('/api/pleroma/admin/users/suggest', { nicknames }) - .then(({ data: { users } }) => { - dispatch({ type: ADMIN_USERS_SUGGEST_SUCCESS, users, accountIds }); - }).catch(error => { - dispatch({ type: ADMIN_USERS_SUGGEST_FAIL, error, accountIds }); - }); - }; -} - -export function unsuggestUsers(accountIds) { - return (dispatch, getState) => { - const nicknames = nicknamesFromIds(getState, accountIds); - dispatch({ type: ADMIN_USERS_UNSUGGEST_REQUEST, accountIds }); - return api(getState) - .patch('/api/pleroma/admin/users/unsuggest', { nicknames }) - .then(({ data: { users } }) => { - dispatch({ type: ADMIN_USERS_UNSUGGEST_SUCCESS, users, accountIds }); - }).catch(error => { - dispatch({ type: ADMIN_USERS_UNSUGGEST_FAIL, error, accountIds }); - }); - }; -} diff --git a/app/soapbox/actions/admin.ts b/app/soapbox/actions/admin.ts new file mode 100644 index 000000000..660b52dce --- /dev/null +++ b/app/soapbox/actions/admin.ts @@ -0,0 +1,581 @@ +import { fetchRelationships } from 'soapbox/actions/accounts'; +import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer'; +import { getFeatures } from 'soapbox/utils/features'; + +import api, { getLinks } from '../api'; + +import type { AxiosResponse } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const ADMIN_CONFIG_FETCH_REQUEST = 'ADMIN_CONFIG_FETCH_REQUEST'; +const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS'; +const ADMIN_CONFIG_FETCH_FAIL = 'ADMIN_CONFIG_FETCH_FAIL'; + +const ADMIN_CONFIG_UPDATE_REQUEST = 'ADMIN_CONFIG_UPDATE_REQUEST'; +const ADMIN_CONFIG_UPDATE_SUCCESS = 'ADMIN_CONFIG_UPDATE_SUCCESS'; +const ADMIN_CONFIG_UPDATE_FAIL = 'ADMIN_CONFIG_UPDATE_FAIL'; + +const ADMIN_REPORTS_FETCH_REQUEST = 'ADMIN_REPORTS_FETCH_REQUEST'; +const ADMIN_REPORTS_FETCH_SUCCESS = 'ADMIN_REPORTS_FETCH_SUCCESS'; +const ADMIN_REPORTS_FETCH_FAIL = 'ADMIN_REPORTS_FETCH_FAIL'; + +const ADMIN_REPORTS_PATCH_REQUEST = 'ADMIN_REPORTS_PATCH_REQUEST'; +const ADMIN_REPORTS_PATCH_SUCCESS = 'ADMIN_REPORTS_PATCH_SUCCESS'; +const ADMIN_REPORTS_PATCH_FAIL = 'ADMIN_REPORTS_PATCH_FAIL'; + +const ADMIN_USERS_FETCH_REQUEST = 'ADMIN_USERS_FETCH_REQUEST'; +const ADMIN_USERS_FETCH_SUCCESS = 'ADMIN_USERS_FETCH_SUCCESS'; +const ADMIN_USERS_FETCH_FAIL = 'ADMIN_USERS_FETCH_FAIL'; + +const ADMIN_USERS_DELETE_REQUEST = 'ADMIN_USERS_DELETE_REQUEST'; +const ADMIN_USERS_DELETE_SUCCESS = 'ADMIN_USERS_DELETE_SUCCESS'; +const ADMIN_USERS_DELETE_FAIL = 'ADMIN_USERS_DELETE_FAIL'; + +const ADMIN_USERS_APPROVE_REQUEST = 'ADMIN_USERS_APPROVE_REQUEST'; +const ADMIN_USERS_APPROVE_SUCCESS = 'ADMIN_USERS_APPROVE_SUCCESS'; +const ADMIN_USERS_APPROVE_FAIL = 'ADMIN_USERS_APPROVE_FAIL'; + +const ADMIN_USERS_DEACTIVATE_REQUEST = 'ADMIN_USERS_DEACTIVATE_REQUEST'; +const ADMIN_USERS_DEACTIVATE_SUCCESS = 'ADMIN_USERS_DEACTIVATE_SUCCESS'; +const ADMIN_USERS_DEACTIVATE_FAIL = 'ADMIN_USERS_DEACTIVATE_FAIL'; + +const ADMIN_STATUS_DELETE_REQUEST = 'ADMIN_STATUS_DELETE_REQUEST'; +const ADMIN_STATUS_DELETE_SUCCESS = 'ADMIN_STATUS_DELETE_SUCCESS'; +const ADMIN_STATUS_DELETE_FAIL = 'ADMIN_STATUS_DELETE_FAIL'; + +const ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST = 'ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST'; +const ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS = 'ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS'; +const ADMIN_STATUS_TOGGLE_SENSITIVITY_FAIL = 'ADMIN_STATUS_TOGGLE_SENSITIVITY_FAIL'; + +const ADMIN_LOG_FETCH_REQUEST = 'ADMIN_LOG_FETCH_REQUEST'; +const ADMIN_LOG_FETCH_SUCCESS = 'ADMIN_LOG_FETCH_SUCCESS'; +const ADMIN_LOG_FETCH_FAIL = 'ADMIN_LOG_FETCH_FAIL'; + +const ADMIN_USERS_TAG_REQUEST = 'ADMIN_USERS_TAG_REQUEST'; +const ADMIN_USERS_TAG_SUCCESS = 'ADMIN_USERS_TAG_SUCCESS'; +const ADMIN_USERS_TAG_FAIL = 'ADMIN_USERS_TAG_FAIL'; + +const ADMIN_USERS_UNTAG_REQUEST = 'ADMIN_USERS_UNTAG_REQUEST'; +const ADMIN_USERS_UNTAG_SUCCESS = 'ADMIN_USERS_UNTAG_SUCCESS'; +const ADMIN_USERS_UNTAG_FAIL = 'ADMIN_USERS_UNTAG_FAIL'; + +const ADMIN_ADD_PERMISSION_GROUP_REQUEST = 'ADMIN_ADD_PERMISSION_GROUP_REQUEST'; +const ADMIN_ADD_PERMISSION_GROUP_SUCCESS = 'ADMIN_ADD_PERMISSION_GROUP_SUCCESS'; +const ADMIN_ADD_PERMISSION_GROUP_FAIL = 'ADMIN_ADD_PERMISSION_GROUP_FAIL'; + +const ADMIN_REMOVE_PERMISSION_GROUP_REQUEST = 'ADMIN_REMOVE_PERMISSION_GROUP_REQUEST'; +const ADMIN_REMOVE_PERMISSION_GROUP_SUCCESS = 'ADMIN_REMOVE_PERMISSION_GROUP_SUCCESS'; +const ADMIN_REMOVE_PERMISSION_GROUP_FAIL = 'ADMIN_REMOVE_PERMISSION_GROUP_FAIL'; + +const ADMIN_USERS_SUGGEST_REQUEST = 'ADMIN_USERS_SUGGEST_REQUEST'; +const ADMIN_USERS_SUGGEST_SUCCESS = 'ADMIN_USERS_SUGGEST_SUCCESS'; +const ADMIN_USERS_SUGGEST_FAIL = 'ADMIN_USERS_SUGGEST_FAIL'; + +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 nicknamesFromIds = (getState: () => RootState, ids: string[]) => ids.map(id => getState().accounts.get(id)!.acct); + +const fetchConfig = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_CONFIG_FETCH_REQUEST }); + return api(getState) + .get('/api/pleroma/admin/config') + .then(({ data }) => { + dispatch({ type: ADMIN_CONFIG_FETCH_SUCCESS, configs: data.configs, needsReboot: data.need_reboot }); + }).catch(error => { + dispatch({ type: ADMIN_CONFIG_FETCH_FAIL, error }); + }); + }; + +const updateConfig = (configs: Record[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_CONFIG_UPDATE_REQUEST, configs }); + return api(getState) + .post('/api/pleroma/admin/config', { configs }) + .then(({ data }) => { + dispatch({ type: ADMIN_CONFIG_UPDATE_SUCCESS, configs: data.configs, needsReboot: data.need_reboot }); + }).catch(error => { + dispatch({ type: ADMIN_CONFIG_UPDATE_FAIL, error, configs }); + }); + }; + +const fetchMastodonReports = (params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => + api(getState) + .get('/api/v1/admin/reports', { params }) + .then(({ data: reports }) => { + reports.forEach((report: APIEntity) => { + dispatch(importFetchedAccount(report.account?.account)); + dispatch(importFetchedAccount(report.target_account?.account)); + dispatch(importFetchedStatuses(report.statuses)); + }); + dispatch({ type: ADMIN_REPORTS_FETCH_SUCCESS, reports, params }); + }).catch(error => { + dispatch({ type: ADMIN_REPORTS_FETCH_FAIL, error, params }); + }); + +const fetchPleromaReports = (params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => + api(getState) + .get('/api/pleroma/admin/reports', { params }) + .then(({ data: { reports } }) => { + reports.forEach((report: APIEntity) => { + dispatch(importFetchedAccount(report.account)); + dispatch(importFetchedAccount(report.actor)); + dispatch(importFetchedStatuses(report.statuses)); + }); + dispatch({ type: ADMIN_REPORTS_FETCH_SUCCESS, reports, params }); + }).catch(error => { + dispatch({ type: ADMIN_REPORTS_FETCH_FAIL, error, params }); + }); + +const fetchReports = (params: Record = {}) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + + const instance = state.instance; + const features = getFeatures(instance); + + dispatch({ type: ADMIN_REPORTS_FETCH_REQUEST, params }); + + if (features.mastodonAdmin) { + return dispatch(fetchMastodonReports(params)); + } else { + const { resolved } = params; + + return dispatch(fetchPleromaReports({ + state: resolved === false ? 'open' : (resolved ? 'resolved' : null), + })); + } + }; + +const patchMastodonReports = (reports: { id: string, state: string }[]) => + (dispatch: AppDispatch, getState: () => RootState) => + Promise.all(reports.map(({ id, state }) => api(getState) + .post(`/api/v1/admin/reports/${id}/${state === 'resolved' ? 'reopen' : 'resolve'}`) + .then(() => { + dispatch({ type: ADMIN_REPORTS_PATCH_SUCCESS, reports }); + }).catch(error => { + dispatch({ type: ADMIN_REPORTS_PATCH_FAIL, error, reports }); + }), + )); + +const patchPleromaReports = (reports: { id: string, state: string }[]) => + (dispatch: AppDispatch, getState: () => RootState) => + api(getState) + .patch('/api/pleroma/admin/reports', { reports }) + .then(() => { + dispatch({ type: ADMIN_REPORTS_PATCH_SUCCESS, reports }); + }).catch(error => { + dispatch({ type: ADMIN_REPORTS_PATCH_FAIL, error, reports }); + }); + +const patchReports = (ids: string[], reportState: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + + const instance = state.instance; + const features = getFeatures(instance); + + const reports = ids.map(id => ({ id, state: reportState })); + + dispatch({ type: ADMIN_REPORTS_PATCH_REQUEST, reports }); + + if (features.mastodonAdmin) { + return dispatch(patchMastodonReports(reports)); + } else { + return dispatch(patchPleromaReports(reports)); + } + }; + +const closeReports = (ids: string[]) => + patchReports(ids, 'closed'); + +const fetchMastodonUsers = (filters: string[], page: number, query: string | null | undefined, pageSize: number, next?: string | null) => + (dispatch: AppDispatch, getState: () => RootState) => { + const params: Record = { + username: query, + }; + + if (filters.includes('local')) params.local = true; + if (filters.includes('active')) params.active = true; + if (filters.includes('need_approval')) params.pending = true; + + return api(getState) + .get(next || '/api/v1/admin/accounts', { params }) + .then(({ data: accounts, ...response }) => { + const next = getLinks(response as AxiosResponse).refs.find(link => link.rel === 'next'); + + const count = next + ? page * pageSize + 1 + : (page - 1) * pageSize + accounts.length; + + dispatch(importFetchedAccounts(accounts.map(({ account }: APIEntity) => account))); + dispatch(fetchRelationships(accounts.map((account: APIEntity) => account.id))); + dispatch({ type: ADMIN_USERS_FETCH_SUCCESS, users: accounts, count, pageSize, filters, page, next: next?.uri || false }); + return { users: accounts, count, pageSize, next: next?.uri || false }; + }).catch(error => + dispatch({ type: ADMIN_USERS_FETCH_FAIL, error, filters, page, pageSize }), + ); + }; + +const fetchPleromaUsers = (filters: string[], page: number, query?: string | null, pageSize?: number) => + (dispatch: AppDispatch, getState: () => RootState) => { + const params: Record = { filters: filters.join(), page, page_size: pageSize }; + if (query) params.query = query; + + return api(getState) + .get('/api/pleroma/admin/users', { params }) + .then(({ data: { users, count, page_size: pageSize } }) => { + dispatch(fetchRelationships(users.map((user: APIEntity) => user.id))); + dispatch({ type: ADMIN_USERS_FETCH_SUCCESS, users, count, pageSize, filters, page }); + return { users, count, pageSize }; + }).catch(error => + dispatch({ type: ADMIN_USERS_FETCH_FAIL, error, filters, page, pageSize }), + ); + }; + +const fetchUsers = (filters: string[] = [], page = 1, query?: string | null, pageSize = 50, next?: string | null) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + + const instance = state.instance; + const features = getFeatures(instance); + + dispatch({ type: ADMIN_USERS_FETCH_REQUEST, filters, page, pageSize }); + + if (features.mastodonAdmin) { + return dispatch(fetchMastodonUsers(filters, page, query, pageSize, next)); + } else { + return dispatch(fetchPleromaUsers(filters, page, query, pageSize)); + } + }; + +const deactivateMastodonUsers = (accountIds: string[], reportId?: string) => + (dispatch: AppDispatch, getState: () => RootState) => + Promise.all(accountIds.map(accountId => { + api(getState) + .post(`/api/v1/admin/accounts/${accountId}/action`, { + type: 'disable', + report_id: reportId, + }) + .then(() => { + dispatch({ type: ADMIN_USERS_DEACTIVATE_SUCCESS, accountIds: [accountId] }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_DEACTIVATE_FAIL, error, accountIds: [accountId] }); + }); + })); + +const deactivatePleromaUsers = (accountIds: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + const nicknames = nicknamesFromIds(getState, accountIds); + return api(getState) + .patch('/api/pleroma/admin/users/deactivate', { nicknames }) + .then(({ data: { users } }) => { + dispatch({ type: ADMIN_USERS_DEACTIVATE_SUCCESS, users, accountIds }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_DEACTIVATE_FAIL, error, accountIds }); + }); + }; + +const deactivateUsers = (accountIds: string[], reportId?: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + + const instance = state.instance; + const features = getFeatures(instance); + + dispatch({ type: ADMIN_USERS_DEACTIVATE_REQUEST, accountIds }); + + if (features.mastodonAdmin) { + return dispatch(deactivateMastodonUsers(accountIds, reportId)); + } else { + return dispatch(deactivatePleromaUsers(accountIds)); + } + }; + +const deleteUsers = (accountIds: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + const nicknames = nicknamesFromIds(getState, accountIds); + dispatch({ type: ADMIN_USERS_DELETE_REQUEST, accountIds }); + return api(getState) + .delete('/api/pleroma/admin/users', { data: { nicknames } }) + .then(({ data: nicknames }) => { + dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames, accountIds }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_DELETE_FAIL, error, accountIds }); + }); + }; + +const approveMastodonUsers = (accountIds: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => + Promise.all(accountIds.map(accountId => { + api(getState) + .post(`/api/v1/admin/accounts/${accountId}/approve`) + .then(({ data: user }) => { + dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users: [user], accountIds: [accountId] }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountIds: [accountId] }); + }); + })); + +const approvePleromaUsers = (accountIds: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + const nicknames = nicknamesFromIds(getState, accountIds); + return api(getState) + .patch('/api/pleroma/admin/users/approve', { nicknames }) + .then(({ data: { users } }) => { + dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users, accountIds }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountIds }); + }); + }; + +const approveUsers = (accountIds: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + + const instance = state.instance; + const features = getFeatures(instance); + + dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountIds }); + + if (features.mastodonAdmin) { + return dispatch(approveMastodonUsers(accountIds)); + } else { + return dispatch(approvePleromaUsers(accountIds)); + } + }; + +const deleteStatus = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_STATUS_DELETE_REQUEST, id }); + return api(getState) + .delete(`/api/pleroma/admin/statuses/${id}`) + .then(() => { + dispatch({ type: ADMIN_STATUS_DELETE_SUCCESS, id }); + }).catch(error => { + dispatch({ type: ADMIN_STATUS_DELETE_FAIL, error, id }); + }); + }; + +const toggleStatusSensitivity = (id: string, sensitive: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST, id }); + return api(getState) + .put(`/api/pleroma/admin/statuses/${id}`, { sensitive: !sensitive }) + .then(() => { + dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS, id }); + }).catch(error => { + dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_FAIL, error, id }); + }); + }; + +const fetchModerationLog = (params?: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_LOG_FETCH_REQUEST }); + return api(getState) + .get('/api/pleroma/admin/moderation_log', { params }) + .then(({ data }) => { + dispatch({ type: ADMIN_LOG_FETCH_SUCCESS, items: data.items, total: data.total }); + return data; + }).catch(error => { + dispatch({ type: ADMIN_LOG_FETCH_FAIL, error }); + }); + }; + +const tagUsers = (accountIds: string[], tags: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + const nicknames = nicknamesFromIds(getState, accountIds); + dispatch({ type: ADMIN_USERS_TAG_REQUEST, accountIds, tags }); + return api(getState) + .put('/api/v1/pleroma/admin/users/tag', { nicknames, tags }) + .then(() => { + dispatch({ type: ADMIN_USERS_TAG_SUCCESS, accountIds, tags }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_TAG_FAIL, error, accountIds, tags }); + }); + }; + +const untagUsers = (accountIds: string[], tags: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + const nicknames = nicknamesFromIds(getState, accountIds); + dispatch({ type: ADMIN_USERS_UNTAG_REQUEST, accountIds, tags }); + return api(getState) + .delete('/api/v1/pleroma/admin/users/tag', { data: { nicknames, tags } }) + .then(() => { + dispatch({ type: ADMIN_USERS_UNTAG_SUCCESS, accountIds, tags }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_UNTAG_FAIL, error, accountIds, tags }); + }); + }; + +const verifyUser = (accountId: string) => + (dispatch: AppDispatch) => + dispatch(tagUsers([accountId], ['verified'])); + +const unverifyUser = (accountId: string) => + (dispatch: AppDispatch) => + dispatch(untagUsers([accountId], ['verified'])); + +const setDonor = (accountId: string) => + (dispatch: AppDispatch) => + dispatch(tagUsers([accountId], ['donor'])); + +const removeDonor = (accountId: string) => + (dispatch: AppDispatch) => + dispatch(untagUsers([accountId], ['donor'])); + +const addPermission = (accountIds: string[], permissionGroup: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const nicknames = nicknamesFromIds(getState, accountIds); + dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_REQUEST, accountIds, permissionGroup }); + return api(getState) + .post(`/api/v1/pleroma/admin/users/permission_group/${permissionGroup}`, { nicknames }) + .then(({ data }) => { + dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_SUCCESS, accountIds, permissionGroup, data }); + }).catch(error => { + dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_FAIL, error, accountIds, permissionGroup }); + }); + }; + +const removePermission = (accountIds: string[], permissionGroup: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const nicknames = nicknamesFromIds(getState, accountIds); + dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_REQUEST, accountIds, permissionGroup }); + return api(getState) + .delete(`/api/v1/pleroma/admin/users/permission_group/${permissionGroup}`, { data: { nicknames } }) + .then(({ data }) => { + dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_SUCCESS, accountIds, permissionGroup, data }); + }).catch(error => { + dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_FAIL, error, accountIds, permissionGroup }); + }); + }; + +const promoteToAdmin = (accountId: string) => + (dispatch: AppDispatch) => + Promise.all([ + dispatch(addPermission([accountId], 'admin')), + dispatch(removePermission([accountId], 'moderator')), + ]); + +const promoteToModerator = (accountId: string) => + (dispatch: AppDispatch) => + Promise.all([ + dispatch(removePermission([accountId], 'admin')), + dispatch(addPermission([accountId], 'moderator')), + ]); + +const demoteToUser = (accountId: string) => + (dispatch: AppDispatch) => + Promise.all([ + dispatch(removePermission([accountId], 'admin')), + dispatch(removePermission([accountId], 'moderator')), + ]); + +const suggestUsers = (accountIds: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + const nicknames = nicknamesFromIds(getState, accountIds); + dispatch({ type: ADMIN_USERS_SUGGEST_REQUEST, accountIds }); + return api(getState) + .patch('/api/pleroma/admin/users/suggest', { nicknames }) + .then(({ data: { users } }) => { + dispatch({ type: ADMIN_USERS_SUGGEST_SUCCESS, users, accountIds }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_SUGGEST_FAIL, error, accountIds }); + }); + }; + +const unsuggestUsers = (accountIds: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + const nicknames = nicknamesFromIds(getState, accountIds); + dispatch({ type: ADMIN_USERS_UNSUGGEST_REQUEST, accountIds }); + return api(getState) + .patch('/api/pleroma/admin/users/unsuggest', { nicknames }) + .then(({ data: { users } }) => { + dispatch({ type: ADMIN_USERS_UNSUGGEST_SUCCESS, users, accountIds }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_UNSUGGEST_FAIL, error, accountIds }); + }); + }; + +export { + ADMIN_CONFIG_FETCH_REQUEST, + ADMIN_CONFIG_FETCH_SUCCESS, + ADMIN_CONFIG_FETCH_FAIL, + ADMIN_CONFIG_UPDATE_REQUEST, + ADMIN_CONFIG_UPDATE_SUCCESS, + ADMIN_CONFIG_UPDATE_FAIL, + ADMIN_REPORTS_FETCH_REQUEST, + ADMIN_REPORTS_FETCH_SUCCESS, + ADMIN_REPORTS_FETCH_FAIL, + ADMIN_REPORTS_PATCH_REQUEST, + ADMIN_REPORTS_PATCH_SUCCESS, + ADMIN_REPORTS_PATCH_FAIL, + ADMIN_USERS_FETCH_REQUEST, + ADMIN_USERS_FETCH_SUCCESS, + ADMIN_USERS_FETCH_FAIL, + ADMIN_USERS_DELETE_REQUEST, + ADMIN_USERS_DELETE_SUCCESS, + ADMIN_USERS_DELETE_FAIL, + ADMIN_USERS_APPROVE_REQUEST, + ADMIN_USERS_APPROVE_SUCCESS, + ADMIN_USERS_APPROVE_FAIL, + ADMIN_USERS_DEACTIVATE_REQUEST, + ADMIN_USERS_DEACTIVATE_SUCCESS, + ADMIN_USERS_DEACTIVATE_FAIL, + ADMIN_STATUS_DELETE_REQUEST, + ADMIN_STATUS_DELETE_SUCCESS, + ADMIN_STATUS_DELETE_FAIL, + ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST, + ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS, + ADMIN_STATUS_TOGGLE_SENSITIVITY_FAIL, + ADMIN_LOG_FETCH_REQUEST, + ADMIN_LOG_FETCH_SUCCESS, + ADMIN_LOG_FETCH_FAIL, + ADMIN_USERS_TAG_REQUEST, + ADMIN_USERS_TAG_SUCCESS, + ADMIN_USERS_TAG_FAIL, + ADMIN_USERS_UNTAG_REQUEST, + ADMIN_USERS_UNTAG_SUCCESS, + ADMIN_USERS_UNTAG_FAIL, + ADMIN_ADD_PERMISSION_GROUP_REQUEST, + ADMIN_ADD_PERMISSION_GROUP_SUCCESS, + ADMIN_ADD_PERMISSION_GROUP_FAIL, + ADMIN_REMOVE_PERMISSION_GROUP_REQUEST, + ADMIN_REMOVE_PERMISSION_GROUP_SUCCESS, + ADMIN_REMOVE_PERMISSION_GROUP_FAIL, + ADMIN_USERS_SUGGEST_REQUEST, + ADMIN_USERS_SUGGEST_SUCCESS, + ADMIN_USERS_SUGGEST_FAIL, + ADMIN_USERS_UNSUGGEST_REQUEST, + ADMIN_USERS_UNSUGGEST_SUCCESS, + ADMIN_USERS_UNSUGGEST_FAIL, + fetchConfig, + updateConfig, + fetchReports, + closeReports, + fetchUsers, + deactivateUsers, + deleteUsers, + approveUsers, + deleteStatus, + toggleStatusSensitivity, + fetchModerationLog, + tagUsers, + untagUsers, + verifyUser, + unverifyUser, + setDonor, + removeDonor, + addPermission, + removePermission, + promoteToAdmin, + promoteToModerator, + demoteToUser, + suggestUsers, + unsuggestUsers, +}; diff --git a/app/soapbox/actions/alerts.js b/app/soapbox/actions/alerts.js deleted file mode 100644 index c71ce3e87..000000000 --- a/app/soapbox/actions/alerts.js +++ /dev/null @@ -1,68 +0,0 @@ -import { defineMessages } from 'react-intl'; - -import { httpErrorMessages } from 'soapbox/utils/errors'; - -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 = () => {}; - -export function dismissAlert(alert) { - return { - type: ALERT_DISMISS, - alert, - }; -} - -export function clearAlert() { - return { - type: ALERT_CLEAR, - }; -} - -export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, severity = 'info') { - return { - type: ALERT_SHOW, - title, - message, - severity, - }; -} - -export function showAlertForError(error) { - return (dispatch, _getState) => { - 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); - } - - let message = 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')); - } - }; -} diff --git a/app/soapbox/actions/alerts.ts b/app/soapbox/actions/alerts.ts new file mode 100644 index 000000000..3e5aed4b3 --- /dev/null +++ b/app/soapbox/actions/alerts.ts @@ -0,0 +1,75 @@ +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 'soapbox/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.js b/app/soapbox/actions/aliases.js deleted file mode 100644 index e28817681..000000000 --- a/app/soapbox/actions/aliases.js +++ /dev/null @@ -1,196 +0,0 @@ -import { defineMessages } from 'react-intl'; - -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'; - -export const ALIASES_FETCH_REQUEST = 'ALIASES_FETCH_REQUEST'; -export const ALIASES_FETCH_SUCCESS = 'ALIASES_FETCH_SUCCESS'; -export const ALIASES_FETCH_FAIL = 'ALIASES_FETCH_FAIL'; - -export const ALIASES_SUGGESTIONS_CHANGE = 'ALIASES_SUGGESTIONS_CHANGE'; -export const ALIASES_SUGGESTIONS_READY = 'ALIASES_SUGGESTIONS_READY'; -export const ALIASES_SUGGESTIONS_CLEAR = 'ALIASES_SUGGESTIONS_CLEAR'; - -export const ALIASES_ADD_REQUEST = 'ALIASES_ADD_REQUEST'; -export const ALIASES_ADD_SUCCESS = 'ALIASES_ADD_SUCCESS'; -export const ALIASES_ADD_FAIL = 'ALIASES_ADD_FAIL'; - -export const ALIASES_REMOVE_REQUEST = 'ALIASES_REMOVE_REQUEST'; -export const ALIASES_REMOVE_SUCCESS = 'ALIASES_REMOVE_SUCCESS'; -export const ALIASES_REMOVE_FAIL = 'ALIASES_REMOVE_FAIL'; - -const messages = defineMessages({ - createSuccess: { id: 'aliases.success.add', defaultMessage: 'Account alias created successfully' }, - removeSuccess: { id: 'aliases.success.remove', defaultMessage: 'Account alias removed successfully' }, -}); - -export const fetchAliases = (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - const state = getState(); - - const instance = state.get('instance'); - const features = getFeatures(instance); - - if (!features.accountMoving) return; - - dispatch(fetchAliasesRequest()); - - api(getState).get('/api/pleroma/aliases') - .then(response => { - dispatch(fetchAliasesSuccess(response.data.aliases)); - }) - .catch(err => dispatch(fetchAliasesFail(err))); -}; - -export const fetchAliasesRequest = () => ({ - type: ALIASES_FETCH_REQUEST, -}); - -export const fetchAliasesSuccess = aliases => ({ - type: ALIASES_FETCH_SUCCESS, - value: aliases, -}); - -export const fetchAliasesFail = error => ({ - type: ALIASES_FETCH_FAIL, - error, -}); - -export const fetchAliasesSuggestions = q => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - const params = { - q, - resolve: true, - limit: 4, - }; - - api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchAliasesSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); -}; - -export const fetchAliasesSuggestionsReady = (query, accounts) => ({ - type: ALIASES_SUGGESTIONS_READY, - query, - accounts, -}); - -export const clearAliasesSuggestions = () => ({ - type: ALIASES_SUGGESTIONS_CLEAR, -}); - -export const changeAliasesSuggestions = value => ({ - type: ALIASES_SUGGESTIONS_CHANGE, - value, -}); - -export const addToAliases = (intl, account) => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - const state = getState(); - - const instance = state.get('instance'); - const features = getFeatures(instance); - - if (!features.accountMoving) { - const me = state.get('me'); - const alsoKnownAs = state.getIn(['accounts_meta', me, 'pleroma', 'also_known_as']); - - dispatch(addToAliasesRequest()); - - api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, account.getIn(['pleroma', 'ap_id'])] }) - .then((response => { - dispatch(snackbar.success(intl.formatMessage(messages.createSuccess))); - dispatch(addToAliasesSuccess); - dispatch(patchMeSuccess(response.data)); - })) - .catch(err => dispatch(addToAliasesFail(err))); - - return; - } - - dispatch(addToAliasesRequest()); - - api(getState).put('/api/pleroma/aliases', { - alias: account.get('acct'), - }) - .then(response => { - dispatch(snackbar.success(intl.formatMessage(messages.createSuccess))); - dispatch(addToAliasesSuccess); - dispatch(fetchAliases); - }) - .catch(err => dispatch(fetchAliasesFail(err))); -}; - -export const addToAliasesRequest = () => ({ - type: ALIASES_ADD_REQUEST, -}); - -export const addToAliasesSuccess = () => ({ - type: ALIASES_ADD_SUCCESS, -}); - -export const addToAliasesFail = error => ({ - type: ALIASES_ADD_FAIL, - error, -}); - -export const removeFromAliases = (intl, account) => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - const state = getState(); - - const instance = state.get('instance'); - const features = getFeatures(instance); - - if (!features.accountMoving) { - const me = state.get('me'); - const alsoKnownAs = state.getIn(['accounts_meta', me, 'pleroma', 'also_known_as']); - - dispatch(removeFromAliasesRequest()); - - api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: alsoKnownAs.filter(id => id !== account) }) - .then(response => { - dispatch(snackbar.success(intl.formatMessage(messages.removeSuccess))); - dispatch(removeFromAliasesSuccess); - dispatch(patchMeSuccess(response.data)); - }) - .catch(err => dispatch(removeFromAliasesFail(err))); - - return; - } - - dispatch(addToAliasesRequest()); - - api(getState).delete('/api/pleroma/aliases', { - data: { - alias: account, - }, - }) - .then(response => { - dispatch(snackbar.success(intl.formatMessage(messages.removeSuccess))); - dispatch(removeFromAliasesSuccess); - dispatch(fetchAliases); - }) - .catch(err => dispatch(fetchAliasesFail(err))); -}; - -export const removeFromAliasesRequest = () => ({ - type: ALIASES_REMOVE_REQUEST, -}); - -export const removeFromAliasesSuccess = () => ({ - type: ALIASES_REMOVE_SUCCESS, -}); - -export const removeFromAliasesFail = error => ({ - type: ALIASES_REMOVE_FAIL, - error, -}); diff --git a/app/soapbox/actions/aliases.ts b/app/soapbox/actions/aliases.ts new file mode 100644 index 000000000..8361e31ad --- /dev/null +++ b/app/soapbox/actions/aliases.ts @@ -0,0 +1,234 @@ +import { defineMessages } from 'react-intl'; + +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'; +import type { APIEntity, Account } from 'soapbox/types/entities'; + +const ALIASES_FETCH_REQUEST = 'ALIASES_FETCH_REQUEST'; +const ALIASES_FETCH_SUCCESS = 'ALIASES_FETCH_SUCCESS'; +const ALIASES_FETCH_FAIL = 'ALIASES_FETCH_FAIL'; + +const ALIASES_SUGGESTIONS_CHANGE = 'ALIASES_SUGGESTIONS_CHANGE'; +const ALIASES_SUGGESTIONS_READY = 'ALIASES_SUGGESTIONS_READY'; +const ALIASES_SUGGESTIONS_CLEAR = 'ALIASES_SUGGESTIONS_CLEAR'; + +const ALIASES_ADD_REQUEST = 'ALIASES_ADD_REQUEST'; +const ALIASES_ADD_SUCCESS = 'ALIASES_ADD_SUCCESS'; +const ALIASES_ADD_FAIL = 'ALIASES_ADD_FAIL'; + +const ALIASES_REMOVE_REQUEST = 'ALIASES_REMOVE_REQUEST'; +const ALIASES_REMOVE_SUCCESS = 'ALIASES_REMOVE_SUCCESS'; +const ALIASES_REMOVE_FAIL = 'ALIASES_REMOVE_FAIL'; + +const messages = defineMessages({ + createSuccess: { id: 'aliases.success.add', defaultMessage: 'Account alias created successfully' }, + removeSuccess: { id: 'aliases.success.remove', defaultMessage: 'Account alias removed successfully' }, +}); + +const fetchAliases = (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const state = getState(); + + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.accountMoving) return; + + dispatch(fetchAliasesRequest()); + + api(getState).get('/api/pleroma/aliases') + .then(response => { + dispatch(fetchAliasesSuccess(response.data.aliases)); + }) + .catch(err => dispatch(fetchAliasesFail(err))); +}; + +const fetchAliasesRequest = () => ({ + type: ALIASES_FETCH_REQUEST, +}); + +const fetchAliasesSuccess = (aliases: APIEntity[]) => ({ + type: ALIASES_FETCH_SUCCESS, + value: aliases, +}); + +const fetchAliasesFail = (error: AxiosError) => ({ + type: ALIASES_FETCH_FAIL, + error, +}); + +const fetchAliasesSuggestions = (q: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const params = { + q, + resolve: true, + limit: 4, + }; + + api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchAliasesSuggestionsReady(q, data)); + }).catch(error => dispatch(showAlertForError(error))); + }; + +const fetchAliasesSuggestionsReady = (query: string, accounts: APIEntity[]) => ({ + type: ALIASES_SUGGESTIONS_READY, + query, + accounts, +}); + +const clearAliasesSuggestions = () => ({ + type: ALIASES_SUGGESTIONS_CLEAR, +}); + +const changeAliasesSuggestions = (value: string) => ({ + type: ALIASES_SUGGESTIONS_CHANGE, + value, +}); + +const addToAliases = (account: Account) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const state = getState(); + + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.accountMoving) { + const me = state.me; + const alsoKnownAs = state.accounts_meta.get(me as string)!.pleroma.get('also_known_as'); + + dispatch(addToAliasesRequest()); + + api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, account.pleroma.get('ap_id')] }) + .then((response => { + dispatch(snackbar.success(messages.createSuccess)); + dispatch(addToAliasesSuccess); + dispatch(patchMeSuccess(response.data)); + })) + .catch(err => dispatch(addToAliasesFail(err))); + + return; + } + + dispatch(addToAliasesRequest()); + + api(getState).put('/api/pleroma/aliases', { + alias: account.acct, + }) + .then(() => { + dispatch(snackbar.success(messages.createSuccess)); + dispatch(addToAliasesSuccess); + dispatch(fetchAliases); + }) + .catch(err => dispatch(fetchAliasesFail(err))); + }; + +const addToAliasesRequest = () => ({ + type: ALIASES_ADD_REQUEST, +}); + +const addToAliasesSuccess = () => ({ + type: ALIASES_ADD_SUCCESS, +}); + +const addToAliasesFail = (error: AxiosError) => ({ + type: ALIASES_ADD_FAIL, + error, +}); + +const removeFromAliases = (account: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const state = getState(); + + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.accountMoving) { + const me = state.me; + const alsoKnownAs = state.accounts_meta.get(me as string)!.pleroma.get('also_known_as'); + + dispatch(removeFromAliasesRequest()); + + api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: alsoKnownAs.filter((id: string) => id !== account) }) + .then(response => { + dispatch(snackbar.success(messages.removeSuccess)); + dispatch(removeFromAliasesSuccess); + dispatch(patchMeSuccess(response.data)); + }) + .catch(err => dispatch(removeFromAliasesFail(err))); + + return; + } + + dispatch(addToAliasesRequest()); + + api(getState).delete('/api/pleroma/aliases', { + data: { + alias: account, + }, + }) + .then(response => { + dispatch(snackbar.success(messages.removeSuccess)); + dispatch(removeFromAliasesSuccess); + dispatch(fetchAliases); + }) + .catch(err => dispatch(fetchAliasesFail(err))); + }; + +const removeFromAliasesRequest = () => ({ + type: ALIASES_REMOVE_REQUEST, +}); + +const removeFromAliasesSuccess = () => ({ + type: ALIASES_REMOVE_SUCCESS, +}); + +const removeFromAliasesFail = (error: AxiosError) => ({ + type: ALIASES_REMOVE_FAIL, + error, +}); + +export { + ALIASES_FETCH_REQUEST, + ALIASES_FETCH_SUCCESS, + ALIASES_FETCH_FAIL, + ALIASES_SUGGESTIONS_CHANGE, + ALIASES_SUGGESTIONS_READY, + ALIASES_SUGGESTIONS_CLEAR, + ALIASES_ADD_REQUEST, + ALIASES_ADD_SUCCESS, + ALIASES_ADD_FAIL, + ALIASES_REMOVE_REQUEST, + ALIASES_REMOVE_SUCCESS, + ALIASES_REMOVE_FAIL, + fetchAliases, + fetchAliasesRequest, + fetchAliasesSuccess, + fetchAliasesFail, + fetchAliasesSuggestions, + fetchAliasesSuggestionsReady, + clearAliasesSuggestions, + changeAliasesSuggestions, + addToAliases, + addToAliasesRequest, + addToAliasesSuccess, + addToAliasesFail, + removeFromAliases, + removeFromAliasesRequest, + removeFromAliasesSuccess, + removeFromAliasesFail, +}; diff --git a/app/soapbox/actions/announcements.ts b/app/soapbox/actions/announcements.ts new file mode 100644 index 000000000..410de3cd9 --- /dev/null +++ b/app/soapbox/actions/announcements.ts @@ -0,0 +1,197 @@ +import api from 'soapbox/api'; +import { getFeatures } from 'soapbox/utils/features'; + +import { importFetchedStatuses } from './importer'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST'; +export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS'; +export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL'; +export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE'; +export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE'; + +export const ANNOUNCEMENTS_DISMISS_REQUEST = 'ANNOUNCEMENTS_DISMISS_REQUEST'; +export const ANNOUNCEMENTS_DISMISS_SUCCESS = 'ANNOUNCEMENTS_DISMISS_SUCCESS'; +export const ANNOUNCEMENTS_DISMISS_FAIL = 'ANNOUNCEMENTS_DISMISS_FAIL'; + +export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST'; +export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS'; +export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL'; + +export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST'; +export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS'; +export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL'; + +export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE'; + +export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW'; + +const noOp = () => {}; + +export const fetchAnnouncements = (done = noOp) => + (dispatch: AppDispatch, getState: () => RootState) => { + const { instance } = getState(); + const features = getFeatures(instance); + + if (!features.announcements) return null; + + dispatch(fetchAnnouncementsRequest()); + + return api(getState).get('/api/v1/announcements').then(response => { + dispatch(fetchAnnouncementsSuccess(response.data)); + dispatch(importFetchedStatuses(response.data.map(({ statuses }: APIEntity) => statuses))); + }).catch(error => { + dispatch(fetchAnnouncementsFail(error)); + }).finally(() => { + done(); + }); + }; + +export const fetchAnnouncementsRequest = () => ({ + type: ANNOUNCEMENTS_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchAnnouncementsSuccess = (announcements: APIEntity) => ({ + type: ANNOUNCEMENTS_FETCH_SUCCESS, + announcements, + skipLoading: true, +}); + +export const fetchAnnouncementsFail = (error: AxiosError) => ({ + type: ANNOUNCEMENTS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); + +export const updateAnnouncements = (announcement: APIEntity) => ({ + type: ANNOUNCEMENTS_UPDATE, + announcement: announcement, +}); + +export const dismissAnnouncement = (announcementId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(dismissAnnouncementRequest(announcementId)); + + return api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => { + dispatch(dismissAnnouncementSuccess(announcementId)); + }).catch(error => { + dispatch(dismissAnnouncementFail(announcementId, error)); + }); + }; + +export const dismissAnnouncementRequest = (announcementId: string) => ({ + type: ANNOUNCEMENTS_DISMISS_REQUEST, + id: announcementId, +}); + +export const dismissAnnouncementSuccess = (announcementId: string) => ({ + type: ANNOUNCEMENTS_DISMISS_SUCCESS, + id: announcementId, +}); + +export const dismissAnnouncementFail = (announcementId: string, error: AxiosError) => ({ + type: ANNOUNCEMENTS_DISMISS_FAIL, + id: announcementId, + error, +}); + +export const addReaction = (announcementId: string, name: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const announcement = getState().announcements.items.find(x => x.get('id') === announcementId); + + let alreadyAdded = false; + + if (announcement) { + const reaction = announcement.reactions.find(x => x.name === name); + + if (reaction && reaction.me) { + alreadyAdded = true; + } + } + + if (!alreadyAdded) { + dispatch(addReactionRequest(announcementId, name, alreadyAdded)); + } + + return api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { + dispatch(addReactionSuccess(announcementId, name, alreadyAdded)); + }).catch(err => { + if (!alreadyAdded) { + dispatch(addReactionFail(announcementId, name, err)); + } + }); + }; + +export const addReactionRequest = (announcementId: string, name: string, alreadyAdded?: boolean) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_REQUEST, + id: announcementId, + name, + skipLoading: true, +}); + +export const addReactionSuccess = (announcementId: string, name: string, alreadyAdded?: boolean) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS, + id: announcementId, + name, + skipLoading: true, +}); + +export const addReactionFail = (announcementId: string, name: string, error: AxiosError) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_FAIL, + id: announcementId, + name, + error, + skipLoading: true, +}); + +export const removeReaction = (announcementId: string, name: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(removeReactionRequest(announcementId, name)); + + return api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { + dispatch(removeReactionSuccess(announcementId, name)); + }).catch(err => { + dispatch(removeReactionFail(announcementId, name, err)); + }); + }; + +export const removeReactionRequest = (announcementId: string, name: string) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, + id: announcementId, + name, + skipLoading: true, +}); + +export const removeReactionSuccess = (announcementId: string, name: string) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS, + id: announcementId, + name, + skipLoading: true, +}); + +export const removeReactionFail = (announcementId: string, name: string, error: AxiosError) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL, + id: announcementId, + name, + error, + skipLoading: true, +}); + +export const updateReaction = (reaction: APIEntity) => ({ + type: ANNOUNCEMENTS_REACTION_UPDATE, + reaction, +}); + +export const toggleShowAnnouncements = () => ({ + type: ANNOUNCEMENTS_TOGGLE_SHOW, +}); + +export const deleteAnnouncement = (id: string) => ({ + type: ANNOUNCEMENTS_DELETE, + id, +}); diff --git a/app/soapbox/actions/apps.js b/app/soapbox/actions/apps.ts similarity index 80% rename from app/soapbox/actions/apps.js rename to app/soapbox/actions/apps.ts index 7eb00c98c..d2ef2ff0d 100644 --- a/app/soapbox/actions/apps.js +++ b/app/soapbox/actions/apps.ts @@ -8,6 +8,8 @@ import { baseClient } from '../api'; +import type { AnyAction } from 'redux'; + export const APP_CREATE_REQUEST = 'APP_CREATE_REQUEST'; export const APP_CREATE_SUCCESS = 'APP_CREATE_SUCCESS'; export const APP_CREATE_FAIL = 'APP_CREATE_FAIL'; @@ -16,12 +18,12 @@ export const APP_VERIFY_CREDENTIALS_REQUEST = 'APP_VERIFY_CREDENTIALS_REQUEST'; export const APP_VERIFY_CREDENTIALS_SUCCESS = 'APP_VERIFY_CREDENTIALS_SUCCESS'; export const APP_VERIFY_CREDENTIALS_FAIL = 'APP_VERIFY_CREDENTIALS_FAIL'; -export function createApp(params, baseURL) { - return (dispatch, getState) => { +export function createApp(params?: Record, baseURL?: string) { + return (dispatch: React.Dispatch) => { dispatch({ type: APP_CREATE_REQUEST, params }); return baseClient(null, baseURL).post('/api/v1/apps', params).then(({ data: app }) => { dispatch({ type: APP_CREATE_SUCCESS, params, app }); - return app; + return app as Record; }).catch(error => { dispatch({ type: APP_CREATE_FAIL, params, error }); throw error; @@ -29,8 +31,8 @@ export function createApp(params, baseURL) { }; } -export function verifyAppCredentials(token) { - return (dispatch, getState) => { +export function verifyAppCredentials(token: string) { + return (dispatch: React.Dispatch) => { dispatch({ type: APP_VERIFY_CREDENTIALS_REQUEST, token }); return baseClient(token).get('/api/v1/apps/verify_credentials').then(({ data: app }) => { dispatch({ type: APP_VERIFY_CREDENTIALS_SUCCESS, token, app }); diff --git a/app/soapbox/actions/auth.js b/app/soapbox/actions/auth.ts similarity index 54% rename from app/soapbox/actions/auth.js rename to app/soapbox/actions/auth.ts index 79ff9d67f..d3252e7fb 100644 --- a/app/soapbox/actions/auth.js +++ b/app/soapbox/actions/auth.ts @@ -26,6 +26,10 @@ 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'; export const AUTH_APP_CREATED = 'AUTH_APP_CREATED'; @@ -48,35 +52,32 @@ export const messages = defineMessages({ invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' }, }); -const noOp = () => new Promise(f => f()); +const noOp = () => new Promise(f => f(undefined)); -const getScopes = state => { - const instance = state.get('instance'); +const getScopes = (state: RootState) => { + const instance = state.instance; const { scopes } = getFeatures(instance); return scopes; }; -function createAppAndToken() { - return (dispatch, getState) => { - return dispatch(getAuthApp()).then(() => { - return dispatch(createAppToken()); - }); - }; -} +const createAppAndToken = () => + (dispatch: AppDispatch) => + dispatch(getAuthApp()).then(() => + dispatch(createAppToken()), + ); /** Create an auth app, or use it from build config */ -function getAuthApp() { - return (dispatch, getState) => { +const getAuthApp = () => + (dispatch: AppDispatch) => { if (customApp?.client_secret) { return noOp().then(() => dispatch({ type: AUTH_APP_CREATED, app: customApp })); } else { return dispatch(createAuthApp()); } }; -} -function createAuthApp() { - return (dispatch, getState) => { +const createAuthApp = () => + (dispatch: AppDispatch, getState: () => RootState) => { const params = { client_name: sourceCode.displayName, redirect_uris: 'urn:ietf:wg:oauth:2.0:oob', @@ -84,15 +85,14 @@ function createAuthApp() { website: sourceCode.homepage, }; - return dispatch(createApp(params)).then(app => { - return dispatch({ type: AUTH_APP_CREATED, app }); - }); + return dispatch(createApp(params)).then((app: Record) => + dispatch({ type: AUTH_APP_CREATED, app }), + ); }; -} -function createAppToken() { - return (dispatch, getState) => { - const app = getState().getIn(['auth', 'app']); +const createAppToken = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const app = getState().auth.get('app'); const params = { client_id: app.get('client_id'), @@ -102,15 +102,14 @@ function createAppToken() { scope: getScopes(getState()), }; - return dispatch(obtainOAuthToken(params)).then(token => { - return dispatch({ type: AUTH_APP_AUTHORIZED, app, token }); - }); + return dispatch(obtainOAuthToken(params)).then((token: Record) => + dispatch({ type: AUTH_APP_AUTHORIZED, app, token }), + ); }; -} -function createUserToken(username, password) { - return (dispatch, getState) => { - const app = getState().getIn(['auth', 'app']); +const createUserToken = (username: string, password: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const app = getState().auth.get('app'); const params = { client_id: app.get('client_id'), @@ -123,14 +122,13 @@ function createUserToken(username, password) { }; return dispatch(obtainOAuthToken(params)) - .then(token => dispatch(authLoggedIn(token))); + .then((token: Record) => dispatch(authLoggedIn(token))); }; -} -export function refreshUserToken() { - return (dispatch, getState) => { - const refreshToken = getState().getIn(['auth', 'user', 'refresh_token']); - const app = getState().getIn(['auth', 'app']); +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); @@ -144,13 +142,12 @@ export function refreshUserToken() { }; return dispatch(obtainOAuthToken(params)) - .then(token => dispatch(authLoggedIn(token))); + .then((token: Record) => dispatch(authLoggedIn(token))); }; -} -export function otpVerify(code, mfa_token) { - return (dispatch, getState) => { - const app = getState().getIn(['auth', 'app']); +export const otpVerify = (code: string, mfa_token: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const app = getState().auth.get('app'); return api(getState, 'app').post('/oauth/mfa/challenge', { client_id: app.get('client_id'), client_secret: app.get('client_secret'), @@ -161,18 +158,17 @@ export function otpVerify(code, mfa_token) { scope: getScopes(getState()), }).then(({ data: token }) => dispatch(authLoggedIn(token))); }; -} -export function verifyCredentials(token, accountUrl) { +export const verifyCredentials = (token: string, accountUrl?: string) => { const baseURL = parseBaseURL(accountUrl); - return (dispatch, getState) => { + return (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: VERIFY_CREDENTIALS_REQUEST, token }); return baseClient(token, baseURL).get('/api/v1/accounts/verify_credentials').then(({ data: account }) => { dispatch(importFetchedAccount(account)); dispatch({ type: VERIFY_CREDENTIALS_SUCCESS, token, account }); - if (account.id === getState().get('me')) dispatch(fetchMeSuccess(account)); + if (account.id === getState().me) dispatch(fetchMeSuccess(account)); return account; }).catch(error => { if (error?.response?.status === 403 && error?.response?.data?.id) { @@ -180,72 +176,66 @@ export function verifyCredentials(token, accountUrl) { const account = error.response.data; dispatch(importFetchedAccount(account)); dispatch({ type: VERIFY_CREDENTIALS_SUCCESS, token, account }); - if (account.id === getState().get('me')) dispatch(fetchMeSuccess(account)); + if (account.id === getState().me) dispatch(fetchMeSuccess(account)); return account; } else { - if (getState().get('me') === null) dispatch(fetchMeFail(error)); - dispatch({ type: VERIFY_CREDENTIALS_FAIL, token, error, skipAlert: true }); - return error; + if (getState().me === null) dispatch(fetchMeFail(error)); + dispatch({ type: VERIFY_CREDENTIALS_FAIL, token, error }); + throw error; } }); }; -} +}; -export function rememberAuthAccount(accountUrl) { - return (dispatch, getState) => { +export const rememberAuthAccount = (accountUrl: string) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: AUTH_ACCOUNT_REMEMBER_REQUEST, accountUrl }); return KVStore.getItemOrError(`authAccount:${accountUrl}`).then(account => { dispatch(importFetchedAccount(account)); dispatch({ type: AUTH_ACCOUNT_REMEMBER_SUCCESS, account, accountUrl }); - if (account.id === getState().get('me')) dispatch(fetchMeSuccess(account)); + if (account.id === getState().me) dispatch(fetchMeSuccess(account)); return account; }).catch(error => { dispatch({ type: AUTH_ACCOUNT_REMEMBER_FAIL, error, accountUrl, skipAlert: true }); }); }; -} -export function loadCredentials(token, accountUrl) { - return (dispatch, getState) => { - return dispatch(rememberAuthAccount(accountUrl)).finally(() => { - return dispatch(verifyCredentials(token, accountUrl)); - }); - }; -} +export const loadCredentials = (token: string, accountUrl: string) => + (dispatch: AppDispatch) => dispatch(rememberAuthAccount(accountUrl)) + .then(() => { + dispatch(verifyCredentials(token, accountUrl)); + }) + .catch(() => dispatch(verifyCredentials(token, accountUrl))); -export function logIn(intl, username, password) { - return (dispatch, getState) => { - return dispatch(getAuthApp()).then(() => { - return dispatch(createUserToken(username, password)); - }).catch(error => { - if (error.response.data.error === 'mfa_required') { - // If MFA is required, throw the error and handle it in the component. - throw error; - } else if (error.response.data.error === 'invalid_grant') { - // Mastodon returns this user-unfriendly error as a catch-all - // for everything from "bad request" to "wrong password". - // Assume our code is correct and it's a wrong password. - dispatch(snackbar.error(intl.formatMessage(messages.invalidCredentials))); - } else if (error.response.data.error) { - // If the backend returns an error, display it. - dispatch(snackbar.error(error.response.data.error)); - } else { - // Return "wrong password" message. - dispatch(snackbar.error(intl.formatMessage(messages.invalidCredentials))); - } +/** Trim the username and strip the leading @. */ +const normalizeUsername = (username: string): string => { + const trimmed = username.trim(); + if (trimmed[0] === '@') { + return trimmed.slice(1); + } else { + return trimmed; + } +}; + +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 MFA is required, throw the error and handle it in the component. throw error; - }); - }; -} + } else { + // Return "wrong password" message. + dispatch(snackbar.error(messages.invalidCredentials)); + } + throw error; + }); -export function deleteSession() { - return (dispatch, getState) => { - return api(getState).delete('/api/sign_out'); - }; -} +export const deleteSession = () => + (dispatch: AppDispatch, getState: () => RootState) => api(getState).delete('/api/sign_out'); -export function logOut(intl) { - return (dispatch, getState) => { +export const logOut = () => + (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); const account = getLoggedInAccount(state); const standalone = isStandalone(state); @@ -253,62 +243,53 @@ export function logOut(intl) { if (!account) return dispatch(noOp); const params = { - client_id: state.getIn(['auth', 'app', 'client_id']), - client_secret: state.getIn(['auth', 'app', 'client_secret']), - token: state.getIn(['auth', 'users', account.url, 'access_token']), + 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']), }; - return Promise.all([ - dispatch(revokeOAuthToken(params)), - dispatch(deleteSession()), - ]).finally(() => { + return dispatch(revokeOAuthToken(params)).finally(() => { dispatch({ type: AUTH_LOGGED_OUT, account, standalone }); - dispatch(snackbar.success(intl.formatMessage(messages.loggedOut))); + return dispatch(snackbar.success(messages.loggedOut)); }); }; -} -export function switchAccount(accountId, background = false) { - return (dispatch, getState) => { - const account = getState().getIn(['accounts', accountId]); - dispatch({ type: SWITCH_ACCOUNT, account, background }); +export const switchAccount = (accountId: string, background = false) => + (dispatch: AppDispatch, getState: () => RootState) => { + const account = getState().accounts.get(accountId); + return dispatch({ type: SWITCH_ACCOUNT, account, background }); }; -} -export function fetchOwnAccounts() { - return (dispatch, getState) => { +export const fetchOwnAccounts = () => + (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - state.getIn(['auth', 'users']).forEach(user => { - const account = state.getIn(['accounts', user.get('id')]); + return state.auth.get('users').forEach((user: ImmutableMap) => { + const account = state.accounts.get(user.get('id')); if (!account) { - dispatch(verifyCredentials(user.get('access_token'), user.get('url'))); + dispatch(verifyCredentials(user.get('access_token')!, user.get('url'))); } }); }; -} -export function register(params) { - return (dispatch, getState) => { +export const register = (params: Record) => + (dispatch: AppDispatch) => { params.fullname = params.username; return dispatch(createAppAndToken()) .then(() => dispatch(createAccount(params))) - .then(({ token }) => { + .then(({ token }: { token: Record }) => { dispatch(startOnboarding()); return dispatch(authLoggedIn(token)); }); }; -} -export function fetchCaptcha() { - return (dispatch, getState) => { +export const fetchCaptcha = () => + (_dispatch: AppDispatch, getState: () => RootState) => { return api(getState).get('/api/pleroma/captcha'); }; -} -export function authLoggedIn(token) { - return (dispatch, getState) => { +export const authLoggedIn = (token: Record) => + (dispatch: AppDispatch) => { dispatch({ type: AUTH_LOGGED_IN, token }); return token; }; -} diff --git a/app/soapbox/actions/backups.js b/app/soapbox/actions/backups.ts similarity index 62% rename from app/soapbox/actions/backups.js rename to app/soapbox/actions/backups.ts index 844c55ce5..c95e504db 100644 --- a/app/soapbox/actions/backups.js +++ b/app/soapbox/actions/backups.ts @@ -1,5 +1,7 @@ import api from '../api'; +import type { AppDispatch, RootState } from 'soapbox/store'; + export const BACKUPS_FETCH_REQUEST = 'BACKUPS_FETCH_REQUEST'; export const BACKUPS_FETCH_SUCCESS = 'BACKUPS_FETCH_SUCCESS'; export const BACKUPS_FETCH_FAIL = 'BACKUPS_FETCH_FAIL'; @@ -8,24 +10,22 @@ export const BACKUPS_CREATE_REQUEST = 'BACKUPS_CREATE_REQUEST'; export const BACKUPS_CREATE_SUCCESS = 'BACKUPS_CREATE_SUCCESS'; export const BACKUPS_CREATE_FAIL = 'BACKUPS_CREATE_FAIL'; -export function fetchBackups() { - return (dispatch, getState) => { +export const fetchBackups = () => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: BACKUPS_FETCH_REQUEST }); - return api(getState).get('/api/v1/pleroma/backups').then(({ data: backups }) => { - dispatch({ type: BACKUPS_FETCH_SUCCESS, backups }); - }).catch(error => { + return api(getState).get('/api/v1/pleroma/backups').then(({ data: backups }) => + dispatch({ type: BACKUPS_FETCH_SUCCESS, backups }), + ).catch(error => { dispatch({ type: BACKUPS_FETCH_FAIL, error }); }); }; -} -export function createBackup() { - return (dispatch, getState) => { +export const createBackup = () => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: BACKUPS_CREATE_REQUEST }); - return api(getState).post('/api/v1/pleroma/backups').then(({ data: backups }) => { - dispatch({ type: BACKUPS_CREATE_SUCCESS, backups }); - }).catch(error => { + return api(getState).post('/api/v1/pleroma/backups').then(({ data: backups }) => + dispatch({ type: BACKUPS_CREATE_SUCCESS, backups }), + ).catch(error => { dispatch({ type: BACKUPS_CREATE_FAIL, error }); }); }; -} diff --git a/app/soapbox/actions/beta.js b/app/soapbox/actions/beta.js deleted file mode 100644 index 21f4013b4..000000000 --- a/app/soapbox/actions/beta.js +++ /dev/null @@ -1,19 +0,0 @@ -import { staticClient } from '../api'; - -export const FETCH_BETA_PAGE_REQUEST = 'FETCH_BETA_PAGE_REQUEST'; -export const FETCH_BETA_PAGE_SUCCESS = 'FETCH_BETA_PAGE_SUCCESS'; -export const FETCH_BETA_PAGE_FAIL = 'FETCH_BETA_PAGE_FAIL'; - -export function fetchBetaPage(slug = 'index', locale) { - return (dispatch, getState) => { - dispatch({ type: FETCH_BETA_PAGE_REQUEST, slug, locale }); - const filename = `${slug}${locale ? `.${locale}` : ''}.html`; - return staticClient.get(`/instance/beta/${filename}`).then(({ data: html }) => { - dispatch({ type: FETCH_BETA_PAGE_SUCCESS, slug, locale, html }); - return html; - }).catch(error => { - dispatch({ type: FETCH_BETA_PAGE_FAIL, slug, locale, error }); - throw error; - }); - }; -} diff --git a/app/soapbox/actions/blocks.js b/app/soapbox/actions/blocks.js deleted file mode 100644 index 554446f2f..000000000 --- a/app/soapbox/actions/blocks.js +++ /dev/null @@ -1,95 +0,0 @@ -import { isLoggedIn } from 'soapbox/utils/auth'; -import { getNextLinkName } from 'soapbox/utils/quirks'; - -import api, { getLinks } from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts } from './importer'; - -export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; -export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; -export const BLOCKS_FETCH_FAIL = 'BLOCKS_FETCH_FAIL'; - -export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST'; -export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS'; -export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL'; - -export function fetchBlocks() { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - const nextLinkName = getNextLinkName(getState); - - dispatch(fetchBlocksRequest()); - - api(getState).get('/api/v1/blocks').then(response => { - const next = getLinks(response).refs.find(link => link.rel === nextLinkName); - dispatch(importFetchedAccounts(response.data)); - dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null)); - dispatch(fetchRelationships(response.data.map(item => item.id))); - }).catch(error => dispatch(fetchBlocksFail(error))); - }; -} - -export function fetchBlocksRequest() { - return { - type: BLOCKS_FETCH_REQUEST, - }; -} - -export function fetchBlocksSuccess(accounts, next) { - return { - type: BLOCKS_FETCH_SUCCESS, - accounts, - next, - }; -} - -export function fetchBlocksFail(error) { - return { - type: BLOCKS_FETCH_FAIL, - error, - }; -} - -export function expandBlocks() { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - const nextLinkName = getNextLinkName(getState); - - const url = getState().getIn(['user_lists', 'blocks', 'next']); - - if (url === null) { - return; - } - - dispatch(expandBlocksRequest()); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === nextLinkName); - dispatch(importFetchedAccounts(response.data)); - dispatch(expandBlocksSuccess(response.data, next ? next.uri : null)); - dispatch(fetchRelationships(response.data.map(item => item.id))); - }).catch(error => dispatch(expandBlocksFail(error))); - }; -} - -export function expandBlocksRequest() { - return { - type: BLOCKS_EXPAND_REQUEST, - }; -} - -export function expandBlocksSuccess(accounts, next) { - return { - type: BLOCKS_EXPAND_SUCCESS, - accounts, - next, - }; -} - -export function expandBlocksFail(error) { - return { - type: BLOCKS_EXPAND_FAIL, - error, - }; -} diff --git a/app/soapbox/actions/blocks.ts b/app/soapbox/actions/blocks.ts new file mode 100644 index 000000000..d3f625884 --- /dev/null +++ b/app/soapbox/actions/blocks.ts @@ -0,0 +1,110 @@ +import { isLoggedIn } from 'soapbox/utils/auth'; +import { getNextLinkName } from 'soapbox/utils/quirks'; + +import api, { getLinks } from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; + +import type { AnyAction } from '@reduxjs/toolkit'; +import type { AxiosError } from 'axios'; +import type { RootState } from 'soapbox/store'; + +const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; +const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; +const BLOCKS_FETCH_FAIL = 'BLOCKS_FETCH_FAIL'; + +const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST'; +const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS'; +const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL'; + +const fetchBlocks = () => (dispatch: React.Dispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + const nextLinkName = getNextLinkName(getState); + + dispatch(fetchBlocksRequest()); + + return api(getState) + .get('/api/v1/blocks') + .then(response => { + const next = getLinks(response).refs.find(link => link.rel === nextLinkName); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: any) => item.id)) as any); + }) + .catch(error => dispatch(fetchBlocksFail(error))); +}; + +function fetchBlocksRequest() { + return { type: BLOCKS_FETCH_REQUEST }; +} + +function fetchBlocksSuccess(accounts: any, next: any) { + return { + type: BLOCKS_FETCH_SUCCESS, + accounts, + next, + }; +} + +function fetchBlocksFail(error: AxiosError) { + return { + type: BLOCKS_FETCH_FAIL, + error, + }; +} + +const expandBlocks = () => (dispatch: React.Dispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + const nextLinkName = getNextLinkName(getState); + + const url = getState().user_lists.blocks.next; + + if (url === null) { + return null; + } + + dispatch(expandBlocksRequest()); + + return api(getState) + .get(url) + .then(response => { + const next = getLinks(response).refs.find(link => link.rel === nextLinkName); + dispatch(importFetchedAccounts(response.data)); + dispatch(expandBlocksSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: any) => item.id)) as any); + }) + .catch(error => dispatch(expandBlocksFail(error))); +}; + +function expandBlocksRequest() { + return { + type: BLOCKS_EXPAND_REQUEST, + }; +} + +function expandBlocksSuccess(accounts: any, next: any) { + return { + type: BLOCKS_EXPAND_SUCCESS, + accounts, + next, + }; +} + +function expandBlocksFail(error: AxiosError) { + return { + type: BLOCKS_EXPAND_FAIL, + error, + }; +} + +export { + fetchBlocks, + expandBlocks, + BLOCKS_FETCH_REQUEST, + BLOCKS_FETCH_SUCCESS, + BLOCKS_FETCH_FAIL, + BLOCKS_EXPAND_REQUEST, + BLOCKS_EXPAND_SUCCESS, + BLOCKS_EXPAND_FAIL, +}; diff --git a/app/soapbox/actions/bookmarks.js b/app/soapbox/actions/bookmarks.js deleted file mode 100644 index a0400ff38..000000000 --- a/app/soapbox/actions/bookmarks.js +++ /dev/null @@ -1,93 +0,0 @@ -import api, { getLinks } from '../api'; - -import { importFetchedStatuses } from './importer'; - -export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST'; -export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS'; -export const BOOKMARKED_STATUSES_FETCH_FAIL = 'BOOKMARKED_STATUSES_FETCH_FAIL'; - -export const BOOKMARKED_STATUSES_EXPAND_REQUEST = 'BOOKMARKED_STATUSES_EXPAND_REQUEST'; -export const BOOKMARKED_STATUSES_EXPAND_SUCCESS = 'BOOKMARKED_STATUSES_EXPAND_SUCCESS'; -export const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL'; - -const noOp = () => new Promise(f => f()); - -export function fetchBookmarkedStatuses() { - return (dispatch, getState) => { - if (getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) { - return dispatch(noOp); - } - - dispatch(fetchBookmarkedStatusesRequest()); - - return api(getState).get('/api/v1/bookmarks').then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedStatuses(response.data)); - dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); - }).catch(error => { - dispatch(fetchBookmarkedStatusesFail(error)); - }); - }; -} - -export function fetchBookmarkedStatusesRequest() { - return { - type: BOOKMARKED_STATUSES_FETCH_REQUEST, - }; -} - -export function fetchBookmarkedStatusesSuccess(statuses, next) { - return { - type: BOOKMARKED_STATUSES_FETCH_SUCCESS, - statuses, - next, - }; -} - -export function fetchBookmarkedStatusesFail(error) { - return { - type: BOOKMARKED_STATUSES_FETCH_FAIL, - error, - }; -} - -export function expandBookmarkedStatuses() { - return (dispatch, getState) => { - const url = getState().getIn(['status_lists', 'bookmarks', 'next'], null); - - if (url === null || getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) { - return dispatch(noOp); - } - - dispatch(expandBookmarkedStatusesRequest()); - - return api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedStatuses(response.data)); - dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); - }).catch(error => { - dispatch(expandBookmarkedStatusesFail(error)); - }); - }; -} - -export function expandBookmarkedStatusesRequest() { - return { - type: BOOKMARKED_STATUSES_EXPAND_REQUEST, - }; -} - -export function expandBookmarkedStatusesSuccess(statuses, next) { - return { - type: BOOKMARKED_STATUSES_EXPAND_SUCCESS, - statuses, - next, - }; -} - -export function expandBookmarkedStatusesFail(error) { - return { - type: BOOKMARKED_STATUSES_EXPAND_FAIL, - error, - }; -} diff --git a/app/soapbox/actions/bookmarks.ts b/app/soapbox/actions/bookmarks.ts new file mode 100644 index 000000000..090196e7b --- /dev/null +++ b/app/soapbox/actions/bookmarks.ts @@ -0,0 +1,100 @@ +import api, { getLinks } from '../api'; + +import { importFetchedStatuses } from './importer'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST'; +const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS'; +const BOOKMARKED_STATUSES_FETCH_FAIL = 'BOOKMARKED_STATUSES_FETCH_FAIL'; + +const BOOKMARKED_STATUSES_EXPAND_REQUEST = 'BOOKMARKED_STATUSES_EXPAND_REQUEST'; +const BOOKMARKED_STATUSES_EXPAND_SUCCESS = 'BOOKMARKED_STATUSES_EXPAND_SUCCESS'; +const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL'; + +const noOp = () => new Promise(f => f(undefined)); + +const fetchBookmarkedStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (getState().status_lists.get('bookmarks')?.isLoading) { + return dispatch(noOp); + } + + dispatch(fetchBookmarkedStatusesRequest()); + + return api(getState).get('/api/v1/bookmarks').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + return dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchBookmarkedStatusesFail(error)); + }); + }; + +const fetchBookmarkedStatusesRequest = () => ({ + type: BOOKMARKED_STATUSES_FETCH_REQUEST, +}); + +const fetchBookmarkedStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: BOOKMARKED_STATUSES_FETCH_SUCCESS, + statuses, + next, +}); + +const fetchBookmarkedStatusesFail = (error: AxiosError) => ({ + type: BOOKMARKED_STATUSES_FETCH_FAIL, + error, +}); + +const expandBookmarkedStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().status_lists.get('bookmarks')?.next || null; + + if (url === null || getState().status_lists.get('bookmarks')?.isLoading) { + return dispatch(noOp); + } + + dispatch(expandBookmarkedStatusesRequest()); + + return api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + return dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandBookmarkedStatusesFail(error)); + }); + }; + +const expandBookmarkedStatusesRequest = () => ({ + type: BOOKMARKED_STATUSES_EXPAND_REQUEST, +}); + +const expandBookmarkedStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: BOOKMARKED_STATUSES_EXPAND_SUCCESS, + statuses, + next, +}); + +const expandBookmarkedStatusesFail = (error: AxiosError) => ({ + type: BOOKMARKED_STATUSES_EXPAND_FAIL, + error, +}); + +export { + BOOKMARKED_STATUSES_FETCH_REQUEST, + BOOKMARKED_STATUSES_FETCH_SUCCESS, + BOOKMARKED_STATUSES_FETCH_FAIL, + BOOKMARKED_STATUSES_EXPAND_REQUEST, + BOOKMARKED_STATUSES_EXPAND_SUCCESS, + BOOKMARKED_STATUSES_EXPAND_FAIL, + fetchBookmarkedStatuses, + fetchBookmarkedStatusesRequest, + fetchBookmarkedStatusesSuccess, + fetchBookmarkedStatusesFail, + expandBookmarkedStatuses, + expandBookmarkedStatusesRequest, + expandBookmarkedStatusesSuccess, + expandBookmarkedStatusesFail, +}; diff --git a/app/soapbox/actions/bundles.js b/app/soapbox/actions/bundles.js deleted file mode 100644 index ecc9c8f7d..000000000 --- a/app/soapbox/actions/bundles.js +++ /dev/null @@ -1,25 +0,0 @@ -export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST'; -export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS'; -export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL'; - -export function fetchBundleRequest(skipLoading) { - return { - type: BUNDLE_FETCH_REQUEST, - skipLoading, - }; -} - -export function fetchBundleSuccess(skipLoading) { - return { - type: BUNDLE_FETCH_SUCCESS, - skipLoading, - }; -} - -export function fetchBundleFail(error, skipLoading) { - return { - type: BUNDLE_FETCH_FAIL, - error, - skipLoading, - }; -} diff --git a/app/soapbox/actions/bundles.ts b/app/soapbox/actions/bundles.ts new file mode 100644 index 000000000..fc5ef9321 --- /dev/null +++ b/app/soapbox/actions/bundles.ts @@ -0,0 +1,28 @@ +const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST'; +const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS'; +const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL'; + +const fetchBundleRequest = (skipLoading?: boolean) => ({ + type: BUNDLE_FETCH_REQUEST, + skipLoading, +}); + +const fetchBundleSuccess = (skipLoading?: boolean) => ({ + type: BUNDLE_FETCH_SUCCESS, + skipLoading, +}); + +const fetchBundleFail = (error: any, skipLoading?: boolean) => ({ + type: BUNDLE_FETCH_FAIL, + error, + skipLoading, +}); + +export { + BUNDLE_FETCH_REQUEST, + BUNDLE_FETCH_SUCCESS, + BUNDLE_FETCH_FAIL, + fetchBundleRequest, + fetchBundleSuccess, + fetchBundleFail, +}; diff --git a/app/soapbox/actions/chats.js b/app/soapbox/actions/chats.ts similarity index 54% rename from app/soapbox/actions/chats.js rename to app/soapbox/actions/chats.ts index 0a8d1d75c..67b796408 100644 --- a/app/soapbox/actions/chats.js +++ b/app/soapbox/actions/chats.ts @@ -1,4 +1,4 @@ -import { Map as ImmutableMap } from 'immutable'; +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { v4 as uuidv4 } from 'uuid'; import { getSettings, changeSetting } from 'soapbox/actions/settings'; @@ -6,47 +6,49 @@ import { getFeatures } from 'soapbox/utils/features'; import api, { getLinks } from '../api'; -export const CHATS_FETCH_REQUEST = 'CHATS_FETCH_REQUEST'; -export const CHATS_FETCH_SUCCESS = 'CHATS_FETCH_SUCCESS'; -export const CHATS_FETCH_FAIL = 'CHATS_FETCH_FAIL'; +import type { History } from 'history'; +import type { AppDispatch, RootState } from 'soapbox/store'; -export const CHATS_EXPAND_REQUEST = 'CHATS_EXPAND_REQUEST'; -export const CHATS_EXPAND_SUCCESS = 'CHATS_EXPAND_SUCCESS'; -export const CHATS_EXPAND_FAIL = 'CHATS_EXPAND_FAIL'; +const CHATS_FETCH_REQUEST = 'CHATS_FETCH_REQUEST'; +const CHATS_FETCH_SUCCESS = 'CHATS_FETCH_SUCCESS'; +const CHATS_FETCH_FAIL = 'CHATS_FETCH_FAIL'; -export const CHAT_MESSAGES_FETCH_REQUEST = 'CHAT_MESSAGES_FETCH_REQUEST'; -export const CHAT_MESSAGES_FETCH_SUCCESS = 'CHAT_MESSAGES_FETCH_SUCCESS'; -export const CHAT_MESSAGES_FETCH_FAIL = 'CHAT_MESSAGES_FETCH_FAIL'; +const CHATS_EXPAND_REQUEST = 'CHATS_EXPAND_REQUEST'; +const CHATS_EXPAND_SUCCESS = 'CHATS_EXPAND_SUCCESS'; +const CHATS_EXPAND_FAIL = 'CHATS_EXPAND_FAIL'; -export const CHAT_MESSAGE_SEND_REQUEST = 'CHAT_MESSAGE_SEND_REQUEST'; -export const CHAT_MESSAGE_SEND_SUCCESS = 'CHAT_MESSAGE_SEND_SUCCESS'; -export const CHAT_MESSAGE_SEND_FAIL = 'CHAT_MESSAGE_SEND_FAIL'; +const CHAT_MESSAGES_FETCH_REQUEST = 'CHAT_MESSAGES_FETCH_REQUEST'; +const CHAT_MESSAGES_FETCH_SUCCESS = 'CHAT_MESSAGES_FETCH_SUCCESS'; +const CHAT_MESSAGES_FETCH_FAIL = 'CHAT_MESSAGES_FETCH_FAIL'; -export const CHAT_FETCH_REQUEST = 'CHAT_FETCH_REQUEST'; -export const CHAT_FETCH_SUCCESS = 'CHAT_FETCH_SUCCESS'; -export const CHAT_FETCH_FAIL = 'CHAT_FETCH_FAIL'; +const CHAT_MESSAGE_SEND_REQUEST = 'CHAT_MESSAGE_SEND_REQUEST'; +const CHAT_MESSAGE_SEND_SUCCESS = 'CHAT_MESSAGE_SEND_SUCCESS'; +const CHAT_MESSAGE_SEND_FAIL = 'CHAT_MESSAGE_SEND_FAIL'; -export const CHAT_READ_REQUEST = 'CHAT_READ_REQUEST'; -export const CHAT_READ_SUCCESS = 'CHAT_READ_SUCCESS'; -export const CHAT_READ_FAIL = 'CHAT_READ_FAIL'; +const CHAT_FETCH_REQUEST = 'CHAT_FETCH_REQUEST'; +const CHAT_FETCH_SUCCESS = 'CHAT_FETCH_SUCCESS'; +const CHAT_FETCH_FAIL = 'CHAT_FETCH_FAIL'; -export const CHAT_MESSAGE_DELETE_REQUEST = 'CHAT_MESSAGE_DELETE_REQUEST'; -export const CHAT_MESSAGE_DELETE_SUCCESS = 'CHAT_MESSAGE_DELETE_SUCCESS'; -export const CHAT_MESSAGE_DELETE_FAIL = 'CHAT_MESSAGE_DELETE_FAIL'; +const CHAT_READ_REQUEST = 'CHAT_READ_REQUEST'; +const CHAT_READ_SUCCESS = 'CHAT_READ_SUCCESS'; +const CHAT_READ_FAIL = 'CHAT_READ_FAIL'; -export function fetchChatsV1() { - return (dispatch, getState) => +const CHAT_MESSAGE_DELETE_REQUEST = 'CHAT_MESSAGE_DELETE_REQUEST'; +const CHAT_MESSAGE_DELETE_SUCCESS = 'CHAT_MESSAGE_DELETE_SUCCESS'; +const CHAT_MESSAGE_DELETE_FAIL = 'CHAT_MESSAGE_DELETE_FAIL'; + +const fetchChatsV1 = () => + (dispatch: AppDispatch, getState: () => RootState) => api(getState).get('/api/v1/pleroma/chats').then((response) => { dispatch({ type: CHATS_FETCH_SUCCESS, chats: response.data }); }).catch(error => { dispatch({ type: CHATS_FETCH_FAIL, error }); }); -} -export function fetchChatsV2() { - return (dispatch, getState) => +const fetchChatsV2 = () => + (dispatch: AppDispatch, getState: () => RootState) => api(getState).get('/api/v2/pleroma/chats').then((response) => { - let next = getLinks(response).refs.find(link => link.rel === 'next'); + let next: { uri: string } | undefined = getLinks(response).refs.find(link => link.rel === 'next'); if (!next && response.data.length) { next = { uri: `/api/v2/pleroma/chats?max_id=${response.data[response.data.length - 1].id}&offset=0` }; @@ -56,10 +58,9 @@ export function fetchChatsV2() { }).catch(error => { dispatch({ type: CHATS_FETCH_FAIL, error }); }); -} -export function fetchChats() { - return (dispatch, getState) => { +const fetchChats = () => + (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); const { instance } = state; const features = getFeatures(instance); @@ -71,11 +72,10 @@ export function fetchChats() { return dispatch(fetchChatsV1()); } }; -} -export function expandChats() { - return (dispatch, getState) => { - const url = getState().getIn(['chats', 'next']); +const expandChats = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().chats.next; if (url === null) { return; @@ -90,10 +90,9 @@ export function expandChats() { dispatch({ type: CHATS_EXPAND_FAIL, error }); }); }; -} -export function fetchChatMessages(chatId, maxId = null) { - return (dispatch, getState) => { +const fetchChatMessages = (chatId: string, maxId: string | null = null) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: CHAT_MESSAGES_FETCH_REQUEST, chatId, maxId }); return api(getState).get(`/api/v1/pleroma/chats/${chatId}/messages`, { params: { max_id: maxId } }).then(({ data }) => { dispatch({ type: CHAT_MESSAGES_FETCH_SUCCESS, chatId, maxId, chatMessages: data }); @@ -101,12 +100,11 @@ export function fetchChatMessages(chatId, maxId = null) { dispatch({ type: CHAT_MESSAGES_FETCH_FAIL, chatId, maxId, error }); }); }; -} -export function sendChatMessage(chatId, params) { - return (dispatch, getState) => { +const sendChatMessage = (chatId: string, params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { const uuid = `末_${Date.now()}_${uuidv4()}`; - const me = getState().get('me'); + const me = getState().me; dispatch({ type: CHAT_MESSAGE_SEND_REQUEST, chatId, params, uuid, me }); return api(getState).post(`/api/v1/pleroma/chats/${chatId}/messages`, params).then(({ data }) => { dispatch({ type: CHAT_MESSAGE_SEND_SUCCESS, chatId, chatMessage: data, uuid }); @@ -114,28 +112,26 @@ export function sendChatMessage(chatId, params) { dispatch({ type: CHAT_MESSAGE_SEND_FAIL, chatId, error, uuid }); }); }; -} -export function openChat(chatId) { - return (dispatch, getState) => { +const openChat = (chatId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - const panes = getSettings(state).getIn(['chats', 'panes']); + const panes = getSettings(state).getIn(['chats', 'panes']) as ImmutableList>; const idx = panes.findIndex(pane => pane.get('chat_id') === chatId); dispatch(markChatRead(chatId)); if (idx > -1) { - return dispatch(changeSetting(['chats', 'panes', idx, 'state'], 'open')); + return dispatch(changeSetting(['chats', 'panes', idx as any, 'state'], 'open')); } else { const newPane = ImmutableMap({ chat_id: chatId, state: 'open' }); return dispatch(changeSetting(['chats', 'panes'], panes.push(newPane))); } }; -} -export function closeChat(chatId) { - return (dispatch, getState) => { - const panes = getSettings(getState()).getIn(['chats', 'panes']); +const closeChat = (chatId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const panes = getSettings(getState()).getIn(['chats', 'panes']) as ImmutableList>; const idx = panes.findIndex(pane => pane.get('chat_id') === chatId); if (idx > -1) { @@ -144,33 +140,30 @@ export function closeChat(chatId) { return false; } }; -} -export function toggleChat(chatId) { - return (dispatch, getState) => { - const panes = getSettings(getState()).getIn(['chats', 'panes']); - const [idx, pane] = panes.findEntry(pane => pane.get('chat_id') === chatId); +const toggleChat = (chatId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const panes = getSettings(getState()).getIn(['chats', 'panes']) as ImmutableList>; + const [idx, pane] = panes.findEntry(pane => pane.get('chat_id') === chatId)!; if (idx > -1) { const state = pane.get('state') === 'minimized' ? 'open' : 'minimized'; if (state === 'open') dispatch(markChatRead(chatId)); - return dispatch(changeSetting(['chats', 'panes', idx, 'state'], state)); + return dispatch(changeSetting(['chats', 'panes', idx as any, 'state'], state)); } else { return false; } }; -} -export function toggleMainWindow() { - return (dispatch, getState) => { - const main = getSettings(getState()).getIn(['chats', 'mainWindow']); +const toggleMainWindow = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const main = getSettings(getState()).getIn(['chats', 'mainWindow']) as 'minimized' | 'open'; const state = main === 'minimized' ? 'open' : 'minimized'; return dispatch(changeSetting(['chats', 'mainWindow'], state)); }; -} -export function fetchChat(chatId) { - return (dispatch, getState) => { +const fetchChat = (chatId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: CHAT_FETCH_REQUEST, chatId }); return api(getState).get(`/api/v1/pleroma/chats/${chatId}`).then(({ data }) => { dispatch({ type: CHAT_FETCH_SUCCESS, chat: data }); @@ -178,10 +171,9 @@ export function fetchChat(chatId) { dispatch({ type: CHAT_FETCH_FAIL, chatId, error }); }); }; -} -export function startChat(accountId) { - return (dispatch, getState) => { +const startChat = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: CHAT_FETCH_REQUEST, accountId }); return api(getState).post(`/api/v1/pleroma/chats/by-account-id/${accountId}`).then(({ data }) => { dispatch({ type: CHAT_FETCH_SUCCESS, chat: data }); @@ -190,12 +182,11 @@ export function startChat(accountId) { dispatch({ type: CHAT_FETCH_FAIL, accountId, error }); }); }; -} -export function markChatRead(chatId, lastReadId) { - return (dispatch, getState) => { - const chat = getState().getIn(['chats', 'items', chatId]); - if (!lastReadId) lastReadId = chat.get('last_message'); +const markChatRead = (chatId: string, lastReadId?: string | null) => + (dispatch: AppDispatch, getState: () => RootState) => { + const chat = getState().chats.items.get(chatId)!; + if (!lastReadId) lastReadId = chat.last_message; if (chat.get('unread') < 1) return; if (!lastReadId) return; @@ -207,10 +198,9 @@ export function markChatRead(chatId, lastReadId) { dispatch({ type: CHAT_READ_FAIL, chatId, error, lastReadId }); }); }; -} -export function deleteChatMessage(chatId, messageId) { - return (dispatch, getState) => { +const deleteChatMessage = (chatId: string, messageId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: CHAT_MESSAGE_DELETE_REQUEST, chatId, messageId }); api(getState).delete(`/api/v1/pleroma/chats/${chatId}/messages/${messageId}`).then(({ data }) => { dispatch({ type: CHAT_MESSAGE_DELETE_SUCCESS, chatId, messageId, chatMessage: data }); @@ -218,13 +208,12 @@ export function deleteChatMessage(chatId, messageId) { dispatch({ type: CHAT_MESSAGE_DELETE_FAIL, chatId, messageId, error }); }); }; -} /** Start a chat and launch it in the UI */ -export function launchChat(accountId, router, forceNavigate = false) { - const isMobile = width => width <= 1190; +const launchChat = (accountId: string, router: History, forceNavigate = false) => { + const isMobile = (width: number) => width <= 1190; - return (dispatch, getState) => { + return (dispatch: AppDispatch) => { // TODO: make this faster return dispatch(startChat(accountId)).then(chat => { if (forceNavigate || isMobile(window.innerWidth)) { @@ -234,4 +223,43 @@ export function launchChat(accountId, router, forceNavigate = false) { } }); }; -} +}; + +export { + CHATS_FETCH_REQUEST, + CHATS_FETCH_SUCCESS, + CHATS_FETCH_FAIL, + CHATS_EXPAND_REQUEST, + CHATS_EXPAND_SUCCESS, + CHATS_EXPAND_FAIL, + CHAT_MESSAGES_FETCH_REQUEST, + CHAT_MESSAGES_FETCH_SUCCESS, + CHAT_MESSAGES_FETCH_FAIL, + CHAT_MESSAGE_SEND_REQUEST, + CHAT_MESSAGE_SEND_SUCCESS, + CHAT_MESSAGE_SEND_FAIL, + CHAT_FETCH_REQUEST, + CHAT_FETCH_SUCCESS, + CHAT_FETCH_FAIL, + CHAT_READ_REQUEST, + CHAT_READ_SUCCESS, + CHAT_READ_FAIL, + CHAT_MESSAGE_DELETE_REQUEST, + CHAT_MESSAGE_DELETE_SUCCESS, + CHAT_MESSAGE_DELETE_FAIL, + fetchChatsV1, + fetchChatsV2, + fetchChats, + expandChats, + fetchChatMessages, + sendChatMessage, + openChat, + closeChat, + toggleChat, + toggleMainWindow, + fetchChat, + startChat, + markChatRead, + deleteChatMessage, + launchChat, +}; diff --git a/app/soapbox/actions/compose.js b/app/soapbox/actions/compose.js deleted file mode 100644 index f733ef3d4..000000000 --- a/app/soapbox/actions/compose.js +++ /dev/null @@ -1,759 +0,0 @@ -import { CancelToken, isCancel } from 'axios'; -import { OrderedSet as ImmutableOrderedSet } from 'immutable'; -import { throttle } from 'lodash'; -import { defineMessages } from 'react-intl'; - -import snackbar from 'soapbox/actions/snackbar'; -import { isLoggedIn } from 'soapbox/utils/auth'; -import { getFeatures, parseVersion } from 'soapbox/utils/features'; -import { formatBytes } from 'soapbox/utils/media'; - -import api from '../api'; -import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; -import { tagHistory } from '../settings'; -import resizeImage from '../utils/resize_image'; - -import { showAlert, showAlertForError } from './alerts'; -import { useEmoji } from './emojis'; -import { importFetchedAccounts } from './importer'; -import { uploadMedia, fetchMedia, updateMedia } from './media'; -import { openModal, closeModal } from './modals'; -import { getSettings } from './settings'; -import { createStatus } from './statuses'; - -let cancelFetchComposeSuggestionsAccounts; - -export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; -export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; -export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; -export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; -export const COMPOSE_REPLY = 'COMPOSE_REPLY'; -export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; -export const COMPOSE_QUOTE = 'COMPOSE_QUOTE'; -export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL'; -export const COMPOSE_DIRECT = 'COMPOSE_DIRECT'; -export const COMPOSE_MENTION = 'COMPOSE_MENTION'; -export const COMPOSE_RESET = 'COMPOSE_RESET'; -export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; -export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; -export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; -export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; -export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; - -export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; -export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; -export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; -export const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE'; - -export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE'; - -export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; -export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; - -export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; -export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; -export const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE'; -export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; -export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; -export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; -export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; - -export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; - -export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; -export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; -export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; - -export const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD'; -export const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE'; -export const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD'; -export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE'; -export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE'; -export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE'; - -export const COMPOSE_SCHEDULE_ADD = 'COMPOSE_SCHEDULE_ADD'; -export const COMPOSE_SCHEDULE_SET = 'COMPOSE_SCHEDULE_SET'; -export const COMPOSE_SCHEDULE_REMOVE = 'COMPOSE_SCHEDULE_REMOVE'; - -export const COMPOSE_ADD_TO_MENTIONS = 'COMPOSE_ADD_TO_MENTIONS'; -export const COMPOSE_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS'; - -export 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})' }, - 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' }, - 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' }, -}); - -const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1); - -export const ensureComposeIsVisible = (getState, routerHistory) => { - if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) { - routerHistory.push('/posts/new'); - } -}; - -export function setComposeToStatus(status, rawText, spoilerText, contentType) { - return (dispatch, getState) => { - const { instance } = getState(); - const { explicitAddressing } = getFeatures(instance); - - dispatch({ - type: COMPOSE_SET_STATUS, - status, - rawText, - explicitAddressing, - spoilerText, - contentType, - v: parseVersion(instance.version), - }); - }; -} - -export function changeCompose(text) { - return { - type: COMPOSE_CHANGE, - text: text, - }; -} - -export function replyCompose(status, routerHistory) { - return (dispatch, getState) => { - const state = getState(); - const instance = state.get('instance'); - const { explicitAddressing } = getFeatures(instance); - - dispatch({ - type: COMPOSE_REPLY, - status: status, - account: state.getIn(['accounts', state.get('me')]), - explicitAddressing, - }); - - dispatch(openModal('COMPOSE')); - }; -} - -export function cancelReplyCompose() { - return { - type: COMPOSE_REPLY_CANCEL, - }; -} - -export function quoteCompose(status, routerHistory) { - return (dispatch, getState) => { - const state = getState(); - const instance = state.get('instance'); - const { explicitAddressing } = getFeatures(instance); - - dispatch({ - type: COMPOSE_QUOTE, - status: status, - account: state.getIn(['accounts', state.get('me')]), - explicitAddressing, - }); - - dispatch(openModal('COMPOSE')); - }; -} - -export function cancelQuoteCompose() { - return { - type: COMPOSE_QUOTE_CANCEL, - }; -} - -export function resetCompose() { - return { - type: COMPOSE_RESET, - }; -} - -export function mentionCompose(account, routerHistory) { - return (dispatch, getState) => { - dispatch({ - type: COMPOSE_MENTION, - account: account, - }); - - dispatch(openModal('COMPOSE')); - }; -} - -export function directCompose(account, routerHistory) { - return (dispatch, getState) => { - dispatch({ - type: COMPOSE_DIRECT, - account: account, - }); - - dispatch(openModal('COMPOSE')); - }; -} - -export function directComposeById(accountId) { - return (dispatch, getState) => { - const account = getState().getIn(['accounts', accountId]); - - dispatch({ - type: COMPOSE_DIRECT, - account: account, - }); - - dispatch(openModal('COMPOSE')); - }; -} - -export function handleComposeSubmit(dispatch, getState, data, status) { - if (!dispatch || !getState) return; - - dispatch(insertIntoTagHistory(data.tags || [], status)); - dispatch(submitComposeSuccess({ ...data })); - dispatch(snackbar.success(messages.success, messages.view, `/@${data.account.acct}/posts/${data.id}`)); -} - -const needsDescriptions = state => { - const media = state.getIn(['compose', 'media_attachments']); - const missingDescriptionModal = getSettings(state).get('missingDescriptionModal'); - - const hasMissing = media.filter(item => !item.get('description')).size > 0; - - return missingDescriptionModal && hasMissing; -}; - -const validateSchedule = state => { - const schedule = state.getIn(['compose', 'schedule']); - if (!schedule) return true; - - const fiveMinutesFromNow = new Date(new Date().getTime() + 300000); - - return schedule.getTime() > fiveMinutesFromNow.getTime(); -}; - -export function submitCompose(routerHistory, force = false) { - return function(dispatch, getState) { - if (!isLoggedIn(getState)) return; - const state = getState(); - - const status = state.getIn(['compose', 'text'], ''); - const media = state.getIn(['compose', 'media_attachments']); - const statusId = state.getIn(['compose', 'id'], null); - let to = state.getIn(['compose', 'to'], ImmutableOrderedSet()); - - if (!validateSchedule(state)) { - dispatch(snackbar.error(messages.scheduleError)); - return; - } - - if ((!status || !status.length) && media.size === 0) { - return; - } - - if (!force && needsDescriptions(state)) { - dispatch(openModal('MISSING_DESCRIPTION', { - onContinue: () => { - dispatch(closeModal('MISSING_DESCRIPTION')); - dispatch(submitCompose(routerHistory, true)); - }, - })); - return; - } - - if (to && status) { - const mentions = status.match(/(?:^|\s|\.)@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/gi); // not a perfect regex - - if (mentions) - to = to.union(mentions.map(mention => mention.trim().slice(1))); - } - - dispatch(submitComposeRequest()); - dispatch(closeModal()); - - const idempotencyKey = state.getIn(['compose', 'idempotencyKey']); - - const params = { - status, - in_reply_to_id: state.getIn(['compose', 'in_reply_to'], null), - quote_id: state.getIn(['compose', 'quote'], null), - media_ids: media.map(item => item.get('id')), - sensitive: state.getIn(['compose', 'sensitive']), - spoiler_text: state.getIn(['compose', 'spoiler_text'], ''), - visibility: state.getIn(['compose', 'privacy']), - content_type: state.getIn(['compose', 'content_type']), - poll: state.getIn(['compose', 'poll'], null), - scheduled_at: state.getIn(['compose', 'schedule'], null), - to, - }; - - dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) { - if (!statusId && data.visibility === 'direct' && getState().getIn(['conversations', 'mounted']) <= 0 && routerHistory) { - routerHistory.push('/messages'); - } - handleComposeSubmit(dispatch, getState, data, status); - }).catch(function(error) { - dispatch(submitComposeFail(error)); - }); - }; -} - -export function submitComposeRequest() { - return { - type: COMPOSE_SUBMIT_REQUEST, - }; -} - -export function submitComposeSuccess(status) { - return { - type: COMPOSE_SUBMIT_SUCCESS, - status: status, - }; -} - -export function submitComposeFail(error) { - return { - type: COMPOSE_SUBMIT_FAIL, - error: error, - }; -} - -export function uploadCompose(files, intl) { - return function(dispatch, getState) { - if (!isLoggedIn(getState)) return; - const attachmentLimit = getState().getIn(['instance', 'configuration', 'statuses', 'max_media_attachments']); - const maxImageSize = getState().getIn(['instance', 'configuration', 'media_attachments', 'image_size_limit']); - const maxVideoSize = getState().getIn(['instance', 'configuration', 'media_attachments', 'video_size_limit']); - - const media = getState().getIn(['compose', 'media_attachments']); - const progress = new Array(files.length).fill(0); - let total = Array.from(files).reduce((a, v) => a + v.size, 0); - - if (files.length + media.size > attachmentLimit) { - dispatch(showAlert(undefined, messages.uploadErrorLimit, 'error')); - return; - } - - dispatch(uploadComposeRequest()); - - Array.from(files).forEach((f, i) => { - if (media.size + i > attachmentLimit - 1) return; - - const isImage = f.type.match(/image.*/); - const isVideo = f.type.match(/video.*/); - if (isImage && maxImageSize && (f.size > maxImageSize)) { - const limit = formatBytes(maxImageSize); - const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); - dispatch(snackbar.error(message)); - dispatch(uploadComposeFail(true)); - return; - } else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) { - const limit = formatBytes(maxVideoSize); - const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit }); - dispatch(snackbar.error(message)); - dispatch(uploadComposeFail(true)); - return; - } - - // FIXME: Don't define function in loop - /* eslint-disable no-loop-func */ - resizeImage(f).then(file => { - const data = new FormData(); - data.append('file', file); - // Account for disparity in size of original image and resized data - total += file.size - f.size; - - const onUploadProgress = function({ loaded }) { - progress[i] = loaded; - dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); - }; - - return dispatch(uploadMedia(data, onUploadProgress)) - .then(({ status, data }) => { - // If server-side processing of the media attachment has not completed yet, - // poll the server until it is, before showing the media attachment as uploaded - if (status === 200) { - dispatch(uploadComposeSuccess(data, f)); - } else if (status === 202) { - const poll = () => { - dispatch(fetchMedia(data.id)).then(({ status, data }) => { - if (status === 200) { - dispatch(uploadComposeSuccess(data, f)); - } else if (status === 206) { - setTimeout(() => poll(), 1000); - } - }).catch(error => dispatch(uploadComposeFail(error))); - }; - - poll(); - } - }); - }).catch(error => dispatch(uploadComposeFail(error))); - /* eslint-enable no-loop-func */ - }); - }; -} - -export function changeUploadCompose(id, params) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(changeUploadComposeRequest()); - - dispatch(updateMedia(id, params)).then(response => { - dispatch(changeUploadComposeSuccess(response.data)); - }).catch(error => { - dispatch(changeUploadComposeFail(id, error)); - }); - }; -} - -export function changeUploadComposeRequest() { - return { - type: COMPOSE_UPLOAD_CHANGE_REQUEST, - skipLoading: true, - }; -} -export function changeUploadComposeSuccess(media) { - return { - type: COMPOSE_UPLOAD_CHANGE_SUCCESS, - media: media, - skipLoading: true, - }; -} - -export function changeUploadComposeFail(error) { - return { - type: COMPOSE_UPLOAD_CHANGE_FAIL, - error: error, - skipLoading: true, - }; -} - -export function uploadComposeRequest() { - return { - type: COMPOSE_UPLOAD_REQUEST, - skipLoading: true, - }; -} - -export function uploadComposeProgress(loaded, total) { - return { - type: COMPOSE_UPLOAD_PROGRESS, - loaded: loaded, - total: total, - }; -} - -export function uploadComposeSuccess(media) { - return { - type: COMPOSE_UPLOAD_SUCCESS, - media: media, - skipLoading: true, - }; -} - -export function uploadComposeFail(error) { - return { - type: COMPOSE_UPLOAD_FAIL, - error: error, - skipLoading: true, - }; -} - -export function undoUploadCompose(media_id) { - return { - type: COMPOSE_UPLOAD_UNDO, - media_id: media_id, - }; -} - -export function clearComposeSuggestions() { - if (cancelFetchComposeSuggestionsAccounts) { - cancelFetchComposeSuggestionsAccounts(); - } - return { - type: COMPOSE_SUGGESTIONS_CLEAR, - }; -} - -const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { - if (cancelFetchComposeSuggestionsAccounts) { - cancelFetchComposeSuggestionsAccounts(); - } - api(getState).get('/api/v1/accounts/search', { - cancelToken: new CancelToken(cancel => { - cancelFetchComposeSuggestionsAccounts = cancel; - }), - params: { - q: token.slice(1), - resolve: false, - limit: 4, - }, - }).then(response => { - dispatch(importFetchedAccounts(response.data)); - dispatch(readyComposeSuggestionsAccounts(token, response.data)); - }).catch(error => { - if (!isCancel(error)) { - dispatch(showAlertForError(error)); - } - }); -}, 200, { leading: true, trailing: true }); - -const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { - const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }); - dispatch(readyComposeSuggestionsEmojis(token, results)); -}; - -const fetchComposeSuggestionsTags = (dispatch, getState, token) => { - dispatch(updateSuggestionTags(token)); -}; - -export function fetchComposeSuggestions(token) { - return (dispatch, getState) => { - switch (token[0]) { - case ':': - fetchComposeSuggestionsEmojis(dispatch, getState, token); - break; - case '#': - fetchComposeSuggestionsTags(dispatch, getState, token); - break; - default: - fetchComposeSuggestionsAccounts(dispatch, getState, token); - break; - } - }; -} - -export function readyComposeSuggestionsEmojis(token, emojis) { - return { - type: COMPOSE_SUGGESTIONS_READY, - token, - emojis, - }; -} - -export function readyComposeSuggestionsAccounts(token, accounts) { - return { - type: COMPOSE_SUGGESTIONS_READY, - token, - accounts, - }; -} - -export function selectComposeSuggestion(position, token, suggestion, path) { - return (dispatch, getState) => { - let completion, startPosition; - - if (typeof suggestion === 'object' && suggestion.id) { - completion = suggestion.native || suggestion.colons; - startPosition = position - 1; - - dispatch(useEmoji(suggestion)); - } else if (suggestion[0] === '#') { - completion = suggestion; - startPosition = position - 1; - } else { - completion = getState().getIn(['accounts', suggestion, 'acct']); - startPosition = position; - } - - dispatch({ - type: COMPOSE_SUGGESTION_SELECT, - position: startPosition, - token, - completion, - path, - }); - }; -} - -export function updateSuggestionTags(token) { - return { - type: COMPOSE_SUGGESTION_TAGS_UPDATE, - token, - }; -} - -export function updateTagHistory(tags) { - return { - type: COMPOSE_TAG_HISTORY_UPDATE, - tags, - }; -} - -function insertIntoTagHistory(recognizedTags, text) { - return (dispatch, getState) => { - const state = getState(); - const oldHistory = state.getIn(['compose', 'tagHistory']); - const me = state.get('me'); - const names = recognizedTags - .filter(tag => text.match(new RegExp(`#${tag.name}`, 'i'))) - .map(tag => tag.name); - const intersectedOldHistory = oldHistory.filter(name => names.findIndex(newName => newName.toLowerCase() === name.toLowerCase()) === -1); - - names.push(...intersectedOldHistory.toJS()); - - const newHistory = names.slice(0, 1000); - - tagHistory.set(me, newHistory); - dispatch(updateTagHistory(newHistory)); - }; -} - -export function mountCompose() { - return { - type: COMPOSE_MOUNT, - }; -} - -export function unmountCompose() { - return { - type: COMPOSE_UNMOUNT, - }; -} - -export function changeComposeSensitivity() { - return { - type: COMPOSE_SENSITIVITY_CHANGE, - }; -} - -export function changeComposeSpoilerness() { - return { - type: COMPOSE_SPOILERNESS_CHANGE, - }; -} - -export function changeComposeContentType(value) { - return { - type: COMPOSE_TYPE_CHANGE, - value, - }; -} - -export function changeComposeSpoilerText(text) { - return { - type: COMPOSE_SPOILER_TEXT_CHANGE, - text, - }; -} - -export function changeComposeVisibility(value) { - return { - type: COMPOSE_VISIBILITY_CHANGE, - value, - }; -} - -export function insertEmojiCompose(position, emoji, needsSpace) { - return { - type: COMPOSE_EMOJI_INSERT, - position, - emoji, - needsSpace, - }; -} - -export function changeComposing(value) { - return { - type: COMPOSE_COMPOSING_CHANGE, - value, - }; -} - -export function addPoll() { - return { - type: COMPOSE_POLL_ADD, - }; -} - -export function removePoll() { - return { - type: COMPOSE_POLL_REMOVE, - }; -} - -export function addSchedule() { - return { - type: COMPOSE_SCHEDULE_ADD, - }; -} - -export function setSchedule(date) { - return { - type: COMPOSE_SCHEDULE_SET, - date: date, - }; -} - -export function removeSchedule() { - return { - type: COMPOSE_SCHEDULE_REMOVE, - }; -} - -export function addPollOption(title) { - return { - type: COMPOSE_POLL_OPTION_ADD, - title, - }; -} - -export function changePollOption(index, title) { - return { - type: COMPOSE_POLL_OPTION_CHANGE, - index, - title, - }; -} - -export function removePollOption(index) { - return { - type: COMPOSE_POLL_OPTION_REMOVE, - index, - }; -} - -export function changePollSettings(expiresIn, isMultiple) { - return { - type: COMPOSE_POLL_SETTINGS_CHANGE, - expiresIn, - isMultiple, - }; -} - -export function openComposeWithText(text = '') { - return (dispatch, getState) => { - dispatch(resetCompose()); - dispatch(openModal('COMPOSE')); - dispatch(changeCompose(text)); - }; -} - -export function addToMentions(accountId) { - return (dispatch, getState) => { - const state = getState(); - const acct = state.getIn(['accounts', accountId, 'acct']); - - return dispatch({ - type: COMPOSE_ADD_TO_MENTIONS, - account: acct, - }); - }; -} - -export function removeFromMentions(accountId) { - return (dispatch, getState) => { - const state = getState(); - const acct = state.getIn(['accounts', accountId, 'acct']); - - return dispatch({ - type: COMPOSE_REMOVE_FROM_MENTIONS, - account: acct, - }); - }; -} diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts new file mode 100644 index 000000000..f33a9fa18 --- /dev/null +++ b/app/soapbox/actions/compose.ts @@ -0,0 +1,809 @@ +import axios, { AxiosError, Canceler } from 'axios'; +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 { 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'; +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 } from 'soapbox/types/entities'; + +const { CancelToken, isCancel } = axios; + +let cancelFetchComposeSuggestionsAccounts: Canceler; + +const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; +const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; +const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; +const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; +const COMPOSE_REPLY = 'COMPOSE_REPLY'; +const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; +const COMPOSE_QUOTE = 'COMPOSE_QUOTE'; +const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL'; +const COMPOSE_DIRECT = 'COMPOSE_DIRECT'; +const COMPOSE_MENTION = 'COMPOSE_MENTION'; +const COMPOSE_RESET = 'COMPOSE_RESET'; +const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; +const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; +const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; +const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; +const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; + +const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; +const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; +const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; +const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE'; + +const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE'; + +const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; +const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; + +const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; +const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; +const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE'; +const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; +const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; +const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; +const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; + +const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; + +const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; +const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; +const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; + +const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD'; +const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE'; +const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD'; +const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE'; +const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE'; +const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE'; + +const COMPOSE_SCHEDULE_ADD = 'COMPOSE_SCHEDULE_ADD'; +const COMPOSE_SCHEDULE_SET = 'COMPOSE_SCHEDULE_SET'; +const COMPOSE_SCHEDULE_REMOVE = 'COMPOSE_SCHEDULE_REMOVE'; + +const COMPOSE_ADD_TO_MENTIONS = 'COMPOSE_ADD_TO_MENTIONS'; +const COMPOSE_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS'; + +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)' }, + 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' }, + 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?' }, +}); + +const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1); + +const ensureComposeIsVisible = (getState: () => RootState, routerHistory: History) => { + if (!getState().compose.mounted && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) { + routerHistory.push('/posts/new'); + } +}; + +const setComposeToStatus = (status: Status, rawText: string, spoilerText?: string, contentType?: string | false, withRedraft?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + const { instance } = getState(); + const { explicitAddressing } = getFeatures(instance); + + dispatch({ + type: COMPOSE_SET_STATUS, + status, + rawText, + explicitAddressing, + spoilerText, + contentType, + v: parseVersion(instance.version), + withRedraft, + }); + }; + +const changeCompose = (text: string) => ({ + type: COMPOSE_CHANGE, + text: text, +}); + +const replyCompose = (status: Status) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const { explicitAddressing } = getFeatures(instance); + + dispatch({ + type: COMPOSE_REPLY, + status: status, + account: state.accounts.get(state.me), + explicitAddressing, + }); + + dispatch(openModal('COMPOSE')); + }; + +const replyComposeWithConfirmation = (status: Status, intl: IntlShape) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + if (state.compose.text.trim().length !== 0) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: () => dispatch(replyCompose(status)), + })); + } else { + dispatch(replyCompose(status)); + } + }; + +const cancelReplyCompose = () => ({ + type: COMPOSE_REPLY_CANCEL, +}); + +const quoteCompose = (status: Status) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const { explicitAddressing } = getFeatures(instance); + + dispatch({ + type: COMPOSE_QUOTE, + status: status, + account: state.accounts.get(state.me), + explicitAddressing, + }); + + dispatch(openModal('COMPOSE')); + }; + +const cancelQuoteCompose = () => ({ + type: COMPOSE_QUOTE_CANCEL, +}); + +const resetCompose = () => ({ + type: COMPOSE_RESET, +}); + +const mentionCompose = (account: Account) => + (dispatch: AppDispatch) => { + dispatch({ + type: COMPOSE_MENTION, + account: account, + }); + + dispatch(openModal('COMPOSE')); + }; + +const directCompose = (account: Account) => + (dispatch: AppDispatch) => { + dispatch({ + type: COMPOSE_DIRECT, + account: account, + }); + + dispatch(openModal('COMPOSE')); + }; + +const directComposeById = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const account = getState().accounts.get(accountId); + + dispatch({ + type: COMPOSE_DIRECT, + account: account, + }); + + dispatch(openModal('COMPOSE')); + }; + +const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, data: APIEntity, status: string, edit?: boolean) => { + if (!dispatch || !getState) return; + + dispatch(insertIntoTagHistory(data.tags || [], status)); + dispatch(submitComposeSuccess({ ...data })); + dispatch(snackbar.success(edit ? messages.editSuccess : messages.success, messages.view, `/@${data.account.acct}/posts/${data.id}`)); +}; + +const needsDescriptions = (state: RootState) => { + const media = state.compose.media_attachments; + const missingDescriptionModal = getSettings(state).get('missingDescriptionModal'); + + const hasMissing = media.filter(item => !item.description).size > 0; + + return missingDescriptionModal && hasMissing; +}; + +const validateSchedule = (state: RootState) => { + const schedule = state.compose.schedule; + if (!schedule) return true; + + const fiveMinutesFromNow = new Date(new Date().getTime() + 300000); + + return schedule.getTime() > fiveMinutesFromNow.getTime(); +}; + +const submitCompose = (routerHistory?: History, force = false) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const state = getState(); + + const status = state.compose.text; + const media = state.compose.media_attachments; + const statusId = state.compose.id; + let to = state.compose.to; + + if (!validateSchedule(state)) { + dispatch(snackbar.error(messages.scheduleError)); + return; + } + + if ((!status || !status.length) && media.size === 0) { + return; + } + + if (!force && needsDescriptions(state)) { + dispatch(openModal('MISSING_DESCRIPTION', { + onContinue: () => { + dispatch(closeModal('MISSING_DESCRIPTION')); + dispatch(submitCompose(routerHistory, true)); + }, + })); + return; + } + + const mentions: string[] | null = status.match(/(?:^|\s)@([a-z\d_-]+(?:@[^@\s]+)?)/gi); + + if (mentions) { + to = to.union(mentions.map(mention => mention.trim().slice(1))); + } + + dispatch(submitComposeRequest()); + dispatch(closeModal()); + + const idempotencyKey = state.compose.idempotencyKey; + + const params = { + status, + in_reply_to_id: state.compose.in_reply_to, + quote_id: state.compose.quote, + media_ids: media.map(item => item.id), + sensitive: state.compose.sensitive, + spoiler_text: state.compose.spoiler_text, + visibility: state.compose.privacy, + content_type: state.compose.content_type, + poll: state.compose.poll, + scheduled_at: state.compose.schedule, + to, + }; + + dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) { + if (!statusId && data.visibility === 'direct' && getState().conversations.mounted <= 0 && routerHistory) { + routerHistory.push('/messages'); + } + handleComposeSubmit(dispatch, getState, data, status, !!statusId); + }).catch(function(error) { + dispatch(submitComposeFail(error)); + }); + }; + +const submitComposeRequest = () => ({ + type: COMPOSE_SUBMIT_REQUEST, +}); + +const submitComposeSuccess = (status: APIEntity) => ({ + type: COMPOSE_SUBMIT_SUCCESS, + status: status, +}); + +const submitComposeFail = (error: AxiosError) => ({ + type: COMPOSE_SUBMIT_FAIL, + error: error, +}); + +const uploadCompose = (files: FileList, intl: IntlShape) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const attachmentLimit = getState().instance.configuration.getIn(['statuses', 'max_media_attachments']) as number; + const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined; + const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined; + const maxVideoDuration = getState().instance.configuration.getIn(['media_attachments', 'video_duration_limit']) as number | undefined; + + const media = getState().compose.media_attachments; + const progress = new Array(files.length).fill(0); + let total = Array.from(files).reduce((a, v) => a + v.size, 0); + + if (files.length + media.size > attachmentLimit) { + dispatch(showAlert(undefined, messages.uploadErrorLimit, 'error')); + return; + } + + dispatch(uploadComposeRequest()); + + Array.from(files).forEach(async(f, i) => { + if (media.size + i > attachmentLimit - 1) return; + + const isImage = f.type.match(/image.*/); + const isVideo = f.type.match(/video.*/); + const videoDurationInSeconds = (isVideo && maxVideoDuration) ? await getVideoDuration(f) : 0; + + if (isImage && maxImageSize && (f.size > maxImageSize)) { + const limit = formatBytes(maxImageSize); + const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); + dispatch(snackbar.error(message)); + dispatch(uploadComposeFail(true)); + return; + } else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) { + const limit = formatBytes(maxVideoSize); + const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit }); + dispatch(snackbar.error(message)); + dispatch(uploadComposeFail(true)); + return; + } else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) { + const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration }); + dispatch(snackbar.error(message)); + dispatch(uploadComposeFail(true)); + return; + } + + // FIXME: Don't define const in loop + /* eslint-disable no-loop-func */ + resizeImage(f).then(file => { + const data = new FormData(); + data.append('file', file); + // Account for disparity in size of original image and resized data + total += file.size - f.size; + + const onUploadProgress = ({ loaded }: any) => { + progress[i] = loaded; + dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); + }; + + return dispatch(uploadMedia(data, onUploadProgress)) + .then(({ status, data }) => { + // If server-side processing of the media attachment has not completed yet, + // poll the server until it is, before showing the media attachment as uploaded + if (status === 200) { + dispatch(uploadComposeSuccess(data, f)); + } else if (status === 202) { + const poll = () => { + dispatch(fetchMedia(data.id)).then(({ status, data }) => { + if (status === 200) { + dispatch(uploadComposeSuccess(data, f)); + } else if (status === 206) { + setTimeout(() => poll(), 1000); + } + }).catch(error => dispatch(uploadComposeFail(error))); + }; + + poll(); + } + }); + }).catch(error => dispatch(uploadComposeFail(error))); + /* eslint-enable no-loop-func */ + }); + }; + +const changeUploadCompose = (id: string, params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(changeUploadComposeRequest()); + + dispatch(updateMedia(id, params)).then(response => { + dispatch(changeUploadComposeSuccess(response.data)); + }).catch(error => { + dispatch(changeUploadComposeFail(id, error)); + }); + }; + +const changeUploadComposeRequest = () => ({ + type: COMPOSE_UPLOAD_CHANGE_REQUEST, + skipLoading: true, +}); + +const changeUploadComposeSuccess = (media: APIEntity) => ({ + type: COMPOSE_UPLOAD_CHANGE_SUCCESS, + media: media, + skipLoading: true, +}); + +const changeUploadComposeFail = (id: string, error: AxiosError) => ({ + type: COMPOSE_UPLOAD_CHANGE_FAIL, + id, + error: error, + skipLoading: true, +}); + +const uploadComposeRequest = () => ({ + type: COMPOSE_UPLOAD_REQUEST, + skipLoading: true, +}); + +const uploadComposeProgress = (loaded: number, total: number) => ({ + type: COMPOSE_UPLOAD_PROGRESS, + loaded: loaded, + total: total, +}); + +const uploadComposeSuccess = (media: APIEntity, file: File) => ({ + type: COMPOSE_UPLOAD_SUCCESS, + media: media, + file, + skipLoading: true, +}); + +const uploadComposeFail = (error: AxiosError | true) => ({ + type: COMPOSE_UPLOAD_FAIL, + error: error, + skipLoading: true, +}); + +const undoUploadCompose = (media_id: string) => ({ + type: COMPOSE_UPLOAD_UNDO, + media_id: media_id, +}); + +const clearComposeSuggestions = () => { + if (cancelFetchComposeSuggestionsAccounts) { + cancelFetchComposeSuggestionsAccounts(); + } + return { + type: COMPOSE_SUGGESTIONS_CLEAR, + }; +}; + +const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { + if (cancelFetchComposeSuggestionsAccounts) { + cancelFetchComposeSuggestionsAccounts(); + } + api(getState).get('/api/v1/accounts/search', { + cancelToken: new CancelToken(cancel => { + cancelFetchComposeSuggestionsAccounts = cancel; + }), + params: { + q: token.slice(1), + resolve: false, + limit: 4, + }, + }).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(readyComposeSuggestionsAccounts(token, response.data)); + }).catch(error => { + if (!isCancel(error)) { + dispatch(showAlertForError(error)); + } + }); +}, 200, { leading: true, trailing: true }); + +const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, token: string) => { + const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any); + dispatch(readyComposeSuggestionsEmojis(token, results)); +}; + +const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, token: string) => { + dispatch(updateSuggestionTags(token)); +}; + +const fetchComposeSuggestions = (token: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + switch (token[0]) { + case ':': + fetchComposeSuggestionsEmojis(dispatch, getState, token); + break; + case '#': + fetchComposeSuggestionsTags(dispatch, getState, token); + break; + default: + fetchComposeSuggestionsAccounts(dispatch, getState, token); + break; + } + }; + +const readyComposeSuggestionsEmojis = (token: string, emojis: Emoji[]) => ({ + type: COMPOSE_SUGGESTIONS_READY, + token, + emojis, +}); + +const readyComposeSuggestionsAccounts = (token: string, accounts: APIEntity[]) => ({ + type: COMPOSE_SUGGESTIONS_READY, + token, + accounts, +}); + +const selectComposeSuggestion = (position: number, token: string | null, suggestion: AutoSuggestion, path: Array) => + (dispatch: AppDispatch, getState: () => RootState) => { + let completion, startPosition; + + if (typeof suggestion === 'object' && suggestion.id) { + completion = suggestion.native || suggestion.colons; + startPosition = position - 1; + + dispatch(useEmoji(suggestion)); + } else if (typeof suggestion === 'string' && suggestion[0] === '#') { + completion = suggestion; + startPosition = position - 1; + } else { + completion = getState().accounts.get(suggestion)!.acct; + startPosition = position; + } + + dispatch({ + type: COMPOSE_SUGGESTION_SELECT, + position: startPosition, + token, + completion, + path, + }); + }; + +const updateSuggestionTags = (token: string) => ({ + type: COMPOSE_SUGGESTION_TAGS_UPDATE, + token, +}); + +const updateTagHistory = (tags: string[]) => ({ + type: COMPOSE_TAG_HISTORY_UPDATE, + tags, +}); + +const insertIntoTagHistory = (recognizedTags: APIEntity[], text: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const oldHistory = state.compose.tagHistory; + const me = state.me; + const names = recognizedTags + .filter(tag => text.match(new RegExp(`#${tag.name}`, 'i'))) + .map(tag => tag.name); + const intersectedOldHistory = oldHistory.filter(name => names.findIndex(newName => newName.toLowerCase() === name.toLowerCase()) === -1); + + names.push(...intersectedOldHistory.toJS()); + + const newHistory = names.slice(0, 1000); + + tagHistory.set(me as string, newHistory); + dispatch(updateTagHistory(newHistory)); + }; + +const mountCompose = () => ({ + type: COMPOSE_MOUNT, +}); + +const unmountCompose = () => ({ + type: COMPOSE_UNMOUNT, +}); + +const changeComposeSensitivity = () => ({ + type: COMPOSE_SENSITIVITY_CHANGE, +}); + +const changeComposeSpoilerness = () => ({ + type: COMPOSE_SPOILERNESS_CHANGE, +}); + +const changeComposeContentType = (value: string) => ({ + type: COMPOSE_TYPE_CHANGE, + value, +}); + +const changeComposeSpoilerText = (text: string) => ({ + type: COMPOSE_SPOILER_TEXT_CHANGE, + text, +}); + +const changeComposeVisibility = (value: string) => ({ + type: COMPOSE_VISIBILITY_CHANGE, + value, +}); + +const insertEmojiCompose = (position: number, emoji: string, needsSpace: boolean) => ({ + type: COMPOSE_EMOJI_INSERT, + position, + emoji, + needsSpace, +}); + +const changeComposing = (value: string) => ({ + type: COMPOSE_COMPOSING_CHANGE, + value, +}); + +const addPoll = () => ({ + type: COMPOSE_POLL_ADD, +}); + +const removePoll = () => ({ + type: COMPOSE_POLL_REMOVE, +}); + +const addSchedule = () => ({ + type: COMPOSE_SCHEDULE_ADD, +}); + +const setSchedule = (date: Date) => ({ + type: COMPOSE_SCHEDULE_SET, + date: date, +}); + +const removeSchedule = () => ({ + type: COMPOSE_SCHEDULE_REMOVE, +}); + +const addPollOption = (title: string) => ({ + type: COMPOSE_POLL_OPTION_ADD, + title, +}); + +const changePollOption = (index: number, title: string) => ({ + type: COMPOSE_POLL_OPTION_CHANGE, + index, + title, +}); + +const removePollOption = (index: number) => ({ + type: COMPOSE_POLL_OPTION_REMOVE, + index, +}); + +const changePollSettings = (expiresIn?: string | number, isMultiple?: boolean) => ({ + type: COMPOSE_POLL_SETTINGS_CHANGE, + expiresIn, + isMultiple, +}); + +const openComposeWithText = (text = '') => + (dispatch: AppDispatch) => { + dispatch(resetCompose()); + dispatch(openModal('COMPOSE')); + dispatch(changeCompose(text)); + }; + +const addToMentions = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const acct = state.accounts.get(accountId)!.acct; + + return dispatch({ + type: COMPOSE_ADD_TO_MENTIONS, + account: acct, + }); + }; + +const removeFromMentions = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const acct = state.accounts.get(accountId)!.acct; + + return dispatch({ + type: COMPOSE_REMOVE_FROM_MENTIONS, + account: acct, + }); + }; + +export { + COMPOSE_CHANGE, + COMPOSE_SUBMIT_REQUEST, + COMPOSE_SUBMIT_SUCCESS, + COMPOSE_SUBMIT_FAIL, + COMPOSE_REPLY, + COMPOSE_REPLY_CANCEL, + COMPOSE_QUOTE, + COMPOSE_QUOTE_CANCEL, + COMPOSE_DIRECT, + COMPOSE_MENTION, + COMPOSE_RESET, + COMPOSE_UPLOAD_REQUEST, + COMPOSE_UPLOAD_SUCCESS, + COMPOSE_UPLOAD_FAIL, + COMPOSE_UPLOAD_PROGRESS, + COMPOSE_UPLOAD_UNDO, + COMPOSE_SUGGESTIONS_CLEAR, + COMPOSE_SUGGESTIONS_READY, + COMPOSE_SUGGESTION_SELECT, + COMPOSE_SUGGESTION_TAGS_UPDATE, + COMPOSE_TAG_HISTORY_UPDATE, + COMPOSE_MOUNT, + COMPOSE_UNMOUNT, + COMPOSE_SENSITIVITY_CHANGE, + COMPOSE_SPOILERNESS_CHANGE, + COMPOSE_TYPE_CHANGE, + COMPOSE_SPOILER_TEXT_CHANGE, + COMPOSE_VISIBILITY_CHANGE, + COMPOSE_LISTABILITY_CHANGE, + COMPOSE_COMPOSING_CHANGE, + COMPOSE_EMOJI_INSERT, + COMPOSE_UPLOAD_CHANGE_REQUEST, + COMPOSE_UPLOAD_CHANGE_SUCCESS, + COMPOSE_UPLOAD_CHANGE_FAIL, + COMPOSE_POLL_ADD, + COMPOSE_POLL_REMOVE, + COMPOSE_POLL_OPTION_ADD, + COMPOSE_POLL_OPTION_CHANGE, + COMPOSE_POLL_OPTION_REMOVE, + COMPOSE_POLL_SETTINGS_CHANGE, + COMPOSE_SCHEDULE_ADD, + COMPOSE_SCHEDULE_SET, + COMPOSE_SCHEDULE_REMOVE, + COMPOSE_ADD_TO_MENTIONS, + COMPOSE_REMOVE_FROM_MENTIONS, + COMPOSE_SET_STATUS, + ensureComposeIsVisible, + setComposeToStatus, + changeCompose, + replyCompose, + replyComposeWithConfirmation, + cancelReplyCompose, + quoteCompose, + cancelQuoteCompose, + resetCompose, + mentionCompose, + directCompose, + directComposeById, + handleComposeSubmit, + submitCompose, + submitComposeRequest, + submitComposeSuccess, + submitComposeFail, + uploadCompose, + changeUploadCompose, + changeUploadComposeRequest, + changeUploadComposeSuccess, + changeUploadComposeFail, + uploadComposeRequest, + uploadComposeProgress, + uploadComposeSuccess, + uploadComposeFail, + undoUploadCompose, + clearComposeSuggestions, + fetchComposeSuggestions, + readyComposeSuggestionsEmojis, + readyComposeSuggestionsAccounts, + selectComposeSuggestion, + updateSuggestionTags, + updateTagHistory, + mountCompose, + unmountCompose, + changeComposeSensitivity, + changeComposeSpoilerness, + changeComposeContentType, + changeComposeSpoilerText, + changeComposeVisibility, + insertEmojiCompose, + changeComposing, + addPoll, + removePoll, + addSchedule, + setSchedule, + removeSchedule, + addPollOption, + changePollOption, + removePollOption, + changePollSettings, + openComposeWithText, + addToMentions, + removeFromMentions, +}; diff --git a/app/soapbox/actions/conversations.js b/app/soapbox/actions/conversations.js deleted file mode 100644 index 4e7eb214f..000000000 --- a/app/soapbox/actions/conversations.js +++ /dev/null @@ -1,91 +0,0 @@ -import { isLoggedIn } from 'soapbox/utils/auth'; - -import api, { getLinks } from '../api'; - -import { - importFetchedAccounts, - importFetchedStatuses, - importFetchedStatus, -} from './importer'; - -export const CONVERSATIONS_MOUNT = 'CONVERSATIONS_MOUNT'; -export const CONVERSATIONS_UNMOUNT = 'CONVERSATIONS_UNMOUNT'; - -export const CONVERSATIONS_FETCH_REQUEST = 'CONVERSATIONS_FETCH_REQUEST'; -export const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS'; -export const CONVERSATIONS_FETCH_FAIL = 'CONVERSATIONS_FETCH_FAIL'; -export const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE'; - -export const CONVERSATIONS_READ = 'CONVERSATIONS_READ'; - -export const mountConversations = () => ({ - type: CONVERSATIONS_MOUNT, -}); - -export const unmountConversations = () => ({ - type: CONVERSATIONS_UNMOUNT, -}); - -export const markConversationRead = conversationId => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch({ - type: CONVERSATIONS_READ, - id: conversationId, - }); - - api(getState).post(`/api/v1/conversations/${conversationId}/read`); -}; - -export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(expandConversationsRequest()); - - const params = { max_id: maxId }; - - if (!maxId) { - params.since_id = getState().getIn(['conversations', 'items', 0, 'id']); - } - - const isLoadingRecent = !!params.since_id; - - api(getState).get('/api/v1/conversations', { params }) - .then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - - dispatch(importFetchedAccounts(response.data.reduce((aggr, item) => aggr.concat(item.accounts), []))); - dispatch(importFetchedStatuses(response.data.map(item => item.last_status).filter(x => !!x))); - dispatch(expandConversationsSuccess(response.data, next ? next.uri : null, isLoadingRecent)); - }) - .catch(err => dispatch(expandConversationsFail(err))); -}; - -export const expandConversationsRequest = () => ({ - type: CONVERSATIONS_FETCH_REQUEST, -}); - -export const expandConversationsSuccess = (conversations, next, isLoadingRecent) => ({ - type: CONVERSATIONS_FETCH_SUCCESS, - conversations, - next, - isLoadingRecent, -}); - -export const expandConversationsFail = error => ({ - type: CONVERSATIONS_FETCH_FAIL, - error, -}); - -export const updateConversations = conversation => dispatch => { - dispatch(importFetchedAccounts(conversation.accounts)); - - if (conversation.last_status) { - dispatch(importFetchedStatus(conversation.last_status)); - } - - dispatch({ - type: CONVERSATIONS_UPDATE, - conversation, - }); -}; diff --git a/app/soapbox/actions/conversations.ts b/app/soapbox/actions/conversations.ts new file mode 100644 index 000000000..73507e389 --- /dev/null +++ b/app/soapbox/actions/conversations.ts @@ -0,0 +1,113 @@ +import { isLoggedIn } from 'soapbox/utils/auth'; + +import api, { getLinks } from '../api'; + +import { + importFetchedAccounts, + importFetchedStatuses, + importFetchedStatus, +} from './importer'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const CONVERSATIONS_MOUNT = 'CONVERSATIONS_MOUNT'; +const CONVERSATIONS_UNMOUNT = 'CONVERSATIONS_UNMOUNT'; + +const CONVERSATIONS_FETCH_REQUEST = 'CONVERSATIONS_FETCH_REQUEST'; +const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS'; +const CONVERSATIONS_FETCH_FAIL = 'CONVERSATIONS_FETCH_FAIL'; +const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE'; + +const CONVERSATIONS_READ = 'CONVERSATIONS_READ'; + +const mountConversations = () => ({ + type: CONVERSATIONS_MOUNT, +}); + +const unmountConversations = () => ({ + type: CONVERSATIONS_UNMOUNT, +}); + +const markConversationRead = (conversationId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch({ + type: CONVERSATIONS_READ, + id: conversationId, + }); + + api(getState).post(`/api/v1/conversations/${conversationId}/read`); +}; + +const expandConversations = ({ maxId }: Record = {}) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(expandConversationsRequest()); + + const params: Record = { max_id: maxId }; + + if (!maxId) { + params.since_id = getState().conversations.items.getIn([0, 'id']); + } + + const isLoadingRecent = !!params.since_id; + + api(getState).get('/api/v1/conversations', { params }) + .then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data.reduce((aggr: Array, item: APIEntity) => aggr.concat(item.accounts), []))); + dispatch(importFetchedStatuses(response.data.map((item: Record) => item.last_status).filter((x?: APIEntity) => !!x))); + dispatch(expandConversationsSuccess(response.data, next ? next.uri : null, isLoadingRecent)); + }) + .catch(err => dispatch(expandConversationsFail(err))); +}; + +const expandConversationsRequest = () => ({ + type: CONVERSATIONS_FETCH_REQUEST, +}); + +const expandConversationsSuccess = (conversations: APIEntity[], next: string | null, isLoadingRecent: boolean) => ({ + type: CONVERSATIONS_FETCH_SUCCESS, + conversations, + next, + isLoadingRecent, +}); + +const expandConversationsFail = (error: AxiosError) => ({ + type: CONVERSATIONS_FETCH_FAIL, + error, +}); + +const updateConversations = (conversation: APIEntity) => (dispatch: AppDispatch) => { + dispatch(importFetchedAccounts(conversation.accounts)); + + if (conversation.last_status) { + dispatch(importFetchedStatus(conversation.last_status)); + } + + return dispatch({ + type: CONVERSATIONS_UPDATE, + conversation, + }); +}; + +export { + CONVERSATIONS_MOUNT, + CONVERSATIONS_UNMOUNT, + CONVERSATIONS_FETCH_REQUEST, + CONVERSATIONS_FETCH_SUCCESS, + CONVERSATIONS_FETCH_FAIL, + CONVERSATIONS_UPDATE, + CONVERSATIONS_READ, + mountConversations, + unmountConversations, + markConversationRead, + expandConversations, + expandConversationsRequest, + expandConversationsSuccess, + expandConversationsFail, + updateConversations, +}; \ No newline at end of file diff --git a/app/soapbox/actions/custom_emojis.js b/app/soapbox/actions/custom_emojis.js deleted file mode 100644 index 9ec8156b1..000000000 --- a/app/soapbox/actions/custom_emojis.js +++ /dev/null @@ -1,40 +0,0 @@ -import api from '../api'; - -export const CUSTOM_EMOJIS_FETCH_REQUEST = 'CUSTOM_EMOJIS_FETCH_REQUEST'; -export const CUSTOM_EMOJIS_FETCH_SUCCESS = 'CUSTOM_EMOJIS_FETCH_SUCCESS'; -export const CUSTOM_EMOJIS_FETCH_FAIL = 'CUSTOM_EMOJIS_FETCH_FAIL'; - -export function fetchCustomEmojis() { - return (dispatch, getState) => { - dispatch(fetchCustomEmojisRequest()); - - api(getState).get('/api/v1/custom_emojis').then(response => { - dispatch(fetchCustomEmojisSuccess(response.data)); - }).catch(error => { - dispatch(fetchCustomEmojisFail(error)); - }); - }; -} - -export function fetchCustomEmojisRequest() { - return { - type: CUSTOM_EMOJIS_FETCH_REQUEST, - skipLoading: true, - }; -} - -export function fetchCustomEmojisSuccess(custom_emojis) { - return { - type: CUSTOM_EMOJIS_FETCH_SUCCESS, - custom_emojis, - skipLoading: true, - }; -} - -export function fetchCustomEmojisFail(error) { - return { - type: CUSTOM_EMOJIS_FETCH_FAIL, - error, - skipLoading: true, - }; -} diff --git a/app/soapbox/actions/custom_emojis.ts b/app/soapbox/actions/custom_emojis.ts new file mode 100644 index 000000000..f6e14eea3 --- /dev/null +++ b/app/soapbox/actions/custom_emojis.ts @@ -0,0 +1,50 @@ +import api from '../api'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const CUSTOM_EMOJIS_FETCH_REQUEST = 'CUSTOM_EMOJIS_FETCH_REQUEST'; +const CUSTOM_EMOJIS_FETCH_SUCCESS = 'CUSTOM_EMOJIS_FETCH_SUCCESS'; +const CUSTOM_EMOJIS_FETCH_FAIL = 'CUSTOM_EMOJIS_FETCH_FAIL'; + +const fetchCustomEmojis = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const me = getState().me; + if (!me) return; + + dispatch(fetchCustomEmojisRequest()); + + api(getState).get('/api/v1/custom_emojis').then(response => { + dispatch(fetchCustomEmojisSuccess(response.data)); + }).catch(error => { + dispatch(fetchCustomEmojisFail(error)); + }); + }; + +const fetchCustomEmojisRequest = () => ({ + type: CUSTOM_EMOJIS_FETCH_REQUEST, + skipLoading: true, +}); + +const fetchCustomEmojisSuccess = (custom_emojis: APIEntity[]) => ({ + type: CUSTOM_EMOJIS_FETCH_SUCCESS, + custom_emojis, + skipLoading: true, +}); + +const fetchCustomEmojisFail = (error: AxiosError) => ({ + type: CUSTOM_EMOJIS_FETCH_FAIL, + error, + skipLoading: true, +}); + +export { + CUSTOM_EMOJIS_FETCH_REQUEST, + CUSTOM_EMOJIS_FETCH_SUCCESS, + CUSTOM_EMOJIS_FETCH_FAIL, + fetchCustomEmojis, + fetchCustomEmojisRequest, + fetchCustomEmojisSuccess, + fetchCustomEmojisFail, +}; diff --git a/app/soapbox/actions/directory.js b/app/soapbox/actions/directory.js deleted file mode 100644 index 0ee15386b..000000000 --- a/app/soapbox/actions/directory.js +++ /dev/null @@ -1,62 +0,0 @@ -import api from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts } from './importer'; - -export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST'; -export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS'; -export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL'; - -export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST'; -export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS'; -export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL'; - -export const fetchDirectory = params => (dispatch, getState) => { - dispatch(fetchDirectoryRequest()); - - api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchDirectorySuccess(data)); - dispatch(fetchRelationships(data.map(x => x.id))); - }).catch(error => dispatch(fetchDirectoryFail(error))); -}; - -export const fetchDirectoryRequest = () => ({ - type: DIRECTORY_FETCH_REQUEST, -}); - -export const fetchDirectorySuccess = accounts => ({ - type: DIRECTORY_FETCH_SUCCESS, - accounts, -}); - -export const fetchDirectoryFail = error => ({ - type: DIRECTORY_FETCH_FAIL, - error, -}); - -export const expandDirectory = params => (dispatch, getState) => { - dispatch(expandDirectoryRequest()); - - const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size; - - api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(expandDirectorySuccess(data)); - dispatch(fetchRelationships(data.map(x => x.id))); - }).catch(error => dispatch(expandDirectoryFail(error))); -}; - -export const expandDirectoryRequest = () => ({ - type: DIRECTORY_EXPAND_REQUEST, -}); - -export const expandDirectorySuccess = accounts => ({ - type: DIRECTORY_EXPAND_SUCCESS, - accounts, -}); - -export const expandDirectoryFail = error => ({ - type: DIRECTORY_EXPAND_FAIL, - error, -}); \ No newline at end of file diff --git a/app/soapbox/actions/directory.ts b/app/soapbox/actions/directory.ts new file mode 100644 index 000000000..37ddcfdfa --- /dev/null +++ b/app/soapbox/actions/directory.ts @@ -0,0 +1,85 @@ +import api from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST'; +const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS'; +const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL'; + +const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST'; +const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS'; +const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL'; + +const fetchDirectory = (params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchDirectoryRequest()); + + api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchDirectorySuccess(data)); + dispatch(fetchRelationships(data.map((x: APIEntity) => x.id))); + }).catch(error => dispatch(fetchDirectoryFail(error))); + }; + +const fetchDirectoryRequest = () => ({ + type: DIRECTORY_FETCH_REQUEST, +}); + +const fetchDirectorySuccess = (accounts: APIEntity[]) => ({ + type: DIRECTORY_FETCH_SUCCESS, + accounts, +}); + +const fetchDirectoryFail = (error: AxiosError) => ({ + type: DIRECTORY_FETCH_FAIL, + error, +}); + +const expandDirectory = (params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(expandDirectoryRequest()); + + const loadedItems = getState().user_lists.directory.items.size; + + api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(expandDirectorySuccess(data)); + dispatch(fetchRelationships(data.map((x: APIEntity) => x.id))); + }).catch(error => dispatch(expandDirectoryFail(error))); + }; + +const expandDirectoryRequest = () => ({ + type: DIRECTORY_EXPAND_REQUEST, +}); + +const expandDirectorySuccess = (accounts: APIEntity[]) => ({ + type: DIRECTORY_EXPAND_SUCCESS, + accounts, +}); + +const expandDirectoryFail = (error: AxiosError) => ({ + type: DIRECTORY_EXPAND_FAIL, + error, +}); + +export { + DIRECTORY_FETCH_REQUEST, + DIRECTORY_FETCH_SUCCESS, + DIRECTORY_FETCH_FAIL, + DIRECTORY_EXPAND_REQUEST, + DIRECTORY_EXPAND_SUCCESS, + DIRECTORY_EXPAND_FAIL, + fetchDirectory, + fetchDirectoryRequest, + fetchDirectorySuccess, + fetchDirectoryFail, + expandDirectory, + expandDirectoryRequest, + expandDirectorySuccess, + expandDirectoryFail, +}; \ No newline at end of file diff --git a/app/soapbox/actions/domain_blocks.js b/app/soapbox/actions/domain_blocks.js deleted file mode 100644 index 92824a55c..000000000 --- a/app/soapbox/actions/domain_blocks.js +++ /dev/null @@ -1,181 +0,0 @@ -import { isLoggedIn } from 'soapbox/utils/auth'; - -import api, { getLinks } from '../api'; - -export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST'; -export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS'; -export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL'; - -export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST'; -export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS'; -export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL'; - -export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST'; -export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS'; -export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL'; - -export const DOMAIN_BLOCKS_EXPAND_REQUEST = 'DOMAIN_BLOCKS_EXPAND_REQUEST'; -export const DOMAIN_BLOCKS_EXPAND_SUCCESS = 'DOMAIN_BLOCKS_EXPAND_SUCCESS'; -export const DOMAIN_BLOCKS_EXPAND_FAIL = 'DOMAIN_BLOCKS_EXPAND_FAIL'; - -export function blockDomain(domain) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(blockDomainRequest(domain)); - - api(getState).post('/api/v1/domain_blocks', { domain }).then(() => { - const at_domain = '@' + domain; - const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); - dispatch(blockDomainSuccess(domain, accounts)); - }).catch(err => { - dispatch(blockDomainFail(domain, err)); - }); - }; -} - -export function blockDomainRequest(domain) { - return { - type: DOMAIN_BLOCK_REQUEST, - domain, - }; -} - -export function blockDomainSuccess(domain, accounts) { - return { - type: DOMAIN_BLOCK_SUCCESS, - domain, - accounts, - }; -} - -export function blockDomainFail(domain, error) { - return { - type: DOMAIN_BLOCK_FAIL, - domain, - error, - }; -} - -export function unblockDomain(domain) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(unblockDomainRequest(domain)); - - // Do it both ways for maximum compatibility - const params = { - params: { domain }, - data: { domain }, - }; - - api(getState).delete('/api/v1/domain_blocks', params).then(() => { - const at_domain = '@' + domain; - const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); - dispatch(unblockDomainSuccess(domain, accounts)); - }).catch(err => { - dispatch(unblockDomainFail(domain, err)); - }); - }; -} - -export function unblockDomainRequest(domain) { - return { - type: DOMAIN_UNBLOCK_REQUEST, - domain, - }; -} - -export function unblockDomainSuccess(domain, accounts) { - return { - type: DOMAIN_UNBLOCK_SUCCESS, - domain, - accounts, - }; -} - -export function unblockDomainFail(domain, error) { - return { - type: DOMAIN_UNBLOCK_FAIL, - domain, - error, - }; -} - -export function fetchDomainBlocks() { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(fetchDomainBlocksRequest()); - - api(getState).get('/api/v1/domain_blocks').then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null)); - }).catch(err => { - dispatch(fetchDomainBlocksFail(err)); - }); - }; -} - -export function fetchDomainBlocksRequest() { - return { - type: DOMAIN_BLOCKS_FETCH_REQUEST, - }; -} - -export function fetchDomainBlocksSuccess(domains, next) { - return { - type: DOMAIN_BLOCKS_FETCH_SUCCESS, - domains, - next, - }; -} - -export function fetchDomainBlocksFail(error) { - return { - type: DOMAIN_BLOCKS_FETCH_FAIL, - error, - }; -} - -export function expandDomainBlocks() { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - const url = getState().getIn(['domain_lists', 'blocks', 'next']); - - if (!url) { - return; - } - - dispatch(expandDomainBlocksRequest()); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(expandDomainBlocksSuccess(response.data, next ? next.uri : null)); - }).catch(err => { - dispatch(expandDomainBlocksFail(err)); - }); - }; -} - -export function expandDomainBlocksRequest() { - return { - type: DOMAIN_BLOCKS_EXPAND_REQUEST, - }; -} - -export function expandDomainBlocksSuccess(domains, next) { - return { - type: DOMAIN_BLOCKS_EXPAND_SUCCESS, - domains, - next, - }; -} - -export function expandDomainBlocksFail(error) { - return { - type: DOMAIN_BLOCKS_EXPAND_FAIL, - error, - }; -} diff --git a/app/soapbox/actions/domain_blocks.ts b/app/soapbox/actions/domain_blocks.ts new file mode 100644 index 000000000..4308edec7 --- /dev/null +++ b/app/soapbox/actions/domain_blocks.ts @@ -0,0 +1,188 @@ +import { isLoggedIn } from 'soapbox/utils/auth'; + +import api, { getLinks } from '../api'; + +import type { AxiosError } from 'axios'; +import type { List as ImmutableList } from 'immutable'; +import type { AppDispatch, RootState } from 'soapbox/store'; + +const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST'; +const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS'; +const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL'; + +const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST'; +const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS'; +const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL'; + +const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST'; +const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS'; +const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL'; + +const DOMAIN_BLOCKS_EXPAND_REQUEST = 'DOMAIN_BLOCKS_EXPAND_REQUEST'; +const DOMAIN_BLOCKS_EXPAND_SUCCESS = 'DOMAIN_BLOCKS_EXPAND_SUCCESS'; +const DOMAIN_BLOCKS_EXPAND_FAIL = 'DOMAIN_BLOCKS_EXPAND_FAIL'; + +const blockDomain = (domain: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(blockDomainRequest(domain)); + + api(getState).post('/api/v1/domain_blocks', { domain }).then(() => { + const at_domain = '@' + domain; + const accounts = getState().accounts.filter(item => item.acct.endsWith(at_domain)).valueSeq().map(item => item.id); + dispatch(blockDomainSuccess(domain, accounts.toList())); + }).catch(err => { + dispatch(blockDomainFail(domain, err)); + }); + }; + +const blockDomainRequest = (domain: string) => ({ + type: DOMAIN_BLOCK_REQUEST, + domain, +}); + +const blockDomainSuccess = (domain: string, accounts: ImmutableList) => ({ + type: DOMAIN_BLOCK_SUCCESS, + domain, + accounts, +}); + +const blockDomainFail = (domain: string, error: AxiosError) => ({ + type: DOMAIN_BLOCK_FAIL, + domain, + error, +}); + +const unblockDomain = (domain: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(unblockDomainRequest(domain)); + + // Do it both ways for maximum compatibility + const params = { + params: { domain }, + data: { domain }, + }; + + api(getState).delete('/api/v1/domain_blocks', params).then(() => { + const at_domain = '@' + domain; + const accounts = getState().accounts.filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); + dispatch(unblockDomainSuccess(domain, accounts.toList())); + }).catch(err => { + dispatch(unblockDomainFail(domain, err)); + }); + }; + +const unblockDomainRequest = (domain: string) => ({ + type: DOMAIN_UNBLOCK_REQUEST, + domain, +}); + +const unblockDomainSuccess = (domain: string, accounts: ImmutableList) => ({ + type: DOMAIN_UNBLOCK_SUCCESS, + domain, + accounts, +}); + +const unblockDomainFail = (domain: string, error: AxiosError) => ({ + type: DOMAIN_UNBLOCK_FAIL, + domain, + error, +}); + +const fetchDomainBlocks = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchDomainBlocksRequest()); + + api(getState).get('/api/v1/domain_blocks').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null)); + }).catch(err => { + dispatch(fetchDomainBlocksFail(err)); + }); + }; + +const fetchDomainBlocksRequest = () => ({ + type: DOMAIN_BLOCKS_FETCH_REQUEST, +}); + +const fetchDomainBlocksSuccess = (domains: string[], next: string | null) => ({ + type: DOMAIN_BLOCKS_FETCH_SUCCESS, + domains, + next, +}); + +const fetchDomainBlocksFail = (error: AxiosError) => ({ + type: DOMAIN_BLOCKS_FETCH_FAIL, + error, +}); + +const expandDomainBlocks = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const url = getState().domain_lists.blocks.next; + + if (!url) { + return; + } + + dispatch(expandDomainBlocksRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandDomainBlocksSuccess(response.data, next ? next.uri : null)); + }).catch(err => { + dispatch(expandDomainBlocksFail(err)); + }); + }; + +const expandDomainBlocksRequest = () => ({ + type: DOMAIN_BLOCKS_EXPAND_REQUEST, +}); + +const expandDomainBlocksSuccess = (domains: string[], next: string | null) => ({ + type: DOMAIN_BLOCKS_EXPAND_SUCCESS, + domains, + next, +}); + +const expandDomainBlocksFail = (error: AxiosError) => ({ + type: DOMAIN_BLOCKS_EXPAND_FAIL, + error, +}); + +export { + DOMAIN_BLOCK_REQUEST, + DOMAIN_BLOCK_SUCCESS, + DOMAIN_BLOCK_FAIL, + DOMAIN_UNBLOCK_REQUEST, + DOMAIN_UNBLOCK_SUCCESS, + DOMAIN_UNBLOCK_FAIL, + DOMAIN_BLOCKS_FETCH_REQUEST, + DOMAIN_BLOCKS_FETCH_SUCCESS, + DOMAIN_BLOCKS_FETCH_FAIL, + DOMAIN_BLOCKS_EXPAND_REQUEST, + DOMAIN_BLOCKS_EXPAND_SUCCESS, + DOMAIN_BLOCKS_EXPAND_FAIL, + blockDomain, + blockDomainRequest, + blockDomainSuccess, + blockDomainFail, + unblockDomain, + unblockDomainRequest, + unblockDomainSuccess, + unblockDomainFail, + fetchDomainBlocks, + fetchDomainBlocksRequest, + fetchDomainBlocksSuccess, + fetchDomainBlocksFail, + expandDomainBlocks, + expandDomainBlocksRequest, + expandDomainBlocksSuccess, + expandDomainBlocksFail, +}; diff --git a/app/soapbox/actions/dropdown_menu.js b/app/soapbox/actions/dropdown_menu.js deleted file mode 100644 index 14f2939c7..000000000 --- a/app/soapbox/actions/dropdown_menu.js +++ /dev/null @@ -1,10 +0,0 @@ -export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; -export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; - -export function openDropdownMenu(id, placement, keyboard) { - return { type: DROPDOWN_MENU_OPEN, id, placement, keyboard }; -} - -export function closeDropdownMenu(id) { - return { type: DROPDOWN_MENU_CLOSE, id }; -} diff --git a/app/soapbox/actions/dropdown_menu.ts b/app/soapbox/actions/dropdown_menu.ts new file mode 100644 index 000000000..2c19735a1 --- /dev/null +++ b/app/soapbox/actions/dropdown_menu.ts @@ -0,0 +1,17 @@ +import type { DropdownPlacement } from 'soapbox/components/dropdown_menu'; + +const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; +const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; + +const openDropdownMenu = (id: number, placement: DropdownPlacement, keyboard: boolean) => + ({ type: DROPDOWN_MENU_OPEN, id, placement, keyboard }); + +const closeDropdownMenu = (id: number) => + ({ type: DROPDOWN_MENU_CLOSE, id }); + +export { + DROPDOWN_MENU_OPEN, + DROPDOWN_MENU_CLOSE, + openDropdownMenu, + closeDropdownMenu, +}; diff --git a/app/soapbox/actions/email_list.js b/app/soapbox/actions/email_list.js deleted file mode 100644 index 9f440b001..000000000 --- a/app/soapbox/actions/email_list.js +++ /dev/null @@ -1,19 +0,0 @@ -import api from '../api'; - -export function getSubscribersCsv() { - return (dispatch, getState) => { - return api(getState).get('/api/v1/pleroma/admin/email_list/subscribers.csv'); - }; -} - -export function getUnsubscribersCsv() { - return (dispatch, getState) => { - return api(getState).get('/api/v1/pleroma/admin/email_list/unsubscribers.csv'); - }; -} - -export function getCombinedCsv() { - return (dispatch, getState) => { - return api(getState).get('/api/v1/pleroma/admin/email_list/combined.csv'); - }; -} diff --git a/app/soapbox/actions/email_list.ts b/app/soapbox/actions/email_list.ts new file mode 100644 index 000000000..eeac0ed47 --- /dev/null +++ b/app/soapbox/actions/email_list.ts @@ -0,0 +1,21 @@ +import api from '../api'; + +import type { RootState } from 'soapbox/store'; + +const getSubscribersCsv = () => + (dispatch: any, getState: () => RootState) => + api(getState).get('/api/v1/pleroma/admin/email_list/subscribers.csv'); + +const getUnsubscribersCsv = () => + (dispatch: any, getState: () => RootState) => + api(getState).get('/api/v1/pleroma/admin/email_list/unsubscribers.csv'); + +const getCombinedCsv = () => + (dispatch: any, getState: () => RootState) => + api(getState).get('/api/v1/pleroma/admin/email_list/combined.csv'); + +export { + getSubscribersCsv, + getUnsubscribersCsv, + getCombinedCsv, +}; diff --git a/app/soapbox/actions/emoji_reacts.js b/app/soapbox/actions/emoji_reacts.js deleted file mode 100644 index a057dd35e..000000000 --- a/app/soapbox/actions/emoji_reacts.js +++ /dev/null @@ -1,182 +0,0 @@ -import { List as ImmutableList } from 'immutable'; - -import { isLoggedIn } from 'soapbox/utils/auth'; - -import api from '../api'; - -import { importFetchedAccounts, importFetchedStatus } from './importer'; -import { favourite, unfavourite } from './interactions'; - -export const EMOJI_REACT_REQUEST = 'EMOJI_REACT_REQUEST'; -export const EMOJI_REACT_SUCCESS = 'EMOJI_REACT_SUCCESS'; -export const EMOJI_REACT_FAIL = 'EMOJI_REACT_FAIL'; - -export const UNEMOJI_REACT_REQUEST = 'UNEMOJI_REACT_REQUEST'; -export const UNEMOJI_REACT_SUCCESS = 'UNEMOJI_REACT_SUCCESS'; -export const UNEMOJI_REACT_FAIL = 'UNEMOJI_REACT_FAIL'; - -export const EMOJI_REACTS_FETCH_REQUEST = 'EMOJI_REACTS_FETCH_REQUEST'; -export const EMOJI_REACTS_FETCH_SUCCESS = 'EMOJI_REACTS_FETCH_SUCCESS'; -export const EMOJI_REACTS_FETCH_FAIL = 'EMOJI_REACTS_FETCH_FAIL'; - -const noOp = () => () => new Promise(f => f()); - -export const simpleEmojiReact = (status, emoji) => { - return (dispatch, getState) => { - const emojiReacts = status.getIn(['pleroma', 'emoji_reactions'], ImmutableList()); - - if (emoji === '👍' && status.get('favourited')) return dispatch(unfavourite(status)); - - const undo = emojiReacts.filter(e => e.get('me') === true && e.get('name') === emoji).count() > 0; - if (undo) return dispatch(unEmojiReact(status, emoji)); - - return Promise.all( - emojiReacts - .filter(emojiReact => emojiReact.get('me') === true) - .map(emojiReact => dispatch(unEmojiReact(status, emojiReact.get('name')))), - status.get('favourited') && dispatch(unfavourite(status)), - ).then(() => { - if (emoji === '👍') { - dispatch(favourite(status)); - } else { - dispatch(emojiReact(status, emoji)); - } - }).catch(err => { - console.error(err); - }); - }; -}; - -export function fetchEmojiReacts(id, emoji) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return dispatch(noOp()); - - dispatch(fetchEmojiReactsRequest(id, emoji)); - - const url = emoji - ? `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` - : `/api/v1/pleroma/statuses/${id}/reactions`; - - return api(getState).get(url).then(response => { - response.data.forEach(emojiReact => { - dispatch(importFetchedAccounts(emojiReact.accounts)); - }); - dispatch(fetchEmojiReactsSuccess(id, response.data)); - }).catch(error => { - dispatch(fetchEmojiReactsFail(id, error)); - }); - }; -} - -export function emojiReact(status, emoji) { - return function(dispatch, getState) { - if (!isLoggedIn(getState)) return dispatch(noOp()); - - dispatch(emojiReactRequest(status, emoji)); - - return api(getState) - .put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`) - .then(function(response) { - dispatch(importFetchedStatus(response.data)); - dispatch(emojiReactSuccess(status, emoji)); - }).catch(function(error) { - dispatch(emojiReactFail(status, emoji, error)); - }); - }; -} - -export function unEmojiReact(status, emoji) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return dispatch(noOp()); - - dispatch(unEmojiReactRequest(status, emoji)); - - return api(getState) - .delete(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`) - .then(response => { - dispatch(importFetchedStatus(response.data)); - dispatch(unEmojiReactSuccess(status, emoji)); - }).catch(error => { - dispatch(unEmojiReactFail(status, emoji, error)); - }); - }; -} - -export function fetchEmojiReactsRequest(id, emoji) { - return { - type: EMOJI_REACTS_FETCH_REQUEST, - id, - emoji, - }; -} - -export function fetchEmojiReactsSuccess(id, emojiReacts) { - return { - type: EMOJI_REACTS_FETCH_SUCCESS, - id, - emojiReacts, - }; -} - -export function fetchEmojiReactsFail(id, error) { - return { - type: EMOJI_REACTS_FETCH_FAIL, - error, - }; -} - -export function emojiReactRequest(status, emoji) { - return { - type: EMOJI_REACT_REQUEST, - status, - emoji, - skipLoading: true, - }; -} - -export function emojiReactSuccess(status, emoji) { - return { - type: EMOJI_REACT_SUCCESS, - status, - emoji, - skipLoading: true, - }; -} - -export function emojiReactFail(status, emoji, error) { - return { - type: EMOJI_REACT_FAIL, - status, - emoji, - error, - skipLoading: true, - }; -} - -export function unEmojiReactRequest(status, emoji) { - return { - type: UNEMOJI_REACT_REQUEST, - status, - emoji, - skipLoading: true, - }; -} - -export function unEmojiReactSuccess(status, emoji) { - return { - type: UNEMOJI_REACT_SUCCESS, - status, - emoji, - skipLoading: true, - }; -} - -export function unEmojiReactFail(status, emoji, error) { - return { - type: UNEMOJI_REACT_FAIL, - status, - emoji, - error, - skipLoading: true, - }; -} diff --git a/app/soapbox/actions/emoji_reacts.ts b/app/soapbox/actions/emoji_reacts.ts new file mode 100644 index 000000000..ac205d38d --- /dev/null +++ b/app/soapbox/actions/emoji_reacts.ts @@ -0,0 +1,190 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +import { isLoggedIn } from 'soapbox/utils/auth'; + +import api from '../api'; + +import { importFetchedAccounts, importFetchedStatus } from './importer'; +import { favourite, unfavourite } from './interactions'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity, Status } from 'soapbox/types/entities'; + +const EMOJI_REACT_REQUEST = 'EMOJI_REACT_REQUEST'; +const EMOJI_REACT_SUCCESS = 'EMOJI_REACT_SUCCESS'; +const EMOJI_REACT_FAIL = 'EMOJI_REACT_FAIL'; + +const UNEMOJI_REACT_REQUEST = 'UNEMOJI_REACT_REQUEST'; +const UNEMOJI_REACT_SUCCESS = 'UNEMOJI_REACT_SUCCESS'; +const UNEMOJI_REACT_FAIL = 'UNEMOJI_REACT_FAIL'; + +const EMOJI_REACTS_FETCH_REQUEST = 'EMOJI_REACTS_FETCH_REQUEST'; +const EMOJI_REACTS_FETCH_SUCCESS = 'EMOJI_REACTS_FETCH_SUCCESS'; +const EMOJI_REACTS_FETCH_FAIL = 'EMOJI_REACTS_FETCH_FAIL'; + +const noOp = () => () => new Promise(f => f(undefined)); + +const simpleEmojiReact = (status: Status, emoji: string) => + (dispatch: AppDispatch) => { + const emojiReacts: ImmutableList> = status.pleroma.get('emoji_reactions') || ImmutableList(); + + if (emoji === '👍' && status.favourited) return dispatch(unfavourite(status)); + + const undo = emojiReacts.filter(e => e.get('me') === true && e.get('name') === emoji).count() > 0; + if (undo) return dispatch(unEmojiReact(status, emoji)); + + return Promise.all([ + ...emojiReacts + .filter((emojiReact) => emojiReact.get('me') === true) + .map(emojiReact => dispatch(unEmojiReact(status, emojiReact.get('name')))).toArray(), + status.favourited && dispatch(unfavourite(status)), + ]).then(() => { + if (emoji === '👍') { + dispatch(favourite(status)); + } else { + dispatch(emojiReact(status, emoji)); + } + }).catch(err => { + console.error(err); + }); + }; + +const fetchEmojiReacts = (id: string, emoji: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return dispatch(noOp()); + + dispatch(fetchEmojiReactsRequest(id, emoji)); + + const url = emoji + ? `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` + : `/api/v1/pleroma/statuses/${id}/reactions`; + + return api(getState).get(url).then(response => { + response.data.forEach((emojiReact: APIEntity) => { + dispatch(importFetchedAccounts(emojiReact.accounts)); + }); + dispatch(fetchEmojiReactsSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchEmojiReactsFail(id, error)); + }); + }; + +const emojiReact = (status: Status, emoji: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return dispatch(noOp()); + + dispatch(emojiReactRequest(status, emoji)); + + return api(getState) + .put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`) + .then(function(response) { + dispatch(importFetchedStatus(response.data)); + dispatch(emojiReactSuccess(status, emoji)); + }).catch(function(error) { + dispatch(emojiReactFail(status, emoji, error)); + }); + }; + +const unEmojiReact = (status: Status, emoji: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return dispatch(noOp()); + + dispatch(unEmojiReactRequest(status, emoji)); + + return api(getState) + .delete(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`) + .then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(unEmojiReactSuccess(status, emoji)); + }).catch(error => { + dispatch(unEmojiReactFail(status, emoji, error)); + }); + }; + +const fetchEmojiReactsRequest = (id: string, emoji: string) => ({ + type: EMOJI_REACTS_FETCH_REQUEST, + id, + emoji, +}); + +const fetchEmojiReactsSuccess = (id: string, emojiReacts: APIEntity[]) => ({ + type: EMOJI_REACTS_FETCH_SUCCESS, + id, + emojiReacts, +}); + +const fetchEmojiReactsFail = (id: string, error: AxiosError) => ({ + type: EMOJI_REACTS_FETCH_FAIL, + id, + error, +}); + +const emojiReactRequest = (status: Status, emoji: string) => ({ + type: EMOJI_REACT_REQUEST, + status, + emoji, + skipLoading: true, +}); + +const emojiReactSuccess = (status: Status, emoji: string) => ({ + type: EMOJI_REACT_SUCCESS, + status, + emoji, + skipLoading: true, +}); + +const emojiReactFail = (status: Status, emoji: string, error: AxiosError) => ({ + type: EMOJI_REACT_FAIL, + status, + emoji, + error, + skipLoading: true, +}); + +const unEmojiReactRequest = (status: Status, emoji: string) => ({ + type: UNEMOJI_REACT_REQUEST, + status, + emoji, + skipLoading: true, +}); + +const unEmojiReactSuccess = (status: Status, emoji: string) => ({ + type: UNEMOJI_REACT_SUCCESS, + status, + emoji, + skipLoading: true, +}); + +const unEmojiReactFail = (status: Status, emoji: string, error: AxiosError) => ({ + type: UNEMOJI_REACT_FAIL, + status, + emoji, + error, + skipLoading: true, +}); + +export { + EMOJI_REACT_REQUEST, + EMOJI_REACT_SUCCESS, + EMOJI_REACT_FAIL, + UNEMOJI_REACT_REQUEST, + UNEMOJI_REACT_SUCCESS, + UNEMOJI_REACT_FAIL, + EMOJI_REACTS_FETCH_REQUEST, + EMOJI_REACTS_FETCH_SUCCESS, + EMOJI_REACTS_FETCH_FAIL, + simpleEmojiReact, + fetchEmojiReacts, + emojiReact, + unEmojiReact, + fetchEmojiReactsRequest, + fetchEmojiReactsSuccess, + fetchEmojiReactsFail, + emojiReactRequest, + emojiReactSuccess, + emojiReactFail, + unEmojiReactRequest, + unEmojiReactSuccess, + unEmojiReactFail, +}; diff --git a/app/soapbox/actions/emojis.js b/app/soapbox/actions/emojis.js deleted file mode 100644 index 3b5d53996..000000000 --- a/app/soapbox/actions/emojis.js +++ /dev/null @@ -1,14 +0,0 @@ -import { saveSettings } from './settings'; - -export const EMOJI_USE = 'EMOJI_USE'; - -export function useEmoji(emoji) { - return dispatch => { - dispatch({ - type: EMOJI_USE, - emoji, - }); - - dispatch(saveSettings()); - }; -} diff --git a/app/soapbox/actions/emojis.ts b/app/soapbox/actions/emojis.ts new file mode 100644 index 000000000..04bda6c89 --- /dev/null +++ b/app/soapbox/actions/emojis.ts @@ -0,0 +1,21 @@ +import { saveSettings } from './settings'; + +import type { Emoji } from 'soapbox/components/autosuggest_emoji'; +import type { AppDispatch } from 'soapbox/store'; + +const EMOJI_USE = 'EMOJI_USE'; + +const useEmoji = (emoji: Emoji) => + (dispatch: AppDispatch) => { + dispatch({ + type: EMOJI_USE, + emoji, + }); + + dispatch(saveSettings()); + }; + +export { + EMOJI_USE, + useEmoji, +}; diff --git a/app/soapbox/actions/export_data.js b/app/soapbox/actions/export_data.js deleted file mode 100644 index 12e0bd58b..000000000 --- a/app/soapbox/actions/export_data.js +++ /dev/null @@ -1,104 +0,0 @@ -import { defineMessages } from 'react-intl'; - -import snackbar from 'soapbox/actions/snackbar'; - -import api, { getLinks } from '../api'; - -export const EXPORT_FOLLOWS_REQUEST = 'EXPORT_FOLLOWS_REQUEST'; -export const EXPORT_FOLLOWS_SUCCESS = 'EXPORT_FOLLOWS_SUCCESS'; -export const EXPORT_FOLLOWS_FAIL = 'EXPORT_FOLLOWS_FAIL'; - -export const EXPORT_BLOCKS_REQUEST = 'EXPORT_BLOCKS_REQUEST'; -export const EXPORT_BLOCKS_SUCCESS = 'EXPORT_BLOCKS_SUCCESS'; -export const EXPORT_BLOCKS_FAIL = 'EXPORT_BLOCKS_FAIL'; - -export const EXPORT_MUTES_REQUEST = 'EXPORT_MUTES_REQUEST'; -export const EXPORT_MUTES_SUCCESS = 'EXPORT_MUTES_SUCCESS'; -export const EXPORT_MUTES_FAIL = 'EXPORT_MUTES_FAIL'; - -const messages = defineMessages({ - blocksSuccess: { id: 'export_data.success.blocks', defaultMessage: 'Blocks exported successfully' }, - followersSuccess: { id: 'export_data.success.followers', defaultMessage: 'Followers exported successfully' }, - mutesSuccess: { id: 'export_data.success.mutes', defaultMessage: 'Mutes exported successfully' }, -}); - -function fileExport(content, fileName) { - const fileToDownload = document.createElement('a'); - - fileToDownload.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(content)); - fileToDownload.setAttribute('download', fileName); - fileToDownload.style.display = 'none'; - document.body.appendChild(fileToDownload); - fileToDownload.click(); - document.body.removeChild(fileToDownload); -} - -function listAccounts(state) { - return async apiResponse => { - const followings = apiResponse.data; - let accounts = []; - let next = getLinks(apiResponse).refs.find(link => link.rel === 'next'); - while (next) { - apiResponse = await api(state).get(next.uri); - next = getLinks(apiResponse).refs.find(link => link.rel === 'next'); - Array.prototype.push.apply(followings, apiResponse.data); - } - - accounts = followings.map(account => account.fqn); - return [... new Set(accounts)]; - }; -} - -export function exportFollows(intl) { - return (dispatch, getState) => { - dispatch({ type: EXPORT_FOLLOWS_REQUEST }); - const me = getState().get('me'); - return api(getState) - .get(`/api/v1/accounts/${me}/following?limit=40`) - .then(listAccounts(getState)) - .then((followings) => { - followings = followings.map(fqn => fqn + ',true'); - followings.unshift('Account address,Show boosts'); - fileExport(followings.join('\n'), 'export_followings.csv'); - - dispatch(snackbar.success(intl.formatMessage(messages.followersSuccess))); - dispatch({ type: EXPORT_FOLLOWS_SUCCESS }); - }).catch(error => { - dispatch({ type: EXPORT_FOLLOWS_FAIL, error }); - }); - }; -} - -export function exportBlocks(intl) { - return (dispatch, getState) => { - dispatch({ type: EXPORT_BLOCKS_REQUEST }); - return api(getState) - .get('/api/v1/blocks?limit=40') - .then(listAccounts(getState)) - .then((blocks) => { - fileExport(blocks.join('\n'), 'export_block.csv'); - - dispatch(snackbar.success(intl.formatMessage(messages.blocksSuccess))); - dispatch({ type: EXPORT_BLOCKS_SUCCESS }); - }).catch(error => { - dispatch({ type: EXPORT_BLOCKS_FAIL, error }); - }); - }; -} - -export function exportMutes(intl) { - return (dispatch, getState) => { - dispatch({ type: EXPORT_MUTES_REQUEST }); - return api(getState) - .get('/api/v1/mutes?limit=40') - .then(listAccounts(getState)) - .then((mutes) => { - fileExport(mutes.join('\n'), 'export_mutes.csv'); - - dispatch(snackbar.success(intl.formatMessage(messages.mutesSuccess))); - dispatch({ type: EXPORT_MUTES_SUCCESS }); - }).catch(error => { - dispatch({ type: EXPORT_MUTES_FAIL, error }); - }); - }; -} diff --git a/app/soapbox/actions/export_data.ts b/app/soapbox/actions/export_data.ts new file mode 100644 index 000000000..b558c9e6e --- /dev/null +++ b/app/soapbox/actions/export_data.ts @@ -0,0 +1,113 @@ +import { defineMessages } from 'react-intl'; + +import snackbar from 'soapbox/actions/snackbar'; +import api, { getLinks } from 'soapbox/api'; +import { normalizeAccount } from 'soapbox/normalizers'; + +import type { SnackbarAction } from './snackbar'; +import type { AxiosResponse } from 'axios'; +import type { RootState } from 'soapbox/store'; + +export const EXPORT_FOLLOWS_REQUEST = 'EXPORT_FOLLOWS_REQUEST'; +export const EXPORT_FOLLOWS_SUCCESS = 'EXPORT_FOLLOWS_SUCCESS'; +export const EXPORT_FOLLOWS_FAIL = 'EXPORT_FOLLOWS_FAIL'; + +export const EXPORT_BLOCKS_REQUEST = 'EXPORT_BLOCKS_REQUEST'; +export const EXPORT_BLOCKS_SUCCESS = 'EXPORT_BLOCKS_SUCCESS'; +export const EXPORT_BLOCKS_FAIL = 'EXPORT_BLOCKS_FAIL'; + +export const EXPORT_MUTES_REQUEST = 'EXPORT_MUTES_REQUEST'; +export const EXPORT_MUTES_SUCCESS = 'EXPORT_MUTES_SUCCESS'; +export const EXPORT_MUTES_FAIL = 'EXPORT_MUTES_FAIL'; + +const messages = defineMessages({ + blocksSuccess: { id: 'export_data.success.blocks', defaultMessage: 'Blocks exported successfully' }, + followersSuccess: { id: 'export_data.success.followers', defaultMessage: 'Followers exported successfully' }, + mutesSuccess: { id: 'export_data.success.mutes', defaultMessage: 'Mutes exported successfully' }, +}); + +type ExportDataActions = { + type: typeof EXPORT_FOLLOWS_REQUEST + | typeof EXPORT_FOLLOWS_SUCCESS + | typeof EXPORT_FOLLOWS_FAIL + | typeof EXPORT_BLOCKS_REQUEST + | typeof EXPORT_BLOCKS_SUCCESS + | typeof EXPORT_BLOCKS_FAIL + | typeof EXPORT_MUTES_REQUEST + | typeof EXPORT_MUTES_SUCCESS + | typeof EXPORT_MUTES_FAIL, + error?: any, +} | SnackbarAction + +function fileExport(content: string, fileName: string) { + const fileToDownload = document.createElement('a'); + + fileToDownload.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(content)); + fileToDownload.setAttribute('download', fileName); + fileToDownload.style.display = 'none'; + document.body.appendChild(fileToDownload); + fileToDownload.click(); + document.body.removeChild(fileToDownload); +} + +const listAccounts = (getState: () => RootState) => async(apiResponse: AxiosResponse) => { + const followings = apiResponse.data; + let accounts = []; + let next = getLinks(apiResponse).refs.find(link => link.rel === 'next'); + while (next) { + apiResponse = await api(getState).get(next.uri); + next = getLinks(apiResponse).refs.find(link => link.rel === 'next'); + Array.prototype.push.apply(followings, apiResponse.data); + } + + accounts = followings.map((account: any) => normalizeAccount(account).fqn); + return Array.from(new Set(accounts)); +}; + +export const exportFollows = () => (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch({ type: EXPORT_FOLLOWS_REQUEST }); + const me = getState().me; + return api(getState) + .get(`/api/v1/accounts/${me}/following?limit=40`) + .then(listAccounts(getState)) + .then((followings) => { + followings = followings.map(fqn => fqn + ',true'); + followings.unshift('Account address,Show boosts'); + fileExport(followings.join('\n'), 'export_followings.csv'); + + dispatch(snackbar.success(messages.followersSuccess)); + dispatch({ type: EXPORT_FOLLOWS_SUCCESS }); + }).catch(error => { + dispatch({ type: EXPORT_FOLLOWS_FAIL, error }); + }); +}; + +export const exportBlocks = () => (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch({ type: EXPORT_BLOCKS_REQUEST }); + return api(getState) + .get('/api/v1/blocks?limit=40') + .then(listAccounts(getState)) + .then((blocks) => { + fileExport(blocks.join('\n'), 'export_block.csv'); + + dispatch(snackbar.success(messages.blocksSuccess)); + dispatch({ type: EXPORT_BLOCKS_SUCCESS }); + }).catch(error => { + dispatch({ type: EXPORT_BLOCKS_FAIL, error }); + }); +}; + +export const exportMutes = () => (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch({ type: EXPORT_MUTES_REQUEST }); + return api(getState) + .get('/api/v1/mutes?limit=40') + .then(listAccounts(getState)) + .then((mutes) => { + fileExport(mutes.join('\n'), 'export_mutes.csv'); + + dispatch(snackbar.success(messages.mutesSuccess)); + dispatch({ type: EXPORT_MUTES_SUCCESS }); + }).catch(error => { + dispatch({ type: EXPORT_MUTES_FAIL, error }); + }); +}; diff --git a/app/soapbox/actions/external_auth.js b/app/soapbox/actions/external_auth.ts similarity index 63% rename from app/soapbox/actions/external_auth.js rename to app/soapbox/actions/external_auth.ts index 4f389667f..064e100c9 100644 --- a/app/soapbox/actions/external_auth.js +++ b/app/soapbox/actions/external_auth.ts @@ -18,7 +18,10 @@ import { getQuirks } from 'soapbox/utils/quirks'; import { baseClient } from '../api'; -const fetchExternalInstance = baseURL => { +import type { AppDispatch } from 'soapbox/store'; +import type { Instance } from 'soapbox/types/entities'; + +const fetchExternalInstance = (baseURL?: string) => { return baseClient(null, baseURL) .get('/api/v1/instance') .then(({ data: instance }) => normalizeInstance(instance)) @@ -33,8 +36,8 @@ const fetchExternalInstance = baseURL => { }); }; -function createExternalApp(instance, baseURL) { - return (dispatch, getState) => { +const createExternalApp = (instance: Instance, baseURL?: string) => + (dispatch: AppDispatch) => { // Mitra: skip creating the auth app if (getQuirks(instance).noApps) return new Promise(f => f({})); @@ -49,14 +52,13 @@ function createExternalApp(instance, baseURL) { return dispatch(createApp(params, baseURL)); }; -} -function externalAuthorize(instance, baseURL) { - return (dispatch, getState) => { +const externalAuthorize = (instance: Instance, baseURL: string) => + (dispatch: AppDispatch) => { const { scopes } = getFeatures(instance); - return dispatch(createExternalApp(instance, baseURL)).then(app => { - const { client_id, redirect_uri } = app; + return dispatch(createExternalApp(instance, baseURL)).then((app) => { + const { client_id, redirect_uri } = app as Record; const query = new URLSearchParams({ client_id, @@ -72,58 +74,56 @@ function externalAuthorize(instance, baseURL) { window.location.href = `${baseURL}/oauth/authorize?${query.toString()}`; }); }; -} -export function externalEthereumLogin(instance, baseURL) { - return (dispatch, getState) => { - const loginMessage = instance.get('login_message'); +const externalEthereumLogin = (instance: Instance, baseURL?: string) => + (dispatch: AppDispatch) => { + const loginMessage = instance.login_message; return getWalletAndSign(loginMessage).then(({ wallet, signature }) => { - return dispatch(createExternalApp(instance, baseURL)).then(app => { + return dispatch(createExternalApp(instance, baseURL)).then((app) => { + const { client_id, client_secret } = app as Record; const params = { grant_type: 'ethereum', wallet_address: wallet.toLowerCase(), - client_id: app.client_id, - client_secret: app.client_secret, - password: signature, + client_id: client_id, + client_secret: client_secret, + password: signature as string, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', scope: getFeatures(instance).scopes, }; return dispatch(obtainOAuthToken(params, baseURL)) - .then(token => dispatch(authLoggedIn(token))) - .then(({ access_token }) => dispatch(verifyCredentials(access_token, baseURL))) - .then(account => dispatch(switchAccount(account.id))) + .then((token: Record) => dispatch(authLoggedIn(token))) + .then(({ access_token }: any) => dispatch(verifyCredentials(access_token, baseURL))) + .then((account: { id: string }) => dispatch(switchAccount(account.id))) .then(() => window.location.href = '/'); }); }); }; -} -export function externalLogin(host) { - return (dispatch, getState) => { +export const externalLogin = (host: string) => + (dispatch: AppDispatch) => { const baseURL = parseBaseURL(host) || parseBaseURL(`https://${host}`); - return fetchExternalInstance(baseURL).then(instance => { + return fetchExternalInstance(baseURL).then((instance) => { const features = getFeatures(instance); const quirks = getQuirks(instance); if (features.ethereumLogin && quirks.noOAuthForm) { - return dispatch(externalEthereumLogin(instance, baseURL)); + dispatch(externalEthereumLogin(instance, baseURL)); } else { - return dispatch(externalAuthorize(instance, baseURL)); + dispatch(externalAuthorize(instance, baseURL)); } }); }; -} -export function loginWithCode(code) { - return (dispatch, getState) => { - const { client_id, client_secret, redirect_uri } = JSON.parse(localStorage.getItem('soapbox:external:app')); - const baseURL = localStorage.getItem('soapbox:external:baseurl'); - const scope = localStorage.getItem('soapbox:external:scopes'); +export const loginWithCode = (code: string) => + (dispatch: AppDispatch) => { + const { client_id, client_secret, redirect_uri } = JSON.parse(localStorage.getItem('soapbox:external:app')!); + const baseURL = localStorage.getItem('soapbox:external:baseurl')!; + const scope = localStorage.getItem('soapbox:external:scopes')!; - const params = { + const params: Record = { client_id, client_secret, redirect_uri, @@ -133,9 +133,8 @@ export function loginWithCode(code) { }; return dispatch(obtainOAuthToken(params, baseURL)) - .then(token => dispatch(authLoggedIn(token))) - .then(({ access_token }) => dispatch(verifyCredentials(access_token, baseURL))) - .then(account => dispatch(switchAccount(account.id))) + .then((token: Record) => dispatch(authLoggedIn(token))) + .then(({ access_token }: any) => dispatch(verifyCredentials(access_token as string, baseURL))) + .then((account: { id: string }) => dispatch(switchAccount(account.id))) .then(() => window.location.href = '/'); }; -} diff --git a/app/soapbox/actions/familiar_followers.ts b/app/soapbox/actions/familiar_followers.ts new file mode 100644 index 000000000..ec6eca6d8 --- /dev/null +++ b/app/soapbox/actions/familiar_followers.ts @@ -0,0 +1,59 @@ +import { RootState } from 'soapbox/store'; + +import api from '../api'; + +import { ACCOUNTS_IMPORT, importFetchedAccounts } from './importer'; + +import type { APIEntity } from 'soapbox/types/entities'; + +export const FAMILIAR_FOLLOWERS_FETCH_REQUEST = 'FAMILIAR_FOLLOWERS_FETCH_REQUEST'; +export const FAMILIAR_FOLLOWERS_FETCH_SUCCESS = 'FAMILIAR_FOLLOWERS_FETCH_SUCCESS'; +export const FAMILIAR_FOLLOWERS_FETCH_FAIL = 'FAMILIAR_FOLLOWERS_FETCH_FAIL'; + +type FamiliarFollowersFetchRequestAction = { + type: typeof FAMILIAR_FOLLOWERS_FETCH_REQUEST, + id: string, +} + +type FamiliarFollowersFetchRequestSuccessAction = { + type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS, + id: string, + accounts: Array, +} + +type FamiliarFollowersFetchRequestFailAction = { + type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL, + id: string, + error: any, +} + +type AccountsImportAction = { + type: typeof ACCOUNTS_IMPORT, + accounts: Array, +} + +export type FamiliarFollowersActions = FamiliarFollowersFetchRequestAction | FamiliarFollowersFetchRequestSuccessAction | FamiliarFollowersFetchRequestFailAction | AccountsImportAction + +export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch({ + type: FAMILIAR_FOLLOWERS_FETCH_REQUEST, + id: accountId, + }); + + api(getState).get(`/api/v1/accounts/familiar_followers?id=${accountId}`) + .then(({ data }) => { + const accounts = data.find(({ id }: { id: string }) => id === accountId).accounts; + + dispatch(importFetchedAccounts(accounts) as AccountsImportAction); + dispatch({ + type: FAMILIAR_FOLLOWERS_FETCH_SUCCESS, + id: accountId, + accounts, + }); + }) + .catch(error => dispatch({ + type: FAMILIAR_FOLLOWERS_FETCH_FAIL, + id: accountId, + error, + })); +}; diff --git a/app/soapbox/actions/favourites.js b/app/soapbox/actions/favourites.js deleted file mode 100644 index c4bce15e9..000000000 --- a/app/soapbox/actions/favourites.js +++ /dev/null @@ -1,201 +0,0 @@ -import { isLoggedIn } from 'soapbox/utils/auth'; - -import api, { getLinks } from '../api'; - -import { importFetchedStatuses } from './importer'; - -export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; -export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; -export const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL'; - -export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST'; -export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS'; -export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL'; - -export const ACCOUNT_FAVOURITED_STATUSES_FETCH_REQUEST = 'ACCOUNT_FAVOURITED_STATUSES_FETCH_REQUEST'; -export const ACCOUNT_FAVOURITED_STATUSES_FETCH_SUCCESS = 'ACCOUNT_FAVOURITED_STATUSES_FETCH_SUCCESS'; -export const ACCOUNT_FAVOURITED_STATUSES_FETCH_FAIL = 'ACCOUNT_FAVOURITED_STATUSES_FETCH_FAIL'; - -export const ACCOUNT_FAVOURITED_STATUSES_EXPAND_REQUEST = 'ACCOUNT_FAVOURITED_STATUSES_EXPAND_REQUEST'; -export const ACCOUNT_FAVOURITED_STATUSES_EXPAND_SUCCESS = 'ACCOUNT_FAVOURITED_STATUSES_EXPAND_SUCCESS'; -export const ACCOUNT_FAVOURITED_STATUSES_EXPAND_FAIL = 'ACCOUNT_FAVOURITED_STATUSES_EXPAND_FAIL'; - -export function fetchFavouritedStatuses() { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - if (getState().getIn(['status_lists', 'favourites', 'isLoading'])) { - return; - } - - dispatch(fetchFavouritedStatusesRequest()); - - api(getState).get('/api/v1/favourites').then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedStatuses(response.data)); - dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); - }).catch(error => { - dispatch(fetchFavouritedStatusesFail(error)); - }); - }; -} - -export function fetchFavouritedStatusesRequest() { - return { - type: FAVOURITED_STATUSES_FETCH_REQUEST, - skipLoading: true, - }; -} - -export function fetchFavouritedStatusesSuccess(statuses, next) { - return { - type: FAVOURITED_STATUSES_FETCH_SUCCESS, - statuses, - next, - skipLoading: true, - }; -} - -export function fetchFavouritedStatusesFail(error) { - return { - type: FAVOURITED_STATUSES_FETCH_FAIL, - error, - skipLoading: true, - }; -} - -export function expandFavouritedStatuses() { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - const url = getState().getIn(['status_lists', 'favourites', 'next'], null); - - if (url === null || getState().getIn(['status_lists', 'favourites', 'isLoading'])) { - return; - } - - dispatch(expandFavouritedStatusesRequest()); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedStatuses(response.data)); - dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); - }).catch(error => { - dispatch(expandFavouritedStatusesFail(error)); - }); - }; -} - -export function expandFavouritedStatusesRequest() { - return { - type: FAVOURITED_STATUSES_EXPAND_REQUEST, - }; -} - -export function expandFavouritedStatusesSuccess(statuses, next) { - return { - type: FAVOURITED_STATUSES_EXPAND_SUCCESS, - statuses, - next, - }; -} - -export function expandFavouritedStatusesFail(error) { - return { - type: FAVOURITED_STATUSES_EXPAND_FAIL, - error, - }; -} - -export function fetchAccountFavouritedStatuses(accountId) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - if (getState().getIn(['status_lists', `favourites:${accountId}`, 'isLoading'])) { - return; - } - - dispatch(fetchAccountFavouritedStatusesRequest(accountId)); - - api(getState).get(`/api/v1/pleroma/accounts/${accountId}/favourites`).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedStatuses(response.data)); - dispatch(fetchAccountFavouritedStatusesSuccess(accountId, response.data, next ? next.uri : null)); - }).catch(error => { - dispatch(fetchAccountFavouritedStatusesFail(accountId, error)); - }); - }; -} - -export function fetchAccountFavouritedStatusesRequest(accountId) { - return { - type: ACCOUNT_FAVOURITED_STATUSES_FETCH_REQUEST, - accountId, - skipLoading: true, - }; -} - -export function fetchAccountFavouritedStatusesSuccess(accountId, statuses, next) { - return { - type: ACCOUNT_FAVOURITED_STATUSES_FETCH_SUCCESS, - accountId, - statuses, - next, - skipLoading: true, - }; -} - -export function fetchAccountFavouritedStatusesFail(accountId, error) { - return { - type: ACCOUNT_FAVOURITED_STATUSES_FETCH_FAIL, - accountId, - error, - skipLoading: true, - }; -} - -export function expandAccountFavouritedStatuses(accountId) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - const url = getState().getIn(['status_lists', `favourites:${accountId}`, 'next'], null); - - if (url === null || getState().getIn(['status_lists', `favourites:${accountId}`, 'isLoading'])) { - return; - } - - dispatch(expandAccountFavouritedStatusesRequest(accountId)); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedStatuses(response.data)); - dispatch(expandAccountFavouritedStatusesSuccess(accountId, response.data, next ? next.uri : null)); - }).catch(error => { - dispatch(expandAccountFavouritedStatusesFail(accountId, error)); - }); - }; -} - -export function expandAccountFavouritedStatusesRequest(accountId) { - return { - type: ACCOUNT_FAVOURITED_STATUSES_EXPAND_REQUEST, - accountId, - }; -} - -export function expandAccountFavouritedStatusesSuccess(accountId, statuses, next) { - return { - type: ACCOUNT_FAVOURITED_STATUSES_EXPAND_SUCCESS, - accountId, - statuses, - next, - }; -} - -export function expandAccountFavouritedStatusesFail(accountId, error) { - return { - type: ACCOUNT_FAVOURITED_STATUSES_EXPAND_FAIL, - accountId, - error, - }; -} diff --git a/app/soapbox/actions/favourites.ts b/app/soapbox/actions/favourites.ts new file mode 100644 index 000000000..da627ef73 --- /dev/null +++ b/app/soapbox/actions/favourites.ts @@ -0,0 +1,208 @@ +import { isLoggedIn } from 'soapbox/utils/auth'; + +import api, { getLinks } from '../api'; + +import { importFetchedStatuses } from './importer'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; +const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; +const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL'; + +const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST'; +const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS'; +const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL'; + +const ACCOUNT_FAVOURITED_STATUSES_FETCH_REQUEST = 'ACCOUNT_FAVOURITED_STATUSES_FETCH_REQUEST'; +const ACCOUNT_FAVOURITED_STATUSES_FETCH_SUCCESS = 'ACCOUNT_FAVOURITED_STATUSES_FETCH_SUCCESS'; +const ACCOUNT_FAVOURITED_STATUSES_FETCH_FAIL = 'ACCOUNT_FAVOURITED_STATUSES_FETCH_FAIL'; + +const ACCOUNT_FAVOURITED_STATUSES_EXPAND_REQUEST = 'ACCOUNT_FAVOURITED_STATUSES_EXPAND_REQUEST'; +const ACCOUNT_FAVOURITED_STATUSES_EXPAND_SUCCESS = 'ACCOUNT_FAVOURITED_STATUSES_EXPAND_SUCCESS'; +const ACCOUNT_FAVOURITED_STATUSES_EXPAND_FAIL = 'ACCOUNT_FAVOURITED_STATUSES_EXPAND_FAIL'; + +const fetchFavouritedStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + if (getState().status_lists.get('favourites')?.isLoading) { + return; + } + + dispatch(fetchFavouritedStatusesRequest()); + + api(getState).get('/api/v1/favourites').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchFavouritedStatusesFail(error)); + }); + }; + +const fetchFavouritedStatusesRequest = () => ({ + type: FAVOURITED_STATUSES_FETCH_REQUEST, + skipLoading: true, +}); + +const fetchFavouritedStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: FAVOURITED_STATUSES_FETCH_SUCCESS, + statuses, + next, + skipLoading: true, +}); + +const fetchFavouritedStatusesFail = (error: AxiosError) => ({ + type: FAVOURITED_STATUSES_FETCH_FAIL, + error, + skipLoading: true, +}); + +const expandFavouritedStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const url = getState().status_lists.get('favourites')?.next || null; + + if (url === null || getState().status_lists.get('favourites')?.isLoading) { + return; + } + + dispatch(expandFavouritedStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandFavouritedStatusesFail(error)); + }); + }; + +const expandFavouritedStatusesRequest = () => ({ + type: FAVOURITED_STATUSES_EXPAND_REQUEST, +}); + +const expandFavouritedStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: FAVOURITED_STATUSES_EXPAND_SUCCESS, + statuses, + next, +}); + +const expandFavouritedStatusesFail = (error: AxiosError) => ({ + type: FAVOURITED_STATUSES_EXPAND_FAIL, + error, +}); + +const fetchAccountFavouritedStatuses = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + if (getState().status_lists.get(`favourites:${accountId}`)?.isLoading) { + return; + } + + dispatch(fetchAccountFavouritedStatusesRequest(accountId)); + + api(getState).get(`/api/v1/pleroma/accounts/${accountId}/favourites`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchAccountFavouritedStatusesSuccess(accountId, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchAccountFavouritedStatusesFail(accountId, error)); + }); + }; + +const fetchAccountFavouritedStatusesRequest = (accountId: string) => ({ + type: ACCOUNT_FAVOURITED_STATUSES_FETCH_REQUEST, + accountId, + skipLoading: true, +}); + +const fetchAccountFavouritedStatusesSuccess = (accountId: string, statuses: APIEntity, next: string | null) => ({ + type: ACCOUNT_FAVOURITED_STATUSES_FETCH_SUCCESS, + accountId, + statuses, + next, + skipLoading: true, +}); + +const fetchAccountFavouritedStatusesFail = (accountId: string, error: AxiosError) => ({ + type: ACCOUNT_FAVOURITED_STATUSES_FETCH_FAIL, + accountId, + error, + skipLoading: true, +}); + +const expandAccountFavouritedStatuses = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const url = getState().status_lists.get(`favourites:${accountId}`)?.next || null; + + if (url === null || getState().status_lists.get(`favourites:${accountId}`)?.isLoading) { + return; + } + + dispatch(expandAccountFavouritedStatusesRequest(accountId)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandAccountFavouritedStatusesSuccess(accountId, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandAccountFavouritedStatusesFail(accountId, error)); + }); + }; + +const expandAccountFavouritedStatusesRequest = (accountId: string) => ({ + type: ACCOUNT_FAVOURITED_STATUSES_EXPAND_REQUEST, + accountId, +}); + +const expandAccountFavouritedStatusesSuccess = (accountId: string, statuses: APIEntity[], next: string | null) => ({ + type: ACCOUNT_FAVOURITED_STATUSES_EXPAND_SUCCESS, + accountId, + statuses, + next, +}); + +const expandAccountFavouritedStatusesFail = (accountId: string, error: AxiosError) => ({ + type: ACCOUNT_FAVOURITED_STATUSES_EXPAND_FAIL, + accountId, + error, +}); + +export { + FAVOURITED_STATUSES_FETCH_REQUEST, + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_FETCH_FAIL, + FAVOURITED_STATUSES_EXPAND_REQUEST, + FAVOURITED_STATUSES_EXPAND_SUCCESS, + FAVOURITED_STATUSES_EXPAND_FAIL, + ACCOUNT_FAVOURITED_STATUSES_FETCH_REQUEST, + ACCOUNT_FAVOURITED_STATUSES_FETCH_SUCCESS, + ACCOUNT_FAVOURITED_STATUSES_FETCH_FAIL, + ACCOUNT_FAVOURITED_STATUSES_EXPAND_REQUEST, + ACCOUNT_FAVOURITED_STATUSES_EXPAND_SUCCESS, + ACCOUNT_FAVOURITED_STATUSES_EXPAND_FAIL, + fetchFavouritedStatuses, + fetchFavouritedStatusesRequest, + fetchFavouritedStatusesSuccess, + fetchFavouritedStatusesFail, + expandFavouritedStatuses, + expandFavouritedStatusesRequest, + expandFavouritedStatusesSuccess, + expandFavouritedStatusesFail, + fetchAccountFavouritedStatuses, + fetchAccountFavouritedStatusesRequest, + fetchAccountFavouritedStatusesSuccess, + fetchAccountFavouritedStatusesFail, + expandAccountFavouritedStatuses, + expandAccountFavouritedStatusesRequest, + expandAccountFavouritedStatusesSuccess, + expandAccountFavouritedStatusesFail, +}; diff --git a/app/soapbox/actions/filters.js b/app/soapbox/actions/filters.js deleted file mode 100644 index e6af25f9c..000000000 --- a/app/soapbox/actions/filters.js +++ /dev/null @@ -1,77 +0,0 @@ -import { defineMessages } from 'react-intl'; - -import snackbar from 'soapbox/actions/snackbar'; -import { isLoggedIn } from 'soapbox/utils/auth'; - -import api from '../api'; - -export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; -export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; -export const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; - -export const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; -export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; -export const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL'; - -export const FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST'; -export const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS'; -export const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL'; - -const messages = defineMessages({ - added: { id: 'filters.added', defaultMessage: 'Filter added.' }, - removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' }, -}); - -export const fetchFilters = () => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch({ - type: FILTERS_FETCH_REQUEST, - skipLoading: true, - }); - - api(getState) - .get('/api/v1/filters') - .then(({ data }) => dispatch({ - type: FILTERS_FETCH_SUCCESS, - filters: data, - skipLoading: true, - })) - .catch(err => dispatch({ - type: FILTERS_FETCH_FAIL, - err, - skipLoading: true, - skipAlert: true, - })); -}; - -export function createFilter(intl, phrase, expires_at, context, whole_word, irreversible) { - return (dispatch, getState) => { - dispatch({ type: FILTERS_CREATE_REQUEST }); - return api(getState).post('/api/v1/filters', { - phrase, - context, - irreversible, - whole_word, - expires_at, - }).then(response => { - dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); - dispatch(snackbar.success(intl.formatMessage(messages.added))); - }).catch(error => { - dispatch({ type: FILTERS_CREATE_FAIL, error }); - }); - }; -} - - -export function deleteFilter(intl, id) { - return (dispatch, getState) => { - 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(intl.formatMessage(messages.removed))); - }).catch(error => { - dispatch({ type: FILTERS_DELETE_FAIL, error }); - }); - }; -} diff --git a/app/soapbox/actions/filters.ts b/app/soapbox/actions/filters.ts new file mode 100644 index 000000000..c0f79c6b8 --- /dev/null +++ b/app/soapbox/actions/filters.ts @@ -0,0 +1,99 @@ +import { defineMessages } from 'react-intl'; + +import snackbar from 'soapbox/actions/snackbar'; +import { isLoggedIn } from 'soapbox/utils/auth'; +import { getFeatures } from 'soapbox/utils/features'; + +import api from '../api'; + +import type { AppDispatch, RootState } from 'soapbox/store'; + +const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; +const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; +const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; + +const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; +const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; +const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL'; + +const FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST'; +const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS'; +const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL'; + +const messages = defineMessages({ + added: { id: 'filters.added', defaultMessage: 'Filter added.' }, + removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' }, +}); + +const fetchFilters = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.filters) return; + + dispatch({ + type: FILTERS_FETCH_REQUEST, + skipLoading: true, + }); + + api(getState) + .get('/api/v1/filters') + .then(({ data }) => dispatch({ + type: FILTERS_FETCH_SUCCESS, + filters: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTERS_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); + }; + +const createFilter = (phrase: string, expires_at: string, context: Array, whole_word: boolean, irreversible: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_CREATE_REQUEST }); + return api(getState).post('/api/v1/filters', { + phrase, + context, + irreversible, + whole_word, + expires_at, + }).then(response => { + dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); + dispatch(snackbar.success(messages.added)); + }).catch(error => { + dispatch({ type: FILTERS_CREATE_FAIL, error }); + }); + }; + +const deleteFilter = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + 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)); + }).catch(error => { + dispatch({ type: FILTERS_DELETE_FAIL, error }); + }); + }; + +export { + FILTERS_FETCH_REQUEST, + FILTERS_FETCH_SUCCESS, + FILTERS_FETCH_FAIL, + FILTERS_CREATE_REQUEST, + FILTERS_CREATE_SUCCESS, + FILTERS_CREATE_FAIL, + FILTERS_DELETE_REQUEST, + FILTERS_DELETE_SUCCESS, + FILTERS_DELETE_FAIL, + fetchFilters, + createFilter, + deleteFilter, +}; \ No newline at end of file diff --git a/app/soapbox/actions/group_editor.js b/app/soapbox/actions/group_editor.js deleted file mode 100644 index f504a743a..000000000 --- a/app/soapbox/actions/group_editor.js +++ /dev/null @@ -1,114 +0,0 @@ -import { isLoggedIn } from 'soapbox/utils/auth'; - -import api from '../api'; - -export const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST'; -export const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS'; -export const GROUP_CREATE_FAIL = 'GROUP_CREATE_FAIL'; - -export const GROUP_UPDATE_REQUEST = 'GROUP_UPDATE_REQUEST'; -export const GROUP_UPDATE_SUCCESS = 'GROUP_UPDATE_SUCCESS'; -export const GROUP_UPDATE_FAIL = 'GROUP_UPDATE_FAIL'; - -export const GROUP_EDITOR_VALUE_CHANGE = 'GROUP_EDITOR_VALUE_CHANGE'; -export const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET'; -export const GROUP_EDITOR_SETUP = 'GROUP_EDITOR_SETUP'; - -export const submit = (routerHistory) => (dispatch, getState) => { - const groupId = getState().getIn(['group_editor', 'groupId']); - const title = getState().getIn(['group_editor', 'title']); - const description = getState().getIn(['group_editor', 'description']); - const coverImage = getState().getIn(['group_editor', 'coverImage']); - - if (groupId === null) { - dispatch(create(title, description, coverImage, routerHistory)); - } else { - dispatch(update(groupId, title, description, coverImage, routerHistory)); - } -}; - - -export const create = (title, description, coverImage, routerHistory) => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(createRequest()); - - const formData = new FormData(); - formData.append('title', title); - formData.append('description', description); - - if (coverImage !== null) { - formData.append('cover_image', coverImage); - } - - api(getState).post('/api/v1/groups', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(({ data }) => { - dispatch(createSuccess(data)); - routerHistory.push(`/groups/${data.id}`); - }).catch(err => dispatch(createFail(err))); -}; - - -export const createRequest = id => ({ - type: GROUP_CREATE_REQUEST, - id, -}); - -export const createSuccess = group => ({ - type: GROUP_CREATE_SUCCESS, - group, -}); - -export const createFail = error => ({ - type: GROUP_CREATE_FAIL, - error, -}); - -export const update = (groupId, title, description, coverImage, routerHistory) => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(updateRequest()); - - const formData = new FormData(); - formData.append('title', title); - formData.append('description', description); - - if (coverImage !== null) { - formData.append('cover_image', coverImage); - } - - api(getState).put(`/api/v1/groups/${groupId}`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(({ data }) => { - dispatch(updateSuccess(data)); - routerHistory.push(`/groups/${data.id}`); - }).catch(err => dispatch(updateFail(err))); -}; - - -export const updateRequest = id => ({ - type: GROUP_UPDATE_REQUEST, - id, -}); - -export const updateSuccess = group => ({ - type: GROUP_UPDATE_SUCCESS, - group, -}); - -export const updateFail = error => ({ - type: GROUP_UPDATE_FAIL, - error, -}); - -export const changeValue = (field, value) => ({ - type: GROUP_EDITOR_VALUE_CHANGE, - field, - value, -}); - -export const reset = () => ({ - type: GROUP_EDITOR_RESET, -}); - -export const setUp = (group) => ({ - type: GROUP_EDITOR_SETUP, - group, -}); diff --git a/app/soapbox/actions/group_editor.ts b/app/soapbox/actions/group_editor.ts new file mode 100644 index 000000000..23f3491ad --- /dev/null +++ b/app/soapbox/actions/group_editor.ts @@ -0,0 +1,143 @@ +import { isLoggedIn } from 'soapbox/utils/auth'; + +import api from '../api'; + +import type { AxiosError } from 'axios'; +import type { History } from 'history'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST'; +const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS'; +const GROUP_CREATE_FAIL = 'GROUP_CREATE_FAIL'; + +const GROUP_UPDATE_REQUEST = 'GROUP_UPDATE_REQUEST'; +const GROUP_UPDATE_SUCCESS = 'GROUP_UPDATE_SUCCESS'; +const GROUP_UPDATE_FAIL = 'GROUP_UPDATE_FAIL'; + +const GROUP_EDITOR_VALUE_CHANGE = 'GROUP_EDITOR_VALUE_CHANGE'; +const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET'; +const GROUP_EDITOR_SETUP = 'GROUP_EDITOR_SETUP'; + +const submit = (routerHistory: History) => + (dispatch: AppDispatch, getState: () => RootState) => { + const groupId = getState().group_editor.get('groupId') as string; + const title = getState().group_editor.get('title') as string; + const description = getState().group_editor.get('description') as string; + const coverImage = getState().group_editor.get('coverImage') as any; + + if (groupId === null) { + dispatch(create(title, description, coverImage, routerHistory)); + } else { + dispatch(update(groupId, title, description, coverImage, routerHistory)); + } + }; + +const create = (title: string, description: string, coverImage: File, routerHistory: History) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(createRequest()); + + const formData = new FormData(); + formData.append('title', title); + formData.append('description', description); + + if (coverImage !== null) { + formData.append('cover_image', coverImage); + } + + api(getState).post('/api/v1/groups', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(({ data }) => { + dispatch(createSuccess(data)); + routerHistory.push(`/groups/${data.id}`); + }).catch(err => dispatch(createFail(err))); + }; + +const createRequest = (id?: string) => ({ + type: GROUP_CREATE_REQUEST, + id, +}); + +const createSuccess = (group: APIEntity) => ({ + type: GROUP_CREATE_SUCCESS, + group, +}); + +const createFail = (error: AxiosError) => ({ + type: GROUP_CREATE_FAIL, + error, +}); + +const update = (groupId: string, title: string, description: string, coverImage: File, routerHistory: History) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(updateRequest(groupId)); + + const formData = new FormData(); + formData.append('title', title); + formData.append('description', description); + + if (coverImage !== null) { + formData.append('cover_image', coverImage); + } + + api(getState).put(`/api/v1/groups/${groupId}`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(({ data }) => { + dispatch(updateSuccess(data)); + routerHistory.push(`/groups/${data.id}`); + }).catch(err => dispatch(updateFail(err))); + }; + +const updateRequest = (id: string) => ({ + type: GROUP_UPDATE_REQUEST, + id, +}); + +const updateSuccess = (group: APIEntity) => ({ + type: GROUP_UPDATE_SUCCESS, + group, +}); + +const updateFail = (error: AxiosError) => ({ + type: GROUP_UPDATE_FAIL, + error, +}); + +const changeValue = (field: string, value: string | File) => ({ + type: GROUP_EDITOR_VALUE_CHANGE, + field, + value, +}); + +const reset = () => ({ + type: GROUP_EDITOR_RESET, +}); + +const setUp = (group: string) => ({ + type: GROUP_EDITOR_SETUP, + group, +}); + +export { + GROUP_CREATE_REQUEST, + GROUP_CREATE_SUCCESS, + GROUP_CREATE_FAIL, + GROUP_UPDATE_REQUEST, + GROUP_UPDATE_SUCCESS, + GROUP_UPDATE_FAIL, + GROUP_EDITOR_VALUE_CHANGE, + GROUP_EDITOR_RESET, + GROUP_EDITOR_SETUP, + submit, + create, + createRequest, + createSuccess, + createFail, + update, + updateRequest, + updateSuccess, + updateFail, + changeValue, + reset, + setUp, +}; diff --git a/app/soapbox/actions/groups.js b/app/soapbox/actions/groups.js deleted file mode 100644 index 3c6255216..000000000 --- a/app/soapbox/actions/groups.js +++ /dev/null @@ -1,526 +0,0 @@ -import { isLoggedIn } from 'soapbox/utils/auth'; - -import api, { getLinks } from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts } from './importer'; - -export const GROUP_FETCH_REQUEST = 'GROUP_FETCH_REQUEST'; -export const GROUP_FETCH_SUCCESS = 'GROUP_FETCH_SUCCESS'; -export const GROUP_FETCH_FAIL = 'GROUP_FETCH_FAIL'; - -export const GROUP_RELATIONSHIPS_FETCH_REQUEST = 'GROUP_RELATIONSHIPS_FETCH_REQUEST'; -export const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS'; -export const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL'; - -export const GROUPS_FETCH_REQUEST = 'GROUPS_FETCH_REQUEST'; -export const GROUPS_FETCH_SUCCESS = 'GROUPS_FETCH_SUCCESS'; -export const GROUPS_FETCH_FAIL = 'GROUPS_FETCH_FAIL'; - -export const GROUP_JOIN_REQUEST = 'GROUP_JOIN_REQUEST'; -export const GROUP_JOIN_SUCCESS = 'GROUP_JOIN_SUCCESS'; -export const GROUP_JOIN_FAIL = 'GROUP_JOIN_FAIL'; - -export const GROUP_LEAVE_REQUEST = 'GROUP_LEAVE_REQUEST'; -export const GROUP_LEAVE_SUCCESS = 'GROUP_LEAVE_SUCCESS'; -export const GROUP_LEAVE_FAIL = 'GROUP_LEAVE_FAIL'; - -export const GROUP_MEMBERS_FETCH_REQUEST = 'GROUP_MEMBERS_FETCH_REQUEST'; -export const GROUP_MEMBERS_FETCH_SUCCESS = 'GROUP_MEMBERS_FETCH_SUCCESS'; -export const GROUP_MEMBERS_FETCH_FAIL = 'GROUP_MEMBERS_FETCH_FAIL'; - -export const GROUP_MEMBERS_EXPAND_REQUEST = 'GROUP_MEMBERS_EXPAND_REQUEST'; -export const GROUP_MEMBERS_EXPAND_SUCCESS = 'GROUP_MEMBERS_EXPAND_SUCCESS'; -export const GROUP_MEMBERS_EXPAND_FAIL = 'GROUP_MEMBERS_EXPAND_FAIL'; - -export const GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST = 'GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST'; -export const GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS'; -export const GROUP_REMOVED_ACCOUNTS_FETCH_FAIL = 'GROUP_REMOVED_ACCOUNTS_FETCH_FAIL'; - -export const GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST = 'GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST'; -export const GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS'; -export const GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL = 'GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL'; - -export const GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST = 'GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST'; -export const GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS'; -export const GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL = 'GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL'; - -export const GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST = 'GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST'; -export const GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS'; -export const GROUP_REMOVED_ACCOUNTS_CREATE_FAIL = 'GROUP_REMOVED_ACCOUNTS_CREATE_FAIL'; - -export const GROUP_REMOVE_STATUS_REQUEST = 'GROUP_REMOVE_STATUS_REQUEST'; -export const GROUP_REMOVE_STATUS_SUCCESS = 'GROUP_REMOVE_STATUS_SUCCESS'; -export const GROUP_REMOVE_STATUS_FAIL = 'GROUP_REMOVE_STATUS_FAIL'; - -export const fetchGroup = id => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(fetchGroupRelationships([id])); - - if (getState().getIn(['groups', id])) { - return; - } - - dispatch(fetchGroupRequest(id)); - - api(getState).get(`/api/v1/groups/${id}`) - .then(({ data }) => dispatch(fetchGroupSuccess(data))) - .catch(err => dispatch(fetchGroupFail(id, err))); -}; - -export const fetchGroupRequest = id => ({ - type: GROUP_FETCH_REQUEST, - id, -}); - -export const fetchGroupSuccess = group => ({ - type: GROUP_FETCH_SUCCESS, - group, -}); - -export const fetchGroupFail = (id, error) => ({ - type: GROUP_FETCH_FAIL, - id, - error, -}); - -export function fetchGroupRelationships(groupIds) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - const loadedRelationships = getState().get('group_relationships'); - const newGroupIds = groupIds.filter(id => loadedRelationships.get(id, null) === null); - - if (newGroupIds.length === 0) { - return; - } - - dispatch(fetchGroupRelationshipsRequest(newGroupIds)); - - api(getState).get(`/api/v1/groups/${newGroupIds[0]}/relationships?${newGroupIds.map(id => `id[]=${id}`).join('&')}`).then(response => { - dispatch(fetchGroupRelationshipsSuccess(response.data)); - }).catch(error => { - dispatch(fetchGroupRelationshipsFail(error)); - }); - }; -} - -export function fetchGroupRelationshipsRequest(ids) { - return { - type: GROUP_RELATIONSHIPS_FETCH_REQUEST, - ids, - skipLoading: true, - }; -} - -export function fetchGroupRelationshipsSuccess(relationships) { - return { - type: GROUP_RELATIONSHIPS_FETCH_SUCCESS, - relationships, - skipLoading: true, - }; -} - -export function fetchGroupRelationshipsFail(error) { - return { - type: GROUP_RELATIONSHIPS_FETCH_FAIL, - error, - skipLoading: true, - }; -} - -export const fetchGroups = (tab) => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(fetchGroupsRequest()); - - api(getState).get('/api/v1/groups?tab=' + tab) - .then(({ data }) => { - dispatch(fetchGroupsSuccess(data, tab)); - dispatch(fetchGroupRelationships(data.map(item => item.id))); - }) - .catch(err => dispatch(fetchGroupsFail(err))); -}; - -export const fetchGroupsRequest = () => ({ - type: GROUPS_FETCH_REQUEST, -}); - -export const fetchGroupsSuccess = (groups, tab) => ({ - type: GROUPS_FETCH_SUCCESS, - groups, - tab, -}); - -export const fetchGroupsFail = error => ({ - type: GROUPS_FETCH_FAIL, - error, -}); - -export function joinGroup(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(joinGroupRequest(id)); - - api(getState).post(`/api/v1/groups/${id}/accounts`).then(response => { - dispatch(joinGroupSuccess(response.data)); - }).catch(error => { - dispatch(joinGroupFail(id, error)); - }); - }; -} - -export function leaveGroup(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(leaveGroupRequest(id)); - - api(getState).delete(`/api/v1/groups/${id}/accounts`).then(response => { - dispatch(leaveGroupSuccess(response.data)); - }).catch(error => { - dispatch(leaveGroupFail(id, error)); - }); - }; -} - -export function joinGroupRequest(id) { - return { - type: GROUP_JOIN_REQUEST, - id, - }; -} - -export function joinGroupSuccess(relationship) { - return { - type: GROUP_JOIN_SUCCESS, - relationship, - }; -} - -export function joinGroupFail(error) { - return { - type: GROUP_JOIN_FAIL, - error, - }; -} - -export function leaveGroupRequest(id) { - return { - type: GROUP_LEAVE_REQUEST, - id, - }; -} - -export function leaveGroupSuccess(relationship) { - return { - type: GROUP_LEAVE_SUCCESS, - relationship, - }; -} - -export function leaveGroupFail(error) { - return { - type: GROUP_LEAVE_FAIL, - error, - }; -} - -export function fetchMembers(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(fetchMembersRequest(id)); - - api(getState).get(`/api/v1/groups/${id}/accounts`).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - - dispatch(importFetchedAccounts(response.data)); - dispatch(fetchMembersSuccess(id, response.data, next ? next.uri : null)); - dispatch(fetchRelationships(response.data.map(item => item.id))); - }).catch(error => { - dispatch(fetchMembersFail(id, error)); - }); - }; -} - -export function fetchMembersRequest(id) { - return { - type: GROUP_MEMBERS_FETCH_REQUEST, - id, - }; -} - -export function fetchMembersSuccess(id, accounts, next) { - return { - type: GROUP_MEMBERS_FETCH_SUCCESS, - id, - accounts, - next, - }; -} - -export function fetchMembersFail(id, error) { - return { - type: GROUP_MEMBERS_FETCH_FAIL, - id, - error, - }; -} - -export function expandMembers(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - const url = getState().getIn(['user_lists', 'groups', id, 'next']); - - if (url === null) { - return; - } - - dispatch(expandMembersRequest(id)); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - - dispatch(importFetchedAccounts(response.data)); - dispatch(expandMembersSuccess(id, response.data, next ? next.uri : null)); - dispatch(fetchRelationships(response.data.map(item => item.id))); - }).catch(error => { - dispatch(expandMembersFail(id, error)); - }); - }; -} - -export function expandMembersRequest(id) { - return { - type: GROUP_MEMBERS_EXPAND_REQUEST, - id, - }; -} - -export function expandMembersSuccess(id, accounts, next) { - return { - type: GROUP_MEMBERS_EXPAND_SUCCESS, - id, - accounts, - next, - }; -} - -export function expandMembersFail(id, error) { - return { - type: GROUP_MEMBERS_EXPAND_FAIL, - id, - error, - }; -} - -export function fetchRemovedAccounts(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(fetchRemovedAccountsRequest(id)); - - api(getState).get(`/api/v1/groups/${id}/removed_accounts`).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - - dispatch(importFetchedAccounts(response.data)); - dispatch(fetchRemovedAccountsSuccess(id, response.data, next ? next.uri : null)); - dispatch(fetchRelationships(response.data.map(item => item.id))); - }).catch(error => { - dispatch(fetchRemovedAccountsFail(id, error)); - }); - }; -} - -export function fetchRemovedAccountsRequest(id) { - return { - type: GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST, - id, - }; -} - -export function fetchRemovedAccountsSuccess(id, accounts, next) { - return { - type: GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS, - id, - accounts, - next, - }; -} - -export function fetchRemovedAccountsFail(id, error) { - return { - type: GROUP_REMOVED_ACCOUNTS_FETCH_FAIL, - id, - error, - }; -} - -export function expandRemovedAccounts(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - const url = getState().getIn(['user_lists', 'groups_removed_accounts', id, 'next']); - - if (url === null) { - return; - } - - dispatch(expandRemovedAccountsRequest(id)); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - - dispatch(importFetchedAccounts(response.data)); - dispatch(expandRemovedAccountsSuccess(id, response.data, next ? next.uri : null)); - dispatch(fetchRelationships(response.data.map(item => item.id))); - }).catch(error => { - dispatch(expandRemovedAccountsFail(id, error)); - }); - }; -} - -export function expandRemovedAccountsRequest(id) { - return { - type: GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST, - id, - }; -} - -export function expandRemovedAccountsSuccess(id, accounts, next) { - return { - type: GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS, - id, - accounts, - next, - }; -} - -export function expandRemovedAccountsFail(id, error) { - return { - type: GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL, - id, - error, - }; -} - -export function removeRemovedAccount(groupId, id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(removeRemovedAccountRequest(groupId, id)); - - api(getState).delete(`/api/v1/groups/${groupId}/removed_accounts?account_id=${id}`).then(response => { - dispatch(removeRemovedAccountSuccess(groupId, id)); - }).catch(error => { - dispatch(removeRemovedAccountFail(groupId, id, error)); - }); - }; -} - -export function removeRemovedAccountRequest(groupId, id) { - return { - type: GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST, - groupId, - id, - }; -} - -export function removeRemovedAccountSuccess(groupId, id) { - return { - type: GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS, - groupId, - id, - }; -} - -export function removeRemovedAccountFail(groupId, id, error) { - return { - type: GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL, - groupId, - id, - error, - }; -} - -export function createRemovedAccount(groupId, id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(createRemovedAccountRequest(groupId, id)); - - api(getState).post(`/api/v1/groups/${groupId}/removed_accounts?account_id=${id}`).then(response => { - dispatch(createRemovedAccountSuccess(groupId, id)); - }).catch(error => { - dispatch(createRemovedAccountFail(groupId, id, error)); - }); - }; -} - -export function createRemovedAccountRequest(groupId, id) { - return { - type: GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST, - groupId, - id, - }; -} - -export function createRemovedAccountSuccess(groupId, id) { - return { - type: GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS, - groupId, - id, - }; -} - -export function createRemovedAccountFail(groupId, id, error) { - return { - type: GROUP_REMOVED_ACCOUNTS_CREATE_FAIL, - groupId, - id, - error, - }; -} - -export function groupRemoveStatus(groupId, id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(groupRemoveStatusRequest(groupId, id)); - - api(getState).delete(`/api/v1/groups/${groupId}/statuses/${id}`).then(response => { - dispatch(groupRemoveStatusSuccess(groupId, id)); - }).catch(error => { - dispatch(groupRemoveStatusFail(groupId, id, error)); - }); - }; -} - -export function groupRemoveStatusRequest(groupId, id) { - return { - type: GROUP_REMOVE_STATUS_REQUEST, - groupId, - id, - }; -} - -export function groupRemoveStatusSuccess(groupId, id) { - return { - type: GROUP_REMOVE_STATUS_SUCCESS, - groupId, - id, - }; -} - -export function groupRemoveStatusFail(groupId, id, error) { - return { - type: GROUP_REMOVE_STATUS_FAIL, - groupId, - id, - error, - }; -} diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts new file mode 100644 index 000000000..808cc3204 --- /dev/null +++ b/app/soapbox/actions/groups.ts @@ -0,0 +1,550 @@ +import { AxiosError } from 'axios'; + +import { isLoggedIn } from 'soapbox/utils/auth'; + +import api, { getLinks } from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; + +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const GROUP_FETCH_REQUEST = 'GROUP_FETCH_REQUEST'; +const GROUP_FETCH_SUCCESS = 'GROUP_FETCH_SUCCESS'; +const GROUP_FETCH_FAIL = 'GROUP_FETCH_FAIL'; + +const GROUP_RELATIONSHIPS_FETCH_REQUEST = 'GROUP_RELATIONSHIPS_FETCH_REQUEST'; +const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS'; +const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL'; + +const GROUPS_FETCH_REQUEST = 'GROUPS_FETCH_REQUEST'; +const GROUPS_FETCH_SUCCESS = 'GROUPS_FETCH_SUCCESS'; +const GROUPS_FETCH_FAIL = 'GROUPS_FETCH_FAIL'; + +const GROUP_JOIN_REQUEST = 'GROUP_JOIN_REQUEST'; +const GROUP_JOIN_SUCCESS = 'GROUP_JOIN_SUCCESS'; +const GROUP_JOIN_FAIL = 'GROUP_JOIN_FAIL'; + +const GROUP_LEAVE_REQUEST = 'GROUP_LEAVE_REQUEST'; +const GROUP_LEAVE_SUCCESS = 'GROUP_LEAVE_SUCCESS'; +const GROUP_LEAVE_FAIL = 'GROUP_LEAVE_FAIL'; + +const GROUP_MEMBERS_FETCH_REQUEST = 'GROUP_MEMBERS_FETCH_REQUEST'; +const GROUP_MEMBERS_FETCH_SUCCESS = 'GROUP_MEMBERS_FETCH_SUCCESS'; +const GROUP_MEMBERS_FETCH_FAIL = 'GROUP_MEMBERS_FETCH_FAIL'; + +const GROUP_MEMBERS_EXPAND_REQUEST = 'GROUP_MEMBERS_EXPAND_REQUEST'; +const GROUP_MEMBERS_EXPAND_SUCCESS = 'GROUP_MEMBERS_EXPAND_SUCCESS'; +const GROUP_MEMBERS_EXPAND_FAIL = 'GROUP_MEMBERS_EXPAND_FAIL'; + +const GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST = 'GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST'; +const GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS'; +const GROUP_REMOVED_ACCOUNTS_FETCH_FAIL = 'GROUP_REMOVED_ACCOUNTS_FETCH_FAIL'; + +const GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST = 'GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST'; +const GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS'; +const GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL = 'GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL'; + +const GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST = 'GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST'; +const GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS'; +const GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL = 'GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL'; + +const GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST = 'GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST'; +const GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS'; +const GROUP_REMOVED_ACCOUNTS_CREATE_FAIL = 'GROUP_REMOVED_ACCOUNTS_CREATE_FAIL'; + +const GROUP_REMOVE_STATUS_REQUEST = 'GROUP_REMOVE_STATUS_REQUEST'; +const GROUP_REMOVE_STATUS_SUCCESS = 'GROUP_REMOVE_STATUS_SUCCESS'; +const GROUP_REMOVE_STATUS_FAIL = 'GROUP_REMOVE_STATUS_FAIL'; + +const fetchGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchGroupRelationships([id])); + + if (getState().groups.get(id)) { + return; + } + + dispatch(fetchGroupRequest(id)); + + api(getState).get(`/api/v1/groups/${id}`) + .then(({ data }) => dispatch(fetchGroupSuccess(data))) + .catch(err => dispatch(fetchGroupFail(id, err))); +}; + +const fetchGroupRequest = (id: string) => ({ + type: GROUP_FETCH_REQUEST, + id, +}); + +const fetchGroupSuccess = (group: APIEntity) => ({ + type: GROUP_FETCH_SUCCESS, + group, +}); + +const fetchGroupFail = (id: string, error: AxiosError) => ({ + type: GROUP_FETCH_FAIL, + id, + error, +}); + +const fetchGroupRelationships = (groupIds: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const loadedRelationships = getState().group_relationships; + const newGroupIds = groupIds.filter(id => loadedRelationships.get(id, null) === null); + + if (newGroupIds.length === 0) { + return; + } + + dispatch(fetchGroupRelationshipsRequest(newGroupIds)); + + api(getState).get(`/api/v1/groups/${newGroupIds[0]}/relationships?${newGroupIds.map(id => `id[]=${id}`).join('&')}`).then(response => { + dispatch(fetchGroupRelationshipsSuccess(response.data)); + }).catch(error => { + dispatch(fetchGroupRelationshipsFail(error)); + }); + }; + +const fetchGroupRelationshipsRequest = (ids: string[]) => ({ + type: GROUP_RELATIONSHIPS_FETCH_REQUEST, + ids, + skipLoading: true, +}); + +const fetchGroupRelationshipsSuccess = (relationships: APIEntity[]) => ({ + type: GROUP_RELATIONSHIPS_FETCH_SUCCESS, + relationships, + skipLoading: true, +}); + +const fetchGroupRelationshipsFail = (error: AxiosError) => ({ + type: GROUP_RELATIONSHIPS_FETCH_FAIL, + error, + skipLoading: true, +}); + +const fetchGroups = (tab: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchGroupsRequest()); + + api(getState).get('/api/v1/groups?tab=' + tab) + .then(({ data }) => { + dispatch(fetchGroupsSuccess(data, tab)); + dispatch(fetchGroupRelationships(data.map((item: APIEntity) => item.id))); + }) + .catch(err => dispatch(fetchGroupsFail(err))); +}; + +const fetchGroupsRequest = () => ({ + type: GROUPS_FETCH_REQUEST, +}); + +const fetchGroupsSuccess = (groups: APIEntity[], tab: string) => ({ + type: GROUPS_FETCH_SUCCESS, + groups, + tab, +}); + +const fetchGroupsFail = (error: AxiosError) => ({ + type: GROUPS_FETCH_FAIL, + error, +}); + +const joinGroup = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(joinGroupRequest(id)); + + api(getState).post(`/api/v1/groups/${id}/accounts`).then(response => { + dispatch(joinGroupSuccess(response.data)); + }).catch(error => { + dispatch(joinGroupFail(id, error)); + }); + }; + +const leaveGroup = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(leaveGroupRequest(id)); + + api(getState).delete(`/api/v1/groups/${id}/accounts`).then(response => { + dispatch(leaveGroupSuccess(response.data)); + }).catch(error => { + dispatch(leaveGroupFail(id, error)); + }); + }; + +const joinGroupRequest = (id: string) => ({ + type: GROUP_JOIN_REQUEST, + id, +}); + +const joinGroupSuccess = (relationship: APIEntity) => ({ + type: GROUP_JOIN_SUCCESS, + relationship, +}); + +const joinGroupFail = (id: string, error: AxiosError) => ({ + type: GROUP_JOIN_FAIL, + id, + error, +}); + +const leaveGroupRequest = (id: string) => ({ + type: GROUP_LEAVE_REQUEST, + id, +}); + +const leaveGroupSuccess = (relationship: APIEntity) => ({ + type: GROUP_LEAVE_SUCCESS, + relationship, +}); + +const leaveGroupFail = (id: string, error: AxiosError) => ({ + type: GROUP_LEAVE_FAIL, + id, + error, +}); + +const fetchMembers = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchMembersRequest(id)); + + api(getState).get(`/api/v1/groups/${id}/accounts`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchMembersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(fetchMembersFail(id, error)); + }); + }; + +const fetchMembersRequest = (id: string) => ({ + type: GROUP_MEMBERS_FETCH_REQUEST, + id, +}); + +const fetchMembersSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchMembersFail = (id: string, error: AxiosError) => ({ + type: GROUP_MEMBERS_FETCH_FAIL, + id, + error, +}); + +const expandMembers = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const url = getState().user_lists.groups.get(id)!.next; + + if (url === null) { + return; + } + + dispatch(expandMembersRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandMembersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandMembersFail(id, error)); + }); + }; + +const expandMembersRequest = (id: string) => ({ + type: GROUP_MEMBERS_EXPAND_REQUEST, + id, +}); + +const expandMembersSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandMembersFail = (id: string, error: AxiosError) => ({ + type: GROUP_MEMBERS_EXPAND_FAIL, + id, + error, +}); + +const fetchRemovedAccounts = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchRemovedAccountsRequest(id)); + + api(getState).get(`/api/v1/groups/${id}/removed_accounts`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchRemovedAccountsSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(fetchRemovedAccountsFail(id, error)); + }); + }; + +const fetchRemovedAccountsRequest = (id: string) => ({ + type: GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST, + id, +}); + +const fetchRemovedAccountsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchRemovedAccountsFail = (id: string, error: AxiosError) => ({ + type: GROUP_REMOVED_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +const expandRemovedAccounts = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const url = getState().user_lists.groups_removed_accounts.get(id)!.next; + + if (url === null) { + return; + } + + dispatch(expandRemovedAccountsRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandRemovedAccountsSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandRemovedAccountsFail(id, error)); + }); + }; + +const expandRemovedAccountsRequest = (id: string) => ({ + type: GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST, + id, +}); + +const expandRemovedAccountsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandRemovedAccountsFail = (id: string, error: AxiosError) => ({ + type: GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL, + id, + error, +}); + +const removeRemovedAccount = (groupId: string, id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(removeRemovedAccountRequest(groupId, id)); + + api(getState).delete(`/api/v1/groups/${groupId}/removed_accounts?account_id=${id}`).then(response => { + dispatch(removeRemovedAccountSuccess(groupId, id)); + }).catch(error => { + dispatch(removeRemovedAccountFail(groupId, id, error)); + }); + }; + +const removeRemovedAccountRequest = (groupId: string, id: string) => ({ + type: GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST, + groupId, + id, +}); + +const removeRemovedAccountSuccess = (groupId: string, id: string) => ({ + type: GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS, + groupId, + id, +}); + +const removeRemovedAccountFail = (groupId: string, id: string, error: AxiosError) => ({ + type: GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL, + groupId, + id, + error, +}); + +const createRemovedAccount = (groupId: string, id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(createRemovedAccountRequest(groupId, id)); + + api(getState).post(`/api/v1/groups/${groupId}/removed_accounts?account_id=${id}`).then(response => { + dispatch(createRemovedAccountSuccess(groupId, id)); + }).catch(error => { + dispatch(createRemovedAccountFail(groupId, id, error)); + }); + }; + +const createRemovedAccountRequest = (groupId: string, id: string) => ({ + type: GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST, + groupId, + id, +}); + +const createRemovedAccountSuccess = (groupId: string, id: string) => ({ + type: GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS, + groupId, + id, +}); + +const createRemovedAccountFail = (groupId: string, id: string, error: AxiosError) => ({ + type: GROUP_REMOVED_ACCOUNTS_CREATE_FAIL, + groupId, + id, + error, +}); + +const groupRemoveStatus = (groupId: string, id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(groupRemoveStatusRequest(groupId, id)); + + api(getState).delete(`/api/v1/groups/${groupId}/statuses/${id}`).then(response => { + dispatch(groupRemoveStatusSuccess(groupId, id)); + }).catch(error => { + dispatch(groupRemoveStatusFail(groupId, id, error)); + }); + }; + +const groupRemoveStatusRequest = (groupId: string, id: string) => ({ + type: GROUP_REMOVE_STATUS_REQUEST, + groupId, + id, +}); + +const groupRemoveStatusSuccess = (groupId: string, id: string) => ({ + type: GROUP_REMOVE_STATUS_SUCCESS, + groupId, + id, +}); + +const groupRemoveStatusFail = (groupId: string, id: string, error: AxiosError) => ({ + type: GROUP_REMOVE_STATUS_FAIL, + groupId, + id, + error, +}); + +export { + GROUP_FETCH_REQUEST, + GROUP_FETCH_SUCCESS, + GROUP_FETCH_FAIL, + GROUP_RELATIONSHIPS_FETCH_REQUEST, + GROUP_RELATIONSHIPS_FETCH_SUCCESS, + GROUP_RELATIONSHIPS_FETCH_FAIL, + GROUPS_FETCH_REQUEST, + GROUPS_FETCH_SUCCESS, + GROUPS_FETCH_FAIL, + GROUP_JOIN_REQUEST, + GROUP_JOIN_SUCCESS, + GROUP_JOIN_FAIL, + GROUP_LEAVE_REQUEST, + GROUP_LEAVE_SUCCESS, + GROUP_LEAVE_FAIL, + GROUP_MEMBERS_FETCH_REQUEST, + GROUP_MEMBERS_FETCH_SUCCESS, + GROUP_MEMBERS_FETCH_FAIL, + GROUP_MEMBERS_EXPAND_REQUEST, + GROUP_MEMBERS_EXPAND_SUCCESS, + GROUP_MEMBERS_EXPAND_FAIL, + GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST, + GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS, + GROUP_REMOVED_ACCOUNTS_FETCH_FAIL, + GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST, + GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS, + GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL, + GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST, + GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS, + GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL, + GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST, + GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS, + GROUP_REMOVED_ACCOUNTS_CREATE_FAIL, + GROUP_REMOVE_STATUS_REQUEST, + GROUP_REMOVE_STATUS_SUCCESS, + GROUP_REMOVE_STATUS_FAIL, + fetchGroup, + fetchGroupRequest, + fetchGroupSuccess, + fetchGroupFail, + fetchGroupRelationships, + fetchGroupRelationshipsRequest, + fetchGroupRelationshipsSuccess, + fetchGroupRelationshipsFail, + fetchGroups, + fetchGroupsRequest, + fetchGroupsSuccess, + fetchGroupsFail, + joinGroup, + leaveGroup, + joinGroupRequest, + joinGroupSuccess, + joinGroupFail, + leaveGroupRequest, + leaveGroupSuccess, + leaveGroupFail, + fetchMembers, + fetchMembersRequest, + fetchMembersSuccess, + fetchMembersFail, + expandMembers, + expandMembersRequest, + expandMembersSuccess, + expandMembersFail, + fetchRemovedAccounts, + fetchRemovedAccountsRequest, + fetchRemovedAccountsSuccess, + fetchRemovedAccountsFail, + expandRemovedAccounts, + expandRemovedAccountsRequest, + expandRemovedAccountsSuccess, + expandRemovedAccountsFail, + removeRemovedAccount, + removeRemovedAccountRequest, + removeRemovedAccountSuccess, + removeRemovedAccountFail, + createRemovedAccount, + createRemovedAccountRequest, + createRemovedAccountSuccess, + createRemovedAccountFail, + groupRemoveStatus, + groupRemoveStatusRequest, + groupRemoveStatusSuccess, + groupRemoveStatusFail, +}; diff --git a/app/soapbox/actions/history.js b/app/soapbox/actions/history.js deleted file mode 100644 index e668d315e..000000000 --- a/app/soapbox/actions/history.js +++ /dev/null @@ -1,38 +0,0 @@ -import api from 'soapbox/api'; - -import { importFetchedAccounts } from './importer'; - -export const HISTORY_FETCH_REQUEST = 'HISTORY_FETCH_REQUEST'; -export const HISTORY_FETCH_SUCCESS = 'HISTORY_FETCH_SUCCESS'; -export const HISTORY_FETCH_FAIL = 'HISTORY_FETCH_FAIL'; - -export const fetchHistory = statusId => (dispatch, getState) => { - const loading = getState().getIn(['history', statusId, 'loading']); - - if (loading) { - return; - } - - dispatch(fetchHistoryRequest(statusId)); - - api(getState).get(`/api/v1/statuses/${statusId}/history`).then(({ data }) => { - dispatch(importFetchedAccounts(data.map(x => x.account))); - dispatch(fetchHistorySuccess(statusId, data)); - }).catch(error => dispatch(fetchHistoryFail(error))); -}; - -export const fetchHistoryRequest = statusId => ({ - type: HISTORY_FETCH_REQUEST, - statusId, -}); - -export const fetchHistorySuccess = (statusId, history) => ({ - type: HISTORY_FETCH_SUCCESS, - statusId, - history, -}); - -export const fetchHistoryFail = error => ({ - type: HISTORY_FETCH_FAIL, - error, -}); \ No newline at end of file diff --git a/app/soapbox/actions/history.ts b/app/soapbox/actions/history.ts new file mode 100644 index 000000000..d38d94ee0 --- /dev/null +++ b/app/soapbox/actions/history.ts @@ -0,0 +1,53 @@ +import api from 'soapbox/api'; + +import { importFetchedAccounts } from './importer'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const HISTORY_FETCH_REQUEST = 'HISTORY_FETCH_REQUEST'; +const HISTORY_FETCH_SUCCESS = 'HISTORY_FETCH_SUCCESS'; +const HISTORY_FETCH_FAIL = 'HISTORY_FETCH_FAIL'; + +const fetchHistory = (statusId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const loading = getState().history.getIn([statusId, 'loading']); + + if (loading) { + return; + } + + dispatch(fetchHistoryRequest(statusId)); + + api(getState).get(`/api/v1/statuses/${statusId}/history`).then(({ data }) => { + dispatch(importFetchedAccounts(data.map((x: APIEntity) => x.account))); + dispatch(fetchHistorySuccess(statusId, data)); + }).catch(error => dispatch(fetchHistoryFail(error))); + }; + +const fetchHistoryRequest = (statusId: string) => ({ + type: HISTORY_FETCH_REQUEST, + statusId, +}); + +const fetchHistorySuccess = (statusId: String, history: APIEntity[]) => ({ + type: HISTORY_FETCH_SUCCESS, + statusId, + history, +}); + +const fetchHistoryFail = (error: AxiosError) => ({ + type: HISTORY_FETCH_FAIL, + error, +}); + +export { + HISTORY_FETCH_REQUEST, + HISTORY_FETCH_SUCCESS, + HISTORY_FETCH_FAIL, + fetchHistory, + fetchHistoryRequest, + fetchHistorySuccess, + fetchHistoryFail, +}; \ No newline at end of file diff --git a/app/soapbox/actions/identity_proofs.js b/app/soapbox/actions/identity_proofs.js deleted file mode 100644 index a14455a64..000000000 --- a/app/soapbox/actions/identity_proofs.js +++ /dev/null @@ -1,30 +0,0 @@ -import api from '../api'; - -export const IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST = 'IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST'; -export const IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS = 'IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS'; -export const IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL = 'IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL'; - -export const fetchAccountIdentityProofs = accountId => (dispatch, getState) => { - dispatch(fetchAccountIdentityProofsRequest(accountId)); - - api(getState).get(`/api/v1/accounts/${accountId}/identity_proofs`) - .then(({ data }) => dispatch(fetchAccountIdentityProofsSuccess(accountId, data))) - .catch(err => dispatch(fetchAccountIdentityProofsFail(accountId, err))); -}; - -export const fetchAccountIdentityProofsRequest = id => ({ - type: IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST, - id, -}); - -export const fetchAccountIdentityProofsSuccess = (accountId, identity_proofs) => ({ - type: IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS, - accountId, - identity_proofs, -}); - -export const fetchAccountIdentityProofsFail = (accountId, error) => ({ - type: IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL, - accountId, - error, -}); diff --git a/app/soapbox/actions/import_data.js b/app/soapbox/actions/import_data.ts similarity index 65% rename from app/soapbox/actions/import_data.js rename to app/soapbox/actions/import_data.ts index 7bde21a4a..43de9f85c 100644 --- a/app/soapbox/actions/import_data.js +++ b/app/soapbox/actions/import_data.ts @@ -4,6 +4,9 @@ import snackbar from 'soapbox/actions/snackbar'; import api from '../api'; +import type { SnackbarAction } from './snackbar'; +import type { RootState } from 'soapbox/store'; + export const IMPORT_FOLLOWS_REQUEST = 'IMPORT_FOLLOWS_REQUEST'; export const IMPORT_FOLLOWS_SUCCESS = 'IMPORT_FOLLOWS_SUCCESS'; export const IMPORT_FOLLOWS_FAIL = 'IMPORT_FOLLOWS_FAIL'; @@ -16,50 +19,61 @@ export const IMPORT_MUTES_REQUEST = 'IMPORT_MUTES_REQUEST'; export const IMPORT_MUTES_SUCCESS = 'IMPORT_MUTES_SUCCESS'; export const IMPORT_MUTES_FAIL = 'IMPORT_MUTES_FAIL'; +type ImportDataActions = { + type: typeof IMPORT_FOLLOWS_REQUEST + | typeof IMPORT_FOLLOWS_SUCCESS + | typeof IMPORT_FOLLOWS_FAIL + | typeof IMPORT_BLOCKS_REQUEST + | typeof IMPORT_BLOCKS_SUCCESS + | typeof IMPORT_BLOCKS_FAIL + | typeof IMPORT_MUTES_REQUEST + | typeof IMPORT_MUTES_SUCCESS + | typeof IMPORT_MUTES_FAIL, + error?: any, + config?: string +} | SnackbarAction + const messages = defineMessages({ blocksSuccess: { id: 'import_data.success.blocks', defaultMessage: 'Blocks imported successfully' }, followersSuccess: { id: 'import_data.success.followers', defaultMessage: 'Followers imported successfully' }, mutesSuccess: { id: 'import_data.success.mutes', defaultMessage: 'Mutes imported successfully' }, }); -export function importFollows(intl, params) { - return (dispatch, getState) => { +export const importFollows = (params: FormData) => + (dispatch: React.Dispatch, getState: () => RootState) => { dispatch({ type: IMPORT_FOLLOWS_REQUEST }); return api(getState) .post('/api/pleroma/follow_import', params) .then(response => { - dispatch(snackbar.success(intl.formatMessage(messages.followersSuccess))); + dispatch(snackbar.success(messages.followersSuccess)); dispatch({ type: IMPORT_FOLLOWS_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_FOLLOWS_FAIL, error }); }); }; -} -export function importBlocks(intl, params) { - return (dispatch, getState) => { +export const importBlocks = (params: FormData) => + (dispatch: React.Dispatch, getState: () => RootState) => { dispatch({ type: IMPORT_BLOCKS_REQUEST }); return api(getState) .post('/api/pleroma/blocks_import', params) .then(response => { - dispatch(snackbar.success(intl.formatMessage(messages.blocksSuccess))); + dispatch(snackbar.success(messages.blocksSuccess)); dispatch({ type: IMPORT_BLOCKS_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_BLOCKS_FAIL, error }); }); }; -} -export function importMutes(intl, params) { - return (dispatch, getState) => { +export const importMutes = (params: FormData) => + (dispatch: React.Dispatch, getState: () => RootState) => { dispatch({ type: IMPORT_MUTES_REQUEST }); return api(getState) .post('/api/pleroma/mutes_import', params) .then(response => { - dispatch(snackbar.success(intl.formatMessage(messages.mutesSuccess))); + dispatch(snackbar.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/importer/index.js b/app/soapbox/actions/importer/index.ts similarity index 54% rename from app/soapbox/actions/importer/index.js rename to app/soapbox/actions/importer/index.ts index 9ad58e114..20041180b 100644 --- a/app/soapbox/actions/importer/index.js +++ b/app/soapbox/actions/importer/index.ts @@ -1,62 +1,70 @@ import { getSettings } from '../settings'; -export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; -export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; -export const STATUS_IMPORT = 'STATUS_IMPORT'; -export const STATUSES_IMPORT = 'STATUSES_IMPORT'; -export const POLLS_IMPORT = 'POLLS_IMPORT'; -export const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; -export function importAccount(account) { +const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; +const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; +const STATUS_IMPORT = 'STATUS_IMPORT'; +const STATUSES_IMPORT = 'STATUSES_IMPORT'; +const POLLS_IMPORT = 'POLLS_IMPORT'; +const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP'; + +export function importAccount(account: APIEntity) { return { type: ACCOUNT_IMPORT, account }; } -export function importAccounts(accounts) { +export function importAccounts(accounts: APIEntity[]) { return { type: ACCOUNTS_IMPORT, accounts }; } -export function importStatus(status, idempotencyKey) { - return (dispatch, getState) => { +export function importStatus(status: APIEntity, idempotencyKey?: string) { + return (dispatch: AppDispatch, getState: () => RootState) => { const expandSpoilers = getSettings(getState()).get('expandSpoilers'); return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers }); }; } -export function importStatuses(statuses) { - return (dispatch, getState) => { +export function importStatuses(statuses: APIEntity[]) { + return (dispatch: AppDispatch, getState: () => RootState) => { const expandSpoilers = getSettings(getState()).get('expandSpoilers'); return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers }); }; } -export function importPolls(polls) { +export function importPolls(polls: APIEntity[]) { return { type: POLLS_IMPORT, polls }; } -export function importFetchedAccount(account) { +export function importFetchedAccount(account: APIEntity) { return importFetchedAccounts([account]); } -export function importFetchedAccounts(accounts) { - const normalAccounts = []; +export function importFetchedAccounts(accounts: APIEntity[], args = { should_refetch: false }) { + const { should_refetch } = args; + const normalAccounts: APIEntity[] = []; - function processAccount(account) { + const processAccount = (account: APIEntity) => { if (!account.id) return; + if (should_refetch) { + account.should_refetch = true; + } + normalAccounts.push(account); if (account.moved) { processAccount(account.moved); } - } + }; accounts.forEach(processAccount); return importAccounts(normalAccounts); } -export function importFetchedStatus(status, idempotencyKey) { - return (dispatch, getState) => { +export function importFetchedStatus(status: APIEntity, idempotencyKey?: string) { + return (dispatch: AppDispatch) => { // Skip broken statuses if (isBroken(status)) return; @@ -69,10 +77,21 @@ export function importFetchedStatus(status, idempotencyKey) { dispatch(importFetchedStatus(status.quote)); } + // Pleroma quotes if (status.pleroma?.quote?.id) { dispatch(importFetchedStatus(status.pleroma.quote)); } + // Fedibird quote from reblog + if (status.reblog?.quote?.id) { + dispatch(importFetchedStatus(status.reblog.quote)); + } + + // Pleroma quote from reblog + if (status.reblog?.pleroma?.quote?.id) { + dispatch(importFetchedStatus(status.reblog.pleroma.quote)); + } + if (status.poll?.id) { dispatch(importFetchedPoll(status.poll)); } @@ -84,7 +103,7 @@ export function importFetchedStatus(status, idempotencyKey) { // Sometimes Pleroma can return an empty account, // or a repost can appear of a deleted account. Skip these statuses. -const isBroken = status => { +const isBroken = (status: APIEntity) => { try { // Skip empty accounts // https://gitlab.com/soapbox-pub/soapbox-fe/-/issues/424 @@ -98,13 +117,13 @@ const isBroken = status => { } }; -export function importFetchedStatuses(statuses) { - return (dispatch, getState) => { - const accounts = []; - const normalStatuses = []; - const polls = []; +export function importFetchedStatuses(statuses: APIEntity[]) { + return (dispatch: AppDispatch, getState: () => RootState) => { + const accounts: APIEntity[] = []; + const normalStatuses: APIEntity[] = []; + const polls: APIEntity[] = []; - function processStatus(status) { + function processStatus(status: APIEntity) { // Skip broken statuses if (isBroken(status)) return; @@ -137,12 +156,21 @@ export function importFetchedStatuses(statuses) { }; } -export function importFetchedPoll(poll) { - return dispatch => { +export function importFetchedPoll(poll: APIEntity) { + return (dispatch: AppDispatch) => { dispatch(importPolls([poll])); }; } -export function importErrorWhileFetchingAccountByUsername(username) { +export function importErrorWhileFetchingAccountByUsername(username: string) { return { type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username }; } + +export { + ACCOUNT_IMPORT, + ACCOUNTS_IMPORT, + STATUS_IMPORT, + STATUSES_IMPORT, + POLLS_IMPORT, + ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, +}; diff --git a/app/soapbox/actions/instance.ts b/app/soapbox/actions/instance.ts index cd99ac470..60a6b2e89 100644 --- a/app/soapbox/actions/instance.ts +++ b/app/soapbox/actions/instance.ts @@ -1,5 +1,5 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; -import { get } from 'lodash'; +import get from 'lodash/get'; import KVStore from 'soapbox/storage/kv_store'; import { RootState } from 'soapbox/store'; @@ -46,7 +46,7 @@ export const fetchInstance = createAsyncThunk( dispatch(fetchNodeinfo()); } return instance; - } catch(e) { + } catch (e) { return rejectWithValue(e); } }, diff --git a/app/soapbox/actions/interactions.js b/app/soapbox/actions/interactions.js deleted file mode 100644 index bb0461411..000000000 --- a/app/soapbox/actions/interactions.js +++ /dev/null @@ -1,528 +0,0 @@ -import { defineMessages } from 'react-intl'; - -import snackbar from 'soapbox/actions/snackbar'; -import { isLoggedIn } from 'soapbox/utils/auth'; - -import api from '../api'; - -import { importFetchedAccounts, importFetchedStatus } from './importer'; - -export const REBLOG_REQUEST = 'REBLOG_REQUEST'; -export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; -export const REBLOG_FAIL = 'REBLOG_FAIL'; - -export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; -export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; -export const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; - -export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; -export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; -export const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; - -export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST'; -export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; -export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; - -export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; -export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; -export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; - -export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; -export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; -export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; - -export const REACTIONS_FETCH_REQUEST = 'REACTIONS_FETCH_REQUEST'; -export const REACTIONS_FETCH_SUCCESS = 'REACTIONS_FETCH_SUCCESS'; -export const REACTIONS_FETCH_FAIL = 'REACTIONS_FETCH_FAIL'; - -export const PIN_REQUEST = 'PIN_REQUEST'; -export const PIN_SUCCESS = 'PIN_SUCCESS'; -export const PIN_FAIL = 'PIN_FAIL'; - -export const UNPIN_REQUEST = 'UNPIN_REQUEST'; -export const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; -export const UNPIN_FAIL = 'UNPIN_FAIL'; - -export const BOOKMARK_REQUEST = 'BOOKMARK_REQUEST'; -export const BOOKMARK_SUCCESS = 'BOOKMARKED_SUCCESS'; -export const BOOKMARK_FAIL = 'BOOKMARKED_FAIL'; - -export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; -export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; -export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; - -export const REMOTE_INTERACTION_REQUEST = 'REMOTE_INTERACTION_REQUEST'; -export const REMOTE_INTERACTION_SUCCESS = 'REMOTE_INTERACTION_SUCCESS'; -export 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' }, -}); - -export function reblog(status) { - return function(dispatch, getState) { - if (!isLoggedIn(getState)) return; - - dispatch(reblogRequest(status)); - - api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function(response) { - // The reblog API method returns a new status wrapped around the original. In this case we are only - // interested in how the original is modified, hence passing it skipping the wrapper - dispatch(importFetchedStatus(response.data.reblog)); - dispatch(reblogSuccess(status)); - }).catch(function(error) { - dispatch(reblogFail(status, error)); - }); - }; -} - -export function unreblog(status) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(unreblogRequest(status)); - - api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => { - dispatch(unreblogSuccess(status)); - }).catch(error => { - dispatch(unreblogFail(status, error)); - }); - }; -} - -export function reblogRequest(status) { - return { - type: REBLOG_REQUEST, - status: status, - skipLoading: true, - }; -} - -export function reblogSuccess(status) { - return { - type: REBLOG_SUCCESS, - status: status, - skipLoading: true, - }; -} - -export function reblogFail(status, error) { - return { - type: REBLOG_FAIL, - status: status, - error: error, - skipLoading: true, - }; -} - -export function unreblogRequest(status) { - return { - type: UNREBLOG_REQUEST, - status: status, - skipLoading: true, - }; -} - -export function unreblogSuccess(status) { - return { - type: UNREBLOG_SUCCESS, - status: status, - skipLoading: true, - }; -} - -export function unreblogFail(status, error) { - return { - type: UNREBLOG_FAIL, - status: status, - error: error, - skipLoading: true, - }; -} - -export function favourite(status) { - return function(dispatch, getState) { - if (!isLoggedIn(getState)) return; - - dispatch(favouriteRequest(status)); - - api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function(response) { - dispatch(importFetchedStatus(response.data)); - dispatch(favouriteSuccess(status)); - }).catch(function(error) { - dispatch(favouriteFail(status, error)); - }); - }; -} - -export function unfavourite(status) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(unfavouriteRequest(status)); - - api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => { - dispatch(unfavouriteSuccess(status)); - }).catch(error => { - dispatch(unfavouriteFail(status, error)); - }); - }; -} - -export function favouriteRequest(status) { - return { - type: FAVOURITE_REQUEST, - status: status, - skipLoading: true, - }; -} - -export function favouriteSuccess(status) { - return { - type: FAVOURITE_SUCCESS, - status: status, - skipLoading: true, - }; -} - -export function favouriteFail(status, error) { - return { - type: FAVOURITE_FAIL, - status: status, - error: error, - skipLoading: true, - }; -} - -export function unfavouriteRequest(status) { - return { - type: UNFAVOURITE_REQUEST, - status: status, - skipLoading: true, - }; -} - -export function unfavouriteSuccess(status) { - return { - type: UNFAVOURITE_SUCCESS, - status: status, - skipLoading: true, - }; -} - -export function unfavouriteFail(status, error) { - return { - type: UNFAVOURITE_FAIL, - status: status, - error: error, - skipLoading: true, - }; -} - -export function bookmark(status) { - return function(dispatch, getState) { - dispatch(bookmarkRequest(status)); - - 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')); - }).catch(function(error) { - dispatch(bookmarkFail(status, error)); - }); - }; -} - -export function unbookmark(status) { - return (dispatch, getState) => { - dispatch(unbookmarkRequest(status)); - - 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)); - }).catch(error => { - dispatch(unbookmarkFail(status, error)); - }); - }; -} - -export function bookmarkRequest(status) { - return { - type: BOOKMARK_REQUEST, - status: status, - }; -} - -export function bookmarkSuccess(status, response) { - return { - type: BOOKMARK_SUCCESS, - status: status, - response: response, - }; -} - -export function bookmarkFail(status, error) { - return { - type: BOOKMARK_FAIL, - status: status, - error: error, - }; -} - -export function unbookmarkRequest(status) { - return { - type: UNBOOKMARK_REQUEST, - status: status, - }; -} - -export function unbookmarkSuccess(status, response) { - return { - type: UNBOOKMARK_SUCCESS, - status: status, - response: response, - }; -} - -export function unbookmarkFail(status, error) { - return { - type: UNBOOKMARK_FAIL, - status: status, - error: error, - }; -} - -export function fetchReblogs(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(fetchReblogsRequest(id)); - - api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { - dispatch(importFetchedAccounts(response.data)); - dispatch(fetchReblogsSuccess(id, response.data)); - }).catch(error => { - dispatch(fetchReblogsFail(id, error)); - }); - }; -} - -export function fetchReblogsRequest(id) { - return { - type: REBLOGS_FETCH_REQUEST, - id, - }; -} - -export function fetchReblogsSuccess(id, accounts) { - return { - type: REBLOGS_FETCH_SUCCESS, - id, - accounts, - }; -} - -export function fetchReblogsFail(id, error) { - return { - type: REBLOGS_FETCH_FAIL, - error, - }; -} - -export function fetchFavourites(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(fetchFavouritesRequest(id)); - - api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { - dispatch(importFetchedAccounts(response.data)); - dispatch(fetchFavouritesSuccess(id, response.data)); - }).catch(error => { - dispatch(fetchFavouritesFail(id, error)); - }); - }; -} - -export function fetchFavouritesRequest(id) { - return { - type: FAVOURITES_FETCH_REQUEST, - id, - }; -} - -export function fetchFavouritesSuccess(id, accounts) { - return { - type: FAVOURITES_FETCH_SUCCESS, - id, - accounts, - }; -} - -export function fetchFavouritesFail(id, error) { - return { - type: FAVOURITES_FETCH_FAIL, - error, - }; -} - -export function fetchReactions(id) { - return (dispatch, getState) => { - dispatch(fetchReactionsRequest(id)); - - api(getState).get(`/api/v1/pleroma/statuses/${id}/reactions`).then(response => { - dispatch(importFetchedAccounts(response.data.map(({ accounts }) => accounts).flat())); - dispatch(fetchReactionsSuccess(id, response.data)); - }).catch(error => { - dispatch(fetchReactionsFail(id, error)); - }); - }; -} - -export function fetchReactionsRequest(id) { - return { - type: REACTIONS_FETCH_REQUEST, - id, - }; -} - -export function fetchReactionsSuccess(id, reactions) { - return { - type: REACTIONS_FETCH_SUCCESS, - id, - reactions, - }; -} - -export function fetchReactionsFail(id, error) { - return { - type: REACTIONS_FETCH_FAIL, - error, - }; -} - -export function pin(status) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(pinRequest(status)); - - api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { - dispatch(importFetchedStatus(response.data)); - dispatch(pinSuccess(status)); - }).catch(error => { - dispatch(pinFail(status, error)); - }); - }; -} - -export function pinRequest(status) { - return { - type: PIN_REQUEST, - status, - skipLoading: true, - }; -} - -export function pinSuccess(status) { - return { - type: PIN_SUCCESS, - status, - skipLoading: true, - }; -} - -export function pinFail(status, error) { - return { - type: PIN_FAIL, - status, - error, - skipLoading: true, - }; -} - -export function unpin(status) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(unpinRequest(status)); - - api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { - dispatch(importFetchedStatus(response.data)); - dispatch(unpinSuccess(status)); - }).catch(error => { - dispatch(unpinFail(status, error)); - }); - }; -} - -export function unpinRequest(status) { - return { - type: UNPIN_REQUEST, - status, - skipLoading: true, - }; -} - -export function unpinSuccess(status) { - return { - type: UNPIN_SUCCESS, - status, - skipLoading: true, - }; -} - -export function unpinFail(status, error) { - return { - type: UNPIN_FAIL, - status, - error, - skipLoading: true, - }; -} - -export function remoteInteraction(ap_id, profile) { - return (dispatch, getState) => { - dispatch(remoteInteractionRequest(ap_id, profile)); - - return api(getState).post('/api/v1/pleroma/remote_interaction', { ap_id, profile }).then(({ data }) => { - if (data.error) throw new Error(data.error); - - dispatch(remoteInteractionSuccess(ap_id, profile, data.url)); - - return data.url; - }).catch(error => { - dispatch(remoteInteractionFail(ap_id, profile, error)); - throw error; - }); - }; -} - -export function remoteInteractionRequest(ap_id, profile) { - return { - type: REMOTE_INTERACTION_REQUEST, - ap_id, - profile, - }; -} - -export function remoteInteractionSuccess(ap_id, profile, url) { - return { - type: REMOTE_INTERACTION_SUCCESS, - ap_id, - profile, - url, - }; -} - -export function remoteInteractionFail(ap_id, profile, error) { - return { - type: REMOTE_INTERACTION_FAIL, - ap_id, - profile, - error, - }; -} diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts new file mode 100644 index 000000000..70fd93317 --- /dev/null +++ b/app/soapbox/actions/interactions.ts @@ -0,0 +1,578 @@ +import { defineMessages } from 'react-intl'; + +import snackbar from 'soapbox/actions/snackbar'; +import { isLoggedIn } from 'soapbox/utils/auth'; + +import api from '../api'; + +import { importFetchedAccounts, importFetchedStatus } from './importer'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities'; + +const REBLOG_REQUEST = 'REBLOG_REQUEST'; +const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; +const REBLOG_FAIL = 'REBLOG_FAIL'; + +const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; +const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; +const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; + +const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; +const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; +const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; + +const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST'; +const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; +const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; + +const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; +const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; +const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; + +const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; +const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; +const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; + +const REACTIONS_FETCH_REQUEST = 'REACTIONS_FETCH_REQUEST'; +const REACTIONS_FETCH_SUCCESS = 'REACTIONS_FETCH_SUCCESS'; +const REACTIONS_FETCH_FAIL = 'REACTIONS_FETCH_FAIL'; + +const PIN_REQUEST = 'PIN_REQUEST'; +const PIN_SUCCESS = 'PIN_SUCCESS'; +const PIN_FAIL = 'PIN_FAIL'; + +const UNPIN_REQUEST = 'UNPIN_REQUEST'; +const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; +const UNPIN_FAIL = 'UNPIN_FAIL'; + +const BOOKMARK_REQUEST = 'BOOKMARK_REQUEST'; +const BOOKMARK_SUCCESS = 'BOOKMARKED_SUCCESS'; +const BOOKMARK_FAIL = 'BOOKMARKED_FAIL'; + +const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; +const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; +const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; + +const REMOTE_INTERACTION_REQUEST = 'REMOTE_INTERACTION_REQUEST'; +const REMOTE_INTERACTION_SUCCESS = 'REMOTE_INTERACTION_SUCCESS'; +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' }, +}); + +const reblog = (status: StatusEntity) => + function(dispatch: AppDispatch, getState: () => RootState) { + if (!isLoggedIn(getState)) return; + + dispatch(reblogRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function(response) { + // The reblog API method returns a new status wrapped around the original. In this case we are only + // interested in how the original is modified, hence passing it skipping the wrapper + dispatch(importFetchedStatus(response.data.reblog)); + dispatch(reblogSuccess(status)); + }).catch(error => { + dispatch(reblogFail(status, error)); + }); + }; + +const unreblog = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(unreblogRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(() => { + dispatch(unreblogSuccess(status)); + }).catch(error => { + dispatch(unreblogFail(status, error)); + }); + }; + +const toggleReblog = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.reblogged) { + dispatch(unreblog(status)); + } else { + dispatch(reblog(status)); + } + }; + +const reblogRequest = (status: StatusEntity) => ({ + type: REBLOG_REQUEST, + status: status, + skipLoading: true, +}); + +const reblogSuccess = (status: StatusEntity) => ({ + type: REBLOG_SUCCESS, + status: status, + skipLoading: true, +}); + +const reblogFail = (status: StatusEntity, error: AxiosError) => ({ + type: REBLOG_FAIL, + status: status, + error: error, + skipLoading: true, +}); + +const unreblogRequest = (status: StatusEntity) => ({ + type: UNREBLOG_REQUEST, + status: status, + skipLoading: true, +}); + +const unreblogSuccess = (status: StatusEntity) => ({ + type: UNREBLOG_SUCCESS, + status: status, + skipLoading: true, +}); + +const unreblogFail = (status: StatusEntity, error: AxiosError) => ({ + type: UNREBLOG_FAIL, + status: status, + error: error, + skipLoading: true, +}); + +const favourite = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(favouriteRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function(response) { + dispatch(favouriteSuccess(status)); + }).catch(function(error) { + dispatch(favouriteFail(status, error)); + }); + }; + +const unfavourite = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(unfavouriteRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(() => { + dispatch(unfavouriteSuccess(status)); + }).catch(error => { + dispatch(unfavouriteFail(status, error)); + }); + }; + +const toggleFavourite = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.favourited) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }; + + +const favouriteRequest = (status: StatusEntity) => ({ + type: FAVOURITE_REQUEST, + status: status, + skipLoading: true, +}); + +const favouriteSuccess = (status: StatusEntity) => ({ + type: FAVOURITE_SUCCESS, + status: status, + skipLoading: true, +}); + +const favouriteFail = (status: StatusEntity, error: AxiosError) => ({ + type: FAVOURITE_FAIL, + status: status, + error: error, + skipLoading: true, +}); + +const unfavouriteRequest = (status: StatusEntity) => ({ + type: UNFAVOURITE_REQUEST, + status: status, + skipLoading: true, +}); + +const unfavouriteSuccess = (status: StatusEntity) => ({ + type: UNFAVOURITE_SUCCESS, + status: status, + skipLoading: true, +}); + +const unfavouriteFail = (status: StatusEntity, error: AxiosError) => ({ + type: UNFAVOURITE_FAIL, + status: status, + error: error, + skipLoading: true, +}); + +const bookmark = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(bookmarkRequest(status)); + + 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')); + }).catch(function(error) { + dispatch(bookmarkFail(status, error)); + }); + }; + +const unbookmark = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(unbookmarkRequest(status)); + + 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)); + }).catch(error => { + dispatch(unbookmarkFail(status, error)); + }); + }; + +const toggleBookmark = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.bookmarked) { + dispatch(unbookmark(status)); + } else { + dispatch(bookmark(status)); + } + }; + +const bookmarkRequest = (status: StatusEntity) => ({ + type: BOOKMARK_REQUEST, + status: status, +}); + +const bookmarkSuccess = (status: StatusEntity, response: APIEntity) => ({ + type: BOOKMARK_SUCCESS, + status: status, + response: response, +}); + +const bookmarkFail = (status: StatusEntity, error: AxiosError) => ({ + type: BOOKMARK_FAIL, + status: status, + error: error, +}); + +const unbookmarkRequest = (status: StatusEntity) => ({ + type: UNBOOKMARK_REQUEST, + status: status, +}); + +const unbookmarkSuccess = (status: StatusEntity, response: APIEntity) => ({ + type: UNBOOKMARK_SUCCESS, + status: status, + response: response, +}); + +const unbookmarkFail = (status: StatusEntity, error: AxiosError) => ({ + type: UNBOOKMARK_FAIL, + status: status, + error, +}); + +const fetchReblogs = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchReblogsRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchReblogsSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchReblogsFail(id, error)); + }); + }; + +const fetchReblogsRequest = (id: string) => ({ + type: REBLOGS_FETCH_REQUEST, + id, +}); + +const fetchReblogsSuccess = (id: string, accounts: APIEntity[]) => ({ + type: REBLOGS_FETCH_SUCCESS, + id, + accounts, +}); + +const fetchReblogsFail = (id: string, error: AxiosError) => ({ + type: REBLOGS_FETCH_FAIL, + id, + error, +}); + +const fetchFavourites = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchFavouritesRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFavouritesSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchFavouritesFail(id, error)); + }); + }; + +const fetchFavouritesRequest = (id: string) => ({ + type: FAVOURITES_FETCH_REQUEST, + id, +}); + +const fetchFavouritesSuccess = (id: string, accounts: APIEntity[]) => ({ + type: FAVOURITES_FETCH_SUCCESS, + id, + accounts, +}); + +const fetchFavouritesFail = (id: string, error: AxiosError) => ({ + type: FAVOURITES_FETCH_FAIL, + id, + error, +}); + +const fetchReactions = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchReactionsRequest(id)); + + api(getState).get(`/api/v1/pleroma/statuses/${id}/reactions`).then(response => { + dispatch(importFetchedAccounts((response.data as APIEntity[]).map(({ accounts }) => accounts).flat())); + dispatch(fetchReactionsSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchReactionsFail(id, error)); + }); + }; + +const fetchReactionsRequest = (id: string) => ({ + type: REACTIONS_FETCH_REQUEST, + id, +}); + +const fetchReactionsSuccess = (id: string, reactions: APIEntity[]) => ({ + type: REACTIONS_FETCH_SUCCESS, + id, + reactions, +}); + +const fetchReactionsFail = (id: string, error: AxiosError) => ({ + type: REACTIONS_FETCH_FAIL, + id, + error, +}); + +const pin = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(pinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(pinSuccess(status)); + }).catch(error => { + dispatch(pinFail(status, error)); + }); + }; + +const pinRequest = (status: StatusEntity) => ({ + type: PIN_REQUEST, + status, + skipLoading: true, +}); + +const pinSuccess = (status: StatusEntity) => ({ + type: PIN_SUCCESS, + status, + skipLoading: true, +}); + +const pinFail = (status: StatusEntity, error: AxiosError) => ({ + type: PIN_FAIL, + status, + error, + skipLoading: true, +}); + +const unpin = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(unpinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(unpinSuccess(status)); + }).catch(error => { + dispatch(unpinFail(status, error)); + }); + }; + +const togglePin = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.pinned) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } + }; + +const unpinRequest = (status: StatusEntity) => ({ + type: UNPIN_REQUEST, + status, + skipLoading: true, +}); + +const unpinSuccess = (status: StatusEntity) => ({ + type: UNPIN_SUCCESS, + status, + skipLoading: true, +}); + +const unpinFail = (status: StatusEntity, error: AxiosError) => ({ + type: UNPIN_FAIL, + status, + error, + skipLoading: true, +}); + +const remoteInteraction = (ap_id: string, profile: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(remoteInteractionRequest(ap_id, profile)); + + return api(getState).post('/api/v1/pleroma/remote_interaction', { ap_id, profile }).then(({ data }) => { + if (data.error) throw new Error(data.error); + + dispatch(remoteInteractionSuccess(ap_id, profile, data.url)); + + return data.url; + }).catch(error => { + dispatch(remoteInteractionFail(ap_id, profile, error)); + throw error; + }); + }; + +const remoteInteractionRequest = (ap_id: string, profile: string) => ({ + type: REMOTE_INTERACTION_REQUEST, + ap_id, + profile, +}); + +const remoteInteractionSuccess = (ap_id: string, profile: string, url: string) => ({ + type: REMOTE_INTERACTION_SUCCESS, + ap_id, + profile, + url, +}); + +const remoteInteractionFail = (ap_id: string, profile: string, error: AxiosError) => ({ + type: REMOTE_INTERACTION_FAIL, + ap_id, + profile, + error, +}); + +export { + REBLOG_REQUEST, + REBLOG_SUCCESS, + REBLOG_FAIL, + FAVOURITE_REQUEST, + FAVOURITE_SUCCESS, + FAVOURITE_FAIL, + UNREBLOG_REQUEST, + UNREBLOG_SUCCESS, + UNREBLOG_FAIL, + UNFAVOURITE_REQUEST, + UNFAVOURITE_SUCCESS, + UNFAVOURITE_FAIL, + REBLOGS_FETCH_REQUEST, + REBLOGS_FETCH_SUCCESS, + REBLOGS_FETCH_FAIL, + FAVOURITES_FETCH_REQUEST, + FAVOURITES_FETCH_SUCCESS, + FAVOURITES_FETCH_FAIL, + REACTIONS_FETCH_REQUEST, + REACTIONS_FETCH_SUCCESS, + REACTIONS_FETCH_FAIL, + PIN_REQUEST, + PIN_SUCCESS, + PIN_FAIL, + UNPIN_REQUEST, + UNPIN_SUCCESS, + UNPIN_FAIL, + BOOKMARK_REQUEST, + BOOKMARK_SUCCESS, + BOOKMARK_FAIL, + UNBOOKMARK_REQUEST, + UNBOOKMARK_SUCCESS, + UNBOOKMARK_FAIL, + REMOTE_INTERACTION_REQUEST, + REMOTE_INTERACTION_SUCCESS, + REMOTE_INTERACTION_FAIL, + reblog, + unreblog, + toggleReblog, + reblogRequest, + reblogSuccess, + reblogFail, + unreblogRequest, + unreblogSuccess, + unreblogFail, + favourite, + unfavourite, + toggleFavourite, + favouriteRequest, + favouriteSuccess, + favouriteFail, + unfavouriteRequest, + unfavouriteSuccess, + unfavouriteFail, + bookmark, + unbookmark, + toggleBookmark, + bookmarkRequest, + bookmarkSuccess, + bookmarkFail, + unbookmarkRequest, + unbookmarkSuccess, + unbookmarkFail, + fetchReblogs, + fetchReblogsRequest, + fetchReblogsSuccess, + fetchReblogsFail, + fetchFavourites, + fetchFavouritesRequest, + fetchFavouritesSuccess, + fetchFavouritesFail, + fetchReactions, + fetchReactionsRequest, + fetchReactionsSuccess, + fetchReactionsFail, + pin, + pinRequest, + pinSuccess, + pinFail, + unpin, + unpinRequest, + unpinSuccess, + unpinFail, + togglePin, + remoteInteraction, + remoteInteractionRequest, + remoteInteractionSuccess, + remoteInteractionFail, +}; diff --git a/app/soapbox/actions/lists.js b/app/soapbox/actions/lists.js deleted file mode 100644 index 6b39978d2..000000000 --- a/app/soapbox/actions/lists.js +++ /dev/null @@ -1,394 +0,0 @@ -import { isLoggedIn } from 'soapbox/utils/auth'; - -import api from '../api'; - -import { showAlertForError } from './alerts'; -import { importFetchedAccounts } from './importer'; - -export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; -export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; -export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL'; - -export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST'; -export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS'; -export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL'; - -export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE'; -export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET'; -export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP'; - -export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST'; -export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS'; -export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL'; - -export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST'; -export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS'; -export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL'; - -export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST'; -export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS'; -export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL'; - -export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST'; -export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS'; -export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL'; - -export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE'; -export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY'; -export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR'; - -export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST'; -export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS'; -export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL'; - -export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST'; -export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS'; -export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL'; - -export const LIST_ADDER_RESET = 'LIST_ADDER_RESET'; -export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP'; - -export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST'; -export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS'; -export const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL'; - -export const fetchList = id => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - if (getState().getIn(['lists', id])) { - return; - } - - dispatch(fetchListRequest(id)); - - api(getState).get(`/api/v1/lists/${id}`) - .then(({ data }) => dispatch(fetchListSuccess(data))) - .catch(err => dispatch(fetchListFail(id, err))); -}; - -export const fetchListRequest = id => ({ - type: LIST_FETCH_REQUEST, - id, -}); - -export const fetchListSuccess = list => ({ - type: LIST_FETCH_SUCCESS, - list, -}); - -export const fetchListFail = (id, error) => ({ - type: LIST_FETCH_FAIL, - id, - error, -}); - -export const fetchLists = () => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(fetchListsRequest()); - - api(getState).get('/api/v1/lists') - .then(({ data }) => dispatch(fetchListsSuccess(data))) - .catch(err => dispatch(fetchListsFail(err))); -}; - -export const fetchListsRequest = () => ({ - type: LISTS_FETCH_REQUEST, -}); - -export const fetchListsSuccess = lists => ({ - type: LISTS_FETCH_SUCCESS, - lists, -}); - -export const fetchListsFail = error => ({ - type: LISTS_FETCH_FAIL, - error, -}); - -export const submitListEditor = shouldReset => (dispatch, getState) => { - const listId = getState().getIn(['listEditor', 'listId']); - const title = getState().getIn(['listEditor', 'title']); - - if (listId === null) { - dispatch(createList(title, shouldReset)); - } else { - dispatch(updateList(listId, title, shouldReset)); - } -}; - -export const setupListEditor = listId => (dispatch, getState) => { - dispatch({ - type: LIST_EDITOR_SETUP, - list: getState().getIn(['lists', listId]), - }); - - dispatch(fetchListAccounts(listId)); -}; - -export const changeListEditorTitle = value => ({ - type: LIST_EDITOR_TITLE_CHANGE, - value, -}); - -export const createList = (title, shouldReset) => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(createListRequest()); - - api(getState).post('/api/v1/lists', { title }).then(({ data }) => { - dispatch(createListSuccess(data)); - - if (shouldReset) { - dispatch(resetListEditor()); - } - }).catch(err => dispatch(createListFail(err))); -}; - -export const createListRequest = () => ({ - type: LIST_CREATE_REQUEST, -}); - -export const createListSuccess = list => ({ - type: LIST_CREATE_SUCCESS, - list, -}); - -export const createListFail = error => ({ - type: LIST_CREATE_FAIL, - error, -}); - -export const updateList = (id, title, shouldReset) => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(updateListRequest(id)); - - api(getState).put(`/api/v1/lists/${id}`, { title }).then(({ data }) => { - dispatch(updateListSuccess(data)); - - if (shouldReset) { - dispatch(resetListEditor()); - } - }).catch(err => dispatch(updateListFail(id, err))); -}; - -export const updateListRequest = id => ({ - type: LIST_UPDATE_REQUEST, - id, -}); - -export const updateListSuccess = list => ({ - type: LIST_UPDATE_SUCCESS, - list, -}); - -export const updateListFail = (id, error) => ({ - type: LIST_UPDATE_FAIL, - id, - error, -}); - -export const resetListEditor = () => ({ - type: LIST_EDITOR_RESET, -}); - -export const deleteList = id => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(deleteListRequest(id)); - - api(getState).delete(`/api/v1/lists/${id}`) - .then(() => dispatch(deleteListSuccess(id))) - .catch(err => dispatch(deleteListFail(id, err))); -}; - -export const deleteListRequest = id => ({ - type: LIST_DELETE_REQUEST, - id, -}); - -export const deleteListSuccess = id => ({ - type: LIST_DELETE_SUCCESS, - id, -}); - -export const deleteListFail = (id, error) => ({ - type: LIST_DELETE_FAIL, - id, - error, -}); - -export const fetchListAccounts = listId => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(fetchListAccountsRequest(listId)); - - api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchListAccountsSuccess(listId, data)); - }).catch(err => dispatch(fetchListAccountsFail(listId, err))); -}; - -export const fetchListAccountsRequest = id => ({ - type: LIST_ACCOUNTS_FETCH_REQUEST, - id, -}); - -export const fetchListAccountsSuccess = (id, accounts, next) => ({ - type: LIST_ACCOUNTS_FETCH_SUCCESS, - id, - accounts, - next, -}); - -export const fetchListAccountsFail = (id, error) => ({ - type: LIST_ACCOUNTS_FETCH_FAIL, - id, - error, -}); - -export const fetchListSuggestions = q => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - const params = { - q, - resolve: false, - limit: 4, - following: true, - }; - - api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchListSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); -}; - -export const fetchListSuggestionsReady = (query, accounts) => ({ - type: LIST_EDITOR_SUGGESTIONS_READY, - query, - accounts, -}); - -export const clearListSuggestions = () => ({ - type: LIST_EDITOR_SUGGESTIONS_CLEAR, -}); - -export const changeListSuggestions = value => ({ - type: LIST_EDITOR_SUGGESTIONS_CHANGE, - value, -}); - -export const addToListEditor = accountId => (dispatch, getState) => { - dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId)); -}; - -export const addToList = (listId, accountId) => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(addToListRequest(listId, accountId)); - - api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] }) - .then(() => dispatch(addToListSuccess(listId, accountId))) - .catch(err => dispatch(addToListFail(listId, accountId, err))); -}; - -export const addToListRequest = (listId, accountId) => ({ - type: LIST_EDITOR_ADD_REQUEST, - listId, - accountId, -}); - -export const addToListSuccess = (listId, accountId) => ({ - type: LIST_EDITOR_ADD_SUCCESS, - listId, - accountId, -}); - -export const addToListFail = (listId, accountId, error) => ({ - type: LIST_EDITOR_ADD_FAIL, - listId, - accountId, - error, -}); - -export const removeFromListEditor = accountId => (dispatch, getState) => { - dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId)); -}; - -export const removeFromList = (listId, accountId) => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(removeFromListRequest(listId, accountId)); - - api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } }) - .then(() => dispatch(removeFromListSuccess(listId, accountId))) - .catch(err => dispatch(removeFromListFail(listId, accountId, err))); -}; - -export const removeFromListRequest = (listId, accountId) => ({ - type: LIST_EDITOR_REMOVE_REQUEST, - listId, - accountId, -}); - -export const removeFromListSuccess = (listId, accountId) => ({ - type: LIST_EDITOR_REMOVE_SUCCESS, - listId, - accountId, -}); - -export const removeFromListFail = (listId, accountId, error) => ({ - type: LIST_EDITOR_REMOVE_FAIL, - listId, - accountId, - error, -}); - -export const resetListAdder = () => ({ - type: LIST_ADDER_RESET, -}); - -export const setupListAdder = accountId => (dispatch, getState) => { - dispatch({ - type: LIST_ADDER_SETUP, - account: getState().getIn(['accounts', accountId]), - }); - dispatch(fetchLists()); - dispatch(fetchAccountLists(accountId)); -}; - -export const fetchAccountLists = accountId => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch(fetchAccountListsRequest(accountId)); - - api(getState).get(`/api/v1/accounts/${accountId}/lists`) - .then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data))) - .catch(err => dispatch(fetchAccountListsFail(accountId, err))); -}; - -export const fetchAccountListsRequest = id => ({ - type: LIST_ADDER_LISTS_FETCH_REQUEST, - id, -}); - -export const fetchAccountListsSuccess = (id, lists) => ({ - type: LIST_ADDER_LISTS_FETCH_SUCCESS, - id, - lists, -}); - -export const fetchAccountListsFail = (id, err) => ({ - type: LIST_ADDER_LISTS_FETCH_FAIL, - id, - err, -}); - -export const addToListAdder = listId => (dispatch, getState) => { - dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId']))); -}; - -export const removeFromListAdder = listId => (dispatch, getState) => { - dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId']))); -}; diff --git a/app/soapbox/actions/lists.ts b/app/soapbox/actions/lists.ts new file mode 100644 index 000000000..bf7dba8ba --- /dev/null +++ b/app/soapbox/actions/lists.ts @@ -0,0 +1,486 @@ +import { isLoggedIn } from 'soapbox/utils/auth'; + +import api from '../api'; + +import { showAlertForError } from './alerts'; +import { importFetchedAccounts } from './importer'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; +const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; +const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL'; + +const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST'; +const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS'; +const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL'; + +const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE'; +const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET'; +const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP'; + +const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST'; +const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS'; +const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL'; + +const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST'; +const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS'; +const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL'; + +const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST'; +const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS'; +const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL'; + +const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST'; +const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS'; +const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL'; + +const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE'; +const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY'; +const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR'; + +const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST'; +const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS'; +const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL'; + +const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST'; +const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS'; +const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL'; + +const LIST_ADDER_RESET = 'LIST_ADDER_RESET'; +const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP'; + +const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST'; +const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS'; +const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL'; + +const fetchList = (id: string | number) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + if (getState().lists.get(String(id))) { + return; + } + + dispatch(fetchListRequest(id)); + + api(getState).get(`/api/v1/lists/${id}`) + .then(({ data }) => dispatch(fetchListSuccess(data))) + .catch(err => dispatch(fetchListFail(id, err))); +}; + +const fetchListRequest = (id: string | number) => ({ + type: LIST_FETCH_REQUEST, + id, +}); + +const fetchListSuccess = (list: APIEntity) => ({ + type: LIST_FETCH_SUCCESS, + list, +}); + +const fetchListFail = (id: string | number, error: AxiosError) => ({ + type: LIST_FETCH_FAIL, + id, + error, +}); + +const fetchLists = () => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchListsRequest()); + + api(getState).get('/api/v1/lists') + .then(({ data }) => dispatch(fetchListsSuccess(data))) + .catch(err => dispatch(fetchListsFail(err))); +}; + +const fetchListsRequest = () => ({ + type: LISTS_FETCH_REQUEST, +}); + +const fetchListsSuccess = (lists: APIEntity[]) => ({ + type: LISTS_FETCH_SUCCESS, + lists, +}); + +const fetchListsFail = (error: AxiosError) => ({ + type: LISTS_FETCH_FAIL, + error, +}); + +const submitListEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => { + const listId = getState().listEditor.listId!; + const title = getState().listEditor.title; + + if (listId === null) { + dispatch(createList(title, shouldReset)); + } else { + dispatch(updateList(listId, title, shouldReset)); + } +}; + +const setupListEditor = (listId: string | number) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: LIST_EDITOR_SETUP, + list: getState().lists.get(String(listId)), + }); + + dispatch(fetchListAccounts(listId)); +}; + +const changeListEditorTitle = (value: string) => ({ + type: LIST_EDITOR_TITLE_CHANGE, + value, +}); + +const createList = (title: string, shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(createListRequest()); + + api(getState).post('/api/v1/lists', { title }).then(({ data }) => { + dispatch(createListSuccess(data)); + + if (shouldReset) { + dispatch(resetListEditor()); + } + }).catch(err => dispatch(createListFail(err))); +}; + +const createListRequest = () => ({ + type: LIST_CREATE_REQUEST, +}); + +const createListSuccess = (list: APIEntity) => ({ + type: LIST_CREATE_SUCCESS, + list, +}); + +const createListFail = (error: AxiosError) => ({ + type: LIST_CREATE_FAIL, + error, +}); + +const updateList = (id: string | number, title: string, shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(updateListRequest(id)); + + api(getState).put(`/api/v1/lists/${id}`, { title }).then(({ data }) => { + dispatch(updateListSuccess(data)); + + if (shouldReset) { + dispatch(resetListEditor()); + } + }).catch(err => dispatch(updateListFail(id, err))); +}; + +const updateListRequest = (id: string | number) => ({ + type: LIST_UPDATE_REQUEST, + id, +}); + +const updateListSuccess = (list: APIEntity) => ({ + type: LIST_UPDATE_SUCCESS, + list, +}); + +const updateListFail = (id: string | number, error: AxiosError) => ({ + type: LIST_UPDATE_FAIL, + id, + error, +}); + +const resetListEditor = () => ({ + type: LIST_EDITOR_RESET, +}); + +const deleteList = (id: string | number) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(deleteListRequest(id)); + + api(getState).delete(`/api/v1/lists/${id}`) + .then(() => dispatch(deleteListSuccess(id))) + .catch(err => dispatch(deleteListFail(id, err))); +}; + +const deleteListRequest = (id: string | number) => ({ + type: LIST_DELETE_REQUEST, + id, +}); + +const deleteListSuccess = (id: string | number) => ({ + type: LIST_DELETE_SUCCESS, + id, +}); + +const deleteListFail = (id: string | number, error: AxiosError) => ({ + type: LIST_DELETE_FAIL, + id, + error, +}); + +const fetchListAccounts = (listId: string | number) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchListAccountsRequest(listId)); + + api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchListAccountsSuccess(listId, data, null)); + }).catch(err => dispatch(fetchListAccountsFail(listId, err))); +}; + +const fetchListAccountsRequest = (id: string | number) => ({ + type: LIST_ACCOUNTS_FETCH_REQUEST, + id, +}); + +const fetchListAccountsSuccess = (id: string | number, accounts: APIEntity[], next: string | null) => ({ + type: LIST_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchListAccountsFail = (id: string | number, error: AxiosError) => ({ + type: LIST_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +const fetchListSuggestions = (q: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const params = { + q, + resolve: false, + limit: 4, + following: true, + }; + + api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchListSuggestionsReady(q, data)); + }).catch(error => dispatch(showAlertForError(error))); +}; + +const fetchListSuggestionsReady = (query: string, accounts: APIEntity[]) => ({ + type: LIST_EDITOR_SUGGESTIONS_READY, + query, + accounts, +}); + +const clearListSuggestions = () => ({ + type: LIST_EDITOR_SUGGESTIONS_CLEAR, +}); + +const changeListSuggestions = (value: string) => ({ + type: LIST_EDITOR_SUGGESTIONS_CHANGE, + value, +}); + +const addToListEditor = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(addToList(getState().listEditor.listId!, accountId)); +}; + +const addToList = (listId: string | number, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(addToListRequest(listId, accountId)); + + api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] }) + .then(() => dispatch(addToListSuccess(listId, accountId))) + .catch(err => dispatch(addToListFail(listId, accountId, err))); +}; + +const addToListRequest = (listId: string | number, accountId: string) => ({ + type: LIST_EDITOR_ADD_REQUEST, + listId, + accountId, +}); + +const addToListSuccess = (listId: string | number, accountId: string) => ({ + type: LIST_EDITOR_ADD_SUCCESS, + listId, + accountId, +}); + +const addToListFail = (listId: string | number, accountId: string, error: APIEntity) => ({ + type: LIST_EDITOR_ADD_FAIL, + listId, + accountId, + error, +}); + +const removeFromListEditor = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(removeFromList(getState().listEditor.listId!, accountId)); +}; + +const removeFromList = (listId: string | number, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(removeFromListRequest(listId, accountId)); + + api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } }) + .then(() => dispatch(removeFromListSuccess(listId, accountId))) + .catch(err => dispatch(removeFromListFail(listId, accountId, err))); +}; + +const removeFromListRequest = (listId: string | number, accountId: string) => ({ + type: LIST_EDITOR_REMOVE_REQUEST, + listId, + accountId, +}); + +const removeFromListSuccess = (listId: string | number, accountId: string) => ({ + type: LIST_EDITOR_REMOVE_SUCCESS, + listId, + accountId, +}); + +const removeFromListFail = (listId: string | number, accountId: string, error: AxiosError) => ({ + type: LIST_EDITOR_REMOVE_FAIL, + listId, + accountId, + error, +}); + +const resetListAdder = () => ({ + type: LIST_ADDER_RESET, +}); + +const setupListAdder = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: LIST_ADDER_SETUP, + account: getState().accounts.get(accountId), + }); + dispatch(fetchLists()); + dispatch(fetchAccountLists(accountId)); +}; + +const fetchAccountLists = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchAccountListsRequest(accountId)); + + api(getState).get(`/api/v1/accounts/${accountId}/lists`) + .then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data))) + .catch(err => dispatch(fetchAccountListsFail(accountId, err))); +}; + +const fetchAccountListsRequest = (id: string) => ({ + type: LIST_ADDER_LISTS_FETCH_REQUEST, + id, +}); + +const fetchAccountListsSuccess = (id: string, lists: APIEntity[]) => ({ + type: LIST_ADDER_LISTS_FETCH_SUCCESS, + id, + lists, +}); + +const fetchAccountListsFail = (id: string, err: AxiosError) => ({ + type: LIST_ADDER_LISTS_FETCH_FAIL, + id, + err, +}); + +const addToListAdder = (listId: string | number) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(addToList(listId, getState().listAdder.accountId!)); +}; + +const removeFromListAdder = (listId: string | number) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(removeFromList(listId, getState().listAdder.accountId!)); +}; + +export { + LIST_FETCH_REQUEST, + LIST_FETCH_SUCCESS, + LIST_FETCH_FAIL, + LISTS_FETCH_REQUEST, + LISTS_FETCH_SUCCESS, + LISTS_FETCH_FAIL, + LIST_EDITOR_TITLE_CHANGE, + LIST_EDITOR_RESET, + LIST_EDITOR_SETUP, + LIST_CREATE_REQUEST, + LIST_CREATE_SUCCESS, + LIST_CREATE_FAIL, + LIST_UPDATE_REQUEST, + LIST_UPDATE_SUCCESS, + LIST_UPDATE_FAIL, + LIST_DELETE_REQUEST, + LIST_DELETE_SUCCESS, + LIST_DELETE_FAIL, + LIST_ACCOUNTS_FETCH_REQUEST, + LIST_ACCOUNTS_FETCH_SUCCESS, + LIST_ACCOUNTS_FETCH_FAIL, + LIST_EDITOR_SUGGESTIONS_CHANGE, + LIST_EDITOR_SUGGESTIONS_READY, + LIST_EDITOR_SUGGESTIONS_CLEAR, + LIST_EDITOR_ADD_REQUEST, + LIST_EDITOR_ADD_SUCCESS, + LIST_EDITOR_ADD_FAIL, + LIST_EDITOR_REMOVE_REQUEST, + LIST_EDITOR_REMOVE_SUCCESS, + LIST_EDITOR_REMOVE_FAIL, + LIST_ADDER_RESET, + LIST_ADDER_SETUP, + LIST_ADDER_LISTS_FETCH_REQUEST, + LIST_ADDER_LISTS_FETCH_SUCCESS, + LIST_ADDER_LISTS_FETCH_FAIL, + fetchList, + fetchListRequest, + fetchListSuccess, + fetchListFail, + fetchLists, + fetchListsRequest, + fetchListsSuccess, + fetchListsFail, + submitListEditor, + setupListEditor, + changeListEditorTitle, + createList, + createListRequest, + createListSuccess, + createListFail, + updateList, + updateListRequest, + updateListSuccess, + updateListFail, + resetListEditor, + deleteList, + deleteListRequest, + deleteListSuccess, + deleteListFail, + fetchListAccounts, + fetchListAccountsRequest, + fetchListAccountsSuccess, + fetchListAccountsFail, + fetchListSuggestions, + fetchListSuggestionsReady, + clearListSuggestions, + changeListSuggestions, + addToListEditor, + addToList, + addToListRequest, + addToListSuccess, + addToListFail, + removeFromListEditor, + removeFromList, + removeFromListRequest, + removeFromListSuccess, + removeFromListFail, + resetListAdder, + setupListAdder, + fetchAccountLists, + fetchAccountListsRequest, + fetchAccountListsSuccess, + fetchAccountListsFail, + addToListAdder, + removeFromListAdder, +}; diff --git a/app/soapbox/actions/markers.js b/app/soapbox/actions/markers.js deleted file mode 100644 index 3d7d12c7e..000000000 --- a/app/soapbox/actions/markers.js +++ /dev/null @@ -1,33 +0,0 @@ -import api from '../api'; - -export const MARKER_FETCH_REQUEST = 'MARKER_FETCH_REQUEST'; -export const MARKER_FETCH_SUCCESS = 'MARKER_FETCH_SUCCESS'; -export const MARKER_FETCH_FAIL = 'MARKER_FETCH_FAIL'; - -export const MARKER_SAVE_REQUEST = 'MARKER_SAVE_REQUEST'; -export const MARKER_SAVE_SUCCESS = 'MARKER_SAVE_SUCCESS'; -export const MARKER_SAVE_FAIL = 'MARKER_SAVE_FAIL'; - -export function fetchMarker(timeline) { - return (dispatch, getState) => { - dispatch({ type: MARKER_FETCH_REQUEST }); - return api(getState).get('/api/v1/markers', { - params: { timeline }, - }).then(({ data: marker }) => { - dispatch({ type: MARKER_FETCH_SUCCESS, marker }); - }).catch(error => { - dispatch({ type: MARKER_FETCH_FAIL, error }); - }); - }; -} - -export function saveMarker(marker) { - return (dispatch, getState) => { - dispatch({ type: MARKER_SAVE_REQUEST, marker }); - return api(getState).post('/api/v1/markers', marker).then(({ data: marker }) => { - dispatch({ type: MARKER_SAVE_SUCCESS, marker }); - }).catch(error => { - dispatch({ type: MARKER_SAVE_FAIL, error }); - }); - }; -} diff --git a/app/soapbox/actions/markers.ts b/app/soapbox/actions/markers.ts new file mode 100644 index 000000000..96ba12349 --- /dev/null +++ b/app/soapbox/actions/markers.ts @@ -0,0 +1,45 @@ +import api from '../api'; + +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const MARKER_FETCH_REQUEST = 'MARKER_FETCH_REQUEST'; +const MARKER_FETCH_SUCCESS = 'MARKER_FETCH_SUCCESS'; +const MARKER_FETCH_FAIL = 'MARKER_FETCH_FAIL'; + +const MARKER_SAVE_REQUEST = 'MARKER_SAVE_REQUEST'; +const MARKER_SAVE_SUCCESS = 'MARKER_SAVE_SUCCESS'; +const MARKER_SAVE_FAIL = 'MARKER_SAVE_FAIL'; + +const fetchMarker = (timeline: Array) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: MARKER_FETCH_REQUEST }); + return api(getState).get('/api/v1/markers', { + params: { timeline }, + }).then(({ data: marker }) => { + dispatch({ type: MARKER_FETCH_SUCCESS, marker }); + }).catch(error => { + dispatch({ type: MARKER_FETCH_FAIL, error }); + }); + }; + +const saveMarker = (marker: APIEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: MARKER_SAVE_REQUEST, marker }); + return api(getState).post('/api/v1/markers', marker).then(({ data: marker }) => { + dispatch({ type: MARKER_SAVE_SUCCESS, marker }); + }).catch(error => { + dispatch({ type: MARKER_SAVE_FAIL, error }); + }); + }; + +export { + MARKER_FETCH_REQUEST, + MARKER_FETCH_SUCCESS, + MARKER_FETCH_FAIL, + MARKER_SAVE_REQUEST, + MARKER_SAVE_SUCCESS, + MARKER_SAVE_FAIL, + fetchMarker, + saveMarker, +}; diff --git a/app/soapbox/actions/me.js b/app/soapbox/actions/me.js deleted file mode 100644 index 96afcab7d..000000000 --- a/app/soapbox/actions/me.js +++ /dev/null @@ -1,123 +0,0 @@ -import KVStore from 'soapbox/storage/kv_store'; -import { getAuthUserId, getAuthUserUrl } from 'soapbox/utils/auth'; - -import api from '../api'; - -import { loadCredentials } from './auth'; -import { importFetchedAccount } from './importer'; - -export const ME_FETCH_REQUEST = 'ME_FETCH_REQUEST'; -export const ME_FETCH_SUCCESS = 'ME_FETCH_SUCCESS'; -export const ME_FETCH_FAIL = 'ME_FETCH_FAIL'; -export const ME_FETCH_SKIP = 'ME_FETCH_SKIP'; - -export const ME_PATCH_REQUEST = 'ME_PATCH_REQUEST'; -export const ME_PATCH_SUCCESS = 'ME_PATCH_SUCCESS'; -export const ME_PATCH_FAIL = 'ME_PATCH_FAIL'; - -const noOp = () => new Promise(f => f()); - -const getMeId = state => state.get('me') || getAuthUserId(state); - -const getMeUrl = state => { - const accountId = getMeId(state); - return state.getIn(['accounts', accountId, 'url']) || getAuthUserUrl(state); -}; - -const getMeToken = state => { - // Fallback for upgrading IDs to URLs - const accountUrl = getMeUrl(state) || state.getIn(['auth', 'me']); - return state.getIn(['auth', 'users', accountUrl, 'access_token']); -}; - -export function fetchMe() { - return (dispatch, getState) => { - const state = getState(); - const token = getMeToken(state); - const accountUrl = getMeUrl(state); - - if (!token) { - dispatch({ type: ME_FETCH_SKIP }); return noOp(); - } - - dispatch(fetchMeRequest()); - return dispatch(loadCredentials(token, accountUrl)).catch(error => { - dispatch(fetchMeFail(error)); - }); - }; -} - -/** Update the auth account in IndexedDB for Mastodon, etc. */ -const persistAuthAccount = (account, params) => { - if (account && account.url) { - if (!account.pleroma) account.pleroma = {}; - - if (!account.pleroma.settings_store) { - account.pleroma.settings_store = params.pleroma_settings_store || {}; - } - KVStore.setItem(`authAccount:${account.url}`, account).catch(console.error); - } -}; - -export function patchMe(params) { - return (dispatch, getState) => { - dispatch(patchMeRequest()); - - return api(getState) - .patch('/api/v1/accounts/update_credentials', params) - .then(response => { - persistAuthAccount(response.data, params); - dispatch(patchMeSuccess(response.data)); - }).catch(error => { - dispatch(patchMeFail(error)); - throw error; - }); - }; -} - -export function fetchMeRequest() { - return { - type: ME_FETCH_REQUEST, - }; -} - -export function fetchMeSuccess(me) { - return (dispatch, getState) => { - dispatch({ - type: ME_FETCH_SUCCESS, - me, - }); - }; -} - -export function fetchMeFail(error) { - return { - type: ME_FETCH_FAIL, - error, - skipAlert: true, - }; -} - -export function patchMeRequest() { - return { - type: ME_PATCH_REQUEST, - }; -} - -export function patchMeSuccess(me) { - return (dispatch, getState) => { - dispatch(importFetchedAccount(me)); - dispatch({ - type: ME_PATCH_SUCCESS, - me, - }); - }; -} - -export function patchMeFail(error) { - return { - type: ME_PATCH_FAIL, - error, - skipAlert: true, - }; -} diff --git a/app/soapbox/actions/me.ts b/app/soapbox/actions/me.ts new file mode 100644 index 000000000..e76399d21 --- /dev/null +++ b/app/soapbox/actions/me.ts @@ -0,0 +1,137 @@ +import KVStore from 'soapbox/storage/kv_store'; +import { getAuthUserId, getAuthUserUrl } from 'soapbox/utils/auth'; + +import api from '../api'; + +import { loadCredentials } from './auth'; +import { importFetchedAccount } from './importer'; + +import type { AxiosError, AxiosRequestHeaders } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const ME_FETCH_REQUEST = 'ME_FETCH_REQUEST'; +const ME_FETCH_SUCCESS = 'ME_FETCH_SUCCESS'; +const ME_FETCH_FAIL = 'ME_FETCH_FAIL'; +const ME_FETCH_SKIP = 'ME_FETCH_SKIP'; + +const ME_PATCH_REQUEST = 'ME_PATCH_REQUEST'; +const ME_PATCH_SUCCESS = 'ME_PATCH_SUCCESS'; +const ME_PATCH_FAIL = 'ME_PATCH_FAIL'; + +const noOp = () => new Promise(f => f(undefined)); + +const getMeId = (state: RootState) => state.me || getAuthUserId(state); + +const getMeUrl = (state: RootState) => { + const accountId = getMeId(state); + return state.accounts.get(accountId)?.url || getAuthUserUrl(state); +}; + +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 fetchMe = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const token = getMeToken(state); + const accountUrl = getMeUrl(state); + + if (!token) { + dispatch({ type: ME_FETCH_SKIP }); + return noOp(); + } + + dispatch(fetchMeRequest()); + return dispatch(loadCredentials(token, accountUrl)) + .catch(error => dispatch(fetchMeFail(error))); + }; + +/** Update the auth account in IndexedDB for Mastodon, etc. */ +const persistAuthAccount = (account: APIEntity, params: Record) => { + if (account && account.url) { + if (!account.pleroma) account.pleroma = {}; + + if (!account.pleroma.settings_store) { + account.pleroma.settings_store = params.pleroma_settings_store || {}; + } + KVStore.setItem(`authAccount:${account.url}`, account).catch(console.error); + } +}; + +const patchMe = (params: Record, isFormData = false) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(patchMeRequest()); + + const headers: AxiosRequestHeaders = isFormData ? { + 'Content-Type': 'multipart/form-data', + } : {}; + + return api(getState) + .patch('/api/v1/accounts/update_credentials', params, { headers }) + .then(response => { + persistAuthAccount(response.data, params); + dispatch(patchMeSuccess(response.data)); + }).catch(error => { + dispatch(patchMeFail(error)); + throw error; + }); + }; + +const fetchMeRequest = () => ({ + type: ME_FETCH_REQUEST, +}); + +const fetchMeSuccess = (me: APIEntity) => + (dispatch: AppDispatch) => { + dispatch({ + type: ME_FETCH_SUCCESS, + me, + }); + }; + +const fetchMeFail = (error: APIEntity) => ({ + type: ME_FETCH_FAIL, + error, + skipAlert: true, +}); + +const patchMeRequest = () => ({ + type: ME_PATCH_REQUEST, +}); + +const patchMeSuccess = (me: APIEntity) => + (dispatch: AppDispatch) => { + dispatch(importFetchedAccount(me)); + dispatch({ + type: ME_PATCH_SUCCESS, + me, + }); + }; + +const patchMeFail = (error: AxiosError) => ({ + type: ME_PATCH_FAIL, + error, + skipAlert: true, +}); + +export { + ME_FETCH_REQUEST, + ME_FETCH_SUCCESS, + ME_FETCH_FAIL, + ME_FETCH_SKIP, + ME_PATCH_REQUEST, + ME_PATCH_SUCCESS, + ME_PATCH_FAIL, + fetchMe, + patchMe, + fetchMeRequest, + fetchMeSuccess, + fetchMeFail, + patchMeRequest, + patchMeSuccess, + patchMeFail, +}; diff --git a/app/soapbox/actions/media.js b/app/soapbox/actions/media.js deleted file mode 100644 index 460c2f079..000000000 --- a/app/soapbox/actions/media.js +++ /dev/null @@ -1,47 +0,0 @@ -import { getFeatures } from 'soapbox/utils/features'; - -import api from '../api'; - -const noOp = () => {}; - -export function fetchMedia(mediaId) { - return (dispatch, getState) => { - return api(getState).get(`/api/v1/media/${mediaId}`); - }; -} - -export function updateMedia(mediaId, params) { - return (dispatch, getState) => { - return api(getState).put(`/api/v1/media/${mediaId}`, params); - }; -} - -export function uploadMediaV1(data, onUploadProgress = noOp) { - return (dispatch, getState) => { - return api(getState).post('/api/v1/media', data, { - onUploadProgress: onUploadProgress, - }); - }; -} - -export function uploadMediaV2(data, onUploadProgress = noOp) { - return (dispatch, getState) => { - return api(getState).post('/api/v2/media', data, { - onUploadProgress: onUploadProgress, - }); - }; -} - -export function uploadMedia(data, onUploadProgress = noOp) { - return (dispatch, getState) => { - const state = getState(); - const instance = state.get('instance'); - const features = getFeatures(instance); - - if (features.mediaV2) { - return dispatch(uploadMediaV2(data, onUploadProgress)); - } else { - return dispatch(uploadMediaV1(data, onUploadProgress)); - } - }; -} diff --git a/app/soapbox/actions/media.ts b/app/soapbox/actions/media.ts new file mode 100644 index 000000000..15c637c35 --- /dev/null +++ b/app/soapbox/actions/media.ts @@ -0,0 +1,50 @@ +import { getFeatures } from 'soapbox/utils/features'; + +import api from '../api'; + +import type { AppDispatch, RootState } from 'soapbox/store'; + +const noOp = (e: any) => {}; + +const fetchMedia = (mediaId: string) => + (dispatch: any, getState: () => RootState) => { + return api(getState).get(`/api/v1/media/${mediaId}`); + }; + +const updateMedia = (mediaId: string, params: Record) => + (dispatch: any, getState: () => RootState) => { + return api(getState).put(`/api/v1/media/${mediaId}`, params); + }; + +const uploadMediaV1 = (data: FormData, onUploadProgress = noOp) => + (dispatch: any, getState: () => RootState) => + api(getState).post('/api/v1/media', data, { + onUploadProgress: onUploadProgress, + }); + +const uploadMediaV2 = (data: FormData, onUploadProgress = noOp) => + (dispatch: any, getState: () => RootState) => + api(getState).post('/api/v2/media', data, { + onUploadProgress: onUploadProgress, + }); + +const uploadMedia = (data: FormData, onUploadProgress = noOp) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.mediaV2) { + return dispatch(uploadMediaV2(data, onUploadProgress)); + } else { + return dispatch(uploadMediaV1(data, onUploadProgress)); + } + }; + +export { + fetchMedia, + updateMedia, + uploadMediaV1, + uploadMediaV2, + uploadMedia, +}; diff --git a/app/soapbox/actions/mfa.js b/app/soapbox/actions/mfa.js deleted file mode 100644 index d41c60f52..000000000 --- a/app/soapbox/actions/mfa.js +++ /dev/null @@ -1,84 +0,0 @@ -import api from '../api'; - -export const MFA_FETCH_REQUEST = 'MFA_FETCH_REQUEST'; -export const MFA_FETCH_SUCCESS = 'MFA_FETCH_SUCCESS'; -export const MFA_FETCH_FAIL = 'MFA_FETCH_FAIL'; - -export const MFA_BACKUP_CODES_FETCH_REQUEST = 'MFA_BACKUP_CODES_FETCH_REQUEST'; -export const MFA_BACKUP_CODES_FETCH_SUCCESS = 'MFA_BACKUP_CODES_FETCH_SUCCESS'; -export const MFA_BACKUP_CODES_FETCH_FAIL = 'MFA_BACKUP_CODES_FETCH_FAIL'; - -export const MFA_SETUP_REQUEST = 'MFA_SETUP_REQUEST'; -export const MFA_SETUP_SUCCESS = 'MFA_SETUP_SUCCESS'; -export const MFA_SETUP_FAIL = 'MFA_SETUP_FAIL'; - -export const MFA_CONFIRM_REQUEST = 'MFA_CONFIRM_REQUEST'; -export const MFA_CONFIRM_SUCCESS = 'MFA_CONFIRM_SUCCESS'; -export const MFA_CONFIRM_FAIL = 'MFA_CONFIRM_FAIL'; - -export const MFA_DISABLE_REQUEST = 'MFA_DISABLE_REQUEST'; -export const MFA_DISABLE_SUCCESS = 'MFA_DISABLE_SUCCESS'; -export const MFA_DISABLE_FAIL = 'MFA_DISABLE_FAIL'; - -export function fetchMfa() { - return (dispatch, getState) => { - dispatch({ type: MFA_FETCH_REQUEST }); - return api(getState).get('/api/pleroma/accounts/mfa').then(({ data }) => { - dispatch({ type: MFA_FETCH_SUCCESS, data }); - }).catch(error => { - dispatch({ type: MFA_FETCH_FAIL }); - }); - }; -} - -export function fetchBackupCodes() { - return (dispatch, getState) => { - dispatch({ type: MFA_BACKUP_CODES_FETCH_REQUEST }); - return api(getState).get('/api/pleroma/accounts/mfa/backup_codes').then(({ data }) => { - dispatch({ type: MFA_BACKUP_CODES_FETCH_SUCCESS, data }); - return data; - }).catch(error => { - dispatch({ type: MFA_BACKUP_CODES_FETCH_FAIL }); - }); - }; -} - -export function setupMfa(method) { - return (dispatch, getState) => { - dispatch({ type: MFA_SETUP_REQUEST, method }); - return api(getState).get(`/api/pleroma/accounts/mfa/setup/${method}`).then(({ data }) => { - dispatch({ type: MFA_SETUP_SUCCESS, data }); - return data; - }).catch(error => { - dispatch({ type: MFA_SETUP_FAIL }); - throw error; - }); - }; -} - -export function confirmMfa(method, code, password) { - return (dispatch, getState) => { - const params = { code, password }; - dispatch({ type: MFA_CONFIRM_REQUEST, method, code }); - return api(getState).post(`/api/pleroma/accounts/mfa/confirm/${method}`, params).then(({ data }) => { - dispatch({ type: MFA_CONFIRM_SUCCESS, method, code }); - return data; - }).catch(error => { - dispatch({ type: MFA_CONFIRM_FAIL, method, code, error, skipAlert: true }); - throw error; - }); - }; -} - -export function disableMfa(method, password) { - return (dispatch, getState) => { - dispatch({ type: MFA_DISABLE_REQUEST, method }); - return api(getState).delete(`/api/pleroma/accounts/mfa/${method}`, { data: { password } }).then(({ data }) => { - dispatch({ type: MFA_DISABLE_SUCCESS, method }); - return data; - }).catch(error => { - dispatch({ type: MFA_DISABLE_FAIL, method, skipAlert: true }); - throw error; - }); - }; -} diff --git a/app/soapbox/actions/mfa.ts b/app/soapbox/actions/mfa.ts new file mode 100644 index 000000000..4c1e85832 --- /dev/null +++ b/app/soapbox/actions/mfa.ts @@ -0,0 +1,105 @@ +import api from '../api'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; + +const MFA_FETCH_REQUEST = 'MFA_FETCH_REQUEST'; +const MFA_FETCH_SUCCESS = 'MFA_FETCH_SUCCESS'; +const MFA_FETCH_FAIL = 'MFA_FETCH_FAIL'; + +const MFA_BACKUP_CODES_FETCH_REQUEST = 'MFA_BACKUP_CODES_FETCH_REQUEST'; +const MFA_BACKUP_CODES_FETCH_SUCCESS = 'MFA_BACKUP_CODES_FETCH_SUCCESS'; +const MFA_BACKUP_CODES_FETCH_FAIL = 'MFA_BACKUP_CODES_FETCH_FAIL'; + +const MFA_SETUP_REQUEST = 'MFA_SETUP_REQUEST'; +const MFA_SETUP_SUCCESS = 'MFA_SETUP_SUCCESS'; +const MFA_SETUP_FAIL = 'MFA_SETUP_FAIL'; + +const MFA_CONFIRM_REQUEST = 'MFA_CONFIRM_REQUEST'; +const MFA_CONFIRM_SUCCESS = 'MFA_CONFIRM_SUCCESS'; +const MFA_CONFIRM_FAIL = 'MFA_CONFIRM_FAIL'; + +const MFA_DISABLE_REQUEST = 'MFA_DISABLE_REQUEST'; +const MFA_DISABLE_SUCCESS = 'MFA_DISABLE_SUCCESS'; +const MFA_DISABLE_FAIL = 'MFA_DISABLE_FAIL'; + +const fetchMfa = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: MFA_FETCH_REQUEST }); + return api(getState).get('/api/pleroma/accounts/mfa').then(({ data }) => { + dispatch({ type: MFA_FETCH_SUCCESS, data }); + }).catch(() => { + dispatch({ type: MFA_FETCH_FAIL }); + }); + }; + +const fetchBackupCodes = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: MFA_BACKUP_CODES_FETCH_REQUEST }); + return api(getState).get('/api/pleroma/accounts/mfa/backup_codes').then(({ data }) => { + dispatch({ type: MFA_BACKUP_CODES_FETCH_SUCCESS, data }); + return data; + }).catch(() => { + dispatch({ type: MFA_BACKUP_CODES_FETCH_FAIL }); + }); + }; + +const setupMfa = (method: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: MFA_SETUP_REQUEST, method }); + return api(getState).get(`/api/pleroma/accounts/mfa/setup/${method}`).then(({ data }) => { + dispatch({ type: MFA_SETUP_SUCCESS, data }); + return data; + }).catch((error: AxiosError) => { + dispatch({ type: MFA_SETUP_FAIL }); + throw error; + }); + }; + +const confirmMfa = (method: string, code: string, password: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const params = { code, password }; + dispatch({ type: MFA_CONFIRM_REQUEST, method, code }); + return api(getState).post(`/api/pleroma/accounts/mfa/confirm/${method}`, params).then(({ data }) => { + dispatch({ type: MFA_CONFIRM_SUCCESS, method, code }); + return data; + }).catch((error: AxiosError) => { + dispatch({ type: MFA_CONFIRM_FAIL, method, code, error, skipAlert: true }); + throw error; + }); + }; + +const disableMfa = (method: string, password: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: MFA_DISABLE_REQUEST, method }); + return api(getState).delete(`/api/pleroma/accounts/mfa/${method}`, { data: { password } }).then(({ data }) => { + dispatch({ type: MFA_DISABLE_SUCCESS, method }); + return data; + }).catch((error: AxiosError) => { + dispatch({ type: MFA_DISABLE_FAIL, method, skipAlert: true }); + throw error; + }); + }; + +export { + MFA_FETCH_REQUEST, + MFA_FETCH_SUCCESS, + MFA_FETCH_FAIL, + MFA_BACKUP_CODES_FETCH_REQUEST, + MFA_BACKUP_CODES_FETCH_SUCCESS, + MFA_BACKUP_CODES_FETCH_FAIL, + MFA_SETUP_REQUEST, + MFA_SETUP_SUCCESS, + MFA_SETUP_FAIL, + MFA_CONFIRM_REQUEST, + MFA_CONFIRM_SUCCESS, + MFA_CONFIRM_FAIL, + MFA_DISABLE_REQUEST, + MFA_DISABLE_SUCCESS, + MFA_DISABLE_FAIL, + fetchMfa, + fetchBackupCodes, + setupMfa, + confirmMfa, + disableMfa, +}; diff --git a/app/soapbox/actions/mobile.js b/app/soapbox/actions/mobile.ts similarity index 51% rename from app/soapbox/actions/mobile.js rename to app/soapbox/actions/mobile.ts index c7707c8f9..1e11f473d 100644 --- a/app/soapbox/actions/mobile.js +++ b/app/soapbox/actions/mobile.ts @@ -1,11 +1,13 @@ import { staticClient } from '../api'; -export const FETCH_MOBILE_PAGE_REQUEST = 'FETCH_MOBILE_PAGE_REQUEST'; -export const FETCH_MOBILE_PAGE_SUCCESS = 'FETCH_MOBILE_PAGE_SUCCESS'; -export const FETCH_MOBILE_PAGE_FAIL = 'FETCH_MOBILE_PAGE_FAIL'; +import type { AppDispatch } from 'soapbox/store'; -export function fetchMobilePage(slug = 'index', locale) { - return (dispatch, getState) => { +const FETCH_MOBILE_PAGE_REQUEST = 'FETCH_MOBILE_PAGE_REQUEST'; +const FETCH_MOBILE_PAGE_SUCCESS = 'FETCH_MOBILE_PAGE_SUCCESS'; +const FETCH_MOBILE_PAGE_FAIL = 'FETCH_MOBILE_PAGE_FAIL'; + +const fetchMobilePage = (slug = 'index', locale?: string) => + (dispatch: AppDispatch) => { dispatch({ type: FETCH_MOBILE_PAGE_REQUEST, slug, locale }); const filename = `${slug}${locale ? `.${locale}` : ''}.html`; return staticClient.get(`/instance/mobile/${filename}`).then(({ data: html }) => { @@ -16,4 +18,10 @@ export function fetchMobilePage(slug = 'index', locale) { throw error; }); }; -} + +export { + FETCH_MOBILE_PAGE_REQUEST, + FETCH_MOBILE_PAGE_SUCCESS, + FETCH_MOBILE_PAGE_FAIL, + fetchMobilePage, +}; \ No newline at end of file diff --git a/app/soapbox/actions/modals.ts b/app/soapbox/actions/modals.ts index 9d6e85139..3e1a106cf 100644 --- a/app/soapbox/actions/modals.ts +++ b/app/soapbox/actions/modals.ts @@ -11,7 +11,7 @@ export function openModal(type: string, props?: any) { } /** Close the modal */ -export function closeModal(type: string) { +export function closeModal(type?: string) { return { type: MODAL_CLOSE, modalType: type, diff --git a/app/soapbox/actions/moderation.js b/app/soapbox/actions/moderation.tsx similarity index 78% rename from app/soapbox/actions/moderation.js rename to app/soapbox/actions/moderation.tsx index d84242d66..ea5861eca 100644 --- a/app/soapbox/actions/moderation.js +++ b/app/soapbox/actions/moderation.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { defineMessages } from 'react-intl'; +import { defineMessages, IntlShape } from 'react-intl'; import { fetchAccountByUsername } from 'soapbox/actions/accounts'; import { deactivateUsers, deleteUsers, deleteStatus, toggleStatusSensitivity } from 'soapbox/actions/admin'; @@ -8,6 +8,8 @@ import snackbar from 'soapbox/actions/snackbar'; import AccountContainer from 'soapbox/containers/account_container'; import { isLocal } from 'soapbox/utils/accounts'; +import type { AppDispatch, RootState } from 'soapbox/store'; + const messages = defineMessages({ deactivateUserHeading: { id: 'confirmations.admin.deactivate_user.heading', defaultMessage: 'Deactivate @{acct}' }, deactivateUserPrompt: { id: 'confirmations.admin.deactivate_user.message', defaultMessage: 'You are about to deactivate @{acct}. Deactivating a user is a reversible action.' }, @@ -35,14 +37,14 @@ const messages = defineMessages({ statusMarkedNotSensitive: { id: 'admin.statuses.status_marked_message_not_sensitive', defaultMessage: 'Post by @{acct} was marked not sensitive' }, }); -export function deactivateUserModal(intl, accountId, afterConfirm = () => {}) { - return function(dispatch, getState) { +const deactivateUserModal = (intl: IntlShape, accountId: string, afterConfirm = () => {}) => + (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - const acct = state.getIn(['accounts', accountId, 'acct']); - const name = state.getIn(['accounts', accountId, 'username']); + const acct = state.accounts.get(accountId)!.acct; + const name = state.accounts.get(accountId)!.username; dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/icons/user-off.svg'), + icon: require('@tabler/icons/user-off.svg'), heading: intl.formatMessage(messages.deactivateUserHeading, { acct }), message: intl.formatMessage(messages.deactivateUserPrompt, { acct }), confirm: intl.formatMessage(messages.deactivateUserConfirm, { name }), @@ -55,15 +57,15 @@ export function deactivateUserModal(intl, accountId, afterConfirm = () => {}) { }, })); }; -} -export function deleteUserModal(intl, accountId, afterConfirm = () => {}) { - return function(dispatch, getState) { +const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () => {}) => + (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - const acct = state.getIn(['accounts', accountId, 'acct']); - const name = state.getIn(['accounts', accountId, 'username']); - const favicon = state.getIn(['accounts', accountId, 'pleroma', 'favicon']); - const local = isLocal(state.getIn(['accounts', accountId])); + const account = state.accounts.get(accountId)!; + const acct = account.acct; + const name = account.username; + const favicon = account.pleroma.get('favicon'); + const local = isLocal(account); const message = (<> @@ -81,7 +83,7 @@ export function deleteUserModal(intl, accountId, afterConfirm = () => {}) { const checkbox = local ? intl.formatMessage(messages.deleteLocalUserCheckbox) : false; dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/icons/user-minus.svg'), + icon: require('@tabler/icons/user-minus.svg'), heading: intl.formatMessage(messages.deleteUserHeading, { acct }), message, confirm, @@ -96,16 +98,15 @@ export function deleteUserModal(intl, accountId, afterConfirm = () => {}) { }, })); }; -} -export function rejectUserModal(intl, accountId, afterConfirm = () => {}) { - return function(dispatch, getState) { +const rejectUserModal = (intl: IntlShape, accountId: string, afterConfirm = () => {}) => + (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - const acct = state.getIn(['accounts', accountId, 'acct']); - const name = state.getIn(['accounts', accountId, 'username']); + const acct = state.accounts.get(accountId)!.acct; + const name = state.accounts.get(accountId)!.username; dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/icons/user-off.svg'), + icon: require('@tabler/icons/user-off.svg'), heading: intl.formatMessage(messages.rejectUserHeading, { acct }), message: intl.formatMessage(messages.rejectUserPrompt, { acct }), confirm: intl.formatMessage(messages.rejectUserConfirm, { name }), @@ -118,16 +119,15 @@ export function rejectUserModal(intl, accountId, afterConfirm = () => {}) { }, })); }; -} -export function toggleStatusSensitivityModal(intl, statusId, sensitive, afterConfirm = () => {}) { - return function(dispatch, getState) { +const toggleStatusSensitivityModal = (intl: IntlShape, statusId: string, sensitive: boolean, afterConfirm = () => {}) => + (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - const accountId = state.getIn(['statuses', statusId, 'account']); - const acct = state.getIn(['accounts', accountId, 'acct']); + const accountId = state.statuses.get(statusId)!.account; + const acct = state.accounts.get(accountId)!.acct; dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/icons/alert-triangle.svg'), + icon: require('@tabler/icons/alert-triangle.svg'), heading: intl.formatMessage(sensitive === false ? messages.markStatusSensitiveHeading : messages.markStatusNotSensitiveHeading), message: intl.formatMessage(sensitive === false ? messages.markStatusSensitivePrompt : messages.markStatusNotSensitivePrompt, { acct }), confirm: intl.formatMessage(sensitive === false ? messages.markStatusSensitiveConfirm : messages.markStatusNotSensitiveConfirm), @@ -140,16 +140,15 @@ export function toggleStatusSensitivityModal(intl, statusId, sensitive, afterCon }, })); }; -} -export function deleteStatusModal(intl, statusId, afterConfirm = () => {}) { - return function(dispatch, getState) { +const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = () => {}) => + (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - const accountId = state.getIn(['statuses', statusId, 'account']); - const acct = state.getIn(['accounts', accountId, 'acct']); + const accountId = state.statuses.get(statusId)!.account; + const acct = state.accounts.get(accountId)!.acct; dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/icons/trash.svg'), + icon: require('@tabler/icons/trash.svg'), heading: intl.formatMessage(messages.deleteStatusHeading), message: intl.formatMessage(messages.deleteStatusPrompt, { acct }), confirm: intl.formatMessage(messages.deleteStatusConfirm), @@ -162,4 +161,11 @@ export function deleteStatusModal(intl, statusId, afterConfirm = () => {}) { }, })); }; -} + +export { + deactivateUserModal, + deleteUserModal, + rejectUserModal, + toggleStatusSensitivityModal, + deleteStatusModal, +}; diff --git a/app/soapbox/actions/mrf.js b/app/soapbox/actions/mrf.js deleted file mode 100644 index 39359e965..000000000 --- a/app/soapbox/actions/mrf.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Set as ImmutableSet } from 'immutable'; - -import ConfigDB from 'soapbox/utils/config_db'; - -import { fetchConfig, updateConfig } from './admin'; - -const simplePolicyMerge = (simplePolicy, host, restrictions) => { - return simplePolicy.map((hosts, key) => { - const isRestricted = restrictions.get(key); - - if (isRestricted) { - return ImmutableSet(hosts).add(host); - } else { - return ImmutableSet(hosts).delete(host); - } - }); -}; - -export function updateMrf(host, restrictions) { - return (dispatch, getState) => { - return dispatch(fetchConfig()) - .then(() => { - const configs = getState().getIn(['admin', 'configs']); - const simplePolicy = ConfigDB.toSimplePolicy(configs); - const merged = simplePolicyMerge(simplePolicy, host, restrictions); - const config = ConfigDB.fromSimplePolicy(merged); - dispatch(updateConfig(config)); - }); - }; -} diff --git a/app/soapbox/actions/mrf.ts b/app/soapbox/actions/mrf.ts new file mode 100644 index 000000000..e2cef5938 --- /dev/null +++ b/app/soapbox/actions/mrf.ts @@ -0,0 +1,33 @@ +import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable'; + +import ConfigDB from 'soapbox/utils/config_db'; + +import { fetchConfig, updateConfig } from './admin'; + +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { Policy } from 'soapbox/utils/config_db'; + +const simplePolicyMerge = (simplePolicy: Policy, host: string, restrictions: ImmutableMap) => { + return simplePolicy.map((hosts, key) => { + const isRestricted = restrictions.get(key); + + if (isRestricted) { + return ImmutableSet(hosts).add(host); + } else { + return ImmutableSet(hosts).delete(host); + } + }); +}; + +const updateMrf = (host: string, restrictions: ImmutableMap) => + (dispatch: AppDispatch, getState: () => RootState) => + dispatch(fetchConfig()) + .then(() => { + const configs = getState().admin.get('configs'); + const simplePolicy = ConfigDB.toSimplePolicy(configs); + const merged = simplePolicyMerge(simplePolicy, host, restrictions); + const config = ConfigDB.fromSimplePolicy(merged); + return dispatch(updateConfig(config.toJS() as Array>)); + }); + +export { updateMrf }; diff --git a/app/soapbox/actions/mutes.js b/app/soapbox/actions/mutes.js deleted file mode 100644 index f204ea9b8..000000000 --- a/app/soapbox/actions/mutes.js +++ /dev/null @@ -1,116 +0,0 @@ -import { isLoggedIn } from 'soapbox/utils/auth'; -import { getNextLinkName } from 'soapbox/utils/quirks'; - -import api, { getLinks } from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts } from './importer'; -import { openModal } from './modals'; - -export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; -export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS'; -export const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL'; - -export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; -export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; -export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; - -export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; -export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; - -export function fetchMutes() { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - const nextLinkName = getNextLinkName(getState); - - dispatch(fetchMutesRequest()); - - api(getState).get('/api/v1/mutes').then(response => { - const next = getLinks(response).refs.find(link => link.rel === nextLinkName); - dispatch(importFetchedAccounts(response.data)); - dispatch(fetchMutesSuccess(response.data, next ? next.uri : null)); - dispatch(fetchRelationships(response.data.map(item => item.id))); - }).catch(error => dispatch(fetchMutesFail(error))); - }; -} - -export function fetchMutesRequest() { - return { - type: MUTES_FETCH_REQUEST, - }; -} - -export function fetchMutesSuccess(accounts, next) { - return { - type: MUTES_FETCH_SUCCESS, - accounts, - next, - }; -} - -export function fetchMutesFail(error) { - return { - type: MUTES_FETCH_FAIL, - error, - }; -} - -export function expandMutes() { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - const nextLinkName = getNextLinkName(getState); - - const url = getState().getIn(['user_lists', 'mutes', 'next']); - - if (url === null) { - return; - } - - dispatch(expandMutesRequest()); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === nextLinkName); - dispatch(importFetchedAccounts(response.data)); - dispatch(expandMutesSuccess(response.data, next ? next.uri : null)); - dispatch(fetchRelationships(response.data.map(item => item.id))); - }).catch(error => dispatch(expandMutesFail(error))); - }; -} - -export function expandMutesRequest() { - return { - type: MUTES_EXPAND_REQUEST, - }; -} - -export function expandMutesSuccess(accounts, next) { - return { - type: MUTES_EXPAND_SUCCESS, - accounts, - next, - }; -} - -export function expandMutesFail(error) { - return { - type: MUTES_EXPAND_FAIL, - error, - }; -} - -export function initMuteModal(account) { - return dispatch => { - dispatch({ - type: MUTES_INIT_MODAL, - account, - }); - - dispatch(openModal('MUTE')); - }; -} - -export function toggleHideNotifications() { - return dispatch => { - dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); - }; -} diff --git a/app/soapbox/actions/mutes.ts b/app/soapbox/actions/mutes.ts new file mode 100644 index 000000000..050a513f0 --- /dev/null +++ b/app/soapbox/actions/mutes.ts @@ -0,0 +1,125 @@ +import { isLoggedIn } from 'soapbox/utils/auth'; +import { getNextLinkName } from 'soapbox/utils/quirks'; + +import api, { getLinks } from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; +import { openModal } from './modals'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity, Account as AccountEntity } from 'soapbox/types/entities'; + +const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; +const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS'; +const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL'; + +const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; +const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; +const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; + +const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; +const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; + +const fetchMutes = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const nextLinkName = getNextLinkName(getState); + + dispatch(fetchMutesRequest()); + + api(getState).get('/api/v1/mutes').then(response => { + const next = getLinks(response).refs.find(link => link.rel === nextLinkName); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchMutesSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => dispatch(fetchMutesFail(error))); + }; + +const fetchMutesRequest = () => ({ + type: MUTES_FETCH_REQUEST, +}); + +const fetchMutesSuccess = (accounts: APIEntity[], next: string | null) => ({ + type: MUTES_FETCH_SUCCESS, + accounts, + next, +}); + +const fetchMutesFail = (error: AxiosError) => ({ + type: MUTES_FETCH_FAIL, + error, +}); + +const expandMutes = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const nextLinkName = getNextLinkName(getState); + + const url = getState().user_lists.mutes.next; + + if (url === null) { + return; + } + + dispatch(expandMutesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === nextLinkName); + dispatch(importFetchedAccounts(response.data)); + dispatch(expandMutesSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => dispatch(expandMutesFail(error))); + }; + +const expandMutesRequest = () => ({ + type: MUTES_EXPAND_REQUEST, +}); + +const expandMutesSuccess = (accounts: APIEntity[], next: string | null) => ({ + type: MUTES_EXPAND_SUCCESS, + accounts, + next, +}); + +const expandMutesFail = (error: AxiosError) => ({ + type: MUTES_EXPAND_FAIL, + error, +}); + +const initMuteModal = (account: AccountEntity) => + (dispatch: AppDispatch) => { + dispatch({ + type: MUTES_INIT_MODAL, + account, + }); + + dispatch(openModal('MUTE')); + }; + +const toggleHideNotifications = () => + (dispatch: AppDispatch) => { + dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); + }; + +export { + MUTES_FETCH_REQUEST, + MUTES_FETCH_SUCCESS, + MUTES_FETCH_FAIL, + MUTES_EXPAND_REQUEST, + MUTES_EXPAND_SUCCESS, + MUTES_EXPAND_FAIL, + MUTES_INIT_MODAL, + MUTES_TOGGLE_HIDE_NOTIFICATIONS, + fetchMutes, + fetchMutesRequest, + fetchMutesSuccess, + fetchMutesFail, + expandMutes, + expandMutesRequest, + expandMutesSuccess, + expandMutesFail, + initMuteModal, + toggleHideNotifications, +}; diff --git a/app/soapbox/actions/notifications.js b/app/soapbox/actions/notifications.ts similarity index 53% rename from app/soapbox/actions/notifications.js rename to app/soapbox/actions/notifications.ts index 66918c698..79994bde5 100644 --- a/app/soapbox/actions/notifications.js +++ b/app/soapbox/actions/notifications.ts @@ -1,20 +1,19 @@ import { List as ImmutableList, Map as ImmutableMap, - OrderedMap as ImmutableOrderedMap, } from 'immutable'; import IntlMessageFormat from 'intl-messageformat'; import 'intl-pluralrules'; import { defineMessages } from 'react-intl'; +import api, { getLinks } from 'soapbox/api'; +import compareId from 'soapbox/compare_id'; +import { getFilters, regexFromFilters } from 'soapbox/selectors'; import { isLoggedIn } from 'soapbox/utils/auth'; -import { parseVersion, PLEROMA } from 'soapbox/utils/features'; +import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features'; +import { unescapeHTML } from 'soapbox/utils/html'; import { joinPublicPath } from 'soapbox/utils/static'; -import api, { getLinks } from '../api'; -import { getFilters, regexFromFilters } from '../selectors'; -import { unescapeHTML } from '../utils/html'; - import { fetchRelationships } from './accounts'; import { importFetchedAccount, @@ -25,32 +24,36 @@ import { import { saveMarker } from './markers'; import { getSettings, saveSettings } from './settings'; -export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; -export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; -export const NOTIFICATIONS_UPDATE_QUEUE = 'NOTIFICATIONS_UPDATE_QUEUE'; -export const NOTIFICATIONS_DEQUEUE = 'NOTIFICATIONS_DEQUEUE'; +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; -export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; -export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; -export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; +const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; +const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; +const NOTIFICATIONS_UPDATE_QUEUE = 'NOTIFICATIONS_UPDATE_QUEUE'; +const NOTIFICATIONS_DEQUEUE = 'NOTIFICATIONS_DEQUEUE'; -export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; +const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; +const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; +const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; -export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; -export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; +const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; -export const NOTIFICATIONS_MARK_READ_REQUEST = 'NOTIFICATIONS_MARK_READ_REQUEST'; -export const NOTIFICATIONS_MARK_READ_SUCCESS = 'NOTIFICATIONS_MARK_READ_SUCCESS'; -export const NOTIFICATIONS_MARK_READ_FAIL = 'NOTIFICATIONS_MARK_READ_FAIL'; +const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; +const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; -export const MAX_QUEUED_NOTIFICATIONS = 40; +const NOTIFICATIONS_MARK_READ_REQUEST = 'NOTIFICATIONS_MARK_READ_REQUEST'; +const NOTIFICATIONS_MARK_READ_SUCCESS = 'NOTIFICATIONS_MARK_READ_SUCCESS'; +const NOTIFICATIONS_MARK_READ_FAIL = 'NOTIFICATIONS_MARK_READ_FAIL'; + +const MAX_QUEUED_NOTIFICATIONS = 40; defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, }); -const fetchRelatedRelationships = (dispatch, notifications) => { +const fetchRelatedRelationships = (dispatch: AppDispatch, notifications: APIEntity[]) => { const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); if (accountIds.length > 0) { @@ -58,8 +61,8 @@ const fetchRelatedRelationships = (dispatch, notifications) => { } }; -export function updateNotifications(notification, intlMessages, intlLocale) { - return (dispatch, getState) => { +const updateNotifications = (notification: APIEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { const showInColumn = getSettings(getState()).getIn(['notifications', 'shows', notification.type], true); if (notification.account) { @@ -84,17 +87,17 @@ export function updateNotifications(notification, intlMessages, intlLocale) { fetchRelatedRelationships(dispatch, [notification]); } }; -} -export function updateNotificationsQueue(notification, intlMessages, intlLocale, curPath) { - return (dispatch, getState) => { +const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record, intlLocale: string, curPath: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!notification.type) return; // drop invalid notifications if (notification.type === 'pleroma:chat_mention') return; // Drop chat notifications, handle them per-chat const showAlert = getSettings(getState()).getIn(['notifications', 'alerts', notification.type]); const filters = getFilters(getState(), { contextType: 'notifications' }); const playSound = getSettings(getState()).getIn(['notifications', 'sounds', notification.type]); - let filtered = false; + let filtered: boolean | null = false; const isOnNotificationsPage = curPath === '/notifications'; @@ -140,21 +143,20 @@ export function updateNotificationsQueue(notification, intlMessages, intlLocale, intlLocale, }); } else { - dispatch(updateNotifications(notification, intlMessages, intlLocale)); + dispatch(updateNotifications(notification)); } }; -} -export function dequeueNotifications() { - return (dispatch, getState) => { - const queuedNotifications = getState().getIn(['notifications', 'queuedNotifications'], ImmutableOrderedMap()); - const totalQueuedNotificationsCount = getState().getIn(['notifications', 'totalQueuedNotificationsCount'], 0); +const dequeueNotifications = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const queuedNotifications = getState().notifications.get('queuedNotifications'); + const totalQueuedNotificationsCount = getState().notifications.get('totalQueuedNotificationsCount'); if (totalQueuedNotificationsCount === 0) { return; } else if (totalQueuedNotificationsCount > 0 && totalQueuedNotificationsCount <= MAX_QUEUED_NOTIFICATIONS) { - queuedNotifications.forEach(block => { - dispatch(updateNotifications(block.notification, block.intlMessages, block.intlLocale)); + queuedNotifications.forEach((block: APIEntity) => { + dispatch(updateNotifications(block.notification)); }); } else { dispatch(expandNotifications()); @@ -165,23 +167,23 @@ export function dequeueNotifications() { }); dispatch(markReadNotifications()); }; -} -const excludeTypesFromSettings = getState => getSettings(getState()).getIn(['notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); +// const excludeTypesFromSettings = (getState: () => RootState) => (getSettings(getState()).getIn(['notifications', 'shows']) as ImmutableMap).filter(enabled => !enabled).keySeq().toJS(); -const excludeTypesFromFilter = filter => { +const excludeTypesFromFilter = (filter: string) => { const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'status', 'poll', 'move', 'pleroma:emoji_reaction']); return allTypes.filterNot(item => item === filter).toJS(); }; -const noOp = () => {}; +const noOp = () => new Promise(f => f(undefined)); -export function expandNotifications({ maxId } = {}, done = noOp) { - return (dispatch, getState) => { +const expandNotifications = ({ maxId }: Record = {}, done: () => any = noOp) => + (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return dispatch(noOp); - const activeFilter = getSettings(getState()).getIn(['notifications', 'quickFilter', 'active']); - const notifications = getState().get('notifications'); + const state = getState(); + const activeFilter = getSettings(state).getIn(['notifications', 'quickFilter', 'active']) as string; + const notifications = state.notifications; const isLoadingMore = !!maxId; if (notifications.get('isLoading')) { @@ -189,13 +191,21 @@ export function expandNotifications({ maxId } = {}, done = noOp) { return dispatch(noOp); } - const params = { + const params: Record = { max_id: maxId, - exclude_types: activeFilter === 'all' - ? excludeTypesFromSettings(getState) - : excludeTypesFromFilter(activeFilter), }; + if (activeFilter !== 'all') { + const instance = state.instance; + const features = getFeatures(instance); + + if (features.notificationsIncludeTypes) { + params.types = [activeFilter]; + } else { + params.exclude_types = excludeTypesFromFilter(activeFilter); + } + } + if (!maxId && notifications.get('items').size > 0) { params.since_id = notifications.getIn(['items', 0, 'id']); } @@ -205,7 +215,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) { return api(getState).get('/api/v1/notifications', { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); - const entries = response.data.reduce((acc, item) => { + const entries = (response.data as APIEntity[]).reduce((acc, item) => { if (item.account?.id) { acc.accounts[item.account.id] = item.account; } @@ -233,34 +243,27 @@ export function expandNotifications({ maxId } = {}, done = noOp) { done(); }); }; -} -export function expandNotificationsRequest(isLoadingMore) { - return { - type: NOTIFICATIONS_EXPAND_REQUEST, - skipLoading: !isLoadingMore, - }; -} +const expandNotificationsRequest = (isLoadingMore: boolean) => ({ + type: NOTIFICATIONS_EXPAND_REQUEST, + skipLoading: !isLoadingMore, +}); -export function expandNotificationsSuccess(notifications, next, isLoadingMore) { - return { - type: NOTIFICATIONS_EXPAND_SUCCESS, - notifications, - next, - skipLoading: !isLoadingMore, - }; -} +const expandNotificationsSuccess = (notifications: APIEntity[], next: string | null, isLoadingMore: boolean) => ({ + type: NOTIFICATIONS_EXPAND_SUCCESS, + notifications, + next, + skipLoading: !isLoadingMore, +}); -export function expandNotificationsFail(error, isLoadingMore) { - return { - type: NOTIFICATIONS_EXPAND_FAIL, - error, - skipLoading: !isLoadingMore, - }; -} +const expandNotificationsFail = (error: AxiosError, isLoadingMore: boolean) => ({ + type: NOTIFICATIONS_EXPAND_FAIL, + error, + skipLoading: !isLoadingMore, +}); -export function clearNotifications() { - return (dispatch, getState) => { +const clearNotifications = () => + (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; dispatch({ @@ -269,20 +272,18 @@ export function clearNotifications() { api(getState).post('/api/v1/notifications/clear'); }; -} -export function scrollTopNotifications(top) { - return (dispatch, getState) => { +const scrollTopNotifications = (top: boolean) => + (dispatch: AppDispatch) => { dispatch({ type: NOTIFICATIONS_SCROLL_TOP, top, }); dispatch(markReadNotifications()); }; -} -export function setFilter(filterType) { - return dispatch => { +const setFilter = (filterType: string) => + (dispatch: AppDispatch) => { dispatch({ type: NOTIFICATIONS_FILTER_SET, path: ['notifications', 'quickFilter', 'active'], @@ -291,38 +292,63 @@ export function setFilter(filterType) { dispatch(expandNotifications()); dispatch(saveSettings()); }; -} // Of course Markers don't work properly in Pleroma. // https://git.pleroma.social/pleroma/pleroma/-/issues/2769 -export function markReadPleroma(max_id) { - return (dispatch, getState) => { +const markReadPleroma = (max_id: string | number) => + (dispatch: AppDispatch, getState: () => RootState) => { return api(getState).post('/api/v1/pleroma/notifications/read', { max_id }); }; -} -export function markReadNotifications() { - return (dispatch, getState) => { +const markReadNotifications = () => + (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; const state = getState(); - const instance = state.get('instance'); - const topNotificationId = state.getIn(['notifications', 'items'], ImmutableOrderedMap()).first(ImmutableMap()).get('id'); - const lastReadId = state.getIn(['notifications', 'lastRead']); - const v = parseVersion(instance.get('version')); + const topNotificationId: string | undefined = state.notifications.get('items').first(ImmutableMap()).get('id'); + const lastReadId: string | -1 = state.notifications.get('lastRead'); + const v = parseVersion(state.instance.version); - if (!(topNotificationId && topNotificationId > lastReadId)) return; + if (topNotificationId && (lastReadId === -1 || compareId(topNotificationId, lastReadId) > 0)) { + const marker = { + notifications: { + last_read_id: topNotificationId, + }, + }; - const marker = { - notifications: { - last_read_id: topNotificationId, - }, - }; + dispatch(saveMarker(marker)); - dispatch(saveMarker(marker)); - - if (v.software === PLEROMA) { - dispatch(markReadPleroma(topNotificationId)); + if (v.software === PLEROMA) { + dispatch(markReadPleroma(topNotificationId)); + } } }; -} + +export { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_UPDATE_NOOP, + NOTIFICATIONS_UPDATE_QUEUE, + NOTIFICATIONS_DEQUEUE, + NOTIFICATIONS_EXPAND_REQUEST, + NOTIFICATIONS_EXPAND_SUCCESS, + NOTIFICATIONS_EXPAND_FAIL, + NOTIFICATIONS_FILTER_SET, + NOTIFICATIONS_CLEAR, + NOTIFICATIONS_SCROLL_TOP, + NOTIFICATIONS_MARK_READ_REQUEST, + NOTIFICATIONS_MARK_READ_SUCCESS, + NOTIFICATIONS_MARK_READ_FAIL, + MAX_QUEUED_NOTIFICATIONS, + updateNotifications, + updateNotificationsQueue, + dequeueNotifications, + expandNotifications, + expandNotificationsRequest, + expandNotificationsSuccess, + expandNotificationsFail, + clearNotifications, + scrollTopNotifications, + setFilter, + markReadPleroma, + markReadNotifications, +}; diff --git a/app/soapbox/actions/oauth.js b/app/soapbox/actions/oauth.ts similarity index 83% rename from app/soapbox/actions/oauth.js rename to app/soapbox/actions/oauth.ts index 9662972a8..55df6f1ae 100644 --- a/app/soapbox/actions/oauth.js +++ b/app/soapbox/actions/oauth.ts @@ -8,6 +8,8 @@ import { baseClient } from '../api'; +import type { AppDispatch } from 'soapbox/store'; + export const OAUTH_TOKEN_CREATE_REQUEST = 'OAUTH_TOKEN_CREATE_REQUEST'; export const OAUTH_TOKEN_CREATE_SUCCESS = 'OAUTH_TOKEN_CREATE_SUCCESS'; export const OAUTH_TOKEN_CREATE_FAIL = 'OAUTH_TOKEN_CREATE_FAIL'; @@ -16,8 +18,8 @@ export const OAUTH_TOKEN_REVOKE_REQUEST = 'OAUTH_TOKEN_REVOKE_REQUEST'; export const OAUTH_TOKEN_REVOKE_SUCCESS = 'OAUTH_TOKEN_REVOKE_SUCCESS'; export const OAUTH_TOKEN_REVOKE_FAIL = 'OAUTH_TOKEN_REVOKE_FAIL'; -export function obtainOAuthToken(params, baseURL) { - return (dispatch, getState) => { +export const obtainOAuthToken = (params: Record, baseURL?: string) => + (dispatch: AppDispatch) => { dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params }); return baseClient(null, baseURL).post('/oauth/token', params).then(({ data: token }) => { dispatch({ type: OAUTH_TOKEN_CREATE_SUCCESS, params, token }); @@ -27,10 +29,9 @@ export function obtainOAuthToken(params, baseURL) { throw error; }); }; -} -export function revokeOAuthToken(params) { - return (dispatch, getState) => { +export const revokeOAuthToken = (params: Record) => + (dispatch: AppDispatch) => { dispatch({ type: OAUTH_TOKEN_REVOKE_REQUEST, params }); return baseClient().post('/oauth/revoke', params).then(({ data }) => { dispatch({ type: OAUTH_TOKEN_REVOKE_SUCCESS, params, data }); @@ -40,4 +41,3 @@ export function revokeOAuthToken(params) { throw error; }); }; -} diff --git a/app/soapbox/actions/patron.js b/app/soapbox/actions/patron.js deleted file mode 100644 index a9a8ff4d5..000000000 --- a/app/soapbox/actions/patron.js +++ /dev/null @@ -1,62 +0,0 @@ -import api from '../api'; - -export const PATRON_INSTANCE_FETCH_REQUEST = 'PATRON_INSTANCE_FETCH_REQUEST'; -export const PATRON_INSTANCE_FETCH_SUCCESS = 'PATRON_INSTANCE_FETCH_SUCCESS'; -export const PATRON_INSTANCE_FETCH_FAIL = 'PATRON_INSTANCE_FETCH_FAIL'; - -export const PATRON_ACCOUNT_FETCH_REQUEST = 'PATRON_ACCOUNT_FETCH_REQUEST'; -export const PATRON_ACCOUNT_FETCH_SUCCESS = 'PATRON_ACCOUNT_FETCH_SUCCESS'; -export const PATRON_ACCOUNT_FETCH_FAIL = 'PATRON_ACCOUNT_FETCH_FAIL'; - -export function fetchPatronInstance() { - return (dispatch, getState) => { - dispatch({ type: PATRON_INSTANCE_FETCH_REQUEST }); - api(getState).get('/api/patron/v1/instance').then(response => { - dispatch(importFetchedInstance(response.data)); - }).catch(error => { - dispatch(fetchInstanceFail(error)); - }); - }; -} - -export function fetchPatronAccount(apId) { - return (dispatch, getState) => { - apId = encodeURIComponent(apId); - dispatch({ type: PATRON_ACCOUNT_FETCH_REQUEST }); - api(getState).get(`/api/patron/v1/accounts/${apId}`).then(response => { - dispatch(importFetchedAccount(response.data)); - }).catch(error => { - dispatch(fetchAccountFail(error)); - }); - }; -} - -function importFetchedInstance(instance) { - return { - type: PATRON_INSTANCE_FETCH_SUCCESS, - instance, - }; -} - -function fetchInstanceFail(error) { - return { - type: PATRON_INSTANCE_FETCH_FAIL, - error, - skipAlert: true, - }; -} - -function importFetchedAccount(account) { - return { - type: PATRON_ACCOUNT_FETCH_SUCCESS, - account, - }; -} - -function fetchAccountFail(error) { - return { - type: PATRON_ACCOUNT_FETCH_FAIL, - error, - skipAlert: true, - }; -} diff --git a/app/soapbox/actions/patron.ts b/app/soapbox/actions/patron.ts new file mode 100644 index 000000000..f2ecca5d1 --- /dev/null +++ b/app/soapbox/actions/patron.ts @@ -0,0 +1,71 @@ +import api from '../api'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const PATRON_INSTANCE_FETCH_REQUEST = 'PATRON_INSTANCE_FETCH_REQUEST'; +const PATRON_INSTANCE_FETCH_SUCCESS = 'PATRON_INSTANCE_FETCH_SUCCESS'; +const PATRON_INSTANCE_FETCH_FAIL = 'PATRON_INSTANCE_FETCH_FAIL'; + +const PATRON_ACCOUNT_FETCH_REQUEST = 'PATRON_ACCOUNT_FETCH_REQUEST'; +const PATRON_ACCOUNT_FETCH_SUCCESS = 'PATRON_ACCOUNT_FETCH_SUCCESS'; +const PATRON_ACCOUNT_FETCH_FAIL = 'PATRON_ACCOUNT_FETCH_FAIL'; + +const fetchPatronInstance = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: PATRON_INSTANCE_FETCH_REQUEST }); + return api(getState).get('/api/patron/v1/instance').then(response => { + dispatch(importFetchedInstance(response.data)); + }).catch(error => { + dispatch(fetchInstanceFail(error)); + }); + }; + +const fetchPatronAccount = (apId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + apId = encodeURIComponent(apId); + dispatch({ type: PATRON_ACCOUNT_FETCH_REQUEST }); + api(getState).get(`/api/patron/v1/accounts/${apId}`).then(response => { + dispatch(importFetchedAccount(response.data)); + }).catch(error => { + dispatch(fetchAccountFail(error)); + }); + }; + +const importFetchedInstance = (instance: APIEntity) => ({ + type: PATRON_INSTANCE_FETCH_SUCCESS, + instance, +}); + +const fetchInstanceFail = (error: AxiosError) => ({ + type: PATRON_INSTANCE_FETCH_FAIL, + error, + skipAlert: true, +}); + +const importFetchedAccount = (account: APIEntity) => ({ + type: PATRON_ACCOUNT_FETCH_SUCCESS, + account, +}); + +const fetchAccountFail = (error: AxiosError) => ({ + type: PATRON_ACCOUNT_FETCH_FAIL, + error, + skipAlert: true, +}); + +export { + PATRON_INSTANCE_FETCH_REQUEST, + PATRON_INSTANCE_FETCH_SUCCESS, + PATRON_INSTANCE_FETCH_FAIL, + PATRON_ACCOUNT_FETCH_REQUEST, + PATRON_ACCOUNT_FETCH_SUCCESS, + PATRON_ACCOUNT_FETCH_FAIL, + fetchPatronInstance, + fetchPatronAccount, + importFetchedInstance, + fetchInstanceFail, + importFetchedAccount, + fetchAccountFail, +}; diff --git a/app/soapbox/actions/pin_statuses.js b/app/soapbox/actions/pin_statuses.js deleted file mode 100644 index 26194f553..000000000 --- a/app/soapbox/actions/pin_statuses.js +++ /dev/null @@ -1,46 +0,0 @@ -import { isLoggedIn } from 'soapbox/utils/auth'; - -import api from '../api'; - -import { importFetchedStatuses } from './importer'; - -export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; -export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; -export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; - -export function fetchPinnedStatuses() { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - const me = getState().get('me'); - - dispatch(fetchPinnedStatusesRequest()); - - api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { - dispatch(importFetchedStatuses(response.data)); - dispatch(fetchPinnedStatusesSuccess(response.data, null)); - }).catch(error => { - dispatch(fetchPinnedStatusesFail(error)); - }); - }; -} - -export function fetchPinnedStatusesRequest() { - return { - type: PINNED_STATUSES_FETCH_REQUEST, - }; -} - -export function fetchPinnedStatusesSuccess(statuses, next) { - return { - type: PINNED_STATUSES_FETCH_SUCCESS, - statuses, - next, - }; -} - -export function fetchPinnedStatusesFail(error) { - return { - type: PINNED_STATUSES_FETCH_FAIL, - error, - }; -} diff --git a/app/soapbox/actions/pin_statuses.ts b/app/soapbox/actions/pin_statuses.ts new file mode 100644 index 000000000..dacf502c2 --- /dev/null +++ b/app/soapbox/actions/pin_statuses.ts @@ -0,0 +1,53 @@ +import { isLoggedIn } from 'soapbox/utils/auth'; + +import api from '../api'; + +import { importFetchedStatuses } from './importer'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; +const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; +const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; + +const fetchPinnedStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const me = getState().me; + + dispatch(fetchPinnedStatusesRequest()); + + api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchPinnedStatusesSuccess(response.data, null)); + }).catch(error => { + dispatch(fetchPinnedStatusesFail(error)); + }); + }; + +const fetchPinnedStatusesRequest = () => ({ + type: PINNED_STATUSES_FETCH_REQUEST, +}); + +const fetchPinnedStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: PINNED_STATUSES_FETCH_SUCCESS, + statuses, + next, +}); + +const fetchPinnedStatusesFail = (error: AxiosError) => ({ + type: PINNED_STATUSES_FETCH_FAIL, + error, +}); + +export { + PINNED_STATUSES_FETCH_REQUEST, + PINNED_STATUSES_FETCH_SUCCESS, + PINNED_STATUSES_FETCH_FAIL, + fetchPinnedStatuses, + fetchPinnedStatusesRequest, + fetchPinnedStatusesSuccess, + fetchPinnedStatusesFail, +}; diff --git a/app/soapbox/actions/polls.js b/app/soapbox/actions/polls.js deleted file mode 100644 index a37410dc9..000000000 --- a/app/soapbox/actions/polls.js +++ /dev/null @@ -1,61 +0,0 @@ -import api from '../api'; - -import { importFetchedPoll } from './importer'; - -export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; -export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS'; -export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL'; - -export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST'; -export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS'; -export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL'; - -export const vote = (pollId, choices) => (dispatch, getState) => { - dispatch(voteRequest()); - - api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices }) - .then(({ data }) => { - dispatch(importFetchedPoll(data)); - dispatch(voteSuccess(data)); - }) - .catch(err => dispatch(voteFail(err))); -}; - -export const fetchPoll = pollId => (dispatch, getState) => { - dispatch(fetchPollRequest()); - - api(getState).get(`/api/v1/polls/${pollId}`) - .then(({ data }) => { - dispatch(importFetchedPoll(data)); - dispatch(fetchPollSuccess(data)); - }) - .catch(err => dispatch(fetchPollFail(err))); -}; - -export const voteRequest = () => ({ - type: POLL_VOTE_REQUEST, -}); - -export const voteSuccess = poll => ({ - type: POLL_VOTE_SUCCESS, - poll, -}); - -export const voteFail = error => ({ - type: POLL_VOTE_FAIL, - error, -}); - -export const fetchPollRequest = () => ({ - type: POLL_FETCH_REQUEST, -}); - -export const fetchPollSuccess = poll => ({ - type: POLL_FETCH_SUCCESS, - poll, -}); - -export const fetchPollFail = error => ({ - type: POLL_FETCH_FAIL, - error, -}); diff --git a/app/soapbox/actions/polls.ts b/app/soapbox/actions/polls.ts new file mode 100644 index 000000000..da4579f71 --- /dev/null +++ b/app/soapbox/actions/polls.ts @@ -0,0 +1,84 @@ +import api from '../api'; + +import { importFetchedPoll } from './importer'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; +const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS'; +const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL'; + +const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST'; +const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS'; +const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL'; + +const vote = (pollId: string, choices: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(voteRequest()); + + api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices }) + .then(({ data }) => { + dispatch(importFetchedPoll(data)); + dispatch(voteSuccess(data)); + }) + .catch(err => dispatch(voteFail(err))); + }; + +const fetchPoll = (pollId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchPollRequest()); + + api(getState).get(`/api/v1/polls/${pollId}`) + .then(({ data }) => { + dispatch(importFetchedPoll(data)); + dispatch(fetchPollSuccess(data)); + }) + .catch(err => dispatch(fetchPollFail(err))); + }; + +const voteRequest = () => ({ + type: POLL_VOTE_REQUEST, +}); + +const voteSuccess = (poll: APIEntity) => ({ + type: POLL_VOTE_SUCCESS, + poll, +}); + +const voteFail = (error: AxiosError) => ({ + type: POLL_VOTE_FAIL, + error, +}); + +const fetchPollRequest = () => ({ + type: POLL_FETCH_REQUEST, +}); + +const fetchPollSuccess = (poll: APIEntity) => ({ + type: POLL_FETCH_SUCCESS, + poll, +}); + +const fetchPollFail = (error: AxiosError) => ({ + type: POLL_FETCH_FAIL, + error, +}); + +export { + POLL_VOTE_REQUEST, + POLL_VOTE_SUCCESS, + POLL_VOTE_FAIL, + POLL_FETCH_REQUEST, + POLL_FETCH_SUCCESS, + POLL_FETCH_FAIL, + vote, + fetchPoll, + voteRequest, + voteSuccess, + voteFail, + fetchPollRequest, + fetchPollSuccess, + fetchPollFail, +}; diff --git a/app/soapbox/actions/preload.js b/app/soapbox/actions/preload.js deleted file mode 100644 index d14c6f9fe..000000000 --- a/app/soapbox/actions/preload.js +++ /dev/null @@ -1,64 +0,0 @@ -import { mapValues } from 'lodash'; - -import { verifyCredentials } from './auth'; -import { importFetchedAccounts } from './importer'; - -export const PLEROMA_PRELOAD_IMPORT = 'PLEROMA_PRELOAD_IMPORT'; -export const MASTODON_PRELOAD_IMPORT = 'MASTODON_PRELOAD_IMPORT'; - -// https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1176/diffs -const decodeUTF8Base64 = data => { - const rawData = atob(data); - const array = Uint8Array.from(rawData.split('').map((char) => char.charCodeAt(0))); - const text = new TextDecoder().decode(array); - return text; -}; - -const decodePleromaData = data => { - return mapValues(data, base64string => JSON.parse(decodeUTF8Base64(base64string))); -}; - -const pleromaDecoder = json => decodePleromaData(JSON.parse(json)); - -// This will throw if it fails. -// Should be called inside a try-catch. -const decodeFromMarkup = (elementId, decoder) => { - const { textContent } = document.getElementById(elementId); - return decoder(textContent); -}; - -function preloadFromMarkup(elementId, decoder, action) { - return (dispatch, getState) => { - try { - const data = decodeFromMarkup(elementId, decoder); - dispatch(action(data)); - } catch { - // Do nothing - } - }; -} - -export function preload() { - return (dispatch, getState) => { - dispatch(preloadFromMarkup('initial-results', pleromaDecoder, preloadPleroma)); - dispatch(preloadFromMarkup('initial-state', JSON.parse, preloadMastodon)); - }; -} - -export function preloadPleroma(data) { - return { - type: PLEROMA_PRELOAD_IMPORT, - data, - }; -} - -export function preloadMastodon(data) { - return (dispatch, getState) => { - const { me, access_token } = data.meta; - const { url } = data.accounts[me]; - - dispatch(importFetchedAccounts(Object.values(data.accounts))); - dispatch(verifyCredentials(access_token, url)); - dispatch({ type: MASTODON_PRELOAD_IMPORT, data }); - }; -} diff --git a/app/soapbox/actions/preload.ts b/app/soapbox/actions/preload.ts new file mode 100644 index 000000000..07b0aa0bb --- /dev/null +++ b/app/soapbox/actions/preload.ts @@ -0,0 +1,69 @@ +import mapValues from 'lodash/mapValues'; + +import { verifyCredentials } from './auth'; +import { importFetchedAccounts } from './importer'; + +import type { AppDispatch } from 'soapbox/store'; + +const PLEROMA_PRELOAD_IMPORT = 'PLEROMA_PRELOAD_IMPORT'; +const MASTODON_PRELOAD_IMPORT = 'MASTODON_PRELOAD_IMPORT'; + +// https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1176/diffs +const decodeUTF8Base64 = (data: string) => { + const rawData = atob(data); + const array = Uint8Array.from(rawData.split('').map((char) => char.charCodeAt(0))); + const text = new TextDecoder().decode(array); + return text; +}; + +const decodePleromaData = (data: Record) => { + return mapValues(data, base64string => JSON.parse(decodeUTF8Base64(base64string))); +}; + +const pleromaDecoder = (json: string) => decodePleromaData(JSON.parse(json)); + +// This will throw if it fails. +// Should be called inside a try-catch. +const decodeFromMarkup = (elementId: string, decoder: (json: string) => Record) => { + const { textContent } = document.getElementById(elementId)!; + return decoder(textContent as string); +}; + +const preloadFromMarkup = (elementId: string, decoder: (json: string) => Record, action: (data: Record) => any) => + (dispatch: AppDispatch) => { + try { + const data = decodeFromMarkup(elementId, decoder); + dispatch(action(data)); + } catch { + // Do nothing + } + }; + +const preload = () => + (dispatch: AppDispatch) => { + dispatch(preloadFromMarkup('initial-results', pleromaDecoder, preloadPleroma)); + dispatch(preloadFromMarkup('initial-state', JSON.parse, preloadMastodon)); + }; + +const preloadPleroma = (data: Record) => ({ + type: PLEROMA_PRELOAD_IMPORT, + data, +}); + +const preloadMastodon = (data: Record) => + (dispatch: AppDispatch) => { + const { me, access_token } = data.meta; + const { url } = data.accounts[me]; + + dispatch(importFetchedAccounts(Object.values(data.accounts))); + dispatch(verifyCredentials(access_token, url)); + dispatch({ type: MASTODON_PRELOAD_IMPORT, data }); + }; + +export { + PLEROMA_PRELOAD_IMPORT, + MASTODON_PRELOAD_IMPORT, + preload, + preloadPleroma, + preloadMastodon, +}; diff --git a/app/soapbox/actions/profile_hover_card.js b/app/soapbox/actions/profile_hover_card.js deleted file mode 100644 index 90543148d..000000000 --- a/app/soapbox/actions/profile_hover_card.js +++ /dev/null @@ -1,24 +0,0 @@ -export const PROFILE_HOVER_CARD_OPEN = 'PROFILE_HOVER_CARD_OPEN'; -export const PROFILE_HOVER_CARD_UPDATE = 'PROFILE_HOVER_CARD_UPDATE'; -export const PROFILE_HOVER_CARD_CLOSE = 'PROFILE_HOVER_CARD_CLOSE'; - -export function openProfileHoverCard(ref, accountId) { - return { - type: PROFILE_HOVER_CARD_OPEN, - ref, - accountId, - }; -} - -export function updateProfileHoverCard() { - return { - type: PROFILE_HOVER_CARD_UPDATE, - }; -} - -export function closeProfileHoverCard(force = false) { - return { - type: PROFILE_HOVER_CARD_CLOSE, - force, - }; -} diff --git a/app/soapbox/actions/profile_hover_card.ts b/app/soapbox/actions/profile_hover_card.ts new file mode 100644 index 000000000..3675d7517 --- /dev/null +++ b/app/soapbox/actions/profile_hover_card.ts @@ -0,0 +1,27 @@ +const PROFILE_HOVER_CARD_OPEN = 'PROFILE_HOVER_CARD_OPEN'; +const PROFILE_HOVER_CARD_UPDATE = 'PROFILE_HOVER_CARD_UPDATE'; +const PROFILE_HOVER_CARD_CLOSE = 'PROFILE_HOVER_CARD_CLOSE'; + +const openProfileHoverCard = (ref: React.MutableRefObject, accountId: string) => ({ + type: PROFILE_HOVER_CARD_OPEN, + ref, + accountId, +}); + +const updateProfileHoverCard = () => ({ + type: PROFILE_HOVER_CARD_UPDATE, +}); + +const closeProfileHoverCard = (force = false) => ({ + type: PROFILE_HOVER_CARD_CLOSE, + force, +}); + +export { + PROFILE_HOVER_CARD_OPEN, + PROFILE_HOVER_CARD_UPDATE, + PROFILE_HOVER_CARD_CLOSE, + openProfileHoverCard, + updateProfileHoverCard, + closeProfileHoverCard, +}; diff --git a/app/soapbox/actions/push_notifications/index.js b/app/soapbox/actions/push_notifications/index.ts similarity index 62% rename from app/soapbox/actions/push_notifications/index.js rename to app/soapbox/actions/push_notifications/index.ts index 32b0ffcaf..69fdf2787 100644 --- a/app/soapbox/actions/push_notifications/index.js +++ b/app/soapbox/actions/push_notifications/index.ts @@ -7,17 +7,19 @@ import { setAlerts, } from './setter'; +import type { AppDispatch } from 'soapbox/store'; + export { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, SET_ALERTS, register, + changeAlerts, }; -export function changeAlerts(path, value) { - return dispatch => { +const changeAlerts = (path: Array, value: any) => + (dispatch: AppDispatch) => { dispatch(setAlerts(path, value)); - dispatch(saveSettings()); + dispatch(saveSettings() as any); }; -} diff --git a/app/soapbox/actions/push_notifications/registerer.js b/app/soapbox/actions/push_notifications/registerer.ts similarity index 61% rename from app/soapbox/actions/push_notifications/registerer.js rename to app/soapbox/actions/push_notifications/registerer.ts index b4d86631e..e66e3a01a 100644 --- a/app/soapbox/actions/push_notifications/registerer.js +++ b/app/soapbox/actions/push_notifications/registerer.ts @@ -1,39 +1,50 @@ -import { createPushSubsription, updatePushSubscription } from 'soapbox/actions/push_subscriptions'; +import { createPushSubscription, updatePushSubscription } from 'soapbox/actions/push_subscriptions'; +import { pushNotificationsSetting } from 'soapbox/settings'; import { getVapidKey } from 'soapbox/utils/auth'; - -import { pushNotificationsSetting } from '../../settings'; -import { decode as decodeBase64 } from '../../utils/base64'; +import { decode as decodeBase64 } from 'soapbox/utils/base64'; import { setBrowserSupport, setSubscription, clearSubscription } from './setter'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { Me } from 'soapbox/types/soapbox'; + // Taken from https://www.npmjs.com/package/web-push -const urlBase64ToUint8Array = (base64String) => { +const urlBase64ToUint8Array = (base64String: string) => { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) - .replace(/\-/g, '+') + .replace(/-/g, '+') .replace(/_/g, '/'); return decodeBase64(base64); }; -const getRegistration = () => navigator.serviceWorker.ready; +const getRegistration = () => { + if (navigator.serviceWorker) { + return navigator.serviceWorker.ready; + } else { + throw 'Your browser does not support Service Workers.'; + } +}; -const getPushSubscription = (registration) => +const getPushSubscription = (registration: ServiceWorkerRegistration) => registration.pushManager.getSubscription() .then(subscription => ({ registration, subscription })); -const subscribe = (registration, getState) => +const subscribe = (registration: ServiceWorkerRegistration, getState: () => RootState) => registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(getVapidKey(getState())), }); -const unsubscribe = ({ registration, subscription }) => - subscription ? subscription.unsubscribe().then(() => registration) : registration; +const unsubscribe = ({ registration, subscription }: { + registration: ServiceWorkerRegistration, + subscription: PushSubscription | null, +}) => + subscription ? subscription.unsubscribe().then(() => registration) : new Promise(r => r(registration)); -const sendSubscriptionToBackend = (subscription, me) => { - return (dispatch, getState) => { - const alerts = getState().getIn(['push_notifications', 'alerts']).toJS(); +const sendSubscriptionToBackend = (subscription: PushSubscription, me: Me) => + (dispatch: AppDispatch, getState: () => RootState) => { + const alerts = getState().push_notifications.alerts.toJS(); const params = { subscription, data: { alerts } }; if (me) { @@ -43,16 +54,16 @@ const sendSubscriptionToBackend = (subscription, me) => { } } - return dispatch(createPushSubsription(params)); + return dispatch(createPushSubscription(params) as any); }; -}; // Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload +// eslint-disable-next-line compat/compat const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype); -export function register() { - return (dispatch, getState) => { - const me = getState().get('me'); +const register = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const me = getState().me; const vapidKey = getVapidKey(getState()); dispatch(setBrowserSupport(supportsPushNotifications)); @@ -69,42 +80,45 @@ export function register() { getRegistration() .then(getPushSubscription) - .then(({ registration, subscription }) => { + // @ts-ignore + .then(({ registration, subscription }: { + registration: ServiceWorkerRegistration, + subscription: PushSubscription | null, + }) => { if (subscription !== null) { // We have a subscription, check if it is still valid - const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString(); + const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey!)).toString(); const subscriptionServerKey = urlBase64ToUint8Array(vapidKey).toString(); - const serverEndpoint = getState().getIn(['push_notifications', 'subscription', 'endpoint']); + const serverEndpoint = getState().push_notifications.subscription?.endpoint; // If the VAPID public key did not change and the endpoint corresponds // to the endpoint saved in the backend, the subscription is valid if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) { - return subscription; + return { subscription }; } else { // Something went wrong, try to subscribe again - return unsubscribe({ registration, subscription }).then(registration => { + return unsubscribe({ registration, subscription }).then((registration: ServiceWorkerRegistration) => { return subscribe(registration, getState); }).then( - subscription => dispatch(sendSubscriptionToBackend(subscription, me))); + (subscription: PushSubscription) => dispatch(sendSubscriptionToBackend(subscription, me) as any)); } } // No subscription, try to subscribe return subscribe(registration, getState) - .then(subscription => dispatch(sendSubscriptionToBackend(subscription, me))); + .then(subscription => dispatch(sendSubscriptionToBackend(subscription, me) as any)); }) - .then(subscription => { + .then(({ subscription }: { subscription: PushSubscription | Record }) => { // If we got a PushSubscription (and not a subscription object from the backend) // it means that the backend subscription is valid (and was set during hydration) if (!(subscription instanceof PushSubscription)) { - dispatch(setSubscription(subscription)); + dispatch(setSubscription(subscription as PushSubscription)); if (me) { pushNotificationsSetting.set(me, { alerts: subscription.alerts }); } } }) .catch(error => { - console.error(error); if (error.code === 20 && error.name === 'AbortError') { console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.'); } else if (error.code === 5 && error.name === 'InvalidCharacterError') { @@ -124,14 +138,13 @@ export function register() { }) .catch(console.warn); }; -} -export function saveSettings() { - return (dispatch, getState) => { - const state = getState().get('push_notifications'); - const alerts = state.get('alerts'); +const saveSettings = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState().push_notifications; + const alerts = state.alerts; const data = { alerts }; - const me = getState().get('me'); + const me = getState().me; return dispatch(updatePushSubscription({ data })).then(() => { if (me) { @@ -139,4 +152,8 @@ export function saveSettings() { } }).catch(console.warn); }; -} + +export { + register, + saveSettings, +}; diff --git a/app/soapbox/actions/push_notifications/setter.js b/app/soapbox/actions/push_notifications/setter.js deleted file mode 100644 index d77f1e4cd..000000000 --- a/app/soapbox/actions/push_notifications/setter.js +++ /dev/null @@ -1,34 +0,0 @@ -export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT'; -export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION'; -export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION'; -export const SET_ALERTS = 'PUSH_NOTIFICATIONS_SET_ALERTS'; - -export function setBrowserSupport(value) { - return { - type: SET_BROWSER_SUPPORT, - value, - }; -} - -export function setSubscription(subscription) { - return { - type: SET_SUBSCRIPTION, - subscription, - }; -} - -export function clearSubscription() { - return { - type: CLEAR_SUBSCRIPTION, - }; -} - -export function setAlerts(path, value) { - return dispatch => { - dispatch({ - type: SET_ALERTS, - path, - value, - }); - }; -} diff --git a/app/soapbox/actions/push_notifications/setter.ts b/app/soapbox/actions/push_notifications/setter.ts new file mode 100644 index 000000000..739427e7e --- /dev/null +++ b/app/soapbox/actions/push_notifications/setter.ts @@ -0,0 +1,39 @@ +import type { AnyAction } from 'redux'; + +const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT'; +const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION'; +const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION'; +const SET_ALERTS = 'PUSH_NOTIFICATIONS_SET_ALERTS'; + +const setBrowserSupport = (value: boolean) => ({ + type: SET_BROWSER_SUPPORT, + value, +}); + +const setSubscription = (subscription: PushSubscription) => ({ + type: SET_SUBSCRIPTION, + subscription, +}); + +const clearSubscription = () => ({ + type: CLEAR_SUBSCRIPTION, +}); + +const setAlerts = (path: Array, value: any) => + (dispatch: React.Dispatch) => + dispatch({ + type: SET_ALERTS, + path, + value, + }); + +export { + SET_BROWSER_SUPPORT, + SET_SUBSCRIPTION, + CLEAR_SUBSCRIPTION, + SET_ALERTS, + setBrowserSupport, + setSubscription, + clearSubscription, + setAlerts, +}; diff --git a/app/soapbox/actions/push_subscriptions.js b/app/soapbox/actions/push_subscriptions.js deleted file mode 100644 index 5b47a4c93..000000000 --- a/app/soapbox/actions/push_subscriptions.js +++ /dev/null @@ -1,62 +0,0 @@ -import api from '../api'; - -export const PUSH_SUBSCRIPTION_CREATE_REQUEST = 'PUSH_SUBSCRIPTION_CREATE_REQUEST'; -export const PUSH_SUBSCRIPTION_CREATE_SUCCESS = 'PUSH_SUBSCRIPTION_CREATE_SUCCESS'; -export const PUSH_SUBSCRIPTION_CREATE_FAIL = 'PUSH_SUBSCRIPTION_CREATE_FAIL'; - -export const PUSH_SUBSCRIPTION_FETCH_REQUEST = 'PUSH_SUBSCRIPTION_FETCH_REQUEST'; -export const PUSH_SUBSCRIPTION_FETCH_SUCCESS = 'PUSH_SUBSCRIPTION_FETCH_SUCCESS'; -export const PUSH_SUBSCRIPTION_FETCH_FAIL = 'PUSH_SUBSCRIPTION_FETCH_FAIL'; - -export const PUSH_SUBSCRIPTION_UPDATE_REQUEST = 'PUSH_SUBSCRIPTION_UPDATE_REQUEST'; -export const PUSH_SUBSCRIPTION_UPDATE_SUCCESS = 'PUSH_SUBSCRIPTION_UPDATE_SUCCESS'; -export const PUSH_SUBSCRIPTION_UPDATE_FAIL = 'PUSH_SUBSCRIPTION_UPDATE_FAIL'; - -export const PUSH_SUBSCRIPTION_DELETE_REQUEST = 'PUSH_SUBSCRIPTION_DELETE_REQUEST'; -export const PUSH_SUBSCRIPTION_DELETE_SUCCESS = 'PUSH_SUBSCRIPTION_DELETE_SUCCESS'; -export const PUSH_SUBSCRIPTION_DELETE_FAIL = 'PUSH_SUBSCRIPTION_DELETE_FAIL'; - -export function createPushSubsription(params) { - return (dispatch, getState) => { - dispatch({ type: PUSH_SUBSCRIPTION_CREATE_REQUEST, params }); - return api(getState).post('/api/v1/push/subscription', params).then(({ data: subscription }) => { - dispatch({ type: PUSH_SUBSCRIPTION_CREATE_SUCCESS, params, subscription }); - return subscription; - }).catch(error => { - dispatch({ type: PUSH_SUBSCRIPTION_CREATE_FAIL, params, error }); - }); - }; -} - -export function fetchPushSubsription() { - return (dispatch, getState) => { - dispatch({ type: PUSH_SUBSCRIPTION_FETCH_REQUEST }); - return api(getState).get('/api/v1/push/subscription').then(({ data: subscription }) => { - dispatch({ type: PUSH_SUBSCRIPTION_FETCH_SUCCESS, subscription }); - }).catch(error => { - dispatch({ type: PUSH_SUBSCRIPTION_FETCH_FAIL, error }); - }); - }; -} - -export function updatePushSubscription(params) { - return (dispatch, getState) => { - dispatch({ type: PUSH_SUBSCRIPTION_UPDATE_REQUEST, params }); - return api(getState).put('/api/v1/push/subscription', params).then(({ data: subscription }) => { - dispatch({ type: PUSH_SUBSCRIPTION_UPDATE_SUCCESS, params, subscription }); - }).catch(error => { - dispatch({ type: PUSH_SUBSCRIPTION_UPDATE_FAIL, params, error }); - }); - }; -} - -export function deletePushSubsription() { - return (dispatch, getState) => { - dispatch({ type: PUSH_SUBSCRIPTION_DELETE_REQUEST }); - return api(getState).delete('/api/v1/push/subscription').then(() => { - dispatch({ type: PUSH_SUBSCRIPTION_DELETE_SUCCESS }); - }).catch(error => { - dispatch({ type: PUSH_SUBSCRIPTION_DELETE_FAIL, error }); - }); - }; -} diff --git a/app/soapbox/actions/push_subscriptions.ts b/app/soapbox/actions/push_subscriptions.ts new file mode 100644 index 000000000..b370c5045 --- /dev/null +++ b/app/soapbox/actions/push_subscriptions.ts @@ -0,0 +1,78 @@ +import api from '../api'; + +const PUSH_SUBSCRIPTION_CREATE_REQUEST = 'PUSH_SUBSCRIPTION_CREATE_REQUEST'; +const PUSH_SUBSCRIPTION_CREATE_SUCCESS = 'PUSH_SUBSCRIPTION_CREATE_SUCCESS'; +const PUSH_SUBSCRIPTION_CREATE_FAIL = 'PUSH_SUBSCRIPTION_CREATE_FAIL'; + +const PUSH_SUBSCRIPTION_FETCH_REQUEST = 'PUSH_SUBSCRIPTION_FETCH_REQUEST'; +const PUSH_SUBSCRIPTION_FETCH_SUCCESS = 'PUSH_SUBSCRIPTION_FETCH_SUCCESS'; +const PUSH_SUBSCRIPTION_FETCH_FAIL = 'PUSH_SUBSCRIPTION_FETCH_FAIL'; + +const PUSH_SUBSCRIPTION_UPDATE_REQUEST = 'PUSH_SUBSCRIPTION_UPDATE_REQUEST'; +const PUSH_SUBSCRIPTION_UPDATE_SUCCESS = 'PUSH_SUBSCRIPTION_UPDATE_SUCCESS'; +const PUSH_SUBSCRIPTION_UPDATE_FAIL = 'PUSH_SUBSCRIPTION_UPDATE_FAIL'; + +const PUSH_SUBSCRIPTION_DELETE_REQUEST = 'PUSH_SUBSCRIPTION_DELETE_REQUEST'; +const PUSH_SUBSCRIPTION_DELETE_SUCCESS = 'PUSH_SUBSCRIPTION_DELETE_SUCCESS'; +const PUSH_SUBSCRIPTION_DELETE_FAIL = 'PUSH_SUBSCRIPTION_DELETE_FAIL'; + +import type { AppDispatch, RootState } from 'soapbox/store'; + +const createPushSubscription = (params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: PUSH_SUBSCRIPTION_CREATE_REQUEST, params }); + return api(getState).post('/api/v1/push/subscription', params).then(({ data: subscription }) => + dispatch({ type: PUSH_SUBSCRIPTION_CREATE_SUCCESS, params, subscription }), + ).catch(error => + dispatch({ type: PUSH_SUBSCRIPTION_CREATE_FAIL, params, error }), + ); + }; + +const fetchPushSubscription = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: PUSH_SUBSCRIPTION_FETCH_REQUEST }); + return api(getState).get('/api/v1/push/subscription').then(({ data: subscription }) => + dispatch({ type: PUSH_SUBSCRIPTION_FETCH_SUCCESS, subscription }), + ).catch(error => + dispatch({ type: PUSH_SUBSCRIPTION_FETCH_FAIL, error }), + ); + }; + +const updatePushSubscription = (params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: PUSH_SUBSCRIPTION_UPDATE_REQUEST, params }); + return api(getState).put('/api/v1/push/subscription', params).then(({ data: subscription }) => + dispatch({ type: PUSH_SUBSCRIPTION_UPDATE_SUCCESS, params, subscription }), + ).catch(error => + dispatch({ type: PUSH_SUBSCRIPTION_UPDATE_FAIL, params, error }), + ); + }; + +const deletePushSubscription = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: PUSH_SUBSCRIPTION_DELETE_REQUEST }); + return api(getState).delete('/api/v1/push/subscription').then(() => + dispatch({ type: PUSH_SUBSCRIPTION_DELETE_SUCCESS }), + ).catch(error => + dispatch({ type: PUSH_SUBSCRIPTION_DELETE_FAIL, error }), + ); + }; + +export { + PUSH_SUBSCRIPTION_CREATE_REQUEST, + PUSH_SUBSCRIPTION_CREATE_SUCCESS, + PUSH_SUBSCRIPTION_CREATE_FAIL, + PUSH_SUBSCRIPTION_FETCH_REQUEST, + PUSH_SUBSCRIPTION_FETCH_SUCCESS, + PUSH_SUBSCRIPTION_FETCH_FAIL, + PUSH_SUBSCRIPTION_UPDATE_REQUEST, + PUSH_SUBSCRIPTION_UPDATE_SUCCESS, + PUSH_SUBSCRIPTION_UPDATE_FAIL, + PUSH_SUBSCRIPTION_DELETE_REQUEST, + PUSH_SUBSCRIPTION_DELETE_SUCCESS, + PUSH_SUBSCRIPTION_DELETE_FAIL, + createPushSubscription, + fetchPushSubscription, + updatePushSubscription, + deletePushSubscription, +}; diff --git a/app/soapbox/actions/remote_timeline.js b/app/soapbox/actions/remote_timeline.js deleted file mode 100644 index 38249c009..000000000 --- a/app/soapbox/actions/remote_timeline.js +++ /dev/null @@ -1,24 +0,0 @@ -import { getSettings, changeSetting } from 'soapbox/actions/settings'; - -const getPinnedHosts = state => { - const settings = getSettings(state); - return settings.getIn(['remote_timeline', 'pinnedHosts']); -}; - -export function pinHost(host) { - return (dispatch, getState) => { - const state = getState(); - const pinnedHosts = getPinnedHosts(state); - - return dispatch(changeSetting(['remote_timeline', 'pinnedHosts'], pinnedHosts.add(host))); - }; -} - -export function unpinHost(host) { - return (dispatch, getState) => { - const state = getState(); - const pinnedHosts = getPinnedHosts(state); - - return dispatch(changeSetting(['remote_timeline', 'pinnedHosts'], pinnedHosts.delete(host))); - }; -} diff --git a/app/soapbox/actions/remote_timeline.ts b/app/soapbox/actions/remote_timeline.ts new file mode 100644 index 000000000..cb21126b9 --- /dev/null +++ b/app/soapbox/actions/remote_timeline.ts @@ -0,0 +1,30 @@ +import { getSettings, changeSetting } from 'soapbox/actions/settings'; + +import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import type { AppDispatch, RootState } from 'soapbox/store'; + +const getPinnedHosts = (state: RootState) => { + const settings = getSettings(state); + return settings.getIn(['remote_timeline', 'pinnedHosts']) as ImmutableOrderedSet; +}; + +const pinHost = (host: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const pinnedHosts = getPinnedHosts(state); + + return dispatch(changeSetting(['remote_timeline', 'pinnedHosts'], pinnedHosts.add(host))); + }; + +const unpinHost = (host: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const pinnedHosts = getPinnedHosts(state); + + return dispatch(changeSetting(['remote_timeline', 'pinnedHosts'], pinnedHosts.remove(host))); + }; + +export { + pinHost, + unpinHost, +}; diff --git a/app/soapbox/actions/reports.js b/app/soapbox/actions/reports.js deleted file mode 100644 index 8e8e45595..000000000 --- a/app/soapbox/actions/reports.js +++ /dev/null @@ -1,116 +0,0 @@ -import api from '../api'; - -import { openModal } from './modals'; - -export const REPORT_INIT = 'REPORT_INIT'; -export const REPORT_CANCEL = 'REPORT_CANCEL'; - -export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST'; -export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS'; -export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; - -export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; -export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; -export const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE'; -export const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE'; - -export const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE'; - -export function initReport(account, status) { - return dispatch => { - dispatch({ - type: REPORT_INIT, - account, - status, - }); - - dispatch(openModal('REPORT')); - }; -} - -export function initReportById(accountId) { - return (dispatch, getState) => { - dispatch({ - type: REPORT_INIT, - account: getState().getIn(['accounts', accountId]), - }); - - dispatch(openModal('REPORT')); - }; -} - -export function cancelReport() { - return { - type: REPORT_CANCEL, - }; -} - -export function toggleStatusReport(statusId, checked) { - return { - type: REPORT_STATUS_TOGGLE, - statusId, - checked, - }; -} - -export function submitReport() { - return (dispatch, getState) => { - dispatch(submitReportRequest()); - const { reports } = getState(); - - return api(getState).post('/api/v1/reports', { - account_id: reports.getIn(['new', 'account_id']), - status_ids: reports.getIn(['new', 'status_ids']), - rule_ids: reports.getIn(['new', 'rule_ids']), - comment: reports.getIn(['new', 'comment']), - forward: reports.getIn(['new', 'forward']), - }); - }; -} - -export function submitReportRequest() { - return { - type: REPORT_SUBMIT_REQUEST, - }; -} - -export function submitReportSuccess() { - return { - type: REPORT_SUBMIT_SUCCESS, - }; -} - -export function submitReportFail(error) { - return { - type: REPORT_SUBMIT_FAIL, - error, - }; -} - -export function changeReportComment(comment) { - return { - type: REPORT_COMMENT_CHANGE, - comment, - }; -} - -export function changeReportForward(forward) { - return { - type: REPORT_FORWARD_CHANGE, - forward, - }; -} - -export function changeReportBlock(block) { - return { - type: REPORT_BLOCK_CHANGE, - block, - }; -} - -export function changeReportRule(ruleId) { - return { - type: REPORT_RULE_CHANGE, - rule_id: ruleId, - }; -} diff --git a/app/soapbox/actions/reports.ts b/app/soapbox/actions/reports.ts new file mode 100644 index 000000000..dce162247 --- /dev/null +++ b/app/soapbox/actions/reports.ts @@ -0,0 +1,124 @@ +import api from '../api'; + +import { openModal } from './modals'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { Account, Status } from 'soapbox/types/entities'; + +const REPORT_INIT = 'REPORT_INIT'; +const REPORT_CANCEL = 'REPORT_CANCEL'; + +const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST'; +const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS'; +const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; + +const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; +const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; +const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE'; +const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE'; + +const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE'; + +const initReport = (account: Account, status?: Status) => + (dispatch: AppDispatch) => { + dispatch({ + type: REPORT_INIT, + account, + status, + }); + + return dispatch(openModal('REPORT')); + }; + +const initReportById = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: REPORT_INIT, + account: getState().accounts.get(accountId), + }); + + dispatch(openModal('REPORT')); + }; + +const cancelReport = () => ({ + type: REPORT_CANCEL, +}); + +const toggleStatusReport = (statusId: string, checked: boolean) => ({ + type: REPORT_STATUS_TOGGLE, + statusId, + checked, +}); + +const submitReport = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(submitReportRequest()); + const { reports } = getState(); + + return api(getState).post('/api/v1/reports', { + account_id: reports.getIn(['new', 'account_id']), + status_ids: reports.getIn(['new', 'status_ids']), + rule_ids: reports.getIn(['new', 'rule_ids']), + comment: reports.getIn(['new', 'comment']), + forward: reports.getIn(['new', 'forward']), + }); + }; + +const submitReportRequest = () => ({ + type: REPORT_SUBMIT_REQUEST, +}); + +const submitReportSuccess = () => ({ + type: REPORT_SUBMIT_SUCCESS, +}); + +const submitReportFail = (error: AxiosError) => ({ + type: REPORT_SUBMIT_FAIL, + error, +}); + +const changeReportComment = (comment: string) => ({ + type: REPORT_COMMENT_CHANGE, + comment, +}); + +const changeReportForward = (forward: boolean) => ({ + type: REPORT_FORWARD_CHANGE, + forward, +}); + +const changeReportBlock = (block: boolean) => ({ + type: REPORT_BLOCK_CHANGE, + block, +}); + +const changeReportRule = (ruleId: string) => ({ + type: REPORT_RULE_CHANGE, + rule_id: ruleId, +}); + +export { + REPORT_INIT, + REPORT_CANCEL, + REPORT_SUBMIT_REQUEST, + REPORT_SUBMIT_SUCCESS, + REPORT_SUBMIT_FAIL, + REPORT_STATUS_TOGGLE, + REPORT_COMMENT_CHANGE, + REPORT_FORWARD_CHANGE, + REPORT_BLOCK_CHANGE, + REPORT_RULE_CHANGE, + initReport, + initReportById, + cancelReport, + toggleStatusReport, + submitReport, + submitReportRequest, + submitReportSuccess, + submitReportFail, + changeReportComment, + changeReportForward, + changeReportBlock, + changeReportRule, +}; diff --git a/app/soapbox/actions/rules.ts b/app/soapbox/actions/rules.ts index 1e2c29eea..b5b3b90a4 100644 --- a/app/soapbox/actions/rules.ts +++ b/app/soapbox/actions/rules.ts @@ -1,6 +1,7 @@ import api from '../api'; import type { Rule } from 'soapbox/reducers/rules'; +import type { RootState } from 'soapbox/store'; const RULES_FETCH_REQUEST = 'RULES_FETCH_REQUEST'; const RULES_FETCH_SUCCESS = 'RULES_FETCH_SUCCESS'; @@ -16,7 +17,7 @@ type RulesFetchRequestSuccessAction = { export type RulesActions = RulesFetchRequestAction | RulesFetchRequestSuccessAction -const fetchRules = () => (dispatch: React.Dispatch, getState: any) => { +const fetchRules = () => (dispatch: React.Dispatch, getState: () => RootState) => { dispatch({ type: RULES_FETCH_REQUEST }); return api(getState) diff --git a/app/soapbox/actions/scheduled_statuses.js b/app/soapbox/actions/scheduled_statuses.js deleted file mode 100644 index fd6f3a241..000000000 --- a/app/soapbox/actions/scheduled_statuses.js +++ /dev/null @@ -1,102 +0,0 @@ -import api, { getLinks } from '../api'; - -export const SCHEDULED_STATUSES_FETCH_REQUEST = 'SCHEDULED_STATUSES_FETCH_REQUEST'; -export const SCHEDULED_STATUSES_FETCH_SUCCESS = 'SCHEDULED_STATUSES_FETCH_SUCCESS'; -export const SCHEDULED_STATUSES_FETCH_FAIL = 'SCHEDULED_STATUSES_FETCH_FAIL'; - -export const SCHEDULED_STATUSES_EXPAND_REQUEST = 'SCHEDULED_STATUSES_EXPAND_REQUEST'; -export const SCHEDULED_STATUSES_EXPAND_SUCCESS = 'SCHEDULED_STATUSES_EXPAND_SUCCESS'; -export const SCHEDULED_STATUSES_EXPAND_FAIL = 'SCHEDULED_STATUSES_EXPAND_FAIL'; - -export const SCHEDULED_STATUS_CANCEL_REQUEST = 'SCHEDULED_STATUS_CANCEL_REQUEST'; -export const SCHEDULED_STATUS_CANCEL_SUCCESS = 'SCHEDULED_STATUS_CANCEL_SUCCESS'; -export const SCHEDULED_STATUS_CANCEL_FAIL = 'SCHEDULED_STATUS_CANCEL_FAIL'; - -export function fetchScheduledStatuses() { - return (dispatch, getState) => { - if (getState().getIn(['status_lists', 'scheduled_statuses', 'isLoading'])) { - return; - } - - dispatch(fetchScheduledStatusesRequest()); - - api(getState).get('/api/v1/scheduled_statuses').then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(fetchScheduledStatusesSuccess(response.data, next ? next.uri : null)); - }).catch(error => { - dispatch(fetchScheduledStatusesFail(error)); - }); - }; -} - -export function cancelScheduledStatus(id) { - return (dispatch, getState) => { - dispatch({ type: SCHEDULED_STATUS_CANCEL_REQUEST, id }); - api(getState).delete(`/api/v1/scheduled_statuses/${id}`).then(({ data }) => { - dispatch({ type: SCHEDULED_STATUS_CANCEL_SUCCESS, id, data }); - }).catch(error => { - dispatch({ type: SCHEDULED_STATUS_CANCEL_FAIL, id, error }); - }); - }; -} - -export function fetchScheduledStatusesRequest() { - return { - type: SCHEDULED_STATUSES_FETCH_REQUEST, - }; -} - -export function fetchScheduledStatusesSuccess(statuses, next) { - return { - type: SCHEDULED_STATUSES_FETCH_SUCCESS, - statuses, - next, - }; -} - -export function fetchScheduledStatusesFail(error) { - return { - type: SCHEDULED_STATUSES_FETCH_FAIL, - error, - }; -} - -export function expandScheduledStatuses() { - return (dispatch, getState) => { - const url = getState().getIn(['status_lists', 'scheduled_statuses', 'next'], null); - - if (url === null || getState().getIn(['status_lists', 'scheduled_statuses', 'isLoading'])) { - return; - } - - dispatch(expandScheduledStatusesRequest()); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(expandScheduledStatusesSuccess(response.data, next ? next.uri : null)); - }).catch(error => { - dispatch(expandScheduledStatusesFail(error)); - }); - }; -} - -export function expandScheduledStatusesRequest() { - return { - type: SCHEDULED_STATUSES_EXPAND_REQUEST, - }; -} - -export function expandScheduledStatusesSuccess(statuses, next) { - return { - type: SCHEDULED_STATUSES_EXPAND_SUCCESS, - statuses, - next, - }; -} - -export function expandScheduledStatusesFail(error) { - return { - type: SCHEDULED_STATUSES_EXPAND_FAIL, - error, - }; -} diff --git a/app/soapbox/actions/scheduled_statuses.ts b/app/soapbox/actions/scheduled_statuses.ts new file mode 100644 index 000000000..ddc550105 --- /dev/null +++ b/app/soapbox/actions/scheduled_statuses.ts @@ -0,0 +1,112 @@ +import api, { getLinks } from '../api'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const SCHEDULED_STATUSES_FETCH_REQUEST = 'SCHEDULED_STATUSES_FETCH_REQUEST'; +const SCHEDULED_STATUSES_FETCH_SUCCESS = 'SCHEDULED_STATUSES_FETCH_SUCCESS'; +const SCHEDULED_STATUSES_FETCH_FAIL = 'SCHEDULED_STATUSES_FETCH_FAIL'; + +const SCHEDULED_STATUSES_EXPAND_REQUEST = 'SCHEDULED_STATUSES_EXPAND_REQUEST'; +const SCHEDULED_STATUSES_EXPAND_SUCCESS = 'SCHEDULED_STATUSES_EXPAND_SUCCESS'; +const SCHEDULED_STATUSES_EXPAND_FAIL = 'SCHEDULED_STATUSES_EXPAND_FAIL'; + +const SCHEDULED_STATUS_CANCEL_REQUEST = 'SCHEDULED_STATUS_CANCEL_REQUEST'; +const SCHEDULED_STATUS_CANCEL_SUCCESS = 'SCHEDULED_STATUS_CANCEL_SUCCESS'; +const SCHEDULED_STATUS_CANCEL_FAIL = 'SCHEDULED_STATUS_CANCEL_FAIL'; + +const fetchScheduledStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (getState().status_lists.get('scheduled_statuses')?.isLoading) { + return; + } + + dispatch(fetchScheduledStatusesRequest()); + + api(getState).get('/api/v1/scheduled_statuses').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchScheduledStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchScheduledStatusesFail(error)); + }); + }; + +const cancelScheduledStatus = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: SCHEDULED_STATUS_CANCEL_REQUEST, id }); + api(getState).delete(`/api/v1/scheduled_statuses/${id}`).then(({ data }) => { + dispatch({ type: SCHEDULED_STATUS_CANCEL_SUCCESS, id, data }); + }).catch(error => { + dispatch({ type: SCHEDULED_STATUS_CANCEL_FAIL, id, error }); + }); + }; + +const fetchScheduledStatusesRequest = () => ({ + type: SCHEDULED_STATUSES_FETCH_REQUEST, +}); + +const fetchScheduledStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: SCHEDULED_STATUSES_FETCH_SUCCESS, + statuses, + next, +}); + +const fetchScheduledStatusesFail = (error: AxiosError) => ({ + type: SCHEDULED_STATUSES_FETCH_FAIL, + error, +}); + +const expandScheduledStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().status_lists.get('scheduled_statuses')?.next || null; + + if (url === null || getState().status_lists.get('scheduled_statuses')?.isLoading) { + return; + } + + dispatch(expandScheduledStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandScheduledStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandScheduledStatusesFail(error)); + }); + }; + +const expandScheduledStatusesRequest = () => ({ + type: SCHEDULED_STATUSES_EXPAND_REQUEST, +}); + +const expandScheduledStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: SCHEDULED_STATUSES_EXPAND_SUCCESS, + statuses, + next, +}); + +const expandScheduledStatusesFail = (error: AxiosError) => ({ + type: SCHEDULED_STATUSES_EXPAND_FAIL, + error, +}); + +export { + SCHEDULED_STATUSES_FETCH_REQUEST, + SCHEDULED_STATUSES_FETCH_SUCCESS, + SCHEDULED_STATUSES_FETCH_FAIL, + SCHEDULED_STATUSES_EXPAND_REQUEST, + SCHEDULED_STATUSES_EXPAND_SUCCESS, + SCHEDULED_STATUSES_EXPAND_FAIL, + SCHEDULED_STATUS_CANCEL_REQUEST, + SCHEDULED_STATUS_CANCEL_SUCCESS, + SCHEDULED_STATUS_CANCEL_FAIL, + fetchScheduledStatuses, + cancelScheduledStatus, + fetchScheduledStatusesRequest, + fetchScheduledStatusesSuccess, + fetchScheduledStatusesFail, + expandScheduledStatuses, + expandScheduledStatusesRequest, + expandScheduledStatusesSuccess, + expandScheduledStatusesFail, +}; diff --git a/app/soapbox/actions/search.js b/app/soapbox/actions/search.js deleted file mode 100644 index 27cb4bdbd..000000000 --- a/app/soapbox/actions/search.js +++ /dev/null @@ -1,158 +0,0 @@ -import api from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts, importFetchedStatuses } from './importer'; - -export const SEARCH_CHANGE = 'SEARCH_CHANGE'; -export const SEARCH_CLEAR = 'SEARCH_CLEAR'; -export const SEARCH_SHOW = 'SEARCH_SHOW'; - -export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; -export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; -export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; - -export const SEARCH_FILTER_SET = 'SEARCH_FILTER_SET'; - -export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST'; -export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; -export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL'; - -export function changeSearch(value) { - return (dispatch, getState) => { - // If backspaced all the way, clear the search - if (value.length === 0) { - return dispatch(clearSearch()); - } else { - return dispatch({ - type: SEARCH_CHANGE, - value, - }); - } - }; -} - -export function clearSearch() { - return { - type: SEARCH_CLEAR, - }; -} - -export function submitSearch(filter) { - return (dispatch, getState) => { - const value = getState().getIn(['search', 'value']); - const type = filter || getState().getIn(['search', 'filter'], 'accounts'); - - // An empty search doesn't return any results - if (value.length === 0) { - return; - } - - dispatch(fetchSearchRequest(value)); - - api(getState).get('/api/v2/search', { - params: { - q: value, - resolve: true, - limit: 20, - type, - }, - }).then(response => { - if (response.data.accounts) { - dispatch(importFetchedAccounts(response.data.accounts)); - } - - if (response.data.statuses) { - dispatch(importFetchedStatuses(response.data.statuses)); - } - - dispatch(fetchSearchSuccess(response.data, value, type)); - dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); - }).catch(error => { - dispatch(fetchSearchFail(error)); - }); - }; -} - -export function fetchSearchRequest(value) { - return { - type: SEARCH_FETCH_REQUEST, - value, - }; -} - -export function fetchSearchSuccess(results, searchTerm, searchType) { - return { - type: SEARCH_FETCH_SUCCESS, - results, - searchTerm, - searchType, - }; -} - -export function fetchSearchFail(error) { - return { - type: SEARCH_FETCH_FAIL, - error, - }; -} - -export function setFilter(filterType) { - return (dispatch) => { - dispatch(submitSearch(filterType)); - - dispatch({ - type: SEARCH_FILTER_SET, - path: ['search', 'filter'], - value: filterType, - }); - }; -} - -export const expandSearch = type => (dispatch, getState) => { - const value = getState().getIn(['search', 'value']); - const offset = getState().getIn(['search', 'results', type]).size; - - dispatch(expandSearchRequest(type)); - - api(getState).get('/api/v2/search', { - params: { - q: value, - type, - offset, - }, - }).then(({ data }) => { - if (data.accounts) { - dispatch(importFetchedAccounts(data.accounts)); - } - - if (data.statuses) { - dispatch(importFetchedStatuses(data.statuses)); - } - - dispatch(expandSearchSuccess(data, value, type)); - dispatch(fetchRelationships(data.accounts.map(item => item.id))); - }).catch(error => { - dispatch(expandSearchFail(error)); - }); -}; - -export const expandSearchRequest = (searchType) => ({ - type: SEARCH_EXPAND_REQUEST, - searchType, -}); - -export const expandSearchSuccess = (results, searchTerm, searchType) => ({ - type: SEARCH_EXPAND_SUCCESS, - results, - searchTerm, - searchType, -}); - -export const expandSearchFail = error => ({ - type: SEARCH_EXPAND_FAIL, - error, -}); - -export const showSearch = () => ({ - type: SEARCH_SHOW, -}); diff --git a/app/soapbox/actions/search.ts b/app/soapbox/actions/search.ts new file mode 100644 index 000000000..e8718d479 --- /dev/null +++ b/app/soapbox/actions/search.ts @@ -0,0 +1,191 @@ +import api from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; + +import type { AxiosError } from 'axios'; +import type { SearchFilter } from 'soapbox/reducers/search'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const SEARCH_CHANGE = 'SEARCH_CHANGE'; +const SEARCH_CLEAR = 'SEARCH_CLEAR'; +const SEARCH_SHOW = 'SEARCH_SHOW'; + +const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; +const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; +const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; + +const SEARCH_FILTER_SET = 'SEARCH_FILTER_SET'; + +const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST'; +const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; +const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL'; + +const SEARCH_ACCOUNT_SET = 'SEARCH_ACCOUNT_SET'; + +const changeSearch = (value: string) => + (dispatch: AppDispatch) => { + // If backspaced all the way, clear the search + if (value.length === 0) { + return dispatch(clearSearch()); + } else { + return dispatch({ + type: SEARCH_CHANGE, + value, + }); + } + }; + +const clearSearch = () => ({ + type: SEARCH_CLEAR, +}); + +const submitSearch = (filter?: SearchFilter) => + (dispatch: AppDispatch, getState: () => RootState) => { + const value = getState().search.value; + const type = filter || getState().search.filter || 'accounts'; + const accountId = getState().search.accountId; + + // An empty search doesn't return any results + if (value.length === 0) { + return; + } + + dispatch(fetchSearchRequest(value)); + + const params: Record = { + q: value, + resolve: true, + limit: 20, + type, + }; + + if (accountId) params.account_id = accountId; + + api(getState).get('/api/v2/search', { + params, + }).then(response => { + if (response.data.accounts) { + dispatch(importFetchedAccounts(response.data.accounts)); + } + + if (response.data.statuses) { + dispatch(importFetchedStatuses(response.data.statuses)); + } + + dispatch(fetchSearchSuccess(response.data, value, type)); + dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(fetchSearchFail(error)); + }); + }; + +const fetchSearchRequest = (value: string) => ({ + type: SEARCH_FETCH_REQUEST, + value, +}); + +const fetchSearchSuccess = (results: APIEntity[], searchTerm: string, searchType: SearchFilter) => ({ + type: SEARCH_FETCH_SUCCESS, + results, + searchTerm, + searchType, +}); + +const fetchSearchFail = (error: AxiosError) => ({ + type: SEARCH_FETCH_FAIL, + error, +}); + +const setFilter = (filterType: SearchFilter) => + (dispatch: AppDispatch) => { + dispatch(submitSearch(filterType)); + + dispatch({ + type: SEARCH_FILTER_SET, + path: ['search', 'filter'], + value: filterType, + }); + }; + +const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: () => RootState) => { + const value = getState().search.value; + const offset = getState().search.results[type].size; + + dispatch(expandSearchRequest(type)); + + api(getState).get('/api/v2/search', { + params: { + q: value, + type, + offset, + }, + }).then(({ data }) => { + if (data.accounts) { + dispatch(importFetchedAccounts(data.accounts)); + } + + if (data.statuses) { + dispatch(importFetchedStatuses(data.statuses)); + } + + dispatch(expandSearchSuccess(data, value, type)); + dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandSearchFail(error)); + }); +}; + +const expandSearchRequest = (searchType: SearchFilter) => ({ + type: SEARCH_EXPAND_REQUEST, + searchType, +}); + +const expandSearchSuccess = (results: APIEntity[], searchTerm: string, searchType: SearchFilter) => ({ + type: SEARCH_EXPAND_SUCCESS, + results, + searchTerm, + searchType, +}); + +const expandSearchFail = (error: AxiosError) => ({ + type: SEARCH_EXPAND_FAIL, + error, +}); + +const showSearch = () => ({ + type: SEARCH_SHOW, +}); + +const setSearchAccount = (accountId: string | null) => ({ + type: SEARCH_ACCOUNT_SET, + accountId, +}); + +export { + SEARCH_CHANGE, + SEARCH_CLEAR, + SEARCH_SHOW, + SEARCH_FETCH_REQUEST, + SEARCH_FETCH_SUCCESS, + SEARCH_FETCH_FAIL, + SEARCH_FILTER_SET, + SEARCH_EXPAND_REQUEST, + SEARCH_EXPAND_SUCCESS, + SEARCH_EXPAND_FAIL, + SEARCH_ACCOUNT_SET, + changeSearch, + clearSearch, + submitSearch, + fetchSearchRequest, + fetchSearchSuccess, + fetchSearchFail, + setFilter, + expandSearch, + expandSearchRequest, + expandSearchSuccess, + expandSearchFail, + showSearch, + setSearchAccount, +}; diff --git a/app/soapbox/actions/security.js b/app/soapbox/actions/security.ts similarity index 53% rename from app/soapbox/actions/security.js rename to app/soapbox/actions/security.ts index 5f40f60f3..430691a06 100644 --- a/app/soapbox/actions/security.js +++ b/app/soapbox/actions/security.ts @@ -12,62 +12,62 @@ import api from '../api'; import { AUTH_LOGGED_OUT, messages } from './auth'; -export const FETCH_TOKENS_REQUEST = 'FETCH_TOKENS_REQUEST'; -export const FETCH_TOKENS_SUCCESS = 'FETCH_TOKENS_SUCCESS'; -export const FETCH_TOKENS_FAIL = 'FETCH_TOKENS_FAIL'; +import type { AppDispatch, RootState } from 'soapbox/store'; -export const REVOKE_TOKEN_REQUEST = 'REVOKE_TOKEN_REQUEST'; -export const REVOKE_TOKEN_SUCCESS = 'REVOKE_TOKEN_SUCCESS'; -export const REVOKE_TOKEN_FAIL = 'REVOKE_TOKEN_FAIL'; +const FETCH_TOKENS_REQUEST = 'FETCH_TOKENS_REQUEST'; +const FETCH_TOKENS_SUCCESS = 'FETCH_TOKENS_SUCCESS'; +const FETCH_TOKENS_FAIL = 'FETCH_TOKENS_FAIL'; -export const RESET_PASSWORD_REQUEST = 'RESET_PASSWORD_REQUEST'; -export const RESET_PASSWORD_SUCCESS = 'RESET_PASSWORD_SUCCESS'; -export const RESET_PASSWORD_FAIL = 'RESET_PASSWORD_FAIL'; +const REVOKE_TOKEN_REQUEST = 'REVOKE_TOKEN_REQUEST'; +const REVOKE_TOKEN_SUCCESS = 'REVOKE_TOKEN_SUCCESS'; +const REVOKE_TOKEN_FAIL = 'REVOKE_TOKEN_FAIL'; -export const RESET_PASSWORD_CONFIRM_REQUEST = 'RESET_PASSWORD_CONFIRM_REQUEST'; -export const RESET_PASSWORD_CONFIRM_SUCCESS = 'RESET_PASSWORD_CONFIRM_SUCCESS'; -export const RESET_PASSWORD_CONFIRM_FAIL = 'RESET_PASSWORD_CONFIRM_FAIL'; +const RESET_PASSWORD_REQUEST = 'RESET_PASSWORD_REQUEST'; +const RESET_PASSWORD_SUCCESS = 'RESET_PASSWORD_SUCCESS'; +const RESET_PASSWORD_FAIL = 'RESET_PASSWORD_FAIL'; -export const CHANGE_PASSWORD_REQUEST = 'CHANGE_PASSWORD_REQUEST'; -export const CHANGE_PASSWORD_SUCCESS = 'CHANGE_PASSWORD_SUCCESS'; -export const CHANGE_PASSWORD_FAIL = 'CHANGE_PASSWORD_FAIL'; +const RESET_PASSWORD_CONFIRM_REQUEST = 'RESET_PASSWORD_CONFIRM_REQUEST'; +const RESET_PASSWORD_CONFIRM_SUCCESS = 'RESET_PASSWORD_CONFIRM_SUCCESS'; +const RESET_PASSWORD_CONFIRM_FAIL = 'RESET_PASSWORD_CONFIRM_FAIL'; -export const CHANGE_EMAIL_REQUEST = 'CHANGE_EMAIL_REQUEST'; -export const CHANGE_EMAIL_SUCCESS = 'CHANGE_EMAIL_SUCCESS'; -export const CHANGE_EMAIL_FAIL = 'CHANGE_EMAIL_FAIL'; +const CHANGE_PASSWORD_REQUEST = 'CHANGE_PASSWORD_REQUEST'; +const CHANGE_PASSWORD_SUCCESS = 'CHANGE_PASSWORD_SUCCESS'; +const CHANGE_PASSWORD_FAIL = 'CHANGE_PASSWORD_FAIL'; -export const DELETE_ACCOUNT_REQUEST = 'DELETE_ACCOUNT_REQUEST'; -export const DELETE_ACCOUNT_SUCCESS = 'DELETE_ACCOUNT_SUCCESS'; -export const DELETE_ACCOUNT_FAIL = 'DELETE_ACCOUNT_FAIL'; +const CHANGE_EMAIL_REQUEST = 'CHANGE_EMAIL_REQUEST'; +const CHANGE_EMAIL_SUCCESS = 'CHANGE_EMAIL_SUCCESS'; +const CHANGE_EMAIL_FAIL = 'CHANGE_EMAIL_FAIL'; -export const MOVE_ACCOUNT_REQUEST = 'MOVE_ACCOUNT_REQUEST'; -export const MOVE_ACCOUNT_SUCCESS = 'MOVE_ACCOUNT_SUCCESS'; -export const MOVE_ACCOUNT_FAIL = 'MOVE_ACCOUNT_FAIL'; +const DELETE_ACCOUNT_REQUEST = 'DELETE_ACCOUNT_REQUEST'; +const DELETE_ACCOUNT_SUCCESS = 'DELETE_ACCOUNT_SUCCESS'; +const DELETE_ACCOUNT_FAIL = 'DELETE_ACCOUNT_FAIL'; -export function fetchOAuthTokens() { - return (dispatch, getState) => { +const MOVE_ACCOUNT_REQUEST = 'MOVE_ACCOUNT_REQUEST'; +const MOVE_ACCOUNT_SUCCESS = 'MOVE_ACCOUNT_SUCCESS'; +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 }) => { dispatch({ type: FETCH_TOKENS_SUCCESS, tokens }); - }).catch(error => { + }).catch(() => { dispatch({ type: FETCH_TOKENS_FAIL }); }); }; -} -export function revokeOAuthTokenById(id) { - return (dispatch, getState) => { +const revokeOAuthTokenById = (id: number) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: REVOKE_TOKEN_REQUEST, id }); return api(getState).delete(`/api/oauth_tokens/${id}`).then(() => { dispatch({ type: REVOKE_TOKEN_SUCCESS, id }); - }).catch(error => { + }).catch(() => { dispatch({ type: REVOKE_TOKEN_FAIL, id }); }); }; -} -export function changePassword(oldPassword, newPassword, confirmation) { - return (dispatch, getState) => { +const changePassword = (oldPassword: string, newPassword: string, confirmation: string) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: CHANGE_PASSWORD_REQUEST }); return api(getState).post('/api/pleroma/change_password', { password: oldPassword, @@ -81,12 +81,11 @@ export function changePassword(oldPassword, newPassword, confirmation) { throw error; }); }; -} -export function resetPassword(usernameOrEmail) { - return (dispatch, getState) => { +const resetPassword = (usernameOrEmail: string) => + (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - const v = parseVersion(state.instance); + const v = parseVersion(state.instance.version); dispatch({ type: RESET_PASSWORD_REQUEST }); @@ -107,10 +106,9 @@ export function resetPassword(usernameOrEmail) { throw error; }); }; -} -export function resetPasswordConfirm(password, token) { - return (dispatch, getState) => { +const resetPasswordConfirm = (password: string, token: string) => + (dispatch: AppDispatch, getState: () => RootState) => { const params = { password, reset_password_token: token }; dispatch({ type: RESET_PASSWORD_CONFIRM_REQUEST }); @@ -121,10 +119,9 @@ export function resetPasswordConfirm(password, token) { throw error; }); }; -} -export function changeEmail(email, password) { - return (dispatch, getState) => { +const changeEmail = (email: string, password: string) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: CHANGE_EMAIL_REQUEST, email }); return api(getState).post('/api/pleroma/change_email', { email, @@ -137,16 +134,13 @@ export function changeEmail(email, password) { throw error; }); }; -} -export function confirmChangedEmail(token) { - return (_dispatch, getState) => { - return api(getState).get(`/api/v1/truth/email/confirm?confirmation_token=${token}`); - }; -} +const confirmChangedEmail = (token: string) => + (_dispatch: AppDispatch, getState: () => RootState) => + api(getState).get(`/api/v1/truth/email/confirm?confirmation_token=${token}`); -export function deleteAccount(intl, password) { - return (dispatch, getState) => { +const deleteAccount = (password: string) => + (dispatch: AppDispatch, getState: () => RootState) => { const account = getLoggedInAccount(getState()); dispatch({ type: DELETE_ACCOUNT_REQUEST }); @@ -156,16 +150,15 @@ export function deleteAccount(intl, password) { 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(intl.formatMessage(messages.loggedOut))); + dispatch(snackbar.success(messages.loggedOut)); }).catch(error => { dispatch({ type: DELETE_ACCOUNT_FAIL, error, skipAlert: true }); throw error; }); }; -} -export function moveAccount(targetAccount, password) { - return (dispatch, getState) => { +const moveAccount = (targetAccount: string, password: string) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: MOVE_ACCOUNT_REQUEST }); return api(getState).post('/api/pleroma/move_account', { password, @@ -178,4 +171,39 @@ export function moveAccount(targetAccount, password) { throw error; }); }; -} + +export { + FETCH_TOKENS_REQUEST, + FETCH_TOKENS_SUCCESS, + FETCH_TOKENS_FAIL, + REVOKE_TOKEN_REQUEST, + REVOKE_TOKEN_SUCCESS, + REVOKE_TOKEN_FAIL, + RESET_PASSWORD_REQUEST, + RESET_PASSWORD_SUCCESS, + RESET_PASSWORD_FAIL, + RESET_PASSWORD_CONFIRM_REQUEST, + RESET_PASSWORD_CONFIRM_SUCCESS, + RESET_PASSWORD_CONFIRM_FAIL, + CHANGE_PASSWORD_REQUEST, + CHANGE_PASSWORD_SUCCESS, + CHANGE_PASSWORD_FAIL, + CHANGE_EMAIL_REQUEST, + CHANGE_EMAIL_SUCCESS, + CHANGE_EMAIL_FAIL, + DELETE_ACCOUNT_REQUEST, + DELETE_ACCOUNT_SUCCESS, + DELETE_ACCOUNT_FAIL, + MOVE_ACCOUNT_REQUEST, + MOVE_ACCOUNT_SUCCESS, + MOVE_ACCOUNT_FAIL, + fetchOAuthTokens, + revokeOAuthTokenById, + changePassword, + resetPassword, + resetPasswordConfirm, + changeEmail, + confirmChangedEmail, + deleteAccount, + moveAccount, +}; diff --git a/app/soapbox/actions/settings.js b/app/soapbox/actions/settings.ts similarity index 73% rename from app/soapbox/actions/settings.js rename to app/soapbox/actions/settings.ts index 05f8f310b..44a22f666 100644 --- a/app/soapbox/actions/settings.js +++ b/app/soapbox/actions/settings.ts @@ -9,17 +9,25 @@ import { isLoggedIn } from 'soapbox/utils/auth'; import { showAlertForError } from './alerts'; import snackbar from './snackbar'; -export const SETTING_CHANGE = 'SETTING_CHANGE'; -export const SETTING_SAVE = 'SETTING_SAVE'; -export const SETTINGS_UPDATE = 'SETTINGS_UPDATE'; +import type { AppDispatch, RootState } from 'soapbox/store'; -export const FE_NAME = 'soapbox_fe'; +const SETTING_CHANGE = 'SETTING_CHANGE'; +const SETTING_SAVE = 'SETTING_SAVE'; +const SETTINGS_UPDATE = 'SETTINGS_UPDATE'; + +const FE_NAME = 'soapbox_fe'; + +/** Options when changing/saving settings. */ +type SettingOpts = { + /** Whether to display an alert when settings are saved. */ + showAlert?: boolean, +} const messages = defineMessages({ saveSuccess: { id: 'settings.save.success', defaultMessage: 'Your preferences have been saved!' }, }); -export const defaultSettings = ImmutableMap({ +const defaultSettings = ImmutableMap({ onboarded: false, skinTone: 1, reduceMotion: false, @@ -166,64 +174,73 @@ export const defaultSettings = ImmutableMap({ }), }); -export const getSettings = createSelector([ - state => state.getIn(['soapbox', 'defaultSettings']), - state => state.get('settings'), +const getSettings = createSelector([ + (state: RootState) => state.soapbox.get('defaultSettings'), + (state: RootState) => state.settings, ], (soapboxSettings, settings) => { return defaultSettings .mergeDeep(soapboxSettings) .mergeDeep(settings); }); -export function changeSettingImmediate(path, value) { - return dispatch => { +const changeSettingImmediate = (path: string[], value: any, opts?: SettingOpts) => + (dispatch: AppDispatch) => { dispatch({ type: SETTING_CHANGE, path, value, }); - dispatch(saveSettingsImmediate()); + dispatch(saveSettingsImmediate(opts)); }; -} -export function changeSetting(path, value, intl) { - return dispatch => { +const changeSetting = (path: string[], value: any, opts?: SettingOpts) => + (dispatch: AppDispatch) => { dispatch({ type: SETTING_CHANGE, path, value, }); - return dispatch(saveSettings(intl)); + return dispatch(saveSettings(opts)); }; -} -export function saveSettingsImmediate(intl) { - return (dispatch, getState) => { +const saveSettingsImmediate = (opts?: SettingOpts) => + (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; const state = getState(); if (getSettings(state).getIn(['saved'])) return; - const data = state.get('settings').delete('saved').toJS(); + const data = state.settings.delete('saved').toJS(); dispatch(patchMe({ pleroma_settings_store: { [FE_NAME]: data, }, - })).then(response => { + })).then(() => { dispatch({ type: SETTING_SAVE }); - if (intl) { - dispatch(snackbar.success(intl.formatMessage(messages.saveSuccess))); + if (opts?.showAlert) { + dispatch(snackbar.success(messages.saveSuccess)); } }).catch(error => { dispatch(showAlertForError(error)); }); }; -} -export function saveSettings(intl) { - return (dispatch, getState) => dispatch(saveSettingsImmediate(intl)); -} +const saveSettings = (opts?: SettingOpts) => + (dispatch: AppDispatch) => dispatch(saveSettingsImmediate(opts)); + +export { + SETTING_CHANGE, + SETTING_SAVE, + SETTINGS_UPDATE, + FE_NAME, + defaultSettings, + getSettings, + changeSettingImmediate, + changeSetting, + saveSettingsImmediate, + saveSettings, +}; diff --git a/app/soapbox/actions/sidebar.js b/app/soapbox/actions/sidebar.js deleted file mode 100644 index 247c6df83..000000000 --- a/app/soapbox/actions/sidebar.js +++ /dev/null @@ -1,14 +0,0 @@ -export const SIDEBAR_OPEN = 'SIDEBAR_OPEN'; -export const SIDEBAR_CLOSE = 'SIDEBAR_CLOSE'; - -export function openSidebar() { - return { - type: SIDEBAR_OPEN, - }; -} - -export function closeSidebar() { - return { - type: SIDEBAR_CLOSE, - }; -} diff --git a/app/soapbox/actions/sidebar.ts b/app/soapbox/actions/sidebar.ts new file mode 100644 index 000000000..3c9d05cff --- /dev/null +++ b/app/soapbox/actions/sidebar.ts @@ -0,0 +1,17 @@ +const SIDEBAR_OPEN = 'SIDEBAR_OPEN'; +const SIDEBAR_CLOSE = 'SIDEBAR_CLOSE'; + +const openSidebar = () => ({ + type: SIDEBAR_OPEN, +}); + +const closeSidebar = () => ({ + type: SIDEBAR_CLOSE, +}); + +export { + SIDEBAR_OPEN, + SIDEBAR_CLOSE, + openSidebar, + closeSidebar, +}; diff --git a/app/soapbox/actions/snackbar.js b/app/soapbox/actions/snackbar.js deleted file mode 100644 index 47fd11137..000000000 --- a/app/soapbox/actions/snackbar.js +++ /dev/null @@ -1,28 +0,0 @@ -import { ALERT_SHOW } from './alerts'; - -export const show = (severity, message, actionLabel, actionLink) => ({ - type: ALERT_SHOW, - message, - actionLabel, - actionLink, - severity, -}); - -export function info(message, actionLabel, actionLink) { - return show('info', message, actionLabel, actionLink); -} - -export function success(message, actionLabel, actionLink) { - return show('success', message, actionLabel, actionLink); -} - -export function error(message, actionLabel, actionLink) { - return show('error', message, actionLabel, actionLink); -} - -export default { - info, - success, - error, - show, -}; diff --git a/app/soapbox/actions/snackbar.ts b/app/soapbox/actions/snackbar.ts new file mode 100644 index 000000000..57d23b64b --- /dev/null +++ b/app/soapbox/actions/snackbar.ts @@ -0,0 +1,50 @@ +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/soapbox.js b/app/soapbox/actions/soapbox.ts similarity index 54% rename from app/soapbox/actions/soapbox.js rename to app/soapbox/actions/soapbox.ts index 318050219..89d3080d8 100644 --- a/app/soapbox/actions/soapbox.js +++ b/app/soapbox/actions/soapbox.ts @@ -8,16 +8,20 @@ import { getFeatures } from 'soapbox/utils/features'; import api, { staticClient } from '../api'; -export const SOAPBOX_CONFIG_REQUEST_SUCCESS = 'SOAPBOX_CONFIG_REQUEST_SUCCESS'; -export const SOAPBOX_CONFIG_REQUEST_FAIL = 'SOAPBOX_CONFIG_REQUEST_FAIL'; +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; -export const SOAPBOX_CONFIG_REMEMBER_REQUEST = 'SOAPBOX_CONFIG_REMEMBER_REQUEST'; -export const SOAPBOX_CONFIG_REMEMBER_SUCCESS = 'SOAPBOX_CONFIG_REMEMBER_SUCCESS'; -export const SOAPBOX_CONFIG_REMEMBER_FAIL = 'SOAPBOX_CONFIG_REMEMBER_FAIL'; +const SOAPBOX_CONFIG_REQUEST_SUCCESS = 'SOAPBOX_CONFIG_REQUEST_SUCCESS'; +const SOAPBOX_CONFIG_REQUEST_FAIL = 'SOAPBOX_CONFIG_REQUEST_FAIL'; -export const getSoapboxConfig = createSelector([ - state => state.soapbox, - state => getFeatures(state.instance), +const SOAPBOX_CONFIG_REMEMBER_REQUEST = 'SOAPBOX_CONFIG_REMEMBER_REQUEST'; +const SOAPBOX_CONFIG_REMEMBER_SUCCESS = 'SOAPBOX_CONFIG_REMEMBER_SUCCESS'; +const SOAPBOX_CONFIG_REMEMBER_FAIL = 'SOAPBOX_CONFIG_REMEMBER_FAIL'; + +const getSoapboxConfig = createSelector([ + (state: RootState) => state.soapbox, + (state: RootState) => getFeatures(state.instance), ], (soapbox, features) => { // Do some additional normalization with the state return normalizeSoapboxConfig(soapbox).withMutations(soapboxConfig => { @@ -35,8 +39,8 @@ export const getSoapboxConfig = createSelector([ }); }); -export function rememberSoapboxConfig(host) { - return (dispatch, getState) => { +const rememberSoapboxConfig = (host: string | null) => + (dispatch: AppDispatch) => { dispatch({ type: SOAPBOX_CONFIG_REMEMBER_REQUEST, host }); return KVStore.getItemOrError(`soapbox_config:${host}`).then(soapboxConfig => { dispatch({ type: SOAPBOX_CONFIG_REMEMBER_SUCCESS, host, soapboxConfig }); @@ -45,58 +49,53 @@ export function rememberSoapboxConfig(host) { dispatch({ type: SOAPBOX_CONFIG_REMEMBER_FAIL, host, error, skipAlert: true }); }); }; -} -export function fetchFrontendConfigurations() { - return (dispatch, getState) => { - return api(getState) +const fetchFrontendConfigurations = () => + (dispatch: AppDispatch, getState: () => RootState) => + api(getState) .get('/api/pleroma/frontend_configurations') .then(({ data }) => data); - }; -} /** Conditionally fetches Soapbox config depending on backend features */ -export function fetchSoapboxConfig(host) { - return (dispatch, getState) => { +const fetchSoapboxConfig = (host: string | null) => + (dispatch: AppDispatch, getState: () => RootState) => { const features = getFeatures(getState().instance); if (features.frontendConfigurations) { return dispatch(fetchFrontendConfigurations()).then(data => { if (data.soapbox_fe) { dispatch(importSoapboxConfig(data.soapbox_fe, host)); + return data.soapbox_fe; } else { - dispatch(fetchSoapboxJson(host)); + return dispatch(fetchSoapboxJson(host)); } }); } else { return dispatch(fetchSoapboxJson(host)); } }; -} /** Tries to remember the config from browser storage before fetching it */ -export function loadSoapboxConfig() { - return (dispatch, getState) => { +const loadSoapboxConfig = () => + (dispatch: AppDispatch, getState: () => RootState) => { const host = getHost(getState()); - return dispatch(rememberSoapboxConfig(host)).finally(() => { - return dispatch(fetchSoapboxConfig(host)); - }); + return dispatch(rememberSoapboxConfig(host)).then(() => + dispatch(fetchSoapboxConfig(host)), + ); }; -} -export function fetchSoapboxJson(host) { - return (dispatch, getState) => { +const fetchSoapboxJson = (host: string | null) => + (dispatch: AppDispatch) => staticClient.get('/instance/soapbox.json').then(({ data }) => { if (!isObject(data)) throw 'soapbox.json failed'; dispatch(importSoapboxConfig(data, host)); + return data; }).catch(error => { dispatch(soapboxConfigFail(error, host)); }); - }; -} -export function importSoapboxConfig(soapboxConfig, host) { +const importSoapboxConfig = (soapboxConfig: APIEntity, host: string | null) => { if (!soapboxConfig.brandColor) { soapboxConfig.brandColor = '#0482d8'; } @@ -105,18 +104,30 @@ export function importSoapboxConfig(soapboxConfig, host) { soapboxConfig, host, }; -} +}; -export function soapboxConfigFail(error, host) { - return { - type: SOAPBOX_CONFIG_REQUEST_FAIL, - error, - skipAlert: true, - host, - }; -} +const soapboxConfigFail = (error: AxiosError, host: string | null) => ({ + type: SOAPBOX_CONFIG_REQUEST_FAIL, + error, + skipAlert: true, + host, +}); // https://stackoverflow.com/a/46663081 -function isObject(o) { - return o instanceof Object && o.constructor === Object; -} +const isObject = (o: any) => o instanceof Object && o.constructor === Object; + +export { + SOAPBOX_CONFIG_REQUEST_SUCCESS, + SOAPBOX_CONFIG_REQUEST_FAIL, + SOAPBOX_CONFIG_REMEMBER_REQUEST, + SOAPBOX_CONFIG_REMEMBER_SUCCESS, + SOAPBOX_CONFIG_REMEMBER_FAIL, + getSoapboxConfig, + rememberSoapboxConfig, + fetchFrontendConfigurations, + fetchSoapboxConfig, + loadSoapboxConfig, + fetchSoapboxJson, + importSoapboxConfig, + soapboxConfigFail, +}; diff --git a/app/soapbox/actions/status-hover-card.ts b/app/soapbox/actions/status-hover-card.ts new file mode 100644 index 000000000..2ce24a745 --- /dev/null +++ b/app/soapbox/actions/status-hover-card.ts @@ -0,0 +1,27 @@ +const STATUS_HOVER_CARD_OPEN = 'STATUS_HOVER_CARD_OPEN'; +const STATUS_HOVER_CARD_UPDATE = 'STATUS_HOVER_CARD_UPDATE'; +const STATUS_HOVER_CARD_CLOSE = 'STATUS_HOVER_CARD_CLOSE'; + +const openStatusHoverCard = (ref: React.MutableRefObject, statusId: string) => ({ + type: STATUS_HOVER_CARD_OPEN, + ref, + statusId, +}); + +const updateStatusHoverCard = () => ({ + type: STATUS_HOVER_CARD_UPDATE, +}); + +const closeStatusHoverCard = (force = false) => ({ + type: STATUS_HOVER_CARD_CLOSE, + force, +}); + +export { + STATUS_HOVER_CARD_OPEN, + STATUS_HOVER_CARD_UPDATE, + STATUS_HOVER_CARD_CLOSE, + openStatusHoverCard, + updateStatusHoverCard, + closeStatusHoverCard, +}; diff --git a/app/soapbox/actions/statuses.js b/app/soapbox/actions/statuses.js deleted file mode 100644 index 176f7325f..000000000 --- a/app/soapbox/actions/statuses.js +++ /dev/null @@ -1,274 +0,0 @@ -import { isLoggedIn } from 'soapbox/utils/auth'; -import { getFeatures } from 'soapbox/utils/features'; -import { shouldHaveCard } from 'soapbox/utils/status'; - -import api, { getNextLink } from '../api'; - -import { setComposeToStatus } from './compose'; -import { importFetchedStatus, importFetchedStatuses } from './importer'; -import { openModal } from './modals'; -import { deleteFromTimelines } from './timelines'; - -export const STATUS_CREATE_REQUEST = 'STATUS_CREATE_REQUEST'; -export const STATUS_CREATE_SUCCESS = 'STATUS_CREATE_SUCCESS'; -export const STATUS_CREATE_FAIL = 'STATUS_CREATE_FAIL'; - -export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST'; -export const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS'; -export const STATUS_FETCH_SOURCE_FAIL = 'STATUS_FETCH_SOURCE_FAIL'; - -export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; -export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; -export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; - -export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST'; -export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; -export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; - -export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST'; -export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS'; -export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL'; - -export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST'; -export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS'; -export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL'; - -export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST'; -export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; -export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; - -export const STATUS_REVEAL = 'STATUS_REVEAL'; -export const STATUS_HIDE = 'STATUS_HIDE'; - -const statusExists = (getState, statusId) => { - return getState().getIn(['statuses', statusId], null) !== null; -}; - -export function createStatus(params, idempotencyKey, statusId) { - return (dispatch, getState) => { - dispatch({ type: STATUS_CREATE_REQUEST, params, idempotencyKey }); - - return api(getState).request({ - url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, - method: statusId === null ? 'post' : 'put', - data: params, - headers: { 'Idempotency-Key': idempotencyKey }, - }).then(({ data: status }) => { - // The backend might still be processing the rich media attachment - if (!status.card && shouldHaveCard(status)) { - status.expectsCard = true; - } - - dispatch(importFetchedStatus(status, idempotencyKey)); - dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey }); - - // Poll the backend for the updated card - if (status.expectsCard) { - const delay = 1000; - - const poll = (retries = 5) => { - api(getState).get(`/api/v1/statuses/${status.id}`).then(response => { - if (response.data?.card) { - dispatch(importFetchedStatus(response.data)); - } else if (retries > 0 && response.status === 200) { - setTimeout(() => poll(retries - 1), delay); - } - }).catch(console.error); - }; - - setTimeout(() => poll(), delay); - } - - return status; - }).catch(error => { - dispatch({ type: STATUS_CREATE_FAIL, error, params, idempotencyKey }); - throw error; - }); - }; -} - -export const editStatus = (id) => (dispatch, getState) => { - let status = getState().getIn(['statuses', id]); - - if (status.get('poll')) { - status = status.set('poll', getState().getIn(['polls', status.get('poll')])); - } - - dispatch({ type: STATUS_FETCH_SOURCE_REQUEST }); - - api(getState).get(`/api/v1/statuses/${id}/source`).then(response => { - dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS }); - dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text)); - dispatch(openModal('COMPOSE')); - }).catch(error => { - dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error }); - - }); -}; - -export function fetchStatus(id) { - return (dispatch, getState) => { - const skipLoading = statusExists(getState, id); - - dispatch({ type: STATUS_FETCH_REQUEST, id, skipLoading }); - - return api(getState).get(`/api/v1/statuses/${id}`).then(({ data: status }) => { - dispatch(importFetchedStatus(status)); - dispatch({ type: STATUS_FETCH_SUCCESS, status, skipLoading }); - return status; - }).catch(error => { - dispatch({ type: STATUS_FETCH_FAIL, id, error, skipLoading, skipAlert: true }); - }); - }; -} - -export function deleteStatus(id, routerHistory, withRedraft = false) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - let status = getState().getIn(['statuses', id]); - - if (status.get('poll')) { - status = status.set('poll', getState().getIn(['polls', status.get('poll')])); - } - - dispatch({ type: STATUS_DELETE_REQUEST, id }); - - api(getState).delete(`/api/v1/statuses/${id}`).then(response => { - dispatch({ type: STATUS_DELETE_SUCCESS, id }); - dispatch(deleteFromTimelines(id)); - - if (withRedraft) { - dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.pleroma?.content_type)); - dispatch(openModal('COMPOSE')); - } - }).catch(error => { - dispatch({ type: STATUS_DELETE_FAIL, id, error }); - }); - }; -} - -export const updateStatus = status => dispatch => - dispatch(importFetchedStatus(status)); - -export function fetchContext(id) { - return (dispatch, getState) => { - dispatch({ type: CONTEXT_FETCH_REQUEST, id }); - - return api(getState).get(`/api/v1/statuses/${id}/context`).then(({ data: context }) => { - if (Array.isArray(context)) { - // Mitra: returns a list of statuses - dispatch(importFetchedStatuses(context)); - } else if (typeof context === 'object') { - // Standard Mastodon API returns a map with `ancestors` and `descendants` - const { ancestors, descendants } = context; - const statuses = ancestors.concat(descendants); - dispatch(importFetchedStatuses(statuses)); - dispatch({ type: CONTEXT_FETCH_SUCCESS, id, ancestors, descendants }); - } else { - throw context; - } - return context; - }).catch(error => { - if (error.response?.status === 404) { - dispatch(deleteFromTimelines(id)); - } - - dispatch({ type: CONTEXT_FETCH_FAIL, id, error, skipAlert: true }); - }); - }; -} - -export function fetchNext(next) { - return async(dispatch, getState) => { - const response = await api(getState).get(next); - dispatch(importFetchedStatuses(response.data)); - return { next: getNextLink(response) }; - }; -} - -export function fetchAncestors(id) { - return async(dispatch, getState) => { - const response = await api(getState).get(`/api/v1/statuses/${id}/context/ancestors`); - dispatch(importFetchedStatuses(response.data)); - return response; - }; -} - -export function fetchDescendants(id) { - return async(dispatch, getState) => { - const response = await api(getState).get(`/api/v1/statuses/${id}/context/descendants`); - dispatch(importFetchedStatuses(response.data)); - return response; - }; -} - -export function fetchStatusWithContext(id) { - return async(dispatch, getState) => { - const features = getFeatures(getState().instance); - - if (features.paginatedContext) { - const responses = await Promise.all([ - dispatch(fetchAncestors(id)), - dispatch(fetchDescendants(id)), - dispatch(fetchStatus(id)), - ]); - const next = getNextLink(responses[1]); - return { next }; - } else { - await Promise.all([ - dispatch(fetchContext(id)), - dispatch(fetchStatus(id)), - ]); - return { next: undefined }; - } - }; -} - -export function muteStatus(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch({ type: STATUS_MUTE_REQUEST, id }); - api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => { - dispatch({ type: STATUS_MUTE_SUCCESS, id }); - }).catch(error => { - dispatch({ type: STATUS_MUTE_FAIL, id, error }); - }); - }; -} - -export function unmuteStatus(id) { - return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch({ type: STATUS_UNMUTE_REQUEST, id }); - api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => { - dispatch({ type: STATUS_UNMUTE_SUCCESS, id }); - }).catch(error => { - dispatch({ type: STATUS_UNMUTE_FAIL, id, error }); - }); - }; -} - -export function hideStatus(ids) { - if (!Array.isArray(ids)) { - ids = [ids]; - } - - return { - type: STATUS_HIDE, - ids, - }; -} - -export function revealStatus(ids) { - if (!Array.isArray(ids)) { - ids = [ids]; - } - - return { - type: STATUS_REVEAL, - ids, - }; -} diff --git a/app/soapbox/actions/statuses.ts b/app/soapbox/actions/statuses.ts new file mode 100644 index 000000000..db15e7a21 --- /dev/null +++ b/app/soapbox/actions/statuses.ts @@ -0,0 +1,348 @@ +import { isLoggedIn } from 'soapbox/utils/auth'; +import { getFeatures } from 'soapbox/utils/features'; +import { shouldHaveCard } from 'soapbox/utils/status'; + +import api, { getNextLink } from '../api'; + +import { setComposeToStatus } from './compose'; +import { importFetchedStatus, importFetchedStatuses } from './importer'; +import { openModal } from './modals'; +import { deleteFromTimelines } from './timelines'; + +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity, Status } from 'soapbox/types/entities'; + +const STATUS_CREATE_REQUEST = 'STATUS_CREATE_REQUEST'; +const STATUS_CREATE_SUCCESS = 'STATUS_CREATE_SUCCESS'; +const STATUS_CREATE_FAIL = 'STATUS_CREATE_FAIL'; + +const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST'; +const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS'; +const STATUS_FETCH_SOURCE_FAIL = 'STATUS_FETCH_SOURCE_FAIL'; + +const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; +const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; +const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; + +const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST'; +const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; +const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; + +const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST'; +const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS'; +const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL'; + +const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST'; +const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS'; +const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL'; + +const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST'; +const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; +const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; + +const STATUS_REVEAL = 'STATUS_REVEAL'; +const STATUS_HIDE = 'STATUS_HIDE'; + +const statusExists = (getState: () => RootState, statusId: string) => { + return (getState().statuses.get(statusId) || null) !== null; +}; + +const createStatus = (params: Record, idempotencyKey: string, statusId: string | null) => { + return (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: STATUS_CREATE_REQUEST, params, idempotencyKey, editing: !!statusId }); + + return api(getState).request({ + url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, + method: statusId === null ? 'post' : 'put', + data: params, + headers: { 'Idempotency-Key': idempotencyKey }, + }).then(({ data: status }) => { + // The backend might still be processing the rich media attachment + if (!status.card && shouldHaveCard(status)) { + status.expectsCard = true; + } + + dispatch(importFetchedStatus(status, idempotencyKey)); + dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey }); + + // Poll the backend for the updated card + if (status.expectsCard) { + const delay = 1000; + + const poll = (retries = 5) => { + api(getState).get(`/api/v1/statuses/${status.id}`).then(response => { + if (response.data?.card) { + dispatch(importFetchedStatus(response.data)); + } else if (retries > 0 && response.status === 200) { + setTimeout(() => poll(retries - 1), delay); + } + }).catch(console.error); + }; + + setTimeout(() => poll(), delay); + } + + return status; + }).catch(error => { + dispatch({ type: STATUS_CREATE_FAIL, error, params, idempotencyKey, editing: !!statusId }); + throw error; + }); + }; +}; + +const editStatus = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { + let status = getState().statuses.get(id)!; + + if (status.poll) { + status = status.set('poll', getState().polls.get(status.poll) as any); + } + + dispatch({ type: STATUS_FETCH_SOURCE_REQUEST }); + + api(getState).get(`/api/v1/statuses/${id}/source`).then(response => { + dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS }); + dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, false)); + dispatch(openModal('COMPOSE')); + }).catch(error => { + dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error }); + + }); +}; + +const fetchStatus = (id: string) => { + return (dispatch: AppDispatch, getState: () => RootState) => { + const skipLoading = statusExists(getState, id); + + dispatch({ type: STATUS_FETCH_REQUEST, id, skipLoading }); + + return api(getState).get(`/api/v1/statuses/${id}`).then(({ data: status }) => { + dispatch(importFetchedStatus(status)); + dispatch({ type: STATUS_FETCH_SUCCESS, status, skipLoading }); + return status; + }).catch(error => { + dispatch({ type: STATUS_FETCH_FAIL, id, error, skipLoading, skipAlert: true }); + }); + }; +}; + +const deleteStatus = (id: string, withRedraft = false) => { + return (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + let status = getState().statuses.get(id)!; + + if (status.poll) { + status = status.set('poll', getState().polls.get(status.poll) as any); + } + + dispatch({ type: STATUS_DELETE_REQUEST, params: status }); + + return api(getState) + .delete(`/api/v1/statuses/${id}`) + .then(response => { + dispatch({ type: STATUS_DELETE_SUCCESS, id }); + dispatch(deleteFromTimelines(id)); + + if (withRedraft) { + dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.pleroma?.content_type, withRedraft)); + dispatch(openModal('COMPOSE')); + } + }) + .catch(error => { + dispatch({ type: STATUS_DELETE_FAIL, params: status, error }); + }); + }; +}; + +const updateStatus = (status: APIEntity) => (dispatch: AppDispatch) => + dispatch(importFetchedStatus(status)); + +const fetchContext = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: CONTEXT_FETCH_REQUEST, id }); + + return api(getState).get(`/api/v1/statuses/${id}/context`).then(({ data: context }) => { + if (Array.isArray(context)) { + // Mitra: returns a list of statuses + dispatch(importFetchedStatuses(context)); + } else if (typeof context === 'object') { + // Standard Mastodon API returns a map with `ancestors` and `descendants` + const { ancestors, descendants } = context; + const statuses = ancestors.concat(descendants); + dispatch(importFetchedStatuses(statuses)); + dispatch({ type: CONTEXT_FETCH_SUCCESS, id, ancestors, descendants }); + } else { + throw context; + } + return context; + }).catch(error => { + if (error.response?.status === 404) { + dispatch(deleteFromTimelines(id)); + } + + dispatch({ type: CONTEXT_FETCH_FAIL, id, error, skipAlert: true }); + }); + }; + +const fetchNext = (statusId: string, next: string) => + async(dispatch: AppDispatch, getState: () => RootState) => { + const response = await api(getState).get(next); + dispatch(importFetchedStatuses(response.data)); + + dispatch({ + type: CONTEXT_FETCH_SUCCESS, + id: statusId, + ancestors: [], + descendants: response.data, + }); + + return { next: getNextLink(response) }; + }; + +const fetchAncestors = (id: string) => + async(dispatch: AppDispatch, getState: () => RootState) => { + const response = await api(getState).get(`/api/v1/statuses/${id}/context/ancestors`); + dispatch(importFetchedStatuses(response.data)); + return response; + }; + +const fetchDescendants = (id: string) => + async(dispatch: AppDispatch, getState: () => RootState) => { + const response = await api(getState).get(`/api/v1/statuses/${id}/context/descendants`); + dispatch(importFetchedStatuses(response.data)); + return response; + }; + +const fetchStatusWithContext = (id: string) => + async(dispatch: AppDispatch, getState: () => RootState) => { + const features = getFeatures(getState().instance); + + if (features.paginatedContext) { + await dispatch(fetchStatus(id)); + const responses = await Promise.all([ + dispatch(fetchAncestors(id)), + dispatch(fetchDescendants(id)), + ]); + + dispatch({ + type: CONTEXT_FETCH_SUCCESS, + id, + ancestors: responses[0].data, + descendants: responses[1].data, + }); + + const next = getNextLink(responses[1]); + return { next }; + } else { + await Promise.all([ + dispatch(fetchContext(id)), + dispatch(fetchStatus(id)), + ]); + return { next: undefined }; + } + }; + +const muteStatus = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch({ type: STATUS_MUTE_REQUEST, id }); + api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => { + dispatch({ type: STATUS_MUTE_SUCCESS, id }); + }).catch(error => { + dispatch({ type: STATUS_MUTE_FAIL, id, error }); + }); + }; + +const unmuteStatus = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch({ type: STATUS_UNMUTE_REQUEST, id }); + api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => { + dispatch({ type: STATUS_UNMUTE_SUCCESS, id }); + }).catch(error => { + dispatch({ type: STATUS_UNMUTE_FAIL, id, error }); + }); + }; + +const toggleMuteStatus = (status: Status) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.muted) { + dispatch(unmuteStatus(status.id)); + } else { + dispatch(muteStatus(status.id)); + } + }; + +const hideStatus = (ids: string[] | string) => { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: STATUS_HIDE, + ids, + }; +}; + +const revealStatus = (ids: string[] | string) => { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: STATUS_REVEAL, + ids, + }; +}; + +const toggleStatusHidden = (status: Status) => { + if (status.hidden) { + return revealStatus(status.id); + } else { + return hideStatus(status.id); + } +}; + +export { + STATUS_CREATE_REQUEST, + STATUS_CREATE_SUCCESS, + STATUS_CREATE_FAIL, + STATUS_FETCH_SOURCE_REQUEST, + STATUS_FETCH_SOURCE_SUCCESS, + STATUS_FETCH_SOURCE_FAIL, + STATUS_FETCH_REQUEST, + STATUS_FETCH_SUCCESS, + STATUS_FETCH_FAIL, + STATUS_DELETE_REQUEST, + STATUS_DELETE_SUCCESS, + STATUS_DELETE_FAIL, + CONTEXT_FETCH_REQUEST, + CONTEXT_FETCH_SUCCESS, + CONTEXT_FETCH_FAIL, + STATUS_MUTE_REQUEST, + STATUS_MUTE_SUCCESS, + STATUS_MUTE_FAIL, + STATUS_UNMUTE_REQUEST, + STATUS_UNMUTE_SUCCESS, + STATUS_UNMUTE_FAIL, + STATUS_REVEAL, + STATUS_HIDE, + createStatus, + editStatus, + fetchStatus, + deleteStatus, + updateStatus, + fetchContext, + fetchNext, + fetchAncestors, + fetchDescendants, + fetchStatusWithContext, + muteStatus, + unmuteStatus, + toggleMuteStatus, + hideStatus, + revealStatus, + toggleStatusHidden, +}; diff --git a/app/soapbox/actions/streaming.js b/app/soapbox/actions/streaming.js deleted file mode 100644 index 5f2365f18..000000000 --- a/app/soapbox/actions/streaming.js +++ /dev/null @@ -1,112 +0,0 @@ -import { getSettings } from 'soapbox/actions/settings'; -import messages from 'soapbox/locales/messages'; - -import { connectStream } from '../stream'; - -import { updateConversations } from './conversations'; -import { fetchFilters } from './filters'; -import { updateNotificationsQueue, expandNotifications } from './notifications'; -import { updateStatus } from './statuses'; -import { - deleteFromTimelines, - expandHomeTimeline, - connectTimeline, - disconnectTimeline, - processTimelineUpdate, -} from './timelines'; - -export const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE'; -export const STREAMING_FOLLOW_RELATIONSHIPS_UPDATE = 'STREAMING_FOLLOW_RELATIONSHIPS_UPDATE'; - -const validLocale = locale => Object.keys(messages).includes(locale); - -const getLocale = state => { - const locale = getSettings(state).get('locale'); - return validLocale(locale) ? locale : 'en'; -}; - -function updateFollowRelationships(relationships) { - return (dispatch, getState) => { - const me = getState().get('me'); - return dispatch({ - type: STREAMING_FOLLOW_RELATIONSHIPS_UPDATE, - me, - ...relationships, - }); - }; -} - -export function connectTimelineStream(timelineId, path, pollingRefresh = null, accept = null) { - - return connectStream (path, pollingRefresh, (dispatch, getState) => { - const locale = getLocale(getState()); - - return { - onConnect() { - dispatch(connectTimeline(timelineId)); - }, - - onDisconnect() { - dispatch(disconnectTimeline(timelineId)); - }, - - onReceive(data) { - switch(data.event) { - case 'update': - dispatch(processTimelineUpdate(timelineId, JSON.parse(data.payload), accept)); - break; - case 'status.update': - dispatch(updateStatus(JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - case 'notification': - messages[locale]().then(messages => { - dispatch(updateNotificationsQueue(JSON.parse(data.payload), messages, locale, window.location.pathname)); - }).catch(error => { - console.error(error); - }); - break; - case 'conversation': - dispatch(updateConversations(JSON.parse(data.payload))); - break; - case 'filters_changed': - dispatch(fetchFilters()); - break; - case 'pleroma:chat_update': - dispatch((dispatch, getState) => { - const chat = JSON.parse(data.payload); - const me = getState().get('me'); - const messageOwned = !(chat.last_message && chat.last_message.account_id !== me); - - dispatch({ - type: STREAMING_CHAT_UPDATE, - chat, - me, - // Only play sounds for recipient messages - meta: !messageOwned && getSettings(getState()).getIn(['chats', 'sound']) && { sound: 'chat' }, - }); - }); - break; - case 'pleroma:follow_relationships_update': - dispatch(updateFollowRelationships(JSON.parse(data.payload))); - break; - } - }, - }; - }); -} - -const refreshHomeTimelineAndNotification = (dispatch, done) => { - dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done)))); -}; - -export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); -export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); -export const connectPublicStream = ({ onlyMedia } = {}) => connectTimelineStream(`public${onlyMedia ? ':media' : ''}`, `public${onlyMedia ? ':media' : ''}`); -export const connectRemoteStream = (instance, { onlyMedia } = {}) => connectTimelineStream(`remote${onlyMedia ? ':media' : ''}:${instance}`, `public:remote${onlyMedia ? ':media' : ''}&instance=${instance}`); -export const connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept); -export const connectDirectStream = () => connectTimelineStream('direct', 'direct'); -export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`); -export const connectGroupStream = id => connectTimelineStream(`group:${id}`, `group&group=${id}`); diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts new file mode 100644 index 000000000..dcb190f25 --- /dev/null +++ b/app/soapbox/actions/streaming.ts @@ -0,0 +1,168 @@ +import { getSettings } from 'soapbox/actions/settings'; +import messages from 'soapbox/locales/messages'; + +import { connectStream } from '../stream'; + +import { + deleteAnnouncement, + fetchAnnouncements, + updateAnnouncements, + updateReaction as updateAnnouncementsReaction, +} from './announcements'; +import { updateConversations } from './conversations'; +import { fetchFilters } from './filters'; +import { MARKER_FETCH_SUCCESS } from './markers'; +import { updateNotificationsQueue, expandNotifications } from './notifications'; +import { updateStatus } from './statuses'; +import { + // deleteFromTimelines, + expandHomeTimeline, + connectTimeline, + disconnectTimeline, + processTimelineUpdate, +} from './timelines'; + +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE'; +const STREAMING_FOLLOW_RELATIONSHIPS_UPDATE = 'STREAMING_FOLLOW_RELATIONSHIPS_UPDATE'; + +const validLocale = (locale: string) => Object.keys(messages).includes(locale); + +const getLocale = (state: RootState) => { + const locale = getSettings(state).get('locale') as string; + return validLocale(locale) ? locale : 'en'; +}; + +const updateFollowRelationships = (relationships: APIEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + const me = getState().me; + return dispatch({ + type: STREAMING_FOLLOW_RELATIONSHIPS_UPDATE, + me, + ...relationships, + }); + }; + +const connectTimelineStream = ( + timelineId: string, + path: string, + pollingRefresh: ((dispatch: AppDispatch, done?: () => void) => void) | null = null, + accept: ((status: APIEntity) => boolean) | null = null, +) => connectStream(path, pollingRefresh, (dispatch: AppDispatch, getState: () => RootState) => { + const locale = getLocale(getState()); + + return { + onConnect() { + dispatch(connectTimeline(timelineId)); + }, + + onDisconnect() { + dispatch(disconnectTimeline(timelineId)); + }, + + onReceive(data: any) { + switch (data.event) { + case 'update': + dispatch(processTimelineUpdate(timelineId, JSON.parse(data.payload), accept)); + break; + case 'status.update': + dispatch(updateStatus(JSON.parse(data.payload))); + break; + // FIXME: We think delete & redraft is causing jumpy timelines. + // Fix that in ScrollableList then re-enable this! + // + // case 'delete': + // dispatch(deleteFromTimelines(data.payload)); + // break; + case 'notification': + messages[locale]().then(messages => { + dispatch(updateNotificationsQueue(JSON.parse(data.payload), messages, locale, window.location.pathname)); + }).catch(error => { + console.error(error); + }); + break; + case 'conversation': + dispatch(updateConversations(JSON.parse(data.payload))); + break; + case 'filters_changed': + dispatch(fetchFilters()); + break; + case 'pleroma:chat_update': + dispatch((dispatch: AppDispatch, getState: () => RootState) => { + const chat = JSON.parse(data.payload); + const me = getState().me; + const messageOwned = !(chat.last_message && chat.last_message.account_id !== me); + + dispatch({ + type: STREAMING_CHAT_UPDATE, + chat, + me, + // Only play sounds for recipient messages + meta: !messageOwned && getSettings(getState()).getIn(['chats', 'sound']) && { sound: 'chat' }, + }); + }); + break; + case 'pleroma:follow_relationships_update': + dispatch(updateFollowRelationships(JSON.parse(data.payload))); + break; + case 'announcement': + dispatch(updateAnnouncements(JSON.parse(data.payload))); + break; + case 'announcement.reaction': + dispatch(updateAnnouncementsReaction(JSON.parse(data.payload))); + break; + case 'announcement.delete': + dispatch(deleteAnnouncement(data.payload)); + break; + case 'marker': + dispatch({ type: MARKER_FETCH_SUCCESS, marker: JSON.parse(data.payload) }); + break; + } + }, + }; +}); + +const refreshHomeTimelineAndNotification = (dispatch: AppDispatch, done?: () => void) => + dispatch(expandHomeTimeline({}, () => + dispatch(expandNotifications({}, () => + dispatch(fetchAnnouncements(done)))))); + +const connectUserStream = () => + connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); + +const connectCommunityStream = ({ onlyMedia }: Record = {}) => + connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); + +const connectPublicStream = ({ onlyMedia }: Record = {}) => + connectTimelineStream(`public${onlyMedia ? ':media' : ''}`, `public${onlyMedia ? ':media' : ''}`); + +const connectRemoteStream = (instance: string, { onlyMedia }: Record = {}) => + connectTimelineStream(`remote${onlyMedia ? ':media' : ''}:${instance}`, `public:remote${onlyMedia ? ':media' : ''}&instance=${instance}`); + +const connectHashtagStream = (id: string, tag: string, accept: (status: APIEntity) => boolean) => + connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept); + +const connectDirectStream = () => + connectTimelineStream('direct', 'direct'); + +const connectListStream = (id: string) => + connectTimelineStream(`list:${id}`, `list&list=${id}`); + +const connectGroupStream = (id: string) => + connectTimelineStream(`group:${id}`, `group&group=${id}`); + +export { + STREAMING_CHAT_UPDATE, + STREAMING_FOLLOW_RELATIONSHIPS_UPDATE, + connectTimelineStream, + connectUserStream, + connectCommunityStream, + connectPublicStream, + connectRemoteStream, + connectHashtagStream, + connectDirectStream, + connectListStream, + connectGroupStream, +}; diff --git a/app/soapbox/actions/suggestions.js b/app/soapbox/actions/suggestions.js deleted file mode 100644 index 0221b3a05..000000000 --- a/app/soapbox/actions/suggestions.js +++ /dev/null @@ -1,83 +0,0 @@ -import { isLoggedIn } from 'soapbox/utils/auth'; -import { getFeatures } from 'soapbox/utils/features'; - -import api from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts } from './importer'; - -export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST'; -export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS'; -export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL'; - -export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS'; - -export const SUGGESTIONS_V2_FETCH_REQUEST = 'SUGGESTIONS_V2_FETCH_REQUEST'; -export const SUGGESTIONS_V2_FETCH_SUCCESS = 'SUGGESTIONS_V2_FETCH_SUCCESS'; -export const SUGGESTIONS_V2_FETCH_FAIL = 'SUGGESTIONS_V2_FETCH_FAIL'; - -export function fetchSuggestionsV1(params = {}) { - return (dispatch, getState) => { - dispatch({ type: SUGGESTIONS_FETCH_REQUEST, skipLoading: true }); - return api(getState).get('/api/v1/suggestions', { params }).then(({ data: accounts }) => { - dispatch(importFetchedAccounts(accounts)); - dispatch({ type: SUGGESTIONS_FETCH_SUCCESS, accounts, skipLoading: true }); - return accounts; - }).catch(error => { - dispatch({ type: SUGGESTIONS_FETCH_FAIL, error, skipLoading: true, skipAlert: true }); - throw error; - }); - }; -} - -export function fetchSuggestionsV2(params = {}) { - return (dispatch, getState) => { - dispatch({ type: SUGGESTIONS_V2_FETCH_REQUEST, skipLoading: true }); - return api(getState).get('/api/v2/suggestions', { params }).then(({ data: suggestions }) => { - const accounts = suggestions.map(({ account }) => account); - dispatch(importFetchedAccounts(accounts)); - dispatch({ type: SUGGESTIONS_V2_FETCH_SUCCESS, suggestions, skipLoading: true }); - return suggestions; - }).catch(error => { - dispatch({ type: SUGGESTIONS_V2_FETCH_FAIL, error, skipLoading: true, skipAlert: true }); - throw error; - }); - }; -} - -export function fetchSuggestions(params = { limit: 50 }) { - return (dispatch, getState) => { - const state = getState(); - const instance = state.get('instance'); - const features = getFeatures(instance); - - if (features.suggestionsV2) { - dispatch(fetchSuggestionsV2(params)) - .then(suggestions => { - const accountIds = suggestions.map(({ account }) => account.id); - dispatch(fetchRelationships(accountIds)); - }) - .catch(() => {}); - } else if (features.suggestions) { - dispatch(fetchSuggestionsV1(params)) - .then(accounts => { - const accountIds = accounts.map(({ id }) => id); - dispatch(fetchRelationships(accountIds)); - }) - .catch(() => {}); - } else { - // Do nothing - } - }; -} - -export const dismissSuggestion = accountId => (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - - dispatch({ - type: SUGGESTIONS_DISMISS, - id: accountId, - }); - - api(getState).delete(`/api/v1/suggestions/${accountId}`); -}; diff --git a/app/soapbox/actions/suggestions.ts b/app/soapbox/actions/suggestions.ts new file mode 100644 index 000000000..600dffb7d --- /dev/null +++ b/app/soapbox/actions/suggestions.ts @@ -0,0 +1,172 @@ +import { AxiosResponse } from 'axios'; + +import { isLoggedIn } from 'soapbox/utils/auth'; +import { getFeatures } from 'soapbox/utils/features'; + +import api, { getLinks } from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; +import { insertSuggestionsIntoTimeline } from './timelines'; + +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST'; +const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS'; +const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL'; + +const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS'; + +const SUGGESTIONS_V2_FETCH_REQUEST = 'SUGGESTIONS_V2_FETCH_REQUEST'; +const SUGGESTIONS_V2_FETCH_SUCCESS = 'SUGGESTIONS_V2_FETCH_SUCCESS'; +const SUGGESTIONS_V2_FETCH_FAIL = 'SUGGESTIONS_V2_FETCH_FAIL'; + +const SUGGESTIONS_TRUTH_FETCH_REQUEST = 'SUGGESTIONS_TRUTH_FETCH_REQUEST'; +const SUGGESTIONS_TRUTH_FETCH_SUCCESS = 'SUGGESTIONS_TRUTH_FETCH_SUCCESS'; +const SUGGESTIONS_TRUTH_FETCH_FAIL = 'SUGGESTIONS_TRUTH_FETCH_FAIL'; + +const fetchSuggestionsV1 = (params: Record = {}) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: SUGGESTIONS_FETCH_REQUEST, skipLoading: true }); + return api(getState).get('/api/v1/suggestions', { params }).then(({ data: accounts }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch({ type: SUGGESTIONS_FETCH_SUCCESS, accounts, skipLoading: true }); + return accounts; + }).catch(error => { + dispatch({ type: SUGGESTIONS_FETCH_FAIL, error, skipLoading: true, skipAlert: true }); + throw error; + }); + }; + +const fetchSuggestionsV2 = (params: Record = {}) => + (dispatch: AppDispatch, getState: () => RootState) => { + const next = getState().suggestions.next; + + dispatch({ type: SUGGESTIONS_V2_FETCH_REQUEST, skipLoading: true }); + + return api(getState).get(next ? next : '/api/v2/suggestions', next ? {} : { params }).then((response) => { + const suggestions: APIEntity[] = response.data; + const accounts = suggestions.map(({ account }) => account); + const next = getLinks(response).refs.find(link => link.rel === 'next')?.uri; + + dispatch(importFetchedAccounts(accounts)); + dispatch({ type: SUGGESTIONS_V2_FETCH_SUCCESS, suggestions, next, skipLoading: true }); + return suggestions; + }).catch(error => { + dispatch({ type: SUGGESTIONS_V2_FETCH_FAIL, error, skipLoading: true, skipAlert: true }); + throw error; + }); + }; + +export type SuggestedProfile = { + account_avatar: string + account_id: string + acct: string + display_name: string + note: string + verified: boolean +} + +const mapSuggestedProfileToAccount = (suggestedProfile: SuggestedProfile) => ({ + id: suggestedProfile.account_id, + avatar: suggestedProfile.account_avatar, + avatar_static: suggestedProfile.account_avatar, + acct: suggestedProfile.acct, + display_name: suggestedProfile.display_name, + note: suggestedProfile.note, + verified: suggestedProfile.verified, +}); + +const fetchTruthSuggestions = (params: Record = {}) => + (dispatch: AppDispatch, getState: () => RootState) => { + const next = getState().suggestions.next; + + dispatch({ type: SUGGESTIONS_V2_FETCH_REQUEST, skipLoading: true }); + + return api(getState) + .get(next ? next : '/api/v1/truth/carousels/suggestions', next ? {} : { params }) + .then((response: AxiosResponse) => { + const suggestedProfiles = response.data; + const next = getLinks(response).refs.find(link => link.rel === 'next')?.uri; + + const accounts = suggestedProfiles.map(mapSuggestedProfileToAccount); + dispatch(importFetchedAccounts(accounts, { should_refetch: true })); + dispatch({ type: SUGGESTIONS_TRUTH_FETCH_SUCCESS, suggestions: suggestedProfiles, next, skipLoading: true }); + return suggestedProfiles; + }) + .catch(error => { + dispatch({ type: SUGGESTIONS_V2_FETCH_FAIL, error, skipLoading: true, skipAlert: true }); + throw error; + }); + }; + +const fetchSuggestions = (params: Record = { limit: 50 }) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const me = state.me; + const instance = state.instance; + const features = getFeatures(instance); + + if (!me) return null; + + if (features.truthSuggestions) { + return dispatch(fetchTruthSuggestions(params)) + .then((suggestions: APIEntity[]) => { + const accountIds = suggestions.map((account) => account.account_id); + dispatch(fetchRelationships(accountIds)); + }) + .catch(() => { }); + } else if (features.suggestionsV2) { + return dispatch(fetchSuggestionsV2(params)) + .then((suggestions: APIEntity[]) => { + const accountIds = suggestions.map(({ account }) => account.id); + dispatch(fetchRelationships(accountIds)); + }) + .catch(() => { }); + } else if (features.suggestions) { + return dispatch(fetchSuggestionsV1(params)) + .then((accounts: APIEntity[]) => { + const accountIds = accounts.map(({ id }) => id); + dispatch(fetchRelationships(accountIds)); + }) + .catch(() => { }); + } else { + // Do nothing + return null; + } + }; + +const fetchSuggestionsForTimeline = () => (dispatch: AppDispatch, _getState: () => RootState) => { + dispatch(fetchSuggestions({ limit: 20 }))?.then(() => dispatch(insertSuggestionsIntoTimeline())); +}; + +const dismissSuggestion = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch({ + type: SUGGESTIONS_DISMISS, + id: accountId, + }); + + api(getState).delete(`/api/v1/suggestions/${accountId}`); + }; + +export { + SUGGESTIONS_FETCH_REQUEST, + SUGGESTIONS_FETCH_SUCCESS, + SUGGESTIONS_FETCH_FAIL, + SUGGESTIONS_DISMISS, + SUGGESTIONS_V2_FETCH_REQUEST, + SUGGESTIONS_V2_FETCH_SUCCESS, + SUGGESTIONS_V2_FETCH_FAIL, + SUGGESTIONS_TRUTH_FETCH_REQUEST, + SUGGESTIONS_TRUTH_FETCH_SUCCESS, + SUGGESTIONS_TRUTH_FETCH_FAIL, + fetchSuggestionsV1, + fetchSuggestionsV2, + fetchSuggestions, + fetchSuggestionsForTimeline, + dismissSuggestion, +}; diff --git a/app/soapbox/actions/sw.ts b/app/soapbox/actions/sw.ts new file mode 100644 index 000000000..c12dc83e8 --- /dev/null +++ b/app/soapbox/actions/sw.ts @@ -0,0 +1,15 @@ +import type { AnyAction } from 'redux'; + +/** Sets the ServiceWorker updating state. */ +const SW_UPDATING = 'SW_UPDATING'; + +/** Dispatch when the ServiceWorker is being updated to display a loading screen. */ +const setSwUpdating = (isUpdating: boolean): AnyAction => ({ + type: SW_UPDATING, + isUpdating, +}); + +export { + SW_UPDATING, + setSwUpdating, +}; diff --git a/app/soapbox/actions/timelines.js b/app/soapbox/actions/timelines.js deleted file mode 100644 index 12223c0ba..000000000 --- a/app/soapbox/actions/timelines.js +++ /dev/null @@ -1,246 +0,0 @@ -import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; - -import { getSettings } from 'soapbox/actions/settings'; -import { shouldFilter } from 'soapbox/utils/timelines'; - -import api, { getLinks } from '../api'; - -import { importFetchedStatus, importFetchedStatuses } from './importer'; - -export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; -export const TIMELINE_DELETE = 'TIMELINE_DELETE'; -export const TIMELINE_CLEAR = 'TIMELINE_CLEAR'; -export const TIMELINE_UPDATE_QUEUE = 'TIMELINE_UPDATE_QUEUE'; -export const TIMELINE_DEQUEUE = 'TIMELINE_DEQUEUE'; -export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; - -export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; -export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; -export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; - -export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; -export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; - -export const MAX_QUEUED_ITEMS = 40; - -export function processTimelineUpdate(timeline, status, accept) { - return (dispatch, getState) => { - const me = getState().get('me'); - const ownStatus = status.account?.id === me; - const hasPendingStatuses = !getState().get('pending_statuses').isEmpty(); - - const columnSettings = getSettings(getState()).get(timeline, ImmutableMap()); - const shouldSkipQueue = shouldFilter(fromJS(status), columnSettings); - - if (ownStatus && hasPendingStatuses) { - // WebSockets push statuses without the Idempotency-Key, - // so if we have pending statuses, don't import it from here. - // We implement optimistic non-blocking statuses. - return; - } - - dispatch(importFetchedStatus(status)); - - if (shouldSkipQueue) { - dispatch(updateTimeline(timeline, status.id, accept)); - } else { - dispatch(updateTimelineQueue(timeline, status.id, accept)); - } - }; -} - -export function updateTimeline(timeline, statusId, accept) { - return dispatch => { - if (typeof accept === 'function' && !accept(status)) { - return; - } - - dispatch({ - type: TIMELINE_UPDATE, - timeline, - statusId, - }); - }; -} - -export function updateTimelineQueue(timeline, statusId, accept) { - return dispatch => { - if (typeof accept === 'function' && !accept(status)) { - return; - } - - dispatch({ - type: TIMELINE_UPDATE_QUEUE, - timeline, - statusId, - }); - }; -} - -export function dequeueTimeline(timelineId, expandFunc, optionalExpandArgs) { - return (dispatch, getState) => { - const state = getState(); - const queuedCount = state.getIn(['timelines', timelineId, 'totalQueuedItemsCount'], 0); - - if (queuedCount <= 0) return; - - if (queuedCount <= MAX_QUEUED_ITEMS) { - dispatch({ type: TIMELINE_DEQUEUE, timeline: timelineId }); - return; - } - - if (typeof expandFunc === 'function') { - dispatch(clearTimeline(timelineId)); - expandFunc(); - } else { - if (timelineId === 'home') { - dispatch(clearTimeline(timelineId)); - dispatch(expandHomeTimeline(optionalExpandArgs)); - } else if (timelineId === 'community') { - dispatch(clearTimeline(timelineId)); - dispatch(expandCommunityTimeline(optionalExpandArgs)); - } - } - }; -} - -export function deleteFromTimelines(id) { - return (dispatch, getState) => { - const accountId = getState().getIn(['statuses', id, 'account']); - const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]); - const reblogOf = getState().getIn(['statuses', id, 'reblog'], null); - - dispatch({ - type: TIMELINE_DELETE, - id, - accountId, - references, - reblogOf, - }); - }; -} - -export function clearTimeline(timeline) { - return (dispatch) => { - dispatch({ type: TIMELINE_CLEAR, timeline }); - }; -} - -const noOp = () => {}; -const noOpAsync = () => () => new Promise(f => f()); - -const parseTags = (tags = {}, mode) => { - return (tags[mode] || []).map((tag) => { - return tag.value; - }); -}; - -export function expandTimeline(timelineId, path, params = {}, done = noOp) { - return (dispatch, getState) => { - const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); - const isLoadingMore = !!params.max_id; - - if (timeline.get('isLoading')) { - done(); - return dispatch(noOpAsync()); - } - - if (!params.max_id && !params.pinned && timeline.get('items', ImmutableOrderedSet()).size > 0) { - params.since_id = timeline.getIn(['items', 0]); - } - - const isLoadingRecent = !!params.since_id; - - dispatch(expandTimelineRequest(timelineId, isLoadingMore)); - - return api(getState).get(path, { params }).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedStatuses(response.data)); - dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore)); - done(); - }).catch(error => { - dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); - done(); - }); - }; -} - -export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); - -export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done); - -export const expandRemoteTimeline = (instance, { maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`remote${onlyMedia ? ':media' : ''}:${instance}`, '/api/v1/timelines/public', { local: false, instance: instance, max_id: maxId, only_media: !!onlyMedia }, done); - -export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); - -export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); - -export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId, with_muted: true }); - -export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, with_muted: true }); - -export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40, with_muted: true }); - -export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); - -export const expandGroupTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done); - -export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => { - return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { - max_id: maxId, - any: parseTags(tags, 'any'), - all: parseTags(tags, 'all'), - none: parseTags(tags, 'none'), - }, done); -}; - -export function expandTimelineRequest(timeline, isLoadingMore) { - return { - type: TIMELINE_EXPAND_REQUEST, - timeline, - skipLoading: !isLoadingMore, - }; -} - -export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore) { - return { - type: TIMELINE_EXPAND_SUCCESS, - timeline, - statuses, - next, - partial, - isLoadingRecent, - skipLoading: !isLoadingMore, - }; -} - -export function expandTimelineFail(timeline, error, isLoadingMore) { - return { - type: TIMELINE_EXPAND_FAIL, - timeline, - error, - skipLoading: !isLoadingMore, - }; -} - -export function connectTimeline(timeline) { - return { - type: TIMELINE_CONNECT, - timeline, - }; -} - -export function disconnectTimeline(timeline) { - return { - type: TIMELINE_DISCONNECT, - timeline, - }; -} - -export function scrollTopTimeline(timeline, top) { - return { - type: TIMELINE_SCROLL_TOP, - timeline, - top, - }; -} diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts new file mode 100644 index 000000000..5e4bd26d6 --- /dev/null +++ b/app/soapbox/actions/timelines.ts @@ -0,0 +1,321 @@ +import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; + +import { getSettings } from 'soapbox/actions/settings'; +import { normalizeStatus } from 'soapbox/normalizers'; +import { shouldFilter } from 'soapbox/utils/timelines'; + +import api, { getLinks } from '../api'; + +import { importFetchedStatus, importFetchedStatuses } from './importer'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity, Status } from 'soapbox/types/entities'; + +const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; +const TIMELINE_DELETE = 'TIMELINE_DELETE'; +const TIMELINE_CLEAR = 'TIMELINE_CLEAR'; +const TIMELINE_UPDATE_QUEUE = 'TIMELINE_UPDATE_QUEUE'; +const TIMELINE_DEQUEUE = 'TIMELINE_DEQUEUE'; +const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; + +const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; +const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; +const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; + +const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; +const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; + +const TIMELINE_REPLACE = 'TIMELINE_REPLACE'; +const TIMELINE_INSERT = 'TIMELINE_INSERT'; +const TIMELINE_CLEAR_FEED_ACCOUNT_ID = 'TIMELINE_CLEAR_FEED_ACCOUNT_ID'; + +const MAX_QUEUED_ITEMS = 40; + +const processTimelineUpdate = (timeline: string, status: APIEntity, accept: ((status: APIEntity) => boolean) | null) => + (dispatch: AppDispatch, getState: () => RootState) => { + const me = getState().me; + const ownStatus = status.account?.id === me; + const hasPendingStatuses = !getState().pending_statuses.isEmpty(); + + const columnSettings = getSettings(getState()).get(timeline, ImmutableMap()); + const shouldSkipQueue = shouldFilter(normalizeStatus(status) as Status, columnSettings); + + if (ownStatus && hasPendingStatuses) { + // WebSockets push statuses without the Idempotency-Key, + // so if we have pending statuses, don't import it from here. + // We implement optimistic non-blocking statuses. + return; + } + + dispatch(importFetchedStatus(status)); + + if (shouldSkipQueue) { + dispatch(updateTimeline(timeline, status.id, accept)); + } else { + dispatch(updateTimelineQueue(timeline, status.id, accept)); + } + }; + +const updateTimeline = (timeline: string, statusId: string, accept: ((status: APIEntity) => boolean) | null) => + (dispatch: AppDispatch) => { + // if (typeof accept === 'function' && !accept(status)) { + // return; + // } + + dispatch({ + type: TIMELINE_UPDATE, + timeline, + statusId, + }); + }; + +const updateTimelineQueue = (timeline: string, statusId: string, accept: ((status: APIEntity) => boolean) | null) => + (dispatch: AppDispatch) => { + // if (typeof accept === 'function' && !accept(status)) { + // return; + // } + + dispatch({ + type: TIMELINE_UPDATE_QUEUE, + timeline, + statusId, + }); + }; + +const dequeueTimeline = (timelineId: string, expandFunc?: (lastStatusId: string) => void, optionalExpandArgs?: any) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const queuedCount = state.timelines.get(timelineId)?.totalQueuedItemsCount || 0; + + if (queuedCount <= 0) return; + + if (queuedCount <= MAX_QUEUED_ITEMS) { + dispatch({ type: TIMELINE_DEQUEUE, timeline: timelineId }); + return; + } + + if (typeof expandFunc === 'function') { + dispatch(clearTimeline(timelineId)); + // @ts-ignore + expandFunc(); + } else { + if (timelineId === 'home') { + dispatch(clearTimeline(timelineId)); + dispatch(expandHomeTimeline(optionalExpandArgs)); + } else if (timelineId === 'community') { + dispatch(clearTimeline(timelineId)); + dispatch(expandCommunityTimeline(optionalExpandArgs)); + } + } + }; + +const deleteFromTimelines = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const accountId = getState().statuses.get(id)?.account; + const references = getState().statuses.filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]); + const reblogOf = getState().statuses.getIn([id, 'reblog'], null); + + dispatch({ + type: TIMELINE_DELETE, + id, + accountId, + references, + reblogOf, + }); + }; + +const clearTimeline = (timeline: string) => + (dispatch: AppDispatch) => + dispatch({ type: TIMELINE_CLEAR, timeline }); + +const noOp = () => { }; +const noOpAsync = () => () => new Promise(f => f(undefined)); + +const parseTags = (tags: Record = {}, mode: 'any' | 'all' | 'none') => { + return (tags[mode] || []).map((tag) => { + return tag.value; + }); +}; + +const replaceHomeTimeline = ( + accountId: string | null, + { maxId }: Record = {}, + done?: () => void, +) => (dispatch: AppDispatch, _getState: () => RootState) => { + dispatch({ type: TIMELINE_REPLACE, accountId }); + dispatch(expandHomeTimeline({ accountId, maxId }, () => { + dispatch(insertSuggestionsIntoTimeline()); + if (done) { + done(); + } + })); +}; + +const expandTimeline = (timelineId: string, path: string, params: Record = {}, done = noOp) => + (dispatch: AppDispatch, getState: () => RootState) => { + const timeline = getState().timelines.get(timelineId) || {} as Record; + const isLoadingMore = !!params.max_id; + + if (timeline.isLoading) { + done(); + return dispatch(noOpAsync()); + } + + if (!params.max_id && !params.pinned && (timeline.items || ImmutableOrderedSet()).size > 0) { + params.since_id = timeline.getIn(['items', 0]); + } + + const isLoadingRecent = !!params.since_id; + + dispatch(expandTimelineRequest(timelineId, isLoadingMore)); + + return api(getState).get(path, { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore)); + done(); + }).catch(error => { + dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); + done(); + }); + }; + +const expandHomeTimeline = ({ accountId, maxId }: Record = {}, done = noOp) => { + const endpoint = accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home'; + const params: any = { max_id: maxId }; + if (accountId) { + params.exclude_replies = true; + params.with_muted = true; + } + + return expandTimeline('home', endpoint, params, done); +}; + +const expandPublicTimeline = ({ maxId, onlyMedia }: Record = {}, done = noOp) => + expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done); + +const expandRemoteTimeline = (instance: string, { maxId, onlyMedia }: Record = {}, done = noOp) => + expandTimeline(`remote${onlyMedia ? ':media' : ''}:${instance}`, '/api/v1/timelines/public', { local: false, instance: instance, max_id: maxId, only_media: !!onlyMedia }, done); + +const expandCommunityTimeline = ({ maxId, onlyMedia }: Record = {}, done = noOp) => + expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); + +const expandDirectTimeline = ({ maxId }: Record = {}, done = noOp) => + expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); + +const expandAccountTimeline = (accountId: string, { maxId, withReplies }: Record = {}) => + expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId, with_muted: true }); + +const expandAccountFeaturedTimeline = (accountId: string) => + expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, with_muted: true }); + +const expandAccountMediaTimeline = (accountId: string | number, { maxId }: Record = {}) => + expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40, with_muted: true }); + +const expandListTimeline = (id: string, { maxId }: Record = {}, done = noOp) => + expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); + +const expandGroupTimeline = (id: string, { maxId }: Record = {}, done = noOp) => + expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done); + +const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record = {}, done = noOp) => { + return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { + max_id: maxId, + any: parseTags(tags, 'any'), + all: parseTags(tags, 'all'), + none: parseTags(tags, 'none'), + }, done); +}; + +const expandTimelineRequest = (timeline: string, isLoadingMore: boolean) => ({ + type: TIMELINE_EXPAND_REQUEST, + timeline, + skipLoading: !isLoadingMore, +}); + +const expandTimelineSuccess = (timeline: string, statuses: APIEntity[], next: string | null, partial: boolean, isLoadingRecent: boolean, isLoadingMore: boolean) => ({ + type: TIMELINE_EXPAND_SUCCESS, + timeline, + statuses, + next, + partial, + isLoadingRecent, + skipLoading: !isLoadingMore, +}); + +const expandTimelineFail = (timeline: string, error: AxiosError, isLoadingMore: boolean) => ({ + type: TIMELINE_EXPAND_FAIL, + timeline, + error, + skipLoading: !isLoadingMore, +}); + +const connectTimeline = (timeline: string) => ({ + type: TIMELINE_CONNECT, + timeline, +}); + +const disconnectTimeline = (timeline: string) => ({ + type: TIMELINE_DISCONNECT, + timeline, +}); + +const scrollTopTimeline = (timeline: string, top: boolean) => ({ + type: TIMELINE_SCROLL_TOP, + timeline, + top, +}); + +const insertSuggestionsIntoTimeline = () => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: TIMELINE_INSERT, timeline: 'home' }); +}; + +const clearFeedAccountId = () => (dispatch: AppDispatch, _getState: () => RootState) => { + dispatch({ type: TIMELINE_CLEAR_FEED_ACCOUNT_ID }); +}; + +export { + TIMELINE_UPDATE, + TIMELINE_DELETE, + TIMELINE_CLEAR, + TIMELINE_UPDATE_QUEUE, + TIMELINE_DEQUEUE, + TIMELINE_SCROLL_TOP, + TIMELINE_EXPAND_REQUEST, + TIMELINE_EXPAND_SUCCESS, + TIMELINE_EXPAND_FAIL, + TIMELINE_CONNECT, + TIMELINE_DISCONNECT, + TIMELINE_REPLACE, + TIMELINE_CLEAR_FEED_ACCOUNT_ID, + TIMELINE_INSERT, + MAX_QUEUED_ITEMS, + processTimelineUpdate, + updateTimeline, + updateTimelineQueue, + dequeueTimeline, + deleteFromTimelines, + clearTimeline, + expandTimeline, + replaceHomeTimeline, + expandHomeTimeline, + expandPublicTimeline, + expandRemoteTimeline, + expandCommunityTimeline, + expandDirectTimeline, + expandAccountTimeline, + expandAccountFeaturedTimeline, + expandAccountMediaTimeline, + expandListTimeline, + expandGroupTimeline, + expandHashtagTimeline, + expandTimelineRequest, + expandTimelineSuccess, + expandTimelineFail, + connectTimeline, + disconnectTimeline, + scrollTopTimeline, + insertSuggestionsIntoTimeline, + clearFeedAccountId, +}; diff --git a/app/soapbox/actions/trending_statuses.js b/app/soapbox/actions/trending_statuses.ts similarity index 54% rename from app/soapbox/actions/trending_statuses.js rename to app/soapbox/actions/trending_statuses.ts index 23cf56977..435fcf6df 100644 --- a/app/soapbox/actions/trending_statuses.js +++ b/app/soapbox/actions/trending_statuses.ts @@ -4,15 +4,17 @@ import api from '../api'; import { importFetchedStatuses } from './importer'; -export const TRENDING_STATUSES_FETCH_REQUEST = 'TRENDING_STATUSES_FETCH_REQUEST'; -export const TRENDING_STATUSES_FETCH_SUCCESS = 'TRENDING_STATUSES_FETCH_SUCCESS'; -export const TRENDING_STATUSES_FETCH_FAIL = 'TRENDING_STATUSES_FETCH_FAIL'; +import type { AppDispatch, RootState } from 'soapbox/store'; -export function fetchTrendingStatuses() { - return (dispatch, getState) => { +const TRENDING_STATUSES_FETCH_REQUEST = 'TRENDING_STATUSES_FETCH_REQUEST'; +const TRENDING_STATUSES_FETCH_SUCCESS = 'TRENDING_STATUSES_FETCH_SUCCESS'; +const TRENDING_STATUSES_FETCH_FAIL = 'TRENDING_STATUSES_FETCH_FAIL'; + +const fetchTrendingStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - const instance = state.get('instance'); + const instance = state.instance; const features = getFeatures(instance); dispatch({ type: TRENDING_STATUSES_FETCH_REQUEST }); @@ -24,4 +26,10 @@ export function fetchTrendingStatuses() { dispatch({ type: TRENDING_STATUSES_FETCH_FAIL, error }); }); }; -} + +export { + TRENDING_STATUSES_FETCH_REQUEST, + TRENDING_STATUSES_FETCH_SUCCESS, + TRENDING_STATUSES_FETCH_FAIL, + fetchTrendingStatuses, +}; diff --git a/app/soapbox/actions/trends.js b/app/soapbox/actions/trends.js deleted file mode 100644 index 36f801adf..000000000 --- a/app/soapbox/actions/trends.js +++ /dev/null @@ -1,39 +0,0 @@ -import api from '../api'; - -export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST'; -export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS'; -export const TRENDS_FETCH_FAIL = 'TRENDS_FETCH_FAIL'; - -export function fetchTrends() { - return (dispatch, getState) => { - dispatch(fetchTrendsRequest()); - - api(getState).get('/api/v1/trends').then(response => { - dispatch(fetchTrendsSuccess(response.data)); - }).catch(error => dispatch(fetchTrendsFail(error))); - }; -} - -export function fetchTrendsRequest() { - return { - type: TRENDS_FETCH_REQUEST, - skipLoading: true, - }; -} - -export function fetchTrendsSuccess(tags) { - return { - type: TRENDS_FETCH_SUCCESS, - tags, - skipLoading: true, - }; -} - -export function fetchTrendsFail(error) { - return { - type: TRENDS_FETCH_FAIL, - error, - skipLoading: true, - skipAlert: true, - }; -} diff --git a/app/soapbox/actions/trends.ts b/app/soapbox/actions/trends.ts new file mode 100644 index 000000000..dc45a5c38 --- /dev/null +++ b/app/soapbox/actions/trends.ts @@ -0,0 +1,46 @@ +import api from '../api'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST'; +const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS'; +const TRENDS_FETCH_FAIL = 'TRENDS_FETCH_FAIL'; + +const fetchTrends = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchTrendsRequest()); + + api(getState).get('/api/v1/trends').then(response => { + dispatch(fetchTrendsSuccess(response.data)); + }).catch(error => dispatch(fetchTrendsFail(error))); + }; + +const fetchTrendsRequest = () => ({ + type: TRENDS_FETCH_REQUEST, + skipLoading: true, +}); + +const fetchTrendsSuccess = (tags: APIEntity[]) => ({ + type: TRENDS_FETCH_SUCCESS, + tags, + skipLoading: true, +}); + +const fetchTrendsFail = (error: AxiosError) => ({ + type: TRENDS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); + +export { + TRENDS_FETCH_REQUEST, + TRENDS_FETCH_SUCCESS, + TRENDS_FETCH_FAIL, + fetchTrends, + fetchTrendsRequest, + fetchTrendsSuccess, + fetchTrendsFail, +}; diff --git a/app/soapbox/actions/verification.js b/app/soapbox/actions/verification.ts similarity index 67% rename from app/soapbox/actions/verification.js rename to app/soapbox/actions/verification.ts index 23b895afa..ce3d27009 100644 --- a/app/soapbox/actions/verification.js +++ b/app/soapbox/actions/verification.ts @@ -1,5 +1,7 @@ import api from '../api'; +import type { AppDispatch, RootState } from 'soapbox/store'; + /** * LocalStorage 'soapbox:verification' * @@ -22,99 +24,88 @@ const SET_NEXT_CHALLENGE = 'SET_NEXT_CHALLENGE'; const SET_CHALLENGES_COMPLETE = 'SET_CHALLENGES_COMPLETE'; const SET_LOADING = 'SET_LOADING'; -const ChallengeTypes = { - EMAIL: 'email', - SMS: 'sms', - AGE: 'age', +const EMAIL: Challenge = 'email'; +const SMS: Challenge = 'sms'; +const AGE: Challenge = 'age'; + +export type Challenge = 'age' | 'sms' | 'email' + +type Challenges = { + email?: 0 | 1, + sms?: number, + age?: number, +} + +type Verification = { + token?: string, + challenges?: Challenges, }; /** * Fetch the state of the user's verification in local storage. - * - * @returns {object} - * { - * token: String, - * challenges: { - * email: Number (0 = incomplete, 1 = complete), - * sms: Number, - * age: Number - * } - * } */ -function fetchStoredVerification() { +const fetchStoredVerification = (): Verification | null => { try { - return JSON.parse(localStorage.getItem(LOCAL_STORAGE_VERIFICATION_KEY)); + return JSON.parse(localStorage.getItem(LOCAL_STORAGE_VERIFICATION_KEY) as string); } catch { return null; } -} +}; /** * Remove the state of the user's verification from local storage. */ -function removeStoredVerification() { +const removeStoredVerification = () => { localStorage.removeItem(LOCAL_STORAGE_VERIFICATION_KEY); -} - +}; /** * Fetch and return the Registration token for Pepe. - * @returns {string} */ -function fetchStoredToken() { +const fetchStoredToken = () => { try { - const verification = fetchStoredVerification(); - return verification.token; + const verification: Verification | null = fetchStoredVerification(); + return verification!.token; } catch { return null; } -} +}; /** * Fetch and return the state of the verification challenges. - * @returns {object} - * { - * challenges: { - * email: Number (0 = incomplete, 1 = complete), - * sms: Number, - * age: Number - * } - * } */ -function fetchStoredChallenges() { +const fetchStoredChallenges = () => { try { - const verification = fetchStoredVerification(); - return verification.challenges; + const verification: Verification | null = fetchStoredVerification(); + return verification!.challenges; } catch { return null; } -} +}; /** * Update the verification object in local storage. * * @param {*} verification object */ -function updateStorage({ ...updatedVerification }) { +const updateStorage = ({ ...updatedVerification }: Verification) => { const verification = fetchStoredVerification(); localStorage.setItem( LOCAL_STORAGE_VERIFICATION_KEY, JSON.stringify({ ...verification, ...updatedVerification }), ); -} +}; /** * Fetch Pepe challenges and registration token - * @returns {promise} */ -function fetchVerificationConfig() { - return async(dispatch) => { +const fetchVerificationConfig = () => + async(dispatch: AppDispatch) => { await dispatch(fetchPepeInstance()); dispatch(fetchRegistrationToken()); }; -} /** * Save the challenges in localStorage. @@ -125,13 +116,11 @@ function fetchVerificationConfig() { * challenge to localStorage. * - Don't overwrite a challenge that has already been completed. * - Update localStorage to the new set of challenges. - * - * @param {array} challenges - ['age', 'sms', 'email'] */ -function saveChallenges(challenges) { - const currentChallenges = fetchStoredChallenges() || {}; +function saveChallenges(challenges: Array<'age' | 'sms' | 'email'>) { + const currentChallenges: Challenges = fetchStoredChallenges() || {}; - const challengesToRemove = Object.keys(currentChallenges).filter((currentChallenge) => !challenges.includes(currentChallenge)); + const challengesToRemove = Object.keys(currentChallenges).filter((currentChallenge) => !challenges.includes(currentChallenge as Challenge)) as Challenge[]; challengesToRemove.forEach((challengeToRemove) => delete currentChallenges[challengeToRemove]); for (let i = 0; i < challenges.length; i++) { @@ -147,10 +136,9 @@ function saveChallenges(challenges) { /** * Finish a challenge. - * @param {string} challenge - "sms" or "email" or "age" */ -function finishChallenge(challenge) { - const currentChallenges = fetchStoredChallenges() || {}; +function finishChallenge(challenge: Challenge) { + const currentChallenges: Challenges = fetchStoredChallenges() || {}; // Set challenge to "complete" currentChallenges[challenge] = 1; @@ -159,17 +147,16 @@ function finishChallenge(challenge) { /** * Fetch the next challenge - * @returns {string} challenge - "sms" or "email" or "age" */ -function fetchNextChallenge() { - const currentChallenges = fetchStoredChallenges() || {}; - return Object.keys(currentChallenges).find((challenge) => currentChallenges[challenge] === 0); -} +const fetchNextChallenge = (): Challenge => { + const currentChallenges: Challenges = fetchStoredChallenges() || {}; + return Object.keys(currentChallenges).find((challenge) => currentChallenges[challenge as Challenge] === 0) as Challenge; +}; /** * Dispatch the next challenge or set to complete if all challenges are completed. */ -function dispatchNextChallenge(dispatch) { +const dispatchNextChallenge = (dispatch: AppDispatch) => { const nextChallenge = fetchNextChallenge(); if (nextChallenge) { @@ -177,14 +164,13 @@ function dispatchNextChallenge(dispatch) { } else { dispatch({ type: SET_CHALLENGES_COMPLETE }); } -} +}; /** * Fetch the challenges and age mininum from Pepe - * @returns {promise} */ -function fetchPepeInstance() { - return (dispatch, getState) => { +const fetchPepeInstance = () => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: SET_LOADING }); return api(getState).get('/api/v1/pepe/instance').then(response => { @@ -203,14 +189,12 @@ function fetchPepeInstance() { }) .finally(() => dispatch({ type: SET_LOADING, value: false })); }; -} /** * Fetch the regristration token from Pepe unless it's already been stored locally - * @returns {promise} */ -function fetchRegistrationToken() { - return (dispatch, getState) => { +const fetchRegistrationToken = () => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: SET_LOADING }); const token = fetchStoredToken(); @@ -222,7 +206,6 @@ function fetchRegistrationToken() { return null; } - return api(getState).post('/api/v1/pepe/registrations') .then(response => { updateStorage({ token: response.data.access_token }); @@ -234,27 +217,25 @@ function fetchRegistrationToken() { }) .finally(() => dispatch({ type: SET_LOADING, value: false })); }; -} -function checkEmailAvailability(email) { - return (dispatch, getState) => { +const checkEmailAvailability = (email: string) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: SET_LOADING }); const token = fetchStoredToken(); return api(getState).get(`/api/v1/pepe/account/exists?email=${email}`, { headers: { Authorization: `Bearer ${token}` }, - }).finally(() => dispatch({ type: SET_LOADING, value: false })); + }) + .catch(() => {}) + .then(() => dispatch({ type: SET_LOADING, value: false })); }; -} /** * Send the user's email to Pepe to request confirmation - * @param {string} email - * @returns {promise} */ -function requestEmailVerification(email) { - return (dispatch, getState) => { +const requestEmailVerification = (email: string) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: SET_LOADING }); const token = fetchStoredToken(); @@ -264,25 +245,21 @@ function requestEmailVerification(email) { }) .finally(() => dispatch({ type: SET_LOADING, value: false })); }; -} -function checkEmailVerification() { - return (dispatch, getState) => { +const checkEmailVerification = () => + (dispatch: AppDispatch, getState: () => RootState) => { const token = fetchStoredToken(); return api(getState).get('/api/v1/pepe/verify_email', { headers: { Authorization: `Bearer ${token}` }, }); }; -} /** * Confirm the user's email with Pepe - * @param {string} emailToken - * @returns {promise} */ -function confirmEmailVerification(emailToken) { - return (dispatch, getState) => { +const confirmEmailVerification = (emailToken: string) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: SET_LOADING }); const token = fetchStoredToken(); @@ -291,27 +268,23 @@ function confirmEmailVerification(emailToken) { headers: { Authorization: `Bearer ${token}` }, }) .then(() => { - finishChallenge(ChallengeTypes.EMAIL); + finishChallenge(EMAIL); dispatchNextChallenge(dispatch); }) .finally(() => dispatch({ type: SET_LOADING, value: false })); }; -} -function postEmailVerification() { - return (dispatch, getState) => { - finishChallenge(ChallengeTypes.EMAIL); +const postEmailVerification = () => + (dispatch: AppDispatch) => { + finishChallenge(EMAIL); dispatchNextChallenge(dispatch); }; -} /** * Send the user's phone number to Pepe to request confirmation - * @param {string} phone - * @returns {promise} */ -function requestPhoneVerification(phone) { - return (dispatch, getState) => { +const requestPhoneVerification = (phone: string) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: SET_LOADING }); const token = fetchStoredToken(); @@ -321,15 +294,23 @@ function requestPhoneVerification(phone) { }) .finally(() => dispatch({ type: SET_LOADING, value: false })); }; -} + +/** + * Send the user's phone number to Pepe to re-request confirmation + */ +const reRequestPhoneVerification = (phone: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: SET_LOADING }); + + return api(getState).post('/api/v1/pepe/reverify_sms/request', { phone }) + .finally(() => dispatch({ type: SET_LOADING, value: false })); + }; /** * Confirm the user's phone number with Pepe - * @param {string} code - * @returns {promise} */ -function confirmPhoneVerification(code) { - return (dispatch, getState) => { +const confirmPhoneVerification = (code: string) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: SET_LOADING }); const token = fetchStoredToken(); @@ -338,20 +319,28 @@ function confirmPhoneVerification(code) { headers: { Authorization: `Bearer ${token}` }, }) .then(() => { - finishChallenge(ChallengeTypes.SMS); + finishChallenge(SMS); dispatchNextChallenge(dispatch); }) .finally(() => dispatch({ type: SET_LOADING, value: false })); }; -} + +/** + * Re-Confirm the user's phone number with Pepe + */ +const reConfirmPhoneVerification = (code: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: SET_LOADING }); + + return api(getState).post('/api/v1/pepe/reverify_sms/confirm', { code }) + .finally(() => dispatch({ type: SET_LOADING, value: false })); + }; /** * Confirm the user's age with Pepe - * @param {date} birthday - * @returns {promise} */ -function verifyAge(birthday) { - return (dispatch, getState) => { +const verifyAge = (birthday: Date) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: SET_LOADING }); const token = fetchStoredToken(); @@ -360,21 +349,17 @@ function verifyAge(birthday) { headers: { Authorization: `Bearer ${token}` }, }) .then(() => { - finishChallenge(ChallengeTypes.AGE); + finishChallenge(AGE); dispatchNextChallenge(dispatch); }) .finally(() => dispatch({ type: SET_LOADING, value: false })); }; -} /** * Create the user's account with Pepe - * @param {string} username - * @param {string} password - * @returns {promise} */ -function createAccount(username, password) { - return (dispatch, getState) => { +const createAccount = (username: string, password: string) => + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: SET_LOADING }); const token = fetchStoredToken(); @@ -383,7 +368,6 @@ function createAccount(username, password) { headers: { Authorization: `Bearer ${token}` }, }).finally(() => dispatch({ type: SET_LOADING, value: false })); }; -} export { PEPE_FETCH_INSTANCE_SUCCESS, @@ -404,6 +388,8 @@ export { requestEmailVerification, checkEmailVerification, postEmailVerification, + reConfirmPhoneVerification, requestPhoneVerification, + reRequestPhoneVerification, verifyAge, }; diff --git a/app/soapbox/api.ts b/app/soapbox/api.ts index 34ed699f8..bdcaf53d8 100644 --- a/app/soapbox/api.ts +++ b/app/soapbox/api.ts @@ -11,8 +11,9 @@ import { createSelector } from 'reselect'; import * as BuildConfig from 'soapbox/build_config'; import { RootState } from 'soapbox/store'; -import { getAccessToken, getAppToken, parseBaseURL } from 'soapbox/utils/auth'; -import { isURL } from 'soapbox/utils/auth'; +import { getAccessToken, getAppToken, isURL, parseBaseURL } from 'soapbox/utils/auth'; + +import type MockAdapter from 'axios-mock-adapter'; /** Parse Link headers, mostly for pagination. @@ -54,7 +55,7 @@ const getAuthBaseURL = createSelector([ * @param {string} baseURL * @returns {object} Axios instance */ -export const baseClient = (accessToken: string, baseURL: string = ''): AxiosInstance => { +export const baseClient = (accessToken?: string | null, baseURL: string = ''): AxiosInstance => { return axios.create({ // When BACKEND_URL is set, always use it. baseURL: isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : baseURL, @@ -91,3 +92,7 @@ export default (getState: () => RootState, authType: string = 'user'): AxiosInst return baseClient(accessToken, baseURL); }; + +// The Jest mock exports these, so they're needed for TypeScript. +export const __stub = (_func: (mock: MockAdapter) => void) => 0; +export const __clear = (): Function[] => []; diff --git a/app/soapbox/base_polyfills.ts b/app/soapbox/base_polyfills.ts index 53146d222..a6e92bb3c 100644 --- a/app/soapbox/base_polyfills.ts +++ b/app/soapbox/base_polyfills.ts @@ -37,7 +37,7 @@ if (!HTMLCanvasElement.prototype.toBlob) { const dataURL = this.toDataURL(type, quality); let data; - if (dataURL.indexOf(BASE64_MARKER) >= 0) { + if (dataURL.includes(BASE64_MARKER)) { const [, base64] = dataURL.split(BASE64_MARKER); data = decodeBase64(base64); } else { diff --git a/app/soapbox/build_config.js b/app/soapbox/build_config.js index 6ddb309cb..04b48bf78 100644 --- a/app/soapbox/build_config.js +++ b/app/soapbox/build_config.js @@ -4,7 +4,8 @@ * @module soapbox/build_config */ -const { trim, trimEnd } = require('lodash'); +const trim = require('lodash/trim'); +const trimEnd = require('lodash/trimEnd'); const { NODE_ENV, diff --git a/app/soapbox/compare_id.ts b/app/soapbox/compare_id.ts index e92d13ef5..b92a44cf1 100644 --- a/app/soapbox/compare_id.ts +++ b/app/soapbox/compare_id.ts @@ -1,5 +1,14 @@ 'use strict'; +/** + * Compare numerical primary keys represented as strings. + * For example, '10' (as a string) is considered less than '9' + * when sorted alphabetically. So compare string length first. + * + * - `0`: id1 == id2 + * - `1`: id1 > id2 + * - `-1`: id1 < id2 + */ export default function compareId(id1: string, id2: string) { if (id1 === id2) { return 0; diff --git a/app/soapbox/components/__tests__/account.test.tsx b/app/soapbox/components/__tests__/account.test.tsx new file mode 100644 index 000000000..7f1458349 --- /dev/null +++ b/app/soapbox/components/__tests__/account.test.tsx @@ -0,0 +1,69 @@ +import { Map as ImmutableMap } from 'immutable'; +import React from 'react'; + +import { render, screen } from '../../jest/test-helpers'; +import { normalizeAccount } from '../../normalizers'; +import Account from '../account'; + +import type { ReducerAccount } from 'soapbox/reducers/accounts'; + +describe('', () => { + it('renders account name and username', () => { + const account = normalizeAccount({ + id: '1', + acct: 'justin-username', + display_name: 'Justin L', + avatar: 'test.jpg', + }) as ReducerAccount; + + const store = { + accounts: ImmutableMap({ + '1': account, + }), + }; + + render(, undefined, store); + expect(screen.getByTestId('account')).toHaveTextContent('Justin L'); + expect(screen.getByTestId('account')).toHaveTextContent(/justin-username/i); + }); + + describe('verification badge', () => { + it('renders verification badge', () => { + const account = normalizeAccount({ + id: '1', + acct: 'justin-username', + display_name: 'Justin L', + avatar: 'test.jpg', + verified: true, + }) as ReducerAccount; + + const store = { + accounts: ImmutableMap({ + '1': account, + }), + }; + + render(, undefined, store); + expect(screen.getByTestId('verified-badge')).toBeInTheDocument(); + }); + + it('does not render verification badge', () => { + const account = normalizeAccount({ + id: '1', + acct: 'justin-username', + display_name: 'Justin L', + avatar: 'test.jpg', + verified: false, + }) as ReducerAccount; + + const store = { + accounts: ImmutableMap({ + '1': account, + }), + }; + + render(, undefined, store); + expect(screen.queryAllByTestId('verified-badge')).toHaveLength(0); + }); + }); +}); diff --git a/app/soapbox/components/__tests__/autosuggest_emoji.test.js b/app/soapbox/components/__tests__/autosuggest_emoji.test.tsx similarity index 88% rename from app/soapbox/components/__tests__/autosuggest_emoji.test.js rename to app/soapbox/components/__tests__/autosuggest_emoji.test.tsx index 938ca737b..8fab0ef8b 100644 --- a/app/soapbox/components/__tests__/autosuggest_emoji.test.js +++ b/app/soapbox/components/__tests__/autosuggest_emoji.test.tsx @@ -10,7 +10,7 @@ describe('', () => { colons: ':foobar:', }; - render(); + render(); expect(screen.getByTestId('emoji')).toHaveTextContent('foobar'); expect(screen.getByRole('img').getAttribute('src')).not.toBe('http://example.com/emoji.png'); @@ -24,7 +24,7 @@ describe('', () => { colons: ':foobar:', }; - render(); + render(); expect(screen.getByTestId('emoji')).toHaveTextContent('foobar'); expect(screen.getByRole('img').getAttribute('src')).toBe('http://example.com/emoji.png'); diff --git a/app/soapbox/components/__tests__/avatar.test.js b/app/soapbox/components/__tests__/avatar.test.tsx similarity index 91% rename from app/soapbox/components/__tests__/avatar.test.js rename to app/soapbox/components/__tests__/avatar.test.tsx index 55abca520..56f592925 100644 --- a/app/soapbox/components/__tests__/avatar.test.js +++ b/app/soapbox/components/__tests__/avatar.test.tsx @@ -5,6 +5,8 @@ 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', @@ -12,7 +14,7 @@ describe('', () => { display_name: 'Alice', avatar: '/animated/alice.gif', avatar_static: '/static/alice.jpg', - }); + }) as ReducerAccount; const size = 100; diff --git a/app/soapbox/components/__tests__/avatar_overlay.test.js b/app/soapbox/components/__tests__/avatar_overlay.test.tsx similarity index 72% rename from app/soapbox/components/__tests__/avatar_overlay.test.js rename to app/soapbox/components/__tests__/avatar_overlay.test.tsx index b62d4eef8..105828556 100644 --- a/app/soapbox/components/__tests__/avatar_overlay.test.js +++ b/app/soapbox/components/__tests__/avatar_overlay.test.tsx @@ -1,25 +1,28 @@ -import { fromJS } from 'immutable'; import React from 'react'; +import { normalizeAccount } from 'soapbox/normalizers'; + import { render, screen } from '../../jest/test-helpers'; import AvatarOverlay from '../avatar_overlay'; +import type { ReducerAccount } from 'soapbox/reducers/accounts'; + describe(' { - const account = fromJS({ + const account = normalizeAccount({ username: 'alice', acct: 'alice', display_name: 'Alice', avatar: '/animated/alice.gif', avatar_static: '/static/alice.jpg', - }); + }) as ReducerAccount; - const friend = fromJS({ + const friend = normalizeAccount({ username: 'eve', acct: 'eve@blackhat.lair', display_name: 'Evelyn', avatar: '/animated/eve.gif', avatar_static: '/static/eve.jpg', - }); + }) as ReducerAccount; it('renders a overlay avatar', () => { render(); diff --git a/app/soapbox/components/__tests__/badge.test.js b/app/soapbox/components/__tests__/badge.test.tsx similarity index 100% rename from app/soapbox/components/__tests__/badge.test.js rename to app/soapbox/components/__tests__/badge.test.tsx diff --git a/app/soapbox/components/__tests__/column_back_button.test.js b/app/soapbox/components/__tests__/column_back_button.test.js deleted file mode 100644 index 6ebc95f40..000000000 --- a/app/soapbox/components/__tests__/column_back_button.test.js +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; - -import { render, screen } from '../../jest/test-helpers'; -import ColumnBackButton from '../column_back_button'; - -describe('', () => { - it('renders correctly', () => { - render(); - - expect(screen.getByRole('button')).toHaveTextContent('Back'); - }); -}); diff --git a/app/soapbox/components/__tests__/display_name.test.js b/app/soapbox/components/__tests__/display_name.test.tsx similarity index 66% rename from app/soapbox/components/__tests__/display_name.test.js rename to app/soapbox/components/__tests__/display_name.test.tsx index 00e175d40..4c1c1bd23 100644 --- a/app/soapbox/components/__tests__/display_name.test.js +++ b/app/soapbox/components/__tests__/display_name.test.tsx @@ -3,11 +3,13 @@ import React from 'react'; import { normalizeAccount } from 'soapbox/normalizers'; import { render, screen } from '../../jest/test-helpers'; -import DisplayName from '../display_name'; +import DisplayName from '../display-name'; + +import type { ReducerAccount } from 'soapbox/reducers/accounts'; describe('', () => { it('renders display name + account name', () => { - const account = normalizeAccount({ acct: 'bar@baz' }); + const account = normalizeAccount({ acct: 'bar@baz' }) as ReducerAccount; render(); expect(screen.getByTestId('display-name')).toHaveTextContent('bar@baz'); diff --git a/app/soapbox/components/__tests__/emoji_selector.test.js b/app/soapbox/components/__tests__/emoji_selector.test.tsx similarity index 95% rename from app/soapbox/components/__tests__/emoji_selector.test.js rename to app/soapbox/components/__tests__/emoji_selector.test.tsx index 891e3e61c..c680d156e 100644 --- a/app/soapbox/components/__tests__/emoji_selector.test.js +++ b/app/soapbox/components/__tests__/emoji_selector.test.tsx @@ -6,6 +6,7 @@ import EmojiSelector from '../emoji_selector'; describe('', () => { it('renders correctly', () => { const children = ; + // @ts-ignore children.__proto__.addEventListener = () => {}; render(children); diff --git a/app/soapbox/components/__tests__/quoted-status.test.tsx b/app/soapbox/components/__tests__/quoted-status.test.tsx new file mode 100644 index 000000000..d57b59b30 --- /dev/null +++ b/app/soapbox/components/__tests__/quoted-status.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { render, screen, rootState } from '../../jest/test-helpers'; +import { normalizeStatus, normalizeAccount } from '../../normalizers'; +import QuotedStatus from '../quoted-status'; + +import type { ReducerStatus } from 'soapbox/reducers/statuses'; + +describe('', () => { + it('renders content', () => { + const account = normalizeAccount({ + id: '1', + acct: 'alex', + }); + + const status = normalizeStatus({ + id: '1', + account, + content: 'hello world', + contentHtml: 'hello world', + }) as ReducerStatus; + + const state = rootState.setIn(['accounts', '1'], account); + + render(, undefined, state); + screen.getByText(/hello world/i); + expect(screen.getByTestId('quoted-status')).toHaveTextContent(/hello world/i); + }); +}); diff --git a/app/soapbox/components/__tests__/scroll-top-button.test.tsx b/app/soapbox/components/__tests__/scroll-top-button.test.tsx new file mode 100644 index 000000000..76e6b7c1c --- /dev/null +++ b/app/soapbox/components/__tests__/scroll-top-button.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { defineMessages } from 'react-intl'; + +import { render, screen } from '../../jest/test-helpers'; +import ScrollTopButton from '../scroll-top-button'; + +const messages = defineMessages({ + queue: { id: 'status_list.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {post} other {posts}}' }, +}); + +describe('', () => { + it('renders correctly', async() => { + render( + {}} + count={0} + message={messages.queue} + />, + ); + expect(screen.queryAllByRole('link')).toHaveLength(0); + + render( + {}} + count={1} + message={messages.queue} + />, + ); + expect(screen.getByText('Click to see 1 new post')).toBeInTheDocument(); + + render( + {}} + count={9999999} + message={messages.queue} + />, + ); + expect(screen.getByText('Click to see 9999999 new posts')).toBeInTheDocument(); + }); +}); diff --git a/app/soapbox/components/__tests__/timeline_queue_button_header.test.js b/app/soapbox/components/__tests__/timeline_queue_button_header.test.js deleted file mode 100644 index 6874452ae..000000000 --- a/app/soapbox/components/__tests__/timeline_queue_button_header.test.js +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import { defineMessages } from 'react-intl'; - -import { render, screen } from '../../jest/test-helpers'; -import TimelineQueueButtonHeader from '../timeline_queue_button_header'; - -const messages = defineMessages({ - queue: { id: 'status_list.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {post} other {posts}}' }, -}); - -describe('', () => { - it('renders correctly', async() => { - render( - {}} // eslint-disable-line react/jsx-no-bind - count={0} - message={messages.queue} - />, - ); - expect(screen.queryAllByRole('link')).toHaveLength(0); - - render( - {}} // eslint-disable-line react/jsx-no-bind - count={1} - message={messages.queue} - />, - ); - expect(screen.getByText('Click to see 1 new post', { hidden: true })).toBeInTheDocument(); - - render( - {}} // eslint-disable-line react/jsx-no-bind - count={9999999} - message={messages.queue} - />, - ); - expect(screen.getByText('Click to see 9999999 new posts', { hidden: true })).toBeInTheDocument(); - }); -}); diff --git a/app/soapbox/components/__tests__/validation-checkmark.test.tsx b/app/soapbox/components/__tests__/validation-checkmark.test.tsx new file mode 100644 index 000000000..c9e204a6e --- /dev/null +++ b/app/soapbox/components/__tests__/validation-checkmark.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { render, screen } from '../../jest/test-helpers'; +import ValidationCheckmark from '../validation-checkmark'; + +describe('', () => { + it('renders text', () => { + const text = 'some validation'; + render(); + + expect(screen.getByTestId('validation-checkmark')).toHaveTextContent(text); + }); + + it('uses a green check when valid', () => { + const text = 'some validation'; + render(); + + expect(screen.getByTestId('svg-icon-loader')).toHaveClass('text-success-500'); + expect(screen.getByTestId('svg-icon-loader')).not.toHaveClass('text-gray-400'); + }); + + it('uses a gray check when valid', () => { + const text = 'some validation'; + render(); + + expect(screen.getByTestId('svg-icon-loader')).toHaveClass('text-gray-400'); + expect(screen.getByTestId('svg-icon-loader')).not.toHaveClass('text-success-500'); + }); +}); diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index 1d670a344..281e1aee2 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -9,7 +9,7 @@ import { getAcct } from 'soapbox/utils/accounts'; import { displayFqn } from 'soapbox/utils/state'; import RelativeTimestamp from './relative_timestamp'; -import { Avatar, HStack, Icon, IconButton, Text } from './ui'; +import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui'; import type { Account as AccountEntity } from 'soapbox/types/entities'; @@ -26,7 +26,7 @@ const InstanceFavicon: React.FC = ({ account }) => { }; return ( - ); @@ -47,7 +47,7 @@ interface IAccount { actionIcon?: string, actionTitle?: string, /** Override other actions for specificity like mute/unmute. */ - actionType?: 'muting' | 'blocking', + actionType?: 'muting' | 'blocking' | 'follow_request', avatarSize?: number, hidden?: boolean, hideActions?: boolean, @@ -56,9 +56,13 @@ interface IAccount { showProfileHoverCard?: boolean, timestamp?: string | Date, timestampUrl?: string, + futureTimestamp?: boolean, + withAccountNote?: boolean, withDate?: boolean, + withLinkToProfile?: boolean, withRelationship?: boolean, showEdit?: boolean, + emoji?: string, } const Account = ({ @@ -75,9 +79,13 @@ const Account = ({ showProfileHoverCard = true, timestamp, timestampUrl, + futureTimestamp = false, + withAccountNote = false, withDate = false, + withLinkToProfile = true, withRelationship = true, showEdit = false, + emoji, }: IAccount) => { const overflowRef = React.useRef(null); const actionRef = React.useRef(null); @@ -109,7 +117,7 @@ const Account = ({ src={actionIcon} title={actionTitle} onClick={handleAction} - className='bg-transparent text-gray-400 hover:text-gray-600' + className='bg-transparent text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500' iconClassName='w-4 h-4' /> ); @@ -150,15 +158,15 @@ const Account = ({ if (withDate) timestamp = account.created_at; - const LinkEl: any = showProfileHoverCard ? Link : 'div'; + const LinkEl: any = withLinkToProfile ? Link : 'div'; return (
- + {children}} + wrapper={(children) => {children}} > event.stopPropagation()} > + {emoji && ( + + )} @@ -192,35 +206,45 @@ const Account = ({ - - @{username} + + + @{username} - {account.favicon && ( - + {account.favicon && ( + + )} + + {(timestamp) ? ( + <> + · + + {timestampUrl ? ( + + + + ) : ( + + )} + + ) : null} + + {showEdit ? ( + <> + · + + + + ) : null} + + + {withAccountNote && ( + )} - - {(timestamp) ? ( - <> - · - - {timestampUrl ? ( - - - - ) : ( - - )} - - ) : null} - - {showEdit ? ( - <> - · - - - - ) : null} - +
diff --git a/app/soapbox/components/account_search.tsx b/app/soapbox/components/account_search.tsx index 961a4b2ef..adbb5501d 100644 --- a/app/soapbox/components/account_search.tsx +++ b/app/soapbox/components/account_search.tsx @@ -14,6 +14,8 @@ interface IAccountSearch { onSelected: (accountId: string) => void, /** Override the default placeholder of the input. */ placeholder?: string, + /** Position of results relative to the input. */ + resultsPosition?: 'above' | 'below', } /** Input to search for accounts. */ @@ -68,8 +70,8 @@ const AccountSearch: React.FC = ({ onSelected, ...rest }) => { />
- - + +
); diff --git a/app/soapbox/components/animated-number.tsx b/app/soapbox/components/animated-number.tsx new file mode 100644 index 000000000..0f6908fde --- /dev/null +++ b/app/soapbox/components/animated-number.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState } from 'react'; +import { FormattedNumber } from 'react-intl'; +import { TransitionMotion, spring } from 'react-motion'; + +import { useSettings } from 'soapbox/hooks'; + +const obfuscatedCount = (count: number) => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + +interface IAnimatedNumber { + value: number; + obfuscate?: boolean; +} + +const AnimatedNumber: React.FC = ({ value, obfuscate }) => { + const reduceMotion = useSettings().get('reduceMotion'); + + const [direction, setDirection] = useState(1); + const [displayedValue, setDisplayedValue] = useState(value); + + useEffect(() => { + if (displayedValue !== undefined) { + if (value > displayedValue) setDirection(1); + else if (value < displayedValue) setDirection(-1); + } + setDisplayedValue(value); + }, [value]); + + const willEnter = () => ({ y: -1 * direction }); + + const willLeave = () => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }); + + if (reduceMotion) { + return obfuscate ? <>{obfuscatedCount(displayedValue)} : ; + } + + const styles = [{ + key: `${displayedValue}`, + data: displayedValue, + style: { y: spring(0, { damping: 35, stiffness: 400 }) }, + }]; + + return ( + + {items => ( + + {items.map(({ key, data, style }) => ( + 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : } + ))} + + )} + + ); +}; + +export default AnimatedNumber; \ No newline at end of file diff --git a/app/soapbox/components/announcements/announcement-content.tsx b/app/soapbox/components/announcements/announcement-content.tsx new file mode 100644 index 000000000..f4265d1fd --- /dev/null +++ b/app/soapbox/components/announcements/announcement-content.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, useRef } from 'react'; +import { useHistory } from 'react-router-dom'; + +import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities'; + +interface IAnnouncementContent { + announcement: AnnouncementEntity; +} + +const AnnouncementContent: React.FC = ({ announcement }) => { + const history = useHistory(); + + const node = useRef(null); + + useEffect(() => { + updateLinks(); + }); + + const onMentionClick = (mention: MentionEntity, e: MouseEvent) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + history.push(`/@${mention.acct}`); + } + }; + + const onHashtagClick = (hashtag: string, e: MouseEvent) => { + hashtag = hashtag.replace(/^#/, '').toLowerCase(); + + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + history.push(`/tags/${hashtag}`); + } + }; + + const onStatusClick = (status: string, e: MouseEvent) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + history.push(status); + } + }; + + const updateLinks = () => { + if (!node.current) return; + + const links = node.current.querySelectorAll('a'); + + links.forEach(link => { + // Skip already processed + if (link.classList.contains('status-link')) return; + + // Add attributes + link.classList.add('status-link'); + link.setAttribute('rel', 'nofollow noopener'); + link.setAttribute('target', '_blank'); + + const mention = announcement.mentions.find(mention => link.href === `${mention.url}`); + + // Add event listeners on mentions, hashtags and statuses + if (mention) { + link.addEventListener('click', onMentionClick.bind(link, mention), false); + link.setAttribute('title', mention.acct); + } else if (link.textContent?.charAt(0) === '#' || (link.previousSibling?.textContent?.charAt(link.previousSibling.textContent.length - 1) === '#')) { + link.addEventListener('click', onHashtagClick.bind(link, link.text), false); + } else { + const status = announcement.statuses.get(link.href); + if (status) { + link.addEventListener('click', onStatusClick.bind(this, status), false); + } + link.setAttribute('title', link.href); + link.classList.add('unhandled-link'); + } + }); + }; + + return ( +
+ ); +}; + +export default AnnouncementContent; diff --git a/app/soapbox/components/announcements/announcement.tsx b/app/soapbox/components/announcements/announcement.tsx new file mode 100644 index 000000000..f6344f7b5 --- /dev/null +++ b/app/soapbox/components/announcements/announcement.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { FormattedDate } from 'react-intl'; + +import { Stack, Text } from 'soapbox/components/ui'; +import { useFeatures } from 'soapbox/hooks'; + +import AnnouncementContent from './announcement-content'; +import ReactionsBar from './reactions-bar'; + +import type { Map as ImmutableMap } from 'immutable'; +import type { Announcement as AnnouncementEntity } from 'soapbox/types/entities'; + +interface IAnnouncement { + announcement: AnnouncementEntity; + addReaction: (id: string, name: string) => void; + removeReaction: (id: string, name: string) => void; + emojiMap: ImmutableMap>; +} + +const Announcement: React.FC = ({ announcement, addReaction, removeReaction, emojiMap }) => { + const features = useFeatures(); + + const startsAt = announcement.starts_at && new Date(announcement.starts_at); + const endsAt = announcement.ends_at && new Date(announcement.ends_at); + const now = new Date(); + const hasTimeRange = startsAt && endsAt; + const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear(); + const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear(); + const skipTime = announcement.all_day; + + return ( + + {hasTimeRange && ( + + + {' '} + - + {' '} + + + )} + + + + {features.announcementsReactions && ( + + )} + + ); +}; + +export default Announcement; diff --git a/app/soapbox/components/announcements/announcements-panel.tsx b/app/soapbox/components/announcements/announcements-panel.tsx new file mode 100644 index 000000000..200615dab --- /dev/null +++ b/app/soapbox/components/announcements/announcements-panel.tsx @@ -0,0 +1,69 @@ +import classNames from 'classnames'; +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import ReactSwipeableViews from 'react-swipeable-views'; +import { createSelector } from 'reselect'; + +import { addReaction as addReactionAction, removeReaction as removeReactionAction } from 'soapbox/actions/announcements'; +import { Card, HStack, Widget } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import Announcement from './announcement'; + +import type { RootState } from 'soapbox/store'; + +const customEmojiMap = createSelector([(state: RootState) => state.custom_emojis], items => (items as ImmutableList>).reduce((map, emoji) => map.set(emoji.get('shortcode')!, emoji), ImmutableMap>())); + +const AnnouncementsPanel = () => { + const dispatch = useAppDispatch(); + const emojiMap = useAppSelector(state => customEmojiMap(state)); + const [index, setIndex] = useState(0); + + const announcements = useAppSelector((state) => state.announcements.items); + + const addReaction = (id: string, name: string) => dispatch(addReactionAction(id, name)); + const removeReaction = (id: string, name: string) => dispatch(removeReactionAction(id, name)); + + if (announcements.size === 0) return null; + + const handleChangeIndex = (index: number) => { + setIndex(index % announcements.size); + }; + + return ( + }> + + + {announcements.map((announcement) => ( + + )).reverse()} + + {announcements.size > 1 && ( + + {announcements.map((_, i) => ( + + ); +}; + +export default Reaction; diff --git a/app/soapbox/components/announcements/reactions-bar.tsx b/app/soapbox/components/announcements/reactions-bar.tsx new file mode 100644 index 000000000..66b5f3f83 --- /dev/null +++ b/app/soapbox/components/announcements/reactions-bar.tsx @@ -0,0 +1,65 @@ +import classNames from 'classnames'; +import React from 'react'; +import { TransitionMotion, spring } from 'react-motion'; + +import { Icon } from 'soapbox/components/ui'; +import EmojiPickerDropdown from 'soapbox/features/compose/containers/emoji_picker_dropdown_container'; +import { useSettings } from 'soapbox/hooks'; + +import Reaction from './reaction'; + +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import type { Emoji } from 'soapbox/components/autosuggest_emoji'; +import type { AnnouncementReaction } from 'soapbox/types/entities'; + +interface IReactionsBar { + announcementId: string; + reactions: ImmutableList; + emojiMap: ImmutableMap>; + addReaction: (id: string, name: string) => void; + removeReaction: (id: string, name: string) => void; +} + +const ReactionsBar: React.FC = ({ announcementId, reactions, addReaction, removeReaction, emojiMap }) => { + const reduceMotion = useSettings().get('reduceMotion'); + + const handleEmojiPick = (data: Emoji) => { + addReaction(announcementId, data.native.replace(/:/g, '')); + }; + + const willEnter = () => ({ scale: reduceMotion ? 1 : 0 }); + + const willLeave = () => ({ scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }); + + const visibleReactions = reactions.filter(x => x.count > 0); + + const styles = visibleReactions.map(reaction => ({ + key: reaction.name, + data: reaction, + style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, + })).toArray(); + + return ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} + + {visibleReactions.size < 8 && } />} +
+ )} +
+ ); +}; + +export default ReactionsBar; diff --git a/app/soapbox/components/attachment-thumbs.tsx b/app/soapbox/components/attachment-thumbs.tsx new file mode 100644 index 000000000..37b9fc9c6 --- /dev/null +++ b/app/soapbox/components/attachment-thumbs.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; + +import { openModal } from 'soapbox/actions/modals'; +import Bundle from 'soapbox/features/ui/components/bundle'; +import { MediaGallery } from 'soapbox/features/ui/util/async-components'; + +import type { List as ImmutableList } from 'immutable'; + +interface IAttachmentThumbs { + media: ImmutableList> + onClick?(): void + sensitive?: boolean +} + +const AttachmentThumbs = (props: IAttachmentThumbs) => { + const { media, onClick, sensitive } = props; + const dispatch = useDispatch(); + + const renderLoading = () =>
; + const onOpenMedia = (media: Immutable.Record, index: number) => dispatch(openModal('MEDIA', { media, index })); + + return ( +
+ + {(Component: any) => ( + + )} + + + {onClick && ( +
+ )} +
+ ); +}; + +export default AttachmentThumbs; diff --git a/app/soapbox/components/attachment_thumbs.js b/app/soapbox/components/attachment_thumbs.js deleted file mode 100644 index 6c998e744..000000000 --- a/app/soapbox/components/attachment_thumbs.js +++ /dev/null @@ -1,52 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { openModal } from 'soapbox/actions/modals'; -import Bundle from 'soapbox/features/ui/components/bundle'; -import { MediaGallery } from 'soapbox/features/ui/util/async-components'; - -export default @connect() -class AttachmentThumbs extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - media: ImmutablePropTypes.list.isRequired, - onClick: PropTypes.func, - sensitive: PropTypes.bool, - }; - - renderLoading() { - return
; - } - - onOpenMedia = (media, index) => { - this.props.dispatch(openModal('MEDIA', { media, index })); - } - - render() { - const { media, onClick, sensitive } = this.props; - - return ( -
- - {Component => ( - - )} - - {onClick && ( -
- )} -
- ); - } - -} diff --git a/app/soapbox/components/autosuggest_account_input.js b/app/soapbox/components/autosuggest_account_input.js deleted file mode 100644 index fd93f52a9..000000000 --- a/app/soapbox/components/autosuggest_account_input.js +++ /dev/null @@ -1,95 +0,0 @@ -import { CancelToken } from 'axios'; -import { OrderedSet as ImmutableOrderedSet } from 'immutable'; -import { throttle } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { accountSearch } from 'soapbox/actions/accounts'; - -import AutosuggestInput from './autosuggest_input'; - -const noOp = () => {}; - -export default @connect() -class AutosuggestAccountInput extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onSelected: PropTypes.func.isRequired, - value: PropTypes.string.isRequired, - limit: PropTypes.number.isRequired, - } - - static defaultProps = { - value: '', - limit: 4, - } - - state = { - accountIds: ImmutableOrderedSet(), - } - - source = CancelToken.source(); - - refreshCancelToken = () => { - this.source.cancel(); - this.source = CancelToken.source(); - return this.source; - } - - clearResults = () => { - this.setState({ accountIds: ImmutableOrderedSet() }); - } - - handleAccountSearch = throttle(q => { - const { dispatch, limit } = this.props; - const source = this.refreshCancelToken(); - - const params = { q, limit, resolve: false }; - - dispatch(accountSearch(params, source.token)) - .then(accounts => { - const accountIds = accounts.map(account => account.id); - this.setState({ accountIds: ImmutableOrderedSet(accountIds) }); - }) - .catch(noOp); - - }, 900, { leading: true, trailing: true }) - - handleChange = e => { - this.handleAccountSearch(e.target.value); - this.props.onChange(e); - } - - handleSelected = (tokenStart, lastToken, accountId) => { - this.props.onSelected(accountId); - } - - componentDidUpdate(prevProps) { - if (this.props.value === '' && prevProps.value !== '') { - this.clearResults(); - } - } - - render() { - const { intl, value, onChange, ...rest } = this.props; - const { accountIds } = this.state; - - return ( - - ); - } - -} diff --git a/app/soapbox/components/autosuggest_account_input.tsx b/app/soapbox/components/autosuggest_account_input.tsx new file mode 100644 index 000000000..79d247fd9 --- /dev/null +++ b/app/soapbox/components/autosuggest_account_input.tsx @@ -0,0 +1,88 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import throttle from 'lodash/throttle'; +import React, { useState, useRef, useCallback, useEffect } from 'react'; + +import { accountSearch } from 'soapbox/actions/accounts'; +import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest_input'; +import { useAppDispatch } from 'soapbox/hooks'; + +import type { Menu } from 'soapbox/components/dropdown_menu'; + +const noOp = () => {}; + +interface IAutosuggestAccountInput { + onChange: React.ChangeEventHandler, + onSelected: (accountId: string) => void, + value: string, + limit?: number, + className?: string, + autoSelect?: boolean, + menu?: Menu, + onKeyDown?: React.KeyboardEventHandler, +} + +const AutosuggestAccountInput: React.FC = ({ + onChange, + onSelected, + value = '', + limit = 4, + ...rest +}) => { + const dispatch = useAppDispatch(); + const [accountIds, setAccountIds] = useState(ImmutableOrderedSet()); + const controller = useRef(new AbortController()); + + const refreshCancelToken = () => { + controller.current.abort(); + controller.current = new AbortController(); + }; + + const clearResults = () => { + setAccountIds(ImmutableOrderedSet()); + }; + + const handleAccountSearch = useCallback(throttle(q => { + const params = { q, limit, resolve: false }; + + dispatch(accountSearch(params, controller.current.signal)) + .then((accounts: { id: string }[]) => { + const accountIds = accounts.map(account => account.id); + setAccountIds(ImmutableOrderedSet(accountIds)); + }) + .catch(noOp); + + }, 900, { leading: true, trailing: true }), [limit]); + + const handleChange: React.ChangeEventHandler = e => { + refreshCancelToken(); + handleAccountSearch(e.target.value); + onChange(e); + }; + + const handleSelected = (_tokenStart: number, _lastToken: string | null, suggestion: AutoSuggestion) => { + if (typeof suggestion === 'string' && suggestion[0] !== '#') { + onSelected(suggestion); + } + }; + + useEffect(() => { + if (value === '') { + clearResults(); + } + }, [value]); + + return ( + + ); +}; + +export default AutosuggestAccountInput; diff --git a/app/soapbox/components/autosuggest_emoji.js b/app/soapbox/components/autosuggest_emoji.js deleted file mode 100644 index e16d2a2c5..000000000 --- a/app/soapbox/components/autosuggest_emoji.js +++ /dev/null @@ -1,43 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; - -import { joinPublicPath } from 'soapbox/utils/static'; - -import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; - -export default class AutosuggestEmoji extends React.PureComponent { - - static propTypes = { - emoji: PropTypes.object.isRequired, - }; - - render() { - const { emoji } = this.props; - let url; - - if (emoji.custom) { - url = emoji.imageUrl; - } else { - const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; - - if (!mapping) { - return null; - } - - url = joinPublicPath(`packs/emoji/${mapping.filename}.svg`); - } - - return ( -
- {emoji.native - - {emoji.colons} -
- ); - } - -} diff --git a/app/soapbox/components/autosuggest_emoji.tsx b/app/soapbox/components/autosuggest_emoji.tsx new file mode 100644 index 000000000..22979d454 --- /dev/null +++ b/app/soapbox/components/autosuggest_emoji.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import unicodeMapping from 'soapbox/features/emoji/emoji_unicode_mapping_light'; +import { joinPublicPath } from 'soapbox/utils/static'; + +export type Emoji = { + id: string, + custom: boolean, + imageUrl: string, + native: string, + colons: string, +} + +type UnicodeMapping = { + filename: string, +} + +interface IAutosuggestEmoji { + emoji: Emoji, +} + +const AutosuggestEmoji: React.FC = ({ emoji }) => { + let url; + + if (emoji.custom) { + url = emoji.imageUrl; + } else { + // @ts-ignore + const mapping: UnicodeMapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; + + if (!mapping) { + return null; + } + + url = joinPublicPath(`packs/emoji/${mapping.filename}.svg`); + } + + return ( +
+ {emoji.native + + {emoji.colons} +
+ ); +}; + +export default AutosuggestEmoji; diff --git a/app/soapbox/components/autosuggest_input.js b/app/soapbox/components/autosuggest_input.js deleted file mode 100644 index af83c7774..000000000 --- a/app/soapbox/components/autosuggest_input.js +++ /dev/null @@ -1,312 +0,0 @@ -import classNames from 'classnames'; -import { List as ImmutableList } from 'immutable'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; - -import Icon from 'soapbox/components/icon'; - -import AutosuggestAccount from '../features/compose/components/autosuggest_account'; -import { isRtl } from '../rtl'; - -import AutosuggestEmoji from './autosuggest_emoji'; - -const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { - let word; - - const left = str.slice(0, caretPosition).search(/\S+$/); - const right = str.slice(caretPosition).search(/\s/); - - if (right < 0) { - word = str.slice(left); - } else { - word = str.slice(left, right + caretPosition); - } - - if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) { - return [null, null]; - } - - word = word.trim().toLowerCase(); - - if (word.length > 0) { - return [left + 1, word]; - } else { - return [null, null]; - } -}; - -export default class AutosuggestInput extends ImmutablePureComponent { - - static propTypes = { - value: PropTypes.string, - suggestions: ImmutablePropTypes.list, - disabled: PropTypes.bool, - placeholder: PropTypes.string, - onSuggestionSelected: PropTypes.func.isRequired, - onSuggestionsClearRequested: PropTypes.func.isRequired, - onSuggestionsFetchRequested: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onKeyUp: PropTypes.func, - onKeyDown: PropTypes.func, - autoFocus: PropTypes.bool, - autoSelect: PropTypes.bool, - className: PropTypes.string, - id: PropTypes.string, - searchTokens: PropTypes.arrayOf(PropTypes.string), - maxLength: PropTypes.number, - menu: PropTypes.arrayOf(PropTypes.object), - }; - - static defaultProps = { - autoFocus: false, - autoSelect: true, - searchTokens: ImmutableList(['@', ':', '#']), - }; - - getFirstIndex = () => { - return this.props.autoSelect ? 0 : -1; - } - - state = { - suggestionsHidden: true, - focused: false, - selectedSuggestion: this.getFirstIndex(), - lastToken: null, - tokenStart: 0, - }; - - onChange = (e) => { - const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens); - - if (token !== null && this.state.lastToken !== token) { - this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); - this.props.onSuggestionsFetchRequested(token); - } else if (token === null) { - this.setState({ lastToken: null }); - this.props.onSuggestionsClearRequested(); - } - - this.props.onChange(e); - } - - onKeyDown = (e) => { - const { suggestions, menu, disabled } = this.props; - const { selectedSuggestion, suggestionsHidden } = this.state; - const firstIndex = this.getFirstIndex(); - const lastIndex = suggestions.size + (menu || []).length - 1; - - if (disabled) { - e.preventDefault(); - return; - } - - if (e.which === 229 || e.isComposing) { - // Ignore key events during text composition - // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) - return; - } - - switch(e.key) { - case 'Escape': - if (suggestions.size === 0 || suggestionsHidden) { - document.querySelector('.ui').parentElement.focus(); - } else { - e.preventDefault(); - this.setState({ suggestionsHidden: true }); - } - - break; - case 'ArrowDown': - if (!suggestionsHidden && (suggestions.size > 0 || menu)) { - e.preventDefault(); - this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, lastIndex) }); - } - - break; - case 'ArrowUp': - if (!suggestionsHidden && (suggestions.size > 0 || menu)) { - e.preventDefault(); - this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, firstIndex) }); - } - - break; - case 'Enter': - case 'Tab': - // Select suggestion - if (!suggestionsHidden && selectedSuggestion > -1 && (suggestions.size > 0 || menu)) { - e.preventDefault(); - e.stopPropagation(); - this.setState({ selectedSuggestion: firstIndex }); - - if (selectedSuggestion < suggestions.size) { - this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); - } else { - const item = menu[selectedSuggestion - suggestions.size]; - this.handleMenuItemAction(item); - } - } - - break; - } - - if (e.defaultPrevented || !this.props.onKeyDown) { - return; - } - - this.props.onKeyDown(e); - } - - onBlur = () => { - this.setState({ suggestionsHidden: true, focused: false }); - } - - onFocus = () => { - this.setState({ focused: true }); - } - - onSuggestionClick = (e) => { - const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); - e.preventDefault(); - this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); - this.input.focus(); - } - - componentDidUpdate(prevProps, prevState) { - const { suggestions } = this.props; - if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) { - this.setState({ suggestionsHidden: false }); - } - } - - setInput = (c) => { - this.input = c; - } - - renderSuggestion = (suggestion, i) => { - const { selectedSuggestion } = this.state; - let inner, key; - - if (typeof suggestion === 'object') { - inner = ; - key = suggestion.id; - } else if (suggestion[0] === '#') { - inner = suggestion; - key = suggestion; - } else { - inner = ; - key = suggestion; - } - - return ( -
- {inner} -
- ); - } - - handleMenuItemAction = item => { - this.onBlur(); - item.action(); - } - - handleMenuItemClick = item => { - return e => { - e.preventDefault(); - this.handleMenuItemAction(item); - }; - } - - renderMenu = () => { - const { menu, suggestions } = this.props; - const { selectedSuggestion } = this.state; - - if (!menu) { - return null; - } - - return menu.map((item, i) => ( - - {item.icon && ( - - )} - - {item.text} - - )); - }; - - render() { - const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu } = this.props; - const { suggestionsHidden } = this.state; - const style = { direction: 'ltr' }; - - const visible = !suggestionsHidden && (!suggestions.isEmpty() || (menu && value)); - - if (isRtl(value)) { - style.direction = 'rtl'; - } - - return ( -
- - - - -
-
- {suggestions.map(this.renderSuggestion)} -
- - {this.renderMenu()} -
-
- ); - } - -} diff --git a/app/soapbox/components/autosuggest_input.tsx b/app/soapbox/components/autosuggest_input.tsx new file mode 100644 index 000000000..54f126a23 --- /dev/null +++ b/app/soapbox/components/autosuggest_input.tsx @@ -0,0 +1,342 @@ +import Portal from '@reach/portal'; +import classNames from 'classnames'; +import { List as ImmutableList } from 'immutable'; +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import AutosuggestEmoji, { Emoji } from 'soapbox/components/autosuggest_emoji'; +import Icon from 'soapbox/components/icon'; +import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest_account'; +import { isRtl } from 'soapbox/rtl'; + +import type { Menu, MenuItem } from 'soapbox/components/dropdown_menu'; + +type CursorMatch = [ + tokenStart: number | null, + token: string | null, +]; + +export type AutoSuggestion = string | Emoji; + +const textAtCursorMatchesToken = (str: string, caretPosition: number, searchTokens: string[]): CursorMatch => { + let word: string; + + const left: number = str.slice(0, caretPosition).search(/\S+$/); + const right: number = str.slice(caretPosition).search(/\s/); + + if (right < 0) { + word = str.slice(left); + } else { + word = str.slice(left, right + caretPosition); + } + + if (!word || word.trim().length < 3 || !searchTokens.includes(word[0])) { + return [null, null]; + } + + word = word.trim().toLowerCase(); + + if (word.length > 0) { + return [left + 1, word]; + } else { + return [null, null]; + } +}; + +interface IAutosuggestInput extends Pick, 'onChange' | 'onKeyUp' | 'onKeyDown'> { + value: string, + suggestions: ImmutableList, + disabled?: boolean, + placeholder?: string, + onSuggestionSelected: (tokenStart: number, lastToken: string | null, suggestion: AutoSuggestion) => void, + onSuggestionsClearRequested: () => void, + onSuggestionsFetchRequested: (token: string) => void, + autoFocus: boolean, + autoSelect: boolean, + className?: string, + id?: string, + searchTokens: string[], + maxLength?: number, + menu?: Menu, + resultsPosition: string, +} + +export default class AutosuggestInput extends ImmutablePureComponent { + + static defaultProps = { + autoFocus: false, + autoSelect: true, + searchTokens: ImmutableList(['@', ':', '#']), + resultsPosition: 'below', + }; + + getFirstIndex = () => { + return this.props.autoSelect ? 0 : -1; + } + + state = { + suggestionsHidden: true, + focused: false, + selectedSuggestion: this.getFirstIndex(), + lastToken: null, + tokenStart: 0, + }; + + input: HTMLInputElement | null = null; + + onChange: React.ChangeEventHandler = (e) => { + const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart || 0, this.props.searchTokens); + + if (token !== null && this.state.lastToken !== token) { + this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); + this.props.onSuggestionsFetchRequested(token); + } else if (token === null) { + this.setState({ lastToken: null }); + this.props.onSuggestionsClearRequested(); + } + + if (this.props.onChange) { + this.props.onChange(e); + } + } + + onKeyDown: React.KeyboardEventHandler = (e) => { + const { suggestions, menu, disabled } = this.props; + const { selectedSuggestion, suggestionsHidden } = this.state; + const firstIndex = this.getFirstIndex(); + const lastIndex = suggestions.size + (menu || []).length - 1; + + if (disabled) { + e.preventDefault(); + return; + } + + if (e.which === 229) { + // Ignore key events during text composition + // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) + return; + } + + switch (e.key) { + case 'Escape': + if (suggestions.size === 0 || suggestionsHidden) { + document.querySelector('.ui')?.parentElement?.focus(); + } else { + e.preventDefault(); + this.setState({ suggestionsHidden: true }); + } + + break; + case 'ArrowDown': + if (!suggestionsHidden && (suggestions.size > 0 || menu)) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, lastIndex) }); + } + + break; + case 'ArrowUp': + if (!suggestionsHidden && (suggestions.size > 0 || menu)) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, firstIndex) }); + } + + break; + case 'Enter': + case 'Tab': + // Select suggestion + if (!suggestionsHidden && selectedSuggestion > -1 && (suggestions.size > 0 || menu)) { + e.preventDefault(); + e.stopPropagation(); + this.setState({ selectedSuggestion: firstIndex }); + + if (selectedSuggestion < suggestions.size) { + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); + } else if (menu) { + const item = menu[selectedSuggestion - suggestions.size]; + this.handleMenuItemAction(item, e); + } + } + + break; + } + + if (e.defaultPrevented || !this.props.onKeyDown) { + return; + } + + if (this.props.onKeyDown) { + this.props.onKeyDown(e); + } + } + + onBlur = () => { + this.setState({ suggestionsHidden: true, focused: false }); + } + + onFocus = () => { + this.setState({ focused: true }); + } + + onSuggestionClick: React.EventHandler = (e) => { + const index = Number(e.currentTarget?.getAttribute('data-index')); + const suggestion = this.props.suggestions.get(index); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); + this.input?.focus(); + e.preventDefault(); + } + + componentDidUpdate(prevProps: IAutosuggestInput, prevState: any) { + const { suggestions } = this.props; + if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) { + this.setState({ suggestionsHidden: false }); + } + } + + setInput = (c: HTMLInputElement) => { + this.input = c; + } + + renderSuggestion = (suggestion: AutoSuggestion, i: number) => { + const { selectedSuggestion } = this.state; + let inner, key; + + if (typeof suggestion === 'object') { + inner = ; + key = suggestion.id; + } else if (suggestion[0] === '#') { + inner = suggestion; + key = suggestion; + } else { + inner = ; + key = suggestion; + } + + return ( +
+ {inner} +
+ ); + } + + 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; + const { selectedSuggestion } = this.state; + + if (!menu) { + return null; + } + + return menu.map((item, i) => ( + + {item?.icon && ( + + )} + + {item?.text} + + )); + }; + + setPortalPosition() { + if (!this.input) { + return {}; + } + + const { top, height, left, width } = this.input.getBoundingClientRect(); + + if (this.props.resultsPosition === 'below') { + return { left, width, top: top + height }; + } + + return { left, width, top, transform: 'translate(0, -100%)' }; + } + + render() { + const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu } = this.props; + const { suggestionsHidden } = this.state; + const style: React.CSSProperties = { direction: 'ltr' }; + + const visible = !suggestionsHidden && (!suggestions.isEmpty() || (menu && value)); + + if (isRtl(value)) { + style.direction = 'rtl'; + } + + return [ +
+ + + +
, + +
+
+ {suggestions.map(this.renderSuggestion)} +
+ + {this.renderMenu()} +
+
, + ]; + } + +} diff --git a/app/soapbox/components/autosuggest_textarea.js b/app/soapbox/components/autosuggest_textarea.tsx similarity index 57% rename from app/soapbox/components/autosuggest_textarea.js rename to app/soapbox/components/autosuggest_textarea.tsx index 183f50a5d..a475e5ce2 100644 --- a/app/soapbox/components/autosuggest_textarea.js +++ b/app/soapbox/components/autosuggest_textarea.tsx @@ -1,20 +1,20 @@ import Portal from '@reach/portal'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Textarea from 'react-textarea-autosize'; import AutosuggestAccount from '../features/compose/components/autosuggest_account'; import { isRtl } from '../rtl'; -import AutosuggestEmoji from './autosuggest_emoji'; +import AutosuggestEmoji, { Emoji } from './autosuggest_emoji'; -const textAtCursorMatchesToken = (str, caretPosition) => { +import type { List as ImmutableList } from 'immutable'; + +const textAtCursorMatchesToken = (str: string, caretPosition: number) => { let word; - const left = str.slice(0, caretPosition).search(/\S+$/); + const left = str.slice(0, caretPosition).search(/\S+$/); const right = str.slice(caretPosition).search(/\s/); if (right < 0) { @@ -23,7 +23,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => { word = str.slice(left, right + caretPosition); } - if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) { + if (!word || word.trim().length < 3 || !['@', ':', '#'].includes(word[0])) { return [null, null]; } @@ -36,25 +36,28 @@ const textAtCursorMatchesToken = (str, caretPosition) => { } }; -export default class AutosuggestTextarea extends ImmutablePureComponent { +interface IAutosuggesteTextarea { + id?: string, + value: string, + suggestions: ImmutableList, + disabled: boolean, + placeholder: string, + onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void, + onSuggestionsClearRequested: () => void, + onSuggestionsFetchRequested: (token: string | number) => void, + onChange: React.ChangeEventHandler, + onKeyUp: React.KeyboardEventHandler, + onKeyDown: React.KeyboardEventHandler, + onPaste: (files: FileList) => void, + autoFocus: boolean, + onFocus: () => void, + onBlur?: () => void, + condensed?: boolean, +} - static propTypes = { - value: PropTypes.string, - suggestions: ImmutablePropTypes.list, - disabled: PropTypes.bool, - placeholder: PropTypes.string, - onSuggestionSelected: PropTypes.func.isRequired, - onSuggestionsClearRequested: PropTypes.func.isRequired, - onSuggestionsFetchRequested: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onKeyUp: PropTypes.func, - onKeyDown: PropTypes.func, - onPaste: PropTypes.func.isRequired, - autoFocus: PropTypes.bool, - onFocus: PropTypes.func, - onBlur: PropTypes.func, - condensed: PropTypes.bool, - }; +class AutosuggestTextarea extends ImmutablePureComponent { + + textarea: HTMLTextAreaElement | null = null; static defaultProps = { autoFocus: true, @@ -68,8 +71,8 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { tokenStart: 0, }; - onChange = (e) => { - const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); + onChange: React.ChangeEventHandler = (e) => { + const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); if (token !== null && this.state.lastToken !== token) { this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); @@ -82,7 +85,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { this.props.onChange(e); } - onKeyDown = (e) => { + onKeyDown: React.KeyboardEventHandler = (e) => { const { suggestions, disabled } = this.props; const { selectedSuggestion, suggestionsHidden } = this.state; @@ -91,46 +94,46 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { return; } - if (e.which === 229 || e.isComposing) { + if (e.which === 229 || (e as any).isComposing) { // Ignore key events during text composition // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) return; } - switch(e.key) { - case 'Escape': - if (suggestions.size === 0 || suggestionsHidden) { - document.querySelector('.ui').parentElement.focus(); - } else { - e.preventDefault(); - this.setState({ suggestionsHidden: true }); - } + switch (e.key) { + case 'Escape': + if (suggestions.size === 0 || suggestionsHidden) { + document.querySelector('.ui')?.parentElement?.focus(); + } else { + e.preventDefault(); + this.setState({ suggestionsHidden: true }); + } - break; - case 'ArrowDown': - if (suggestions.size > 0 && !suggestionsHidden) { - e.preventDefault(); - this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); - } + break; + case 'ArrowDown': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); + } - break; - case 'ArrowUp': - if (suggestions.size > 0 && !suggestionsHidden) { - e.preventDefault(); - this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); - } + break; + case 'ArrowUp': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); + } - break; - case 'Enter': - case 'Tab': - // Select suggestion - if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { - e.preventDefault(); - e.stopPropagation(); - this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); - } + break; + case 'Enter': + case 'Tab': + // Select suggestion + if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + e.stopPropagation(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); + } - break; + break; } if (e.defaultPrevented || !this.props.onKeyDown) { @@ -156,14 +159,14 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } } - onSuggestionClick = (e) => { - const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); + 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(); + this.textarea?.focus(); } - shouldComponentUpdate(nextProps, nextState) { + shouldComponentUpdate(nextProps: IAutosuggesteTextarea, nextState: any) { // Skip updating when only the lastToken changes so the // cursor doesn't jump around due to re-rendering unnecessarily const lastTokenUpdated = this.state.lastToken !== nextState.lastToken; @@ -172,52 +175,52 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { if (lastTokenUpdated && !valueUpdated) { return false; } else { - return super.shouldComponentUpdate(nextProps, nextState); + return super.shouldComponentUpdate!(nextProps, nextState, undefined); } } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps: IAutosuggesteTextarea, prevState: any) { const { suggestions } = this.props; if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) { this.setState({ suggestionsHidden: false }); } } - setTextarea = (c) => { + setTextarea: React.Ref = (c) => { this.textarea = c; } - onPaste = (e) => { + onPaste: React.ClipboardEventHandler = (e) => { if (e.clipboardData && e.clipboardData.files.length === 1) { this.props.onPaste(e.clipboardData.files); e.preventDefault(); } } - renderSuggestion = (suggestion, i) => { + renderSuggestion = (suggestion: string | Emoji, i: number) => { const { selectedSuggestion } = this.state; let inner, key; if (typeof suggestion === 'object') { inner = ; - key = suggestion.id; + key = suggestion.id; } else if (suggestion[0] === '#') { inner = suggestion; - key = suggestion; + key = suggestion; } else { inner = ; - key = suggestion; + key = suggestion; } return (
@@ -257,7 +260,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {