diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..41b87855a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +.git + +/node_modules/ +/tmp/ +/build/ +/coverage/ +/.coverage/ +/.eslintcache +/.env +/deploy.sh +/.vs/ +yarn-error.log* +/junit.xml + +/static/ +/static-test/ +/public/ +/dist/ + +.idea +.DS_Store + +# Custom build files +/custom/**/* +!/custom/* +/custom/*.* +!/custom/.gitkeep +!/custom/**/.gitkeep + +# surge.sh +/CNAME +/AUTH +/CORS +/ROUTER diff --git a/.eslintignore b/.eslintignore index 0c17e6907..1ab6f8d8c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,4 +5,4 @@ /tmp/** /coverage/** /custom/** -!.eslintrc.js +!.eslintrc.cjs diff --git a/.eslintrc.js b/.eslintrc.cjs similarity index 86% rename from .eslintrc.js rename to .eslintrc.cjs index c140fa524..953c7e252 100644 --- a/.eslintrc.js +++ b/.eslintrc.cjs @@ -4,6 +4,8 @@ module.exports = { extends: [ 'eslint:recommended', 'plugin:import/typescript', + 'plugin:compat/recommended', + 'plugin:tailwindcss/recommended', ], env: { @@ -17,7 +19,7 @@ module.exports = { ATTACHMENT_HOST: false, }, - parser: 'babel-eslint', + parser: '@babel/eslint-parser', plugins: [ 'react', @@ -42,7 +44,7 @@ module.exports = { react: { version: 'detect', }, - 'import/extensions': ['.js', '.jsx', '.ts', '.tsx'], + 'import/extensions': ['.js', '.jsx', '.cjs', '.mjs', '.ts', '.tsx'], 'import/ignore': [ 'node_modules', '\\.(css|scss|json)$', @@ -52,6 +54,17 @@ module.exports = { paths: ['app'], }, }, + polyfills: [ + 'es:all', // core-js + 'IntersectionObserver', // npm:intersection-observer + 'Promise', // core-js + 'ResizeObserver', // npm:resize-observer-polyfill + 'URL', // core-js + 'URLSearchParams', // core-js + ], + tailwindcss: { + config: 'tailwind.config.cjs', + }, }, rules: { @@ -69,7 +82,6 @@ module.exports = { 'space-infix-ops': 'error', 'space-in-parens': ['error', 'never'], 'keyword-spacing': 'error', - 'consistent-return': 'error', 'dot-notation': 'error', eqeqeq: 'error', indent: ['error', 2, { @@ -227,18 +239,7 @@ module.exports = { }, ], 'import/newline-after-import': 'error', - 'import/no-extraneous-dependencies': [ - 'error', - // { - // devDependencies: [ - // 'webpack/**', - // 'app/soapbox/test_setup.js', - // 'app/soapbox/test_helpers.js', - // 'app/**/__tests__/**', - // 'app/**/__mocks__/**', - // ], - // }, - ], + 'import/no-extraneous-dependencies': 'error', 'import/no-unresolved': 'error', 'import/no-webpack-loader-syntax': 'error', 'import/order': [ @@ -259,16 +260,37 @@ module.exports = { }, ], '@typescript-eslint/no-duplicate-imports': 'error', + '@typescript-eslint/member-delimiter-style': [ + 'error', + { + multiline: { + delimiter: 'none', + }, + singleline: { + delimiter: 'comma', + }, + }, + ], 'promise/catch-or-return': 'error', 'react-hooks/rules-of-hooks': 'error', + + 'tailwindcss/classnames-order': [ + 'error', + { + classRegex: '^(base|container|icon|item|list|outer|wrapper)?[c|C]lass(Name)?$', + config: 'tailwind.config.cjs', + }, + ], + 'tailwindcss/migration-from-tailwind-2': 'error', }, overrides: [ { files: ['**/*.ts', '**/*.tsx'], rules: { 'no-undef': 'off', // https://stackoverflow.com/a/69155899 + 'space-before-function-paren': 'off', }, parser: '@typescript-eslint/parser', }, diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..4157440bf --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +CHANGELOG.md merge=union \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 90c856e40..9516ec2d6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,6 +3,9 @@ image: node:18 variables: NODE_ENV: test +default: + interruptible: true + cache: &cache key: files: @@ -15,6 +18,7 @@ stages: - deps - test - deploy + - release deps: stage: deps @@ -32,6 +36,9 @@ danger: # https://github.com/danger/danger-js/issues/1029#issuecomment-998915436 - export CI_MERGE_REQUEST_IID=${CI_OPEN_MERGE_REQUESTS#*!} - npx danger ci + except: + variables: + - $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME allow_failure: true lint-js: @@ -41,10 +48,12 @@ lint-js: changes: - "**/*.js" - "**/*.jsx" + - "**/*.cjs" + - "**/*.mjs" - "**/*.ts" - "**/*.tsx" - ".eslintignore" - - ".eslintrc.js" + - ".eslintrc.cjs" lint-sass: stage: test @@ -65,7 +74,7 @@ jest: - "app/soapbox/**/*" - "webpack/**/*" - "custom/**/*" - - "jest.config.js" + - "jest.config.cjs" - "package.json" - "yarn.lock" - ".gitlab-ci.yml" @@ -80,7 +89,8 @@ jest: nginx-test: stage: test image: nginx:latest - before_script: cp installation/mastodon.conf /etc/nginx/conf.d/default.conf + before_script: + - cp installation/mastodon.conf /etc/nginx/conf.d/default.conf script: nginx -t only: changes: @@ -88,7 +98,12 @@ nginx-test: build-production: stage: test - script: yarn build + script: + - yarn build + - yarn manage:translations en + # Fail if files got changed. + # https://stackoverflow.com/a/9066385 + - git diff --quiet variables: NODE_ENV: production artifacts: @@ -103,22 +118,11 @@ docs-deploy: script: - curl -X POST -F"token=$CI_JOB_TOKEN" -F'ref=master' https://gitlab.com/api/v4/projects/15685485/trigger/pipeline only: - refs: - - develop + variables: + - $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME changes: - "docs/**/*" -# Supposed to fail when translations are outdated, instead always passes -# -# i18n: -# stage: build -# script: yarn manage:translations -# variables: -# NODE_ENV: development -# before_script: -# - yarn -# - yarn build - review: stage: deploy environment: @@ -140,5 +144,33 @@ pages: paths: - public only: - refs: - - develop + variables: + - $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME + +docker: + stage: deploy + image: docker:23.0.0 + services: + - docker:23.0.0-dind + tags: + - dind + # https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df + script: + - echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin + - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG . + - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG + rules: + - if: $CI_COMMIT_TAG + interruptible: false + +release: + stage: release + rules: + - if: $CI_COMMIT_TAG + script: + - npx ts-node ./scripts/do-release.ts + interruptible: false + +include: + - template: Jobs/Dependency-Scanning.gitlab-ci.yml + - template: Security/License-Scanning.gitlab-ci.yml \ No newline at end of file diff --git a/.gitlab/merge_request_templates/BeforeAndAfter.md b/.gitlab/merge_request_templates/BeforeAndAfter.md new file mode 100644 index 000000000..6e457a708 --- /dev/null +++ b/.gitlab/merge_request_templates/BeforeAndAfter.md @@ -0,0 +1,8 @@ +## Summary + + + +## Screenshots (if appropriate): +| Before | After | +| ------ | ----- | +| | | diff --git a/.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/.lintstagedrc.json b/.lintstagedrc.json index 5dcd5926a..97bad7f28 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,5 +1,8 @@ { "*.js": "eslint --cache", + "*.cjs": "eslint --cache", + "*.mjs": "eslint --cache", "*.ts": "eslint --cache", + "*.tsx": "eslint --cache", "app/styles/**/*.scss": "stylelint" } diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 000000000..bb4c1d232 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,43 @@ +import sharedConfig from '../webpack/shared'; + +import type { StorybookConfig } from '@storybook/core-common'; + +const config: StorybookConfig = { + stories: [ + '../stories/**/*.stories.mdx', + '../stories/**/*.stories.@(js|jsx|ts|tsx)' + ], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + 'storybook-react-intl', + { + name: '@storybook/addon-postcss', + options: { + postcssLoaderOptions: { + implementation: require('postcss'), + }, + }, + }, + ], + framework: '@storybook/react', + core: { + builder: '@storybook/builder-webpack5', + }, + webpackFinal: async (config) => { + config.resolve!.alias = { + ...sharedConfig.resolve!.alias, + ...config.resolve!.alias, + }; + + config.resolve!.modules = [ + ...sharedConfig.resolve!.modules!, + ...config.resolve!.modules!, + ]; + + return config; + }, +}; + +export default config; \ No newline at end of file diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 000000000..df2195f0c --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,22 @@ +import '../app/styles/tailwind.css'; +import '../stories/theme.css'; + +import { addDecorator, Story } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import React from 'react'; + +const withProvider = (Story: Story) => ( + +); + +addDecorator(withProvider); + +export const parameters = { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, +}; diff --git a/.stylelintrc.json b/.stylelintrc.json index c8a71a164..1a610d5eb 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,16 +1,22 @@ { - "extends": ["stylelint-config-standard"], - "ignoreFiles": ["app/styles/reset.scss"], - "plugins": ["stylelint-scss"], + "extends": ["stylelint-config-standard-scss"], "rules": { + "alpha-value-notation": null, "at-rule-no-unknown": null, "at-rule-empty-line-before": ["always", { "ignore": ["after-comment", "first-nested", "inside-block", "blockless-after-same-name-blockless", "blockless-after-blockless"] }], + "color-function-notation": null, + "custom-property-pattern": null, + "declaration-block-no-redundant-longhand-properties": null, "declaration-colon-newline-after": null, "declaration-empty-line-before": "never", - "font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free", "OpenDyslexic", "soapbox"] }], + "font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free"] }], + "max-line-length": null, "no-descending-specificity": null, "no-duplicate-selectors": null, - "scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["/tailwind/", "layer"]}], - "no-invalid-position-at-import-rule": null + "no-invalid-position-at-import-rule": null, + "scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["tailwind", "apply", "layer", "config"]}], + "scss/operator-no-unspaced": null, + "selector-class-pattern": null, + "string-quotes": "single" } } diff --git a/.tool-versions b/.tool-versions index f0c37ee48..ab43e6ab2 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs 18.2.0 +nodejs 18.14.0 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..d1762aa9a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "bradlc.vscode-tailwindcss", + "stylelint.vscode-stylelint", + "wix.vscode-import-cost", + "redhat.vscode-yaml" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..1b3f69961 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "css.validate": false, + "editor.insertSpaces": true, + "editor.tabSize": 2, + "files.associations": { + "*.conf.template": "properties" + }, + "files.eol": "\n", + "files.insertFinalNewline": false, + "json.schemas": [ + { + "fileMatch": [".lintstagedrc.json"], + "url": "https://json.schemastore.org/lintstagedrc.schema.json" + }, + { + "fileMatch": ["renovate.json"], + "url": "https://docs.renovatebot.com/renovate-schema.json" + } + ], + "scss.validate": false +} diff --git a/.vscode/soapbox.code-snippets b/.vscode/soapbox.code-snippets new file mode 100644 index 000000000..b31d50ff5 --- /dev/null +++ b/.vscode/soapbox.code-snippets @@ -0,0 +1,58 @@ +{ + // Place your Soapbox 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/CHANGELOG.md b/CHANGELOG.md index b6971b861..a9ac41b44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,175 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +### Changed + +### Fixed +- Posts: fixed emojis being cut off in reactions modal. +- Posts: fix audio player progress bar visibility. + +## [3.2.0] - 2023-02-15 + +### Added +- Admin: redirect the homepage to any URL. +- Compatibility: added compatibility with Friendica. +- Posts: bot badge on statuses from bot accounts. +- Compatibility: improved browser support for older browsers. +- Events: allow to repost events in event menu. +- Profile: Add RSS link to user profiles. +- Reactions: adds support for reacting to chat messages. +- Groups: initial support for groups. +- Profile: add RSS link to user profiles. +- Chats: reset chat message field height after sending a message. +- Admin: allow to manage announcements. + +### Changed +- Chats: improved display of media attachments. +- ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away. +- Posts: increased font size of focused status in threads. +- Posts: let "mute conversation" be clicked from any feed, not just noficiations. +- Posts: display all emoji reactions. +- Reactions: improved UI of reactions on statuses. +- Profile: make verified badge more prominent, overlapping with avatar. + +### Fixed +- Admin: fixed hover card in reports modal shows reporter not reportee +- Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load. +- Chats: don't display "copy" button for messages without text. +- Posts: don't have to click the play button twice for embedded videos. +- index.html: remove `referrer` meta tag so it doesn't conflict with backend's `Referrer-Policy` header. +- Modals: fix media modal automatically switching to video. +- Navigation: profile dropdown erratic behavior. +- Posts: fix posts filtering. + +### Removed +- Admin: single user mode. Now the homepage can be redirected to any URL. + +## [3.1.0] - 2023-01-13 + +### Added +- Compatibility: rudimentary support for Takahē. +- UI: added backdrop blur behind modals. +- Admin: let admins configure media preview for attachment thumbnails. +- Login: accept `?server` param in external login, eg `fe.soapbox.pub/login/external?server=gleasonator.com`. +- Backups: restored Pleroma backups functionality. +- Export: restored "Export data" to CSV. + +### Changed +- Posts: letterbox images to 19:6 again. +- Status Info: moved context (repost, pinned) to improve UX. +- Posts: remove file icon from empty link previews. +- Settings: moved "Import data" under settings. +- Composer: add more descriptive discard confirmation message. + +### Fixed +- Layout: use accent color for "floating action button" (mobile compose button). +- ServiceWorker: don't serve favicon, robots.txt, and others from ServiceWorker. +- Datepicker: correctly default to the current year. +- Scheduled posts: fix page crashing on deleting a scheduled post. +- Events: don't crash when searching for a location. +- Search: fixes an abort error when using the navbar search component. +- Posts: fix monospace font in Markdown code blocks. +- Modals: fix action buttons overflow +- Editing: don't insert edited posts to the top of the feed. +- Editing: don't display edited posts as pending posts. +- Modals: close modal when navigating to a different page. +- Modals: fix "View context" button in media modal. +- Posts: let unauthenticated users to translate posts if allowed by backend. +- Chats: fix jumpy scrollbar. +- Composer: fix alignment of icon in submit button. +- Login: add a border around QR codes. +- Composer: don't display action button in reply indicator. + +## [3.0.0] - 2022-12-25 + +### Added +- Editing: ability to edit posts and view edit history (on Rebased, Pleroma, and Mastodon). +- Events: ability to create, view, and comment on Events (on Rebased). +- Onboarding: display an introduction wizard to newly registered accounts. +- Posts: translate foreign language posts into your native language (on Rebased, Mastodon; if configured by the admin). +- Posts: ability to view quotes of a post (on Rebased). +- Posts: hover the "replying to" line to see a preview card of the parent post. +- Chats: ability to leave a chat (on Rebased, Truth Social). +- Chats: ability to disable chats for yourself. +- Layout: added right-to-left support for Arabic, Hebrew, Persian, and Central Kurdish languages. +- Composer: support custom emoji categories. +- Search: ability to search posts from a specific account (on Pleroma, Rebased). +- Theme: auto-detect system theme by default. +- Profile: remove a specific user from your followers (on Rebased, Mastodon). +- Suggestions: ability to view all suggested profiles. +- Feeds: display suggested accounts in Home feed (optional by admin). +- Compatibility: added compatibility with Truth Social, Fedibird, Pixelfed, Akkoma, and Glitch. +- Developers: added Test feed, Service Worker debugger, and Network Error preview. +- Reports: display server rules in reports. Let users select rule violations when submitting a report. +- Admin: added Theme Editor, a GUI for customizing the color scheme. +- Admin: custom badges. Admins can add non-federating badges to any user's profile (on Rebased, Pleroma). +- Admin: consolidated user dropdown actions (verify/suggest/etc) into a unified "Moderate User" modal. +- i18n: updated translations for Italian, Polish, Arabic, Hebrew, and German. +- Toast: added the ability to dismiss toast notifications. + +### Changed +- UI: the whole UI has been overhauled both inside and out. 97% of the codebase has been rewritten to TypeScript, and a new component library has been introduced with Tailwind CSS. +- Chats: redesigned chats. Includes an improved desktop UI, unified chat widget, expanding textarea, and autosuggestions. +- Lists: ability to edit and delete a list. +- Settings: unified settings under one path with separate sections. +- Posts: changed the thumbs-up icon to a heart. +- Posts: move instance favicon beside username instead of post timestamp. +- Posts: changed the behavior of content warnings. CWs and sensitive media are unified into one design. +- Posts: redesigned interaction counters to use text instead of icons. +- Posts: letterbox images taller than 1:1. +- Profile: overhauled user profiles to be consistent with the rest of the UI. +- Composer: move emoji button alongside other composer buttons, add numerical counter. +- Birthdays: move today's birthdays out of notifications into right sidebar. +- Performance: improve scrolling/navigation between feeds by using a virtual window library. +- Admin: reorganize UI into 3-column layout. +- Admin: include external link to frontend repo for the running commit. +- Toast: redesigned toast notifications. + +### Removed +- Theme: Halloween theme. +- Settings: advanced notification settings. +- Settings: dyslexic mode. +- Settings: demetricator. +- Profile: ability to set and view private notes on an account. +- Feeds: per-feed filters for replies, media, etc. +- Backup and export functionality (for now). +- Posts: hide non-emoji images embedded in post content. + +### Security +- Glitch Social: fixed XSS vulnerability on Glitch Social where custom emojis could be exploited to embed a script tag. + +## [2.0.0] - 2022-05-01 +### Added +- Quote Posting: repost with comment on Fedibird and Rebased. +- Profile: ability to feature other users on your profile (on Rebased, Mastodon). +- Profile: ability to add location to the user's profile (on Rebased, Truth Social). +- Birthdays: ability to add a birthday to your profile (on Rebased, Pleroma). +- Birthdays: support for age-gated registration if configured by the admin (on Rebased, Pleroma). +- Birthdays: display today's birthdays in notifications. +- Notifications: added unread badge to favicon when user has notifications. +- Notifications: display full attachments in notifications instead of links. +- Search: added a dedicated search page with prefilled suggestions. +- Compatibility: improved support for Mastodon, added support for Mitra. +- Ethereum: Metamask sign-in with Mitra. +- i18n: added Shavian alphabet (`en-Shaw`) transliteration. +- i18n: added Icelandic translation. + +### Changed +- Feeds: added gaps between posts in feeds. +- Feeds: automatically load new posts when scrolled to the top of the feed. +- Layout: improved design of top navigation bar. +- Layout: add left sidebar navigation. +- Icons: replaced Fork Awesome icons with Tabler icons. +- Posts: moved mentions out of the post content into an area above the post for replies (on Pleroma and Rebased - Mastodon falls back to the old behavior). +- Composer: use graphical ring counter for character count. + +### Fixed +- Multi-Account: fix switching between profiles on different servers with the same local username. + ## [1.3.0] - 2021-07-02 ### Changed - Layout: show right sidebar on all pages. @@ -211,7 +380,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial beta release. -[Unreleased]: https://gitlab.com/soapbox-pub/soapbox-fe/-/compare/v1.0.0...develop -[Unreleased patch]: https://gitlab.com/soapbox-pub/soapbox-fe/-/compare/v1.0.0...stable/1.0.x -[1.0.0]: https://gitlab.com/soapbox-pub/soapbox-fe/-/compare/v0.9.0...v1.0.0 -[0.9.0]: https://gitlab.com/soapbox-pub/soapbox-fe/-/tags/v0.9.0 +[Unreleased]: https://gitlab.com/soapbox-pub/soapbox/-/compare/v1.0.0...develop +[Unreleased patch]: https://gitlab.com/soapbox-pub/soapbox/-/compare/v1.0.0...stable/1.0.x +[1.0.0]: https://gitlab.com/soapbox-pub/soapbox/-/compare/v0.9.0...v1.0.0 +[0.9.0]: https://gitlab.com/soapbox-pub/soapbox/-/tags/v0.9.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..bfb7c2e48 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:18 as build +WORKDIR /app +COPY package.json . +COPY yarn.lock . +RUN yarn +COPY . . +ARG NODE_ENV=production +RUN yarn build + +FROM nginx:stable-alpine +EXPOSE 5000 +ENV PORT=5000 +ENV FALLBACK_PORT=4444 +ENV BACKEND_URL=http://localhost:4444 +ENV CSP= +COPY installation/docker.conf.template /etc/nginx/templates/default.conf.template +COPY --from=build /app/static /usr/share/nginx/html diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..8d1655db0 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,18 @@ +FROM node:18 + +RUN apt-get update &&\ + apt-get install -y inotify-tools &&\ + # clean up apt + rm -rf /var/lib/apt/lists/* + +WORKDIR /app +ENV NODE_ENV=development + +COPY package.json . +COPY yarn.lock . +RUN yarn + +COPY . . + +ENV DEVSERVER_URL=http://0.0.0.0:3036 +CMD yarn dev \ No newline at end of file diff --git a/README.md b/README.md index 0422c88d5..2504de278 100644 --- a/README.md +++ b/README.md @@ -1,208 +1,91 @@ -# Soapbox FE +![Soapbox Screenshot](soapbox-screenshot.png) -![Soapbox FE Screenshot](soapbox-screenshot.png) +**Soapbox** is customizable open-source software that puts the power of social media in the hands of the people. Feature-rich and hyper-focused on providing a user experience to rival Big Tech, Soapbox is already home to some of the biggest alternative social platforms. -**Soapbox FE** is a frontend for Mastodon and Pleroma with a focus on custom branding and ease of use. -It's part of the [Soapbox](https://soapbox.pub) project. +# On The Fediverse -## Try it out +You may have heard of **Mastodon**. Soapbox builds upon what Mastodon made great to make something even better. -Visit https://fe.soapbox.pub/ and point it to your favorite instance. +You can run **Mastodon+Soapbox**, **Rebased+Soapbox**, and more. -## :rocket: Deploy on Pleroma +Soapbox is the **frontend** (what users see) while Mastodon is the **backend** (data, APIs). You can mix-and-match in the Fediverse ecosystem. -Installing Soapbox FE on an existing Pleroma server is extremely easy. -Just ssh into the server and download a .zip of the latest build: +> 💡 If you're starting a new server, we highly recommend **Rebased+Soapbox**. Rebased is our custom-built backend just for Soapbox, providing important new features such as **quote posting** and **chats**. +> +> See: [Installing Rebased+Soapbox](https://soapbox.pub/install/) -```sh -curl -L https://gitlab.com/soapbox-pub/soapbox-fe/-/jobs/artifacts/v2.0.0/download?job=build-production -o soapbox-fe.zip -``` +# Try It Out -Then unpack it into Pleroma's `instance` directory: +Want to give Soapbox a shot? Here are some suggested servers: -```sh -busybox unzip soapbox-fe.zip -o -d /opt/pleroma/instance -``` +- [gleasonator.com](https://gleasonator.com/) - operated by the lead developer of Soapbox +- [social.teci.world](https://social.teci.world/) - free speech server run by a Soapbox contributor +- [spinster.xyz](https://spinster.xyz/) - one of the largest feminist communities on the internet +- [poa.st](https://poa.st/) - the largest Soapbox server on the network -**That's it!** :tada: -**Soapbox FE is installed.** -The change will take effect immediately, just refresh your browser tab. -It's not necessary to restart the Pleroma service. +Want to use Soapbox against **any existing Mastodon/Pleroma server?** Try: -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). +- [fe.soapbox.pub](https://fe.soapbox.pub) - enter your server's domain name to use Soapbox on any server! -## How does it work? +# 🚀 Starting Your Own Server -Soapbox FE is a [single-page application (SPA)](https://en.wikipedia.org/wiki/Single-page_application) that runs entirely in the browser with JavaScript. +Starting your own server is one of the best ways to have freedom online! We recommend installing **Rebased+Soapbox**. -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). +See here for a detailed setup guide: [Installing Rebased+Soapbox](https://soapbox.pub/install/) -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. +# Adding Soapbox to an Existing Server -# Running locally +Already have a server? No problem — it is still possible to use Soapbox. -To get it running, just clone the repo: +- [Deploying on Pleroma](https://docs.soapbox.pub/frontend/installing/#install-soapbox) +- [Deploying on Mastodon](https://docs.soapbox.pub/frontend/administration/mastodon/) -```sh -git clone https://gitlab.com/soapbox-pub/soapbox-fe.git -cd soapbox-fe -``` +> 💡 If using Pleroma, it's recommended to [upgrade it to Rebased](https://gitlab.com/-/snippets/2411739). This comes with better support and many new features, helping you get the most out of Soapbox. -Ensure that Node.js and Yarn are installed, then install dependencies: +# Developing Soapbox -```sh -yarn -``` +tl;dr — `git clone`, `yarn`, and `yarn dev`. -Finally, run the dev server: +For detailed guides, see these pages: -```sh -yarn dev -``` +1. [Soapbox local development](https://docs.soapbox.pub/frontend/development/running-locally/) +2. [yarn commands](https://docs.soapbox.pub/frontend/development/yarn-commands/) +3. [How it works](https://docs.soapbox.pub/frontend/development/how-it-works/) +4. [Environment variables](https://docs.soapbox.pub/frontend/development/local-config/) +5. [Developing a backend](https://docs.soapbox.pub/frontend/development/developing-backend/) -**That's it!** :tada: +## Contributing -It will serve at `http://localhost:3036` by default. +We welcome contributions to this project. +To contribute, see [Contributing to Soapbox](docs/contributing.md). -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. +Translators can help by providing [translations through Weblate](https://hosted.weblate.org/projects/soapbox-pub/soapbox/). +Native speakers from all around the world are welcome! -### Troubleshooting: `ERROR: NODE_ENV must be set` +# Project Philosophy -Create a `.env` file if you haven't already. +Soapbox was born out of the need to build independent platforms with **a unique identity and brand**. -```sh -cp .env.example .env -``` +This is in contrast to Mastodon's idea, where all servers are called "Mastodon" and use the Mastodon colors and logo. Users won't see the word "Soapbox" throughout the UI, they'll see the name of **your website** and your logo. To facilitate this, Soapbox has a robust customization UI and integrated moderation tools. Large servers are a priority. -And ensure that it contains `NODE_ENV=development`. -Try again. - -## Developing against a live backend - -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. - -## Local Dev Configuration - -The following configuration variables are supported supported in local development. -Edit `.env` to set them. - -All configuration is optional, except `NODE_ENV`. - -#### `NODE_ENV` - -The Node environment. -Soapbox FE checks for the following options: - -- `development` - What you should use while developing Soapbox FE. -- `production` - Use when compiling to deploy to a live server. -- `test` - Use when running automated tests. - -#### `BACKEND_URL` - -URL to the backend server. -Can be http or https, and can include a port. -For https, be sure to also set `PROXY_HTTPS_INSECURE=true`. - -**Default:** `http://localhost:4000` - -#### `PROXY_HTTPS_INSECURE` - -Allows using an HTTPS backend if set to `true`. - -This is needed if `BACKEND_URL` is set to an `https://` value. -[More info](https://stackoverflow.com/a/48624590/8811886). - -**Default:** `false` - -# Yarn Commands - -The following commands are supported. -You must set `NODE_ENV` to use these commands. -To do so, you can add the following line to your `.env` file: - -```sh -NODE_ENV=development -``` - -#### Local dev server -- `yarn dev` - Run the local dev server. - -#### Building -- `yarn build` - Compile without a dev server, into `/static` directory. - -#### Translations -- `yarn manage:translations` - Normalizes translation files. Should always be run after editing i18n strings. - -#### Tests -- `yarn test:all` - Runs all tests and linters. - -- `yarn test` - Runs Jest for frontend unit tests. - -- `yarn lint` - Runs all linters. - -- `yarn lint:js` - Runs only JavaScript linter. - -- `yarn lint:sass` - Runs only SASS linter. - -# 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) - -# Customization - -Soapbox supports customization of the user interface, to allow per instance branding and other features. Current customization features 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 - -Customization details can be found in the [Customization doc](docs/customization.md) +One disadvantage of this approach is that it does not help the software spread. Some of the biggest servers on the network and running Soapbox and people don't even know it! # License & Credits -Soapbox FE is based on [Gab Social](https://code.gab.com/gab/social/gab-social)'s frontend which is in turn based on [Mastodon](https://github.com/tootsuite/mastodon/)'s frontend. +© Alex Gleason & other Soapbox contributors +© Eugen Rochko & other Mastodon contributors +© Trump Media & Technology Group +© Gab AI, Inc. -- `static/sounds/chat.mp3` and `static/sounds/chat.oga` are from [notificationsounds.com](https://notificationsounds.com/notification-sounds/intuition-561) licensed under CC BY 4.0. - -Soapbox FE is free software: you can redistribute it and/or modify +Soapbox is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. -Soapbox FE is distributed in the hope that it will be useful, +Soapbox is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License -along with Soapbox FE. If not, see . +along with Soapbox. If not, see . diff --git a/app.json b/app.json new file mode 100644 index 000000000..bd168fb5a --- /dev/null +++ b/app.json @@ -0,0 +1,7 @@ +{ + "name": "Soapbox", + "description": "Software for the next generation of social media.", + "keywords": ["fediverse"], + "website": "https://soapbox.pub", + "stack": "container" +} diff --git a/app/application.ts b/app/application.ts deleted file mode 100644 index ba0d8f877..000000000 --- a/app/application.ts +++ /dev/null @@ -1,14 +0,0 @@ -import loadPolyfills from './soapbox/load_polyfills'; - -// @ts-ignore -require.context('./images/', true); - -// Load stylesheet -require('react-datepicker/dist/react-datepicker.css'); -require('./styles/application.scss'); - -loadPolyfills().then(() => { - require('./soapbox/main').default(); -}).catch(e => { - console.error(e); -}); diff --git a/app/icons/COPYING.md b/app/assets/icons/COPYING.md similarity index 76% rename from app/icons/COPYING.md rename to app/assets/icons/COPYING.md index 5e84c0b5c..1dcc928d9 100644 --- a/app/icons/COPYING.md +++ b/app/assets/icons/COPYING.md @@ -1,6 +1,5 @@ # Custom icons -- fediverse.svg - Modified from Wikipedia, CC0 - verified.svg - Created by Alex Gleason. CC0 Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg diff --git a/app/icons/verified.svg b/app/assets/icons/verified.svg similarity index 100% rename from app/icons/verified.svg rename to app/assets/icons/verified.svg diff --git a/app/images/audio-placeholder.png b/app/assets/images/audio-placeholder.png similarity index 100% rename from app/images/audio-placeholder.png rename to app/assets/images/audio-placeholder.png diff --git a/app/images/avatar-missing.png b/app/assets/images/avatar-missing.png similarity index 100% rename from app/images/avatar-missing.png rename to app/assets/images/avatar-missing.png diff --git a/app/images/avatar-missing.svg b/app/assets/images/avatar-missing.svg similarity index 100% rename from app/images/avatar-missing.svg rename to app/assets/images/avatar-missing.svg diff --git a/app/images/header-missing.png b/app/assets/images/header-missing.png similarity index 100% rename from app/images/header-missing.png rename to app/assets/images/header-missing.png diff --git a/app/images/soapbox-logo-white.svg b/app/assets/images/soapbox-logo-white.svg similarity index 100% rename from app/images/soapbox-logo-white.svg rename to app/assets/images/soapbox-logo-white.svg diff --git a/app/images/soapbox-logo.svg b/app/assets/images/soapbox-logo.svg similarity index 100% rename from app/images/soapbox-logo.svg rename to app/assets/images/soapbox-logo.svg diff --git a/app/images/video-placeholder.png b/app/assets/images/video-placeholder.png similarity index 100% rename from app/images/video-placeholder.png rename to app/assets/images/video-placeholder.png diff --git a/app/images/void.png b/app/assets/images/void.png similarity index 100% rename from app/images/void.png rename to app/assets/images/void.png diff --git a/app/images/web-push/web-push-icon_expand.png b/app/assets/images/web-push/web-push-icon_expand.png similarity index 100% rename from app/images/web-push/web-push-icon_expand.png rename to app/assets/images/web-push/web-push-icon_expand.png diff --git a/app/images/web-push/web-push-icon_favourite.png b/app/assets/images/web-push/web-push-icon_favourite.png similarity index 100% rename from app/images/web-push/web-push-icon_favourite.png rename to app/assets/images/web-push/web-push-icon_favourite.png diff --git a/app/images/web-push/web-push-icon_reblog.png b/app/assets/images/web-push/web-push-icon_reblog.png similarity index 100% rename from app/images/web-push/web-push-icon_reblog.png rename to app/assets/images/web-push/web-push-icon_reblog.png diff --git a/app/assets/sounds/LICENSE.md b/app/assets/sounds/LICENSE.md new file mode 100644 index 000000000..42d569b40 --- /dev/null +++ b/app/assets/sounds/LICENSE.md @@ -0,0 +1,6 @@ +# Sound licenses + +- `chat.mp3` +- `chat.oga` + +© [notificationsounds.com](https://notificationsounds.com/notification-sounds/intuition-561), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by-sa/4.0/). diff --git a/app/sounds/boop.mp3 b/app/assets/sounds/boop.mp3 similarity index 100% rename from app/sounds/boop.mp3 rename to app/assets/sounds/boop.mp3 diff --git a/app/sounds/boop.ogg b/app/assets/sounds/boop.ogg similarity index 100% rename from app/sounds/boop.ogg rename to app/assets/sounds/boop.ogg diff --git a/app/sounds/chat.mp3 b/app/assets/sounds/chat.mp3 similarity index 100% rename from app/sounds/chat.mp3 rename to app/assets/sounds/chat.mp3 diff --git a/app/sounds/chat.oga b/app/assets/sounds/chat.oga similarity index 100% rename from app/sounds/chat.oga rename to app/assets/sounds/chat.oga diff --git a/app/fonts/OpenDyslexic/LICENSE b/app/fonts/OpenDyslexic/LICENSE deleted file mode 100644 index bb867823f..000000000 --- a/app/fonts/OpenDyslexic/LICENSE +++ /dev/null @@ -1,94 +0,0 @@ -Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es), -with Reserved Font Name OpenDyslexic. -Copyright (c) 12/2012 - 2019 -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/fonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 b/app/fonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 deleted file mode 100644 index aa7bcdea9..000000000 Binary files a/app/fonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 and /dev/null differ diff --git a/app/fonts/OpenDyslexic/OpenDyslexic-Bold.woff2 b/app/fonts/OpenDyslexic/OpenDyslexic-Bold.woff2 deleted file mode 100644 index 2f04ad119..000000000 Binary files a/app/fonts/OpenDyslexic/OpenDyslexic-Bold.woff2 and /dev/null differ diff --git a/app/fonts/OpenDyslexic/OpenDyslexic-Italic.woff2 b/app/fonts/OpenDyslexic/OpenDyslexic-Italic.woff2 deleted file mode 100644 index 00c19082d..000000000 Binary files a/app/fonts/OpenDyslexic/OpenDyslexic-Italic.woff2 and /dev/null differ diff --git a/app/fonts/OpenDyslexic/OpenDyslexic-Regular.woff2 b/app/fonts/OpenDyslexic/OpenDyslexic-Regular.woff2 deleted file mode 100644 index 47e26d82a..000000000 Binary files a/app/fonts/OpenDyslexic/OpenDyslexic-Regular.woff2 and /dev/null differ diff --git a/app/fonts/soapbox/soapbox.eot b/app/fonts/soapbox/soapbox.eot deleted file mode 100644 index d66bb100a..000000000 Binary files a/app/fonts/soapbox/soapbox.eot and /dev/null differ diff --git a/app/fonts/soapbox/soapbox.svg b/app/fonts/soapbox/soapbox.svg deleted file mode 100644 index 20d08a586..000000000 --- a/app/fonts/soapbox/soapbox.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - -Generated by IcoMoon - - - - - - - - \ No newline at end of file diff --git a/app/fonts/soapbox/soapbox.ttf b/app/fonts/soapbox/soapbox.ttf deleted file mode 100644 index df64210fb..000000000 Binary files a/app/fonts/soapbox/soapbox.ttf and /dev/null differ diff --git a/app/fonts/soapbox/soapbox.woff b/app/fonts/soapbox/soapbox.woff deleted file mode 100644 index 1902dbc36..000000000 Binary files a/app/fonts/soapbox/soapbox.woff and /dev/null differ diff --git a/app/icons/fediverse.svg b/app/icons/fediverse.svg deleted file mode 100644 index 4cd3cb938..000000000 --- a/app/icons/fediverse.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/images/halloween/clouds.png b/app/images/halloween/clouds.png deleted file mode 100644 index 29962c104..000000000 Binary files a/app/images/halloween/clouds.png and /dev/null differ diff --git a/app/images/halloween/halloween-emblem.svg b/app/images/halloween/halloween-emblem.svg deleted file mode 100644 index ad23be14c..000000000 --- a/app/images/halloween/halloween-emblem.svg +++ /dev/null @@ -1,311 +0,0 @@ - - - - Flying Witch during Full Moon - - - - - image/svg+xml - - Flying Witch during Full Moon - 2017-10-10 - - - Urs Roesch - - - - - - OpenClipart - - - - - remix+287475 - remix+288242 - remix+170669 - yellow - moon - yellow moon - full moon - moon - witch - cat - silhouette - bat - bats - flying bat - flying witch - black - dark - night - halloween - walpurgis night - walpurgis - - - Flying witch with cat flying during full moon. - - - gnokii - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/images/halloween/spider.svg b/app/images/halloween/spider.svg deleted file mode 100644 index 077b60d65..000000000 --- a/app/images/halloween/spider.svg +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/app/images/halloween/spiderweb.svg b/app/images/halloween/spiderweb.svg deleted file mode 100644 index 16ae81984..000000000 --- a/app/images/halloween/spiderweb.svg +++ /dev/null @@ -1,78 +0,0 @@ - - - - - Realistic spider web - - - - - - - image/svg+xml - - - - - Openclipart - - - Realistic spider web - - - - - - - - - diff --git a/app/images/halloween/starfield.png b/app/images/halloween/starfield.png deleted file mode 100644 index 1e7995895..000000000 Binary files a/app/images/halloween/starfield.png and /dev/null differ diff --git a/app/images/halloween/twinkle.svg b/app/images/halloween/twinkle.svg deleted file mode 100644 index 9869cb094..000000000 --- a/app/images/halloween/twinkle.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - diff --git a/app/images/reticle.png b/app/images/reticle.png deleted file mode 100644 index 1bcb3d261..000000000 Binary files a/app/images/reticle.png and /dev/null differ diff --git a/app/images/sprite-post-functions.png b/app/images/sprite-post-functions.png deleted file mode 100644 index aea7f57ba..000000000 Binary files a/app/images/sprite-post-functions.png and /dev/null differ diff --git a/app/index.ejs b/app/index.ejs index 495eeaff7..e7c9b7330 100644 --- a/app/index.ejs +++ b/app/index.ejs @@ -5,10 +5,8 @@ - - <%= snippets %> diff --git a/app/instance/about.example/index.html b/app/instance/about.example/index.html index 5efb11fc9..6af826f85 100644 --- a/app/instance/about.example/index.html +++ b/app/instance/about.example/index.html @@ -23,6 +23,5 @@

Open Source Software

-

Soapbox is free and open source (FOSS) software that runs atop a Pleroma server

-

The Soapbox repository can be found at Soapbox-fe

-

The Pleroma server repository can be found at Pleroma-be

+

Soapbox is free and open source (FOSS) software.

+

The Soapbox repository can be found at Soapbox

diff --git a/app/soapbox/__fixtures__/akkoma-instance.json b/app/soapbox/__fixtures__/akkoma-instance.json new file mode 100644 index 000000000..a4da0dc94 --- /dev/null +++ b/app/soapbox/__fixtures__/akkoma-instance.json @@ -0,0 +1,105 @@ +{ + "approval_required": false, + "avatar_upload_limit": 2000000, + "background_image": "https://fe.disroot.org/images/city.jpg", + "background_upload_limit": 4000000, + "banner_upload_limit": 4000000, + "description": "FEDIsroot - Federated social network powered by Pleroma (open beta)", + "description_limit": 5000, + "email": "admin@example.lan", + "languages": [ + "en" + ], + "max_toot_chars": 5000, + "pleroma": { + "metadata": { + "account_activation_required": false, + "features": [ + "pleroma_api", + "akkoma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "v2_suggestions", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + "editing", + "media_proxy", + "relay", + "pleroma_emoji_reactions", + "exposable_reactions", + "profile_directory", + "custom_emoji_reactions", + "pleroma:get:main/ostatus" + ], + "federation": { + "enabled": true, + "exclusions": false, + "mrf_hashtag": { + "federated_timeline_removal": [], + "reject": [], + "sensitive": [ + "nsfw" + ] + }, + "mrf_object_age": { + "actions": [ + "delist", + "strip_followers" + ], + "threshold": 604800 + }, + "mrf_policies": [ + "ObjectAgePolicy", + "TagPolicy", + "HashtagPolicy", + "InlineQuotePolicy" + ], + "quarantined_instances": [], + "quarantined_instances_info": { + "quarantined_instances": {} + } + }, + "fields_limits": { + "max_fields": 10, + "max_remote_fields": 20, + "name_length": 512, + "value_length": 2048 + }, + "post_formats": [ + "text/plain", + "text/html", + "text/markdown", + "text/bbcode", + "text/x.misskeymarkdown" + ], + "privileged_staff": false + }, + "stats": { + "mau": 83 + }, + "vapid_public_key": null + }, + "poll_limits": { + "max_expiration": 31536000, + "max_option_chars": 200, + "max_options": 20, + "min_expiration": 0 + }, + "registrations": false, + "stats": { + "domain_count": 6972, + "status_count": 8081, + "user_count": 357 + }, + "thumbnail": "https://fe.disroot.org/instance/thumbnail.jpeg", + "title": "FEDIsroot", + "upload_limit": 16000000, + "uri": "https://fe.disroot.org", + "urls": { + "streaming_api": "wss://fe.disroot.org" + }, + "version": "2.7.2 (compatible; Akkoma 3.3.1-0-gaf90a4e51)" +} 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__/intlMessages.json b/app/soapbox/__fixtures__/intlMessages.json index 361eeffd9..30ce6f35c 100644 --- a/app/soapbox/__fixtures__/intlMessages.json +++ b/app/soapbox/__fixtures__/intlMessages.json @@ -87,7 +87,7 @@ "compose_form.poll.add_option": "Add a choice", "compose_form.poll.duration": "Poll duration", "compose_form.poll.option_placeholder": "Choice {number}", - "compose_form.poll.remove_option": "Remove this choice", + "compose_form.poll.remove_option": "Delete", "compose_form.poll.type.hint": "Click to toggle poll type. Radio button (default) is single. Checkbox is multiple.", "compose_form.publish": "Publish", "compose_form.publish_loud": "{publish}!", @@ -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", @@ -319,6 +319,7 @@ "poll_button.add_poll": "Add a poll", "poll_button.remove_poll": "Remove poll", "preferences.fields.auto_play_gif_label": "Auto-play animated GIFs", + "preferences.fields.auto_play_video_label": "Auto-play videos", "preferences.fields.boost_modal_label": "Show confirmation dialog before reposting", "preferences.fields.delete_modal_label": "Show confirmation dialog before deleting a post", "preferences.fields.demetricator_label": "Use Demetricator", @@ -397,7 +398,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", @@ -565,7 +566,7 @@ "compose_form.poll.add_option": "Add a choice", "compose_form.poll.duration": "Poll duration", "compose_form.poll.option_placeholder": "Choice {number}", - "compose_form.poll.remove_option": "Remove this choice", + "compose_form.poll.remove_option": "Delete", "compose_form.poll.type.hint": "Click to toggle poll type. Radio button (default) is single. Checkbox is multiple.", "compose_form.publish": "Publish", "compose_form.publish_loud": "{publish}!", @@ -721,7 +722,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 +837,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 +879,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/__fixtures__/pleroma-status.json b/app/soapbox/__fixtures__/pleroma-status.json new file mode 100644 index 000000000..69f84afab --- /dev/null +++ b/app/soapbox/__fixtures__/pleroma-status.json @@ -0,0 +1,183 @@ +{ + "account": { + "acct": "alex", + "avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png", + "bot": false, + "created_at": "2020-01-08T01:25:43.000Z", + "display_name": "Alex Gleason", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "followers_count": 2465, + "following_count": 1581, + "fqn": "alex@gleasonator.com", + "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", + "last_status_at": "2022-03-10T18:19:50", + "locked": false, + "note": "I create Fediverse software that empowers people online.

I'm vegan btw

Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "accepts_chat_messages": true, + "also_known_as": [ + "https://mitra.social/users/alex" + ], + "ap_id": "https://gleasonator.com/users/alex", + "background_image": null, + "birthday": "1993-07-03", + "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_confirmed": true, + "is_moderator": false, + "is_suggested": true, + "relationship": {}, + "skip_thread_containment": false, + "tags": [] + }, + "source": { + "fields": [ + { + "name": "Website", + "value": "https://alexgleason.me" + }, + { + "name": "Soapbox", + "value": "https://soapbox.pub" + }, + { + "name": "Email", + "value": "alex@alexgleason.me" + }, + { + "name": "Gender identity", + "value": "Soyboy" + }, + { + "name": "Donate (PayPal)", + "value": "https://paypal.me/gleasonator" + }, + { + "name": "$BTC", + "value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n" + }, + { + "name": "$ETH", + "value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717" + }, + { + "name": "$DOGE", + "value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D" + }, + { + "name": "$XMR", + "value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK" + } + ], + "note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.", + "pleroma": { + "actor_type": "Person", + "discoverable": false + }, + "sensitive": false + }, + "statuses_count": 23648, + "url": "https://gleasonator.com/users/alex", + "username": "alex" + }, + "application": null, + "bookmarked": false, + "card": null, + "content": "

Good morning! Hope you have a wonderful day.

", + "created_at": "2020-03-23T19:33:06.000Z", + "emojis": [], + "favourited": false, + "favourites_count": 49, + "id": "103874034845713213", + "in_reply_to_account_id": null, + "in_reply_to_id": null, + "language": null, + "media_attachments": [], + "mentions": [], + "muted": false, + "pinned": true, + "pleroma": { + "content": { + "text/plain": "What is tolerance?" + }, + "conversation_id": "3023268", + "direct_conversation_id": null, + "emoji_reactions": [ + { + "count": 3, + "me": false, + "name": "❤️" + } + ], + "expires_at": null, + "in_reply_to_account_acct": null, + "local": true, + "parent_visible": false, + "pinned_at": "2021-11-23T01:38:44.000Z", + "quote": null, + "quote_url": null, + "quote_visible": false, + "spoiler_text": { + "text/plain": "" + }, + "thread_muted": false + }, + "poll": null, + "reblog": null, + "reblogged": false, + "reblogs_count": 27, + "replies_count": 15, + "sensitive": false, + "spoiler_text": "", + "tags": [], + "text": null, + "uri": "https://gleasonator.com/users/alex/statuses/103874034847713213", + "url": "https://gleasonator.com/notice/103874034847713213", + "visibility": "public" +} diff --git a/app/soapbox/__fixtures__/status-quotes.json b/app/soapbox/__fixtures__/status-quotes.json new file mode 100644 index 000000000..d74a149c9 --- /dev/null +++ b/app/soapbox/__fixtures__/status-quotes.json @@ -0,0 +1,15 @@ +[ + { + "account": { + "id": "ABDSjI3Q0R8aDaz1U0" + }, + "content": "quoast", + "id": "AJsajx9hY4Q7IKQXEe", + "pleroma": { + "quote": { + "content": "

10

", + "id": "AJmoVikzI3SkyITyim" + } + } + } +] diff --git a/app/soapbox/__fixtures__/truthsocial-status-in-moderation.json b/app/soapbox/__fixtures__/truthsocial-status-in-moderation.json new file mode 100644 index 000000000..7613ebd93 --- /dev/null +++ b/app/soapbox/__fixtures__/truthsocial-status-in-moderation.json @@ -0,0 +1,85 @@ +{ + "id": "108046224464672537", + "created_at": "2022-03-30T15:40:53.287Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "self", + "language": null, + "uri": "https://truthsocial.com/users/alex/statuses/108046244464677537", + "url": "https://truthsocial.com/@alex/108046244464677537", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "content": "

A federal agent inspects a 'lumber' truck after smelling alcohol during the prohibition period. Los Angeles, 1926 (during the Prohibition era).

", + "reblog": null, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "account": { + "id": "107759994408336377", + "username": "alex", + "acct": "alex", + "display_name": "Alex Gleason", + "locked": false, + "bot": false, + "discoverable": null, + "group": false, + "created_at": "2022-02-08T00:00:00.000Z", + "note": "

Launching Truth Social

", + "url": "https://truthsocial.com/@alex", + "avatar": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/avatars/107/759/994/408/336/377/original/119cb0dd1fa615b7.png", + "avatar_static": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/avatars/107/759/994/408/336/377/original/119cb0dd1fa615b7.png", + "header": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/headers/107/759/994/408/336/377/original/31f62b0453ccf554.png", + "header_static": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/headers/107/759/994/408/336/377/original/31f62b0453ccf554.png", + "followers_count": 4713, + "following_count": 43, + "statuses_count": 7, + "last_status_at": "2022-03-30", + "verified": true, + "location": "Texas", + "website": "https://soapbox.pub/", + "emojis": [], + "fields": [] + }, + "media_attachments": [ + { + "id": "108635651287436632", + "type": "image", + "url": "https://static-assets-1.truthsocial.com/tmtg:prime-ts-assets/media_attachments/files/108/635/651/487/436/632/original/7873bda5a7ab45d3.jpeg", + "preview_url": "https://static-assets-1.truthsocial.com/tmtg:prime-ts-assets/media_attachments/files/108/635/651/487/436/632/small/7873bda5a7ab45d3.jpeg", + "external_video_id": null, + "remote_url": null, + "preview_remote_url": null, + "text_url": "https://truthsocial.com/media/_Kc-2w2Pe7knhYJV-CM", + "meta": { + "original": { + "width": 1080, + "height": 841, + "size": "1080x841", + "aspect": 1.2841854934601664 + }, + "small": { + "width": 907, + "height": 706, + "size": "907x706", + "aspect": 1.2847025495750708 + } + }, + "description": null, + "blurhash": "UIIY5?4n~q9FIUIUD%WB?bt7M{t7of%MofIU" + } + ], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null +} \ No newline at end of file diff --git a/app/soapbox/__tests__/toast.test.tsx b/app/soapbox/__tests__/toast.test.tsx new file mode 100644 index 000000000..4c38755e2 --- /dev/null +++ b/app/soapbox/__tests__/toast.test.tsx @@ -0,0 +1,166 @@ +import { render } from '@testing-library/react'; +import { AxiosError } from 'axios'; +import React from 'react'; +import { IntlProvider } from 'react-intl'; + +import { act, screen } from 'soapbox/jest/test-helpers'; + +function renderApp() { + const { Toaster } = require('react-hot-toast'); + const toast = require('../toast').default; + + return { + toast, + ...render( + + , + , + ), + }; +} + +beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + (console.error as any).mockClear(); +}); + +afterAll(() => { + (console.error as any).mockRestore(); +}); + +describe('toasts', () =>{ + it('renders successfully', async() => { + const { toast } = renderApp(); + + act(() => { + toast.success('hello'); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent('hello'); + }); + + describe('actionable button', () => { + it('renders the button', async() => { + const { toast } = renderApp(); + + act(() => { + toast.success('hello', { action: () => null, actionLabel: 'click me' }); + }); + + expect(screen.getByTestId('toast-action')).toHaveTextContent('click me'); + }); + + it('does not render the button', async() => { + const { toast } = renderApp(); + + act(() => { + toast.success('hello'); + }); + + expect(screen.queryAllByTestId('toast-action')).toHaveLength(0); + }); + }); + + describe('showAlertForError()', () => { + const buildError = (message: string, status: number) => new AxiosError(message, String(status), undefined, null, { + data: { + error: message, + }, + statusText: String(status), + status, + headers: {}, + config: {}, + }); + + describe('with a 502 status code', () => { + it('renders the correct message', async() => { + const message = 'The server is down'; + const error = buildError(message, 502); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent('The server is down'); + }); + }); + + describe('with a 404 status code', () => { + it('renders the correct message', async() => { + const error = buildError('', 404); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.queryAllByTestId('toast')).toHaveLength(0); + }); + }); + + describe('with a 410 status code', () => { + it('renders the correct message', async() => { + const error = buildError('', 410); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.queryAllByTestId('toast')).toHaveLength(0); + }); + }); + + describe('with an accepted status code', () => { + describe('with a message from the server', () => { + it('renders the correct message', async() => { + const message = 'custom message'; + const error = buildError(message, 200); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent(message); + }); + }); + + describe('without a message from the server', () => { + it('renders the correct message', async() => { + const message = 'The request has been accepted for processing'; + const error = buildError(message, 202); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent(message); + }); + }); + }); + + describe('without a response', () => { + it('renders the default message', async() => { + const error = new AxiosError(); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent('An unexpected error occurred.'); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/actions/__tests__/account-notes.test.ts b/app/soapbox/actions/__tests__/account-notes.test.ts index 61e0c20b0..8b85eecc5 100644 --- a/app/soapbox/actions/__tests__/account-notes.test.ts +++ b/app/soapbox/actions/__tests__/account-notes.test.ts @@ -2,7 +2,7 @@ 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 { ReducerRecord, EditRecord } from 'soapbox/reducers/account-notes'; import { normalizeAccount, normalizeRelationship } from '../../normalizers'; import { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes'; diff --git a/app/soapbox/actions/__tests__/accounts.test.ts b/app/soapbox/actions/__tests__/accounts.test.ts index 0793e36f7..d9faa0213 100644 --- a/app/soapbox/actions/__tests__/accounts.test.ts +++ b/app/soapbox/actions/__tests__/accounts.test.ts @@ -2,7 +2,7 @@ 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 { ListRecord, ReducerRecord } from 'soapbox/reducers/user-lists'; import { normalizeAccount, normalizeInstance, normalizeRelationship } from '../../normalizers'; import { diff --git a/app/soapbox/actions/__tests__/alerts.test.ts b/app/soapbox/actions/__tests__/alerts.test.ts deleted file mode 100644 index 5f1f9f4d6..000000000 --- a/app/soapbox/actions/__tests__/alerts.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { AxiosError } from 'axios'; - -import { mockStore, rootState } from 'soapbox/jest/test-helpers'; - -import { dismissAlert, showAlert, showAlertForError } from '../alerts'; - -const buildError = (message: string, status: number) => new AxiosError(message, String(status), undefined, null, { - data: { - error: message, - }, - statusText: String(status), - status, - headers: {}, - config: {}, -}); - -let store: ReturnType; - -beforeEach(() => { - const state = rootState; - store = mockStore(state); -}); - -describe('dismissAlert()', () => { - it('dispatches the proper actions', async() => { - const alert = 'hello world'; - const expectedActions = [ - { type: 'ALERT_DISMISS', alert }, - ]; - await store.dispatch(dismissAlert(alert as any)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); -}); - -describe('showAlert()', () => { - it('dispatches the proper actions', async() => { - const title = 'title'; - const message = 'msg'; - const severity = 'info'; - const expectedActions = [ - { type: 'ALERT_SHOW', title, message, severity }, - ]; - await store.dispatch(showAlert(title, message, severity)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); -}); - -describe('showAlert()', () => { - describe('with a 502 status code', () => { - it('dispatches the proper actions', async() => { - const message = 'The server is down'; - const error = buildError(message, 502); - - const expectedActions = [ - { type: 'ALERT_SHOW', title: '', message, severity: 'error' }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('with a 404 status code', () => { - it('dispatches the proper actions', async() => { - const error = buildError('', 404); - - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual([]); - }); - }); - - describe('with a 410 status code', () => { - it('dispatches the proper actions', async() => { - const error = buildError('', 410); - - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual([]); - }); - }); - - describe('with an accepted status code', () => { - describe('with a message from the server', () => { - it('dispatches the proper actions', async() => { - const message = 'custom message'; - const error = buildError(message, 200); - - const expectedActions = [ - { type: 'ALERT_SHOW', title: '', message, severity: 'error' }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('without a message from the server', () => { - it('dispatches the proper actions', async() => { - const message = 'The request has been accepted for processing'; - const error = buildError(message, 202); - - const expectedActions = [ - { type: 'ALERT_SHOW', title: '', message, severity: 'error' }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - }); - - describe('without a response', () => { - it('dispatches the proper actions', async() => { - const error = new AxiosError(); - - const expectedActions = [ - { - type: 'ALERT_SHOW', - title: { - defaultMessage: 'Oops!', - id: 'alert.unexpected.title', - }, - message: { - defaultMessage: 'An unexpected error occurred.', - id: 'alert.unexpected.message', - }, - severity: 'error', - }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); diff --git a/app/soapbox/actions/__tests__/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 index 8b4c040b3..49f649ab6 100644 --- a/app/soapbox/actions/__tests__/blocks.test.ts +++ b/app/soapbox/actions/__tests__/blocks.test.ts @@ -1,6 +1,6 @@ import { __stub } from 'soapbox/api'; import { mockStore, rootState } from 'soapbox/jest/test-helpers'; -import { ListRecord, ReducerRecord as UserListsRecord } from 'soapbox/reducers/user_lists'; +import { ListRecord, ReducerRecord as UserListsRecord } from 'soapbox/reducers/user-lists'; import { expandBlocks, fetchBlocks } from '../blocks'; diff --git a/app/soapbox/actions/__tests__/carousels.test.ts b/app/soapbox/actions/__tests__/carousels.test.ts deleted file mode 100644 index 44e4ff0c0..000000000 --- a/app/soapbox/actions/__tests__/carousels.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { __stub } from 'soapbox/api'; -import { mockStore, rootState } from 'soapbox/jest/test-helpers'; - -import { fetchCarouselAvatars } from '../carousels'; - -describe('fetchCarouselAvatars()', () => { - let store: ReturnType; - - beforeEach(() => { - store = mockStore(rootState); - }); - - describe('with a successful API request', () => { - let avatars: Record[]; - - beforeEach(() => { - avatars = [ - { account_id: '1', acct: 'jl', account_avatar: 'https://example.com/some.jpg' }, - ]; - - __stub((mock) => { - mock.onGet('/api/v1/truth/carousels/avatars').reply(200, avatars); - }); - }); - - it('should fetch the users from the API', async() => { - const expectedActions = [ - { type: 'CAROUSEL_AVATAR_REQUEST' }, - { type: 'CAROUSEL_AVATAR_SUCCESS', payload: avatars }, - ]; - - await store.dispatch(fetchCarouselAvatars()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('with an unsuccessful API request', () => { - beforeEach(() => { - __stub((mock) => { - mock.onGet('/api/v1/truth/carousels/avatars').networkError(); - }); - }); - - it('should dispatch failed action', async() => { - const expectedActions = [ - { type: 'CAROUSEL_AVATAR_REQUEST' }, - { type: 'CAROUSEL_AVATAR_FAIL' }, - ]; - - await store.dispatch(fetchCarouselAvatars()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); diff --git a/app/soapbox/actions/__tests__/compose.test.ts b/app/soapbox/actions/__tests__/compose.test.ts index 6a9ac6e43..58f83e537 100644 --- a/app/soapbox/actions/__tests__/compose.test.ts +++ b/app/soapbox/actions/__tests__/compose.test.ts @@ -1,9 +1,11 @@ -import { Map as ImmutableMap } from 'immutable'; +import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; import { mockStore, rootState } from 'soapbox/jest/test-helpers'; import { InstanceRecord } from 'soapbox/normalizers'; +import { ReducerCompose } from 'soapbox/reducers/compose'; -import { uploadCompose } from '../compose'; +import { uploadCompose, submitCompose } from '../compose'; +import { STATUS_CREATE_REQUEST } from '../statuses'; import type { IntlShape } from 'react-intl'; @@ -25,7 +27,8 @@ describe('uploadCompose()', () => { const state = rootState .set('me', '1234') - .set('instance', instance); + .set('instance', instance) + .setIn(['compose', 'home'], ReducerCompose()); store = mockStore(state); files = [{ @@ -42,18 +45,11 @@ describe('uploadCompose()', () => { } as unknown as IntlShape; const expectedActions = [ - { type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true }, - { - type: 'ALERT_SHOW', - message: 'Image exceeds the current file size limit (10 Bytes)', - actionLabel: undefined, - actionLink: undefined, - severity: 'error', - }, - { type: 'COMPOSE_UPLOAD_FAIL', error: true, skipLoading: true }, + { type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true }, + { type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true }, ]; - await store.dispatch(uploadCompose(files, mockIntl)); + await store.dispatch(uploadCompose('home', files, mockIntl)); const actions = store.getActions(); expect(actions).toEqual(expectedActions); @@ -77,7 +73,8 @@ describe('uploadCompose()', () => { const state = rootState .set('me', '1234') - .set('instance', instance); + .set('instance', instance) + .setIn(['compose', 'home'], ReducerCompose()); store = mockStore(state); files = [{ @@ -94,21 +91,37 @@ describe('uploadCompose()', () => { } as unknown as IntlShape; const expectedActions = [ - { type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true }, - { - type: 'ALERT_SHOW', - message: 'Video exceeds the current file size limit (10 Bytes)', - actionLabel: undefined, - actionLink: undefined, - severity: 'error', - }, - { type: 'COMPOSE_UPLOAD_FAIL', error: true, skipLoading: true }, + { type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true }, + { type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true }, ]; - await store.dispatch(uploadCompose(files, mockIntl)); + await store.dispatch(uploadCompose('home', files, mockIntl)); const actions = store.getActions(); expect(actions).toEqual(expectedActions); }); }); }); + +describe('submitCompose()', () => { + it('inserts mentions from text', async() => { + const state = rootState + .set('me', '123') + .setIn(['compose', 'home'], ReducerCompose({ text: '@alex hello @mkljczk@pl.fediverse.pl @gg@汉语/漢語.com alex@alexgleason.me' })); + + const store = mockStore(state); + await store.dispatch(submitCompose('home')); + 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..d4dc1d31f --- /dev/null +++ b/app/soapbox/actions/__tests__/me.test.ts @@ -0,0 +1,117 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { AccountRecord } from 'soapbox/normalizers'; + +import { AuthUserRecord, ReducerRecord } from '../../reducers/auth'; +import { + fetchMe, patchMe, +} from '../me'; + +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', ReducerRecord({ + me: accountUrl, + users: ImmutableMap({ + [accountUrl]: AuthUserRecord({ + 'access_token': token, + }), + }), + })) + .set('accounts', ImmutableMap({ + [accountUrl]: AccountRecord({ + 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); + }); + }); +}); 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__/status-quotes.test.ts b/app/soapbox/actions/__tests__/status-quotes.test.ts new file mode 100644 index 000000000..1e68dc882 --- /dev/null +++ b/app/soapbox/actions/__tests__/status-quotes.test.ts @@ -0,0 +1,150 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { StatusListRecord } from 'soapbox/reducers/status-lists'; + +import { fetchStatusQuotes, expandStatusQuotes } from '../status-quotes'; + +const status = { + account: { + id: 'ABDSjI3Q0R8aDaz1U0', + }, + content: 'quoast', + id: 'AJsajx9hY4Q7IKQXEe', + pleroma: { + quote: { + content: '

10

', + id: 'AJmoVikzI3SkyITyim', + }, + }, +}; + +const statusId = 'AJmoVikzI3SkyITyim'; + +describe('fetchStatusQuotes()', () => { + let store: ReturnType; + + beforeEach(() => { + const state = rootState.set('me', '1234'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + const quotes = require('soapbox/__fixtures__/status-quotes.json'); + + __stub((mock) => { + mock.onGet(`/api/v1/pleroma/statuses/${statusId}/quotes`).reply(200, quotes, { + link: `; rel='prev'`, + }); + }); + }); + + it('should fetch quotes from the API', async() => { + const expectedActions = [ + { type: 'STATUS_QUOTES_FETCH_REQUEST', statusId }, + { type: 'POLLS_IMPORT', polls: [] }, + { type: 'ACCOUNTS_IMPORT', accounts: [status.account] }, + { type: 'STATUSES_IMPORT', statuses: [status], expandSpoilers: false }, + { type: 'STATUS_QUOTES_FETCH_SUCCESS', statusId, statuses: [status], next: null }, + ]; + await store.dispatch(fetchStatusQuotes(statusId)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/pleroma/statuses/${statusId}/quotes`).networkError(); + }); + }); + + it('should dispatch failed action', async() => { + const expectedActions = [ + { type: 'STATUS_QUOTES_FETCH_REQUEST', statusId }, + { type: 'STATUS_QUOTES_FETCH_FAIL', statusId, error: new Error('Network Error') }, + ]; + await store.dispatch(fetchStatusQuotes(statusId)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); + +describe('expandStatusQuotes()', () => { + let store: ReturnType; + + describe('without a url', () => { + beforeEach(() => { + const state = rootState + .set('me', '1234') + .set('status_lists', ImmutableMap({ [`quotes:${statusId}`]: StatusListRecord({ next: null }) })); + store = mockStore(state); + }); + + it('should do nothing', async() => { + await store.dispatch(expandStatusQuotes(statusId)); + const actions = store.getActions(); + + expect(actions).toEqual([]); + }); + }); + + describe('with a url', () => { + beforeEach(() => { + const state = rootState.set('me', '1234') + .set('status_lists', ImmutableMap({ [`quotes:${statusId}`]: StatusListRecord({ next: 'example' }) })); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + beforeEach(() => { + const quotes = require('soapbox/__fixtures__/status-quotes.json'); + + __stub((mock) => { + mock.onGet('example').reply(200, quotes, { + link: `; rel='prev'`, + }); + }); + }); + + it('should fetch quotes from the API', async() => { + const expectedActions = [ + { type: 'STATUS_QUOTES_EXPAND_REQUEST', statusId }, + { type: 'POLLS_IMPORT', polls: [] }, + { type: 'ACCOUNTS_IMPORT', accounts: [status.account] }, + { type: 'STATUSES_IMPORT', statuses: [status], expandSpoilers: false }, + { type: 'STATUS_QUOTES_EXPAND_SUCCESS', statusId, statuses: [status], next: null }, + ]; + await store.dispatch(expandStatusQuotes(statusId)); + 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: 'STATUS_QUOTES_EXPAND_REQUEST', statusId }, + { type: 'STATUS_QUOTES_EXPAND_FAIL', statusId, error: new Error('Network Error') }, + ]; + await store.dispatch(expandStatusQuotes(statusId)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); diff --git a/app/soapbox/actions/__tests__/statuses.test.ts b/app/soapbox/actions/__tests__/statuses.test.ts index 18cbc173b..68af7608f 100644 --- a/app/soapbox/actions/__tests__/statuses.test.ts +++ b/app/soapbox/actions/__tests__/statuses.test.ts @@ -121,6 +121,7 @@ describe('deleteStatus()', () => { version: '0.0.0', }, withRedraft: true, + id: 'compose-modal', }, { type: 'MODAL_OPEN', modalType: 'COMPOSE', modalProps: undefined }, ]; 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/accounts.ts b/app/soapbox/actions/accounts.ts index 31c5c6717..a389e29cd 100644 --- a/app/soapbox/actions/accounts.ts +++ b/app/soapbox/actions/accounts.ts @@ -1,5 +1,5 @@ import { isLoggedIn } from 'soapbox/utils/auth'; -import { getFeatures } from 'soapbox/utils/features'; +import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features'; import api, { getLinks } from '../api'; @@ -10,10 +10,10 @@ import { } from './importer'; import type { AxiosError, CancelToken } from 'axios'; -import type { History } from 'history'; import type { Map as ImmutableMap } from 'immutable'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { APIEntity, Status } from 'soapbox/types/entities'; +import type { History } from 'soapbox/types/history'; const ACCOUNT_CREATE_REQUEST = 'ACCOUNT_CREATE_REQUEST'; const ACCOUNT_CREATE_SUCCESS = 'ACCOUNT_CREATE_SUCCESS'; @@ -130,6 +130,8 @@ const maybeRedirectLogin = (error: AxiosError, history?: History) => { } }; +const noOp = () => new Promise(f => f(undefined)); + const createAccount = (params: Record) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: ACCOUNT_CREATE_REQUEST, params }); @@ -225,7 +227,12 @@ const fetchAccountFail = (id: string | null, error: AxiosError) => ({ skipAlert: true, }); -const followAccount = (id: string, options = { reblogs: true }) => +type FollowAccountOpts = { + reblogs?: boolean + notify?: boolean +}; + +const followAccount = (id: string, options?: FollowAccountOpts) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return null; @@ -352,14 +359,30 @@ const unblockAccountFail = (error: AxiosError) => ({ error, }); -const muteAccount = (id: string, notifications?: boolean) => +const muteAccount = (id: string, notifications?: boolean, duration = 0) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return null; dispatch(muteAccountRequest(id)); + const params: Record = { + notifications, + }; + + if (duration) { + const state = getState(); + const instance = state.instance; + const v = parseVersion(instance.version); + + if (v.software === PLEROMA) { + params.expires_in = duration; + } else { + params.duration = duration; + } + } + return api(getState) - .post(`/api/v1/accounts/${id}/mute`, { notifications }) + .post(`/api/v1/accounts/${id}/mute`, params) .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)); @@ -815,11 +838,11 @@ const rejectFollowRequestFail = (id: string, error: AxiosError) => ({ const pinAccount = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; + if (!isLoggedIn(getState)) return dispatch(noOp); dispatch(pinAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => { + return api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => { dispatch(pinAccountSuccess(response.data)); }).catch(error => { dispatch(pinAccountFail(error)); @@ -828,11 +851,11 @@ const pinAccount = (id: string) => const unpinAccount = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; + if (!isLoggedIn(getState)) return dispatch(noOp); dispatch(unpinAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => { + return api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => { dispatch(unpinAccountSuccess(response.data)); }).catch(error => { dispatch(unpinAccountFail(error)); @@ -944,7 +967,7 @@ const fetchBirthdayReminders = (month: number, day: number) => dispatch({ type: BIRTHDAY_REMINDERS_FETCH_REQUEST, day, month, id: me }); - api(getState).get('/api/v1/pleroma/birthdays', { params: { day, month } }).then(response => { + return api(getState).get('/api/v1/pleroma/birthdays', { params: { day, month } }).then(response => { dispatch(importFetchedAccounts(response.data)); dispatch({ type: BIRTHDAY_REMINDERS_FETCH_SUCCESS, diff --git a/app/soapbox/actions/admin.ts b/app/soapbox/actions/admin.ts index 660b52dce..52be03ac2 100644 --- a/app/soapbox/actions/admin.ts +++ b/app/soapbox/actions/admin.ts @@ -1,12 +1,18 @@ +import { defineMessages } from 'react-intl'; + import { fetchRelationships } from 'soapbox/actions/accounts'; import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer'; +import toast from 'soapbox/toast'; +import { filterBadges, getTagDiff } from 'soapbox/utils/badges'; import { getFeatures } from 'soapbox/utils/features'; import api, { getLinks } from '../api'; +import { openModal } from './modals'; + import type { AxiosResponse } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; -import type { APIEntity } from 'soapbox/types/entities'; +import type { APIEntity, Announcement } from 'soapbox/types/entities'; const ADMIN_CONFIG_FETCH_REQUEST = 'ADMIN_CONFIG_FETCH_REQUEST'; const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS'; @@ -76,6 +82,45 @@ const ADMIN_USERS_UNSUGGEST_REQUEST = 'ADMIN_USERS_UNSUGGEST_REQUEST'; const ADMIN_USERS_UNSUGGEST_SUCCESS = 'ADMIN_USERS_UNSUGGEST_SUCCESS'; const ADMIN_USERS_UNSUGGEST_FAIL = 'ADMIN_USERS_UNSUGGEST_FAIL'; +const ADMIN_USER_INDEX_EXPAND_FAIL = 'ADMIN_USER_INDEX_EXPAND_FAIL'; +const ADMIN_USER_INDEX_EXPAND_REQUEST = 'ADMIN_USER_INDEX_EXPAND_REQUEST'; +const ADMIN_USER_INDEX_EXPAND_SUCCESS = 'ADMIN_USER_INDEX_EXPAND_SUCCESS'; + +const ADMIN_USER_INDEX_FETCH_FAIL = 'ADMIN_USER_INDEX_FETCH_FAIL'; +const ADMIN_USER_INDEX_FETCH_REQUEST = 'ADMIN_USER_INDEX_FETCH_REQUEST'; +const ADMIN_USER_INDEX_FETCH_SUCCESS = 'ADMIN_USER_INDEX_FETCH_SUCCESS'; + +const ADMIN_USER_INDEX_QUERY_SET = 'ADMIN_USER_INDEX_QUERY_SET'; + +const ADMIN_ANNOUNCEMENTS_FETCH_FAIL = 'ADMIN_ANNOUNCEMENTS_FETCH_FAILS'; +const ADMIN_ANNOUNCEMENTS_FETCH_REQUEST = 'ADMIN_ANNOUNCEMENTS_FETCH_REQUEST'; +const ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS = 'ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS'; + +const ADMIN_ANNOUNCEMENTS_EXPAND_FAIL = 'ADMIN_ANNOUNCEMENTS_EXPAND_FAILS'; +const ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST = 'ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST'; +const ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS = 'ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS'; + +const ADMIN_ANNOUNCEMENT_CHANGE_CONTENT = 'ADMIN_ANNOUNCEMENT_CHANGE_CONTENT'; +const ADMIN_ANNOUNCEMENT_CHANGE_START_TIME = 'ADMIN_ANNOUNCEMENT_CHANGE_START_TIME'; +const ADMIN_ANNOUNCEMENT_CHANGE_END_TIME = 'ADMIN_ANNOUNCEMENT_CHANGE_END_TIME'; +const ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY = 'ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY'; + +const ADMIN_ANNOUNCEMENT_CREATE_REQUEST = 'ADMIN_ANNOUNCEMENT_CREATE_REQUEST'; +const ADMIN_ANNOUNCEMENT_CREATE_SUCCESS = 'ADMIN_ANNOUNCEMENT_CREATE_REQUEST'; +const ADMIN_ANNOUNCEMENT_CREATE_FAIL = 'ADMIN_ANNOUNCEMENT_CREATE_FAIL'; + +const ADMIN_ANNOUNCEMENT_DELETE_REQUEST = 'ADMIN_ANNOUNCEMENT_DELETE_REQUEST'; +const ADMIN_ANNOUNCEMENT_DELETE_SUCCESS = 'ADMIN_ANNOUNCEMENT_DELETE_REQUEST'; +const ADMIN_ANNOUNCEMENT_DELETE_FAIL = 'ADMIN_ANNOUNCEMENT_DELETE_FAIL'; + +const ADMIN_ANNOUNCEMENT_MODAL_INIT = 'ADMIN_ANNOUNCEMENT_MODAL_INIT'; + +const messages = defineMessages({ + announcementCreateSuccess: { id: 'admin.edit_announcement.created', defaultMessage: 'Announcement created' }, + announcementDeleteSuccess: { id: 'admin.edit_announcement.deleted', defaultMessage: 'Announcement deleted' }, + announcementUpdateSuccess: { id: 'admin.edit_announcement.updated', defaultMessage: 'Announcement edited' }, +}); + const nicknamesFromIds = (getState: () => RootState, ids: string[]) => ids.map(id => getState().accounts.get(id)!.acct); const fetchConfig = () => @@ -102,6 +147,19 @@ const updateConfig = (configs: Record[]) => }); }; +const updateSoapboxConfig = (data: Record) => + (dispatch: AppDispatch, _getState: () => RootState) => { + const params = [{ + group: ':pleroma', + key: ':frontend_configurations', + value: [{ + tuple: [':soapbox_fe', data], + }], + }]; + + return dispatch(updateConfig(params)); + }; + const fetchMastodonReports = (params: Record) => (dispatch: AppDispatch, getState: () => RootState) => api(getState) @@ -403,6 +461,12 @@ const tagUsers = (accountIds: string[], tags: string[]) => const untagUsers = (accountIds: string[], tags: string[]) => (dispatch: AppDispatch, getState: () => RootState) => { const nicknames = nicknamesFromIds(getState, accountIds); + + // Legacy: allow removing legacy 'donor' tags. + if (tags.includes('badge:donor')) { + tags = [...tags, 'donor']; + } + dispatch({ type: ADMIN_USERS_UNTAG_REQUEST, accountIds, tags }); return api(getState) .delete('/api/v1/pleroma/admin/users/tag', { data: { nicknames, tags } }) @@ -413,6 +477,24 @@ const untagUsers = (accountIds: string[], tags: string[]) => }); }; +/** Synchronizes user tags to the backend. */ +const setTags = (accountId: string, oldTags: string[], newTags: string[]) => + async(dispatch: AppDispatch) => { + const diff = getTagDiff(oldTags, newTags); + + await dispatch(tagUsers([accountId], diff.added)); + await dispatch(untagUsers([accountId], diff.removed)); + }; + +/** Synchronizes badges to the backend. */ +const setBadges = (accountId: string, oldTags: string[], newTags: string[]) => + (dispatch: AppDispatch) => { + const oldBadges = filterBadges(oldTags); + const newBadges = filterBadges(newTags); + + return dispatch(setTags(accountId, oldBadges, newBadges)); + }; + const verifyUser = (accountId: string) => (dispatch: AppDispatch) => dispatch(tagUsers([accountId], ['verified'])); @@ -421,14 +503,6 @@ 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); @@ -476,6 +550,18 @@ const demoteToUser = (accountId: string) => dispatch(removePermission([accountId], 'moderator')), ]); +const setRole = (accountId: string, role: 'user' | 'moderator' | 'admin') => + (dispatch: AppDispatch) => { + switch (role) { + case 'user': + return dispatch(demoteToUser(accountId)); + case 'moderator': + return dispatch(promoteToModerator(accountId)); + case 'admin': + return dispatch(promoteToAdmin(accountId)); + } + }; + const suggestUsers = (accountIds: string[]) => (dispatch: AppDispatch, getState: () => RootState) => { const nicknames = nicknamesFromIds(getState, accountIds); @@ -502,6 +588,137 @@ const unsuggestUsers = (accountIds: string[]) => }); }; +const setUserIndexQuery = (query: string) => ({ type: ADMIN_USER_INDEX_QUERY_SET, query }); + +const fetchUserIndex = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const { filters, page, query, pageSize, isLoading } = getState().admin_user_index; + + if (isLoading) return; + + dispatch({ type: ADMIN_USER_INDEX_FETCH_REQUEST }); + + dispatch(fetchUsers(filters.toJS() as string[], page + 1, query, pageSize)) + .then((data: any) => { + if (data.error) { + dispatch({ type: ADMIN_USER_INDEX_FETCH_FAIL }); + } else { + const { users, count, next } = (data); + dispatch({ type: ADMIN_USER_INDEX_FETCH_SUCCESS, users, count, next }); + } + }).catch(() => { + dispatch({ type: ADMIN_USER_INDEX_FETCH_FAIL }); + }); + }; + +const expandUserIndex = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const { filters, page, query, pageSize, isLoading, next, loaded } = getState().admin_user_index; + + if (!loaded || isLoading) return; + + dispatch({ type: ADMIN_USER_INDEX_EXPAND_REQUEST }); + + dispatch(fetchUsers(filters.toJS() as string[], page + 1, query, pageSize, next)) + .then((data: any) => { + if (data.error) { + dispatch({ type: ADMIN_USER_INDEX_EXPAND_FAIL }); + } else { + const { users, count, next } = (data); + dispatch({ type: ADMIN_USER_INDEX_EXPAND_SUCCESS, users, count, next }); + } + }).catch(() => { + dispatch({ type: ADMIN_USER_INDEX_EXPAND_FAIL }); + }); + }; + +const fetchAdminAnnouncements = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_REQUEST }); + return api(getState) + .get('/api/pleroma/admin/announcements', { params: { limit: 50 } }) + .then(({ data }) => { + dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS, announcements: data }); + return data; + }).catch(error => { + dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_FAIL, error }); + }); + }; + +const expandAdminAnnouncements = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const page = getState().admin_announcements.page; + + dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST }); + return api(getState) + .get('/api/pleroma/admin/announcements', { params: { limit: 50, offset: page * 50 } }) + .then(({ data }) => { + dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS, announcements: data }); + return data; + }).catch(error => { + dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_FAIL, error }); + }); + }; + +const changeAnnouncementContent = (content: string) => ({ + type: ADMIN_ANNOUNCEMENT_CHANGE_CONTENT, + value: content, +}); + +const changeAnnouncementStartTime = (time: Date | null) => ({ + type: ADMIN_ANNOUNCEMENT_CHANGE_START_TIME, + value: time, +}); + +const changeAnnouncementEndTime = (time: Date | null) => ({ + type: ADMIN_ANNOUNCEMENT_CHANGE_END_TIME, + value: time, +}); + +const changeAnnouncementAllDay = (allDay: boolean) => ({ + type: ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY, + value: allDay, +}); + +const handleCreateAnnouncement = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_REQUEST }); + + const { id, content, starts_at, ends_at, all_day } = getState().admin_announcements.form; + + return api(getState)[id ? 'patch' : 'post']( + id ? `/api/pleroma/admin/announcements/${id}` : '/api/pleroma/admin/announcements', + { content, starts_at, ends_at, all_day }, + ).then(({ data }) => { + dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_SUCCESS, announcement: data }); + toast.success(id ? messages.announcementUpdateSuccess : messages.announcementCreateSuccess); + dispatch(fetchAdminAnnouncements()); + return data; + }).catch(error => { + dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_FAIL, error }); + }); + }; + +const deleteAnnouncement = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_REQUEST, id }); + + return api(getState).delete(`/api/pleroma/admin/announcements/${id}`).then(({ data }) => { + dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_SUCCESS, id }); + toast.success(messages.announcementDeleteSuccess); + dispatch(fetchAdminAnnouncements()); + return data; + }).catch(error => { + dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_FAIL, id, error }); + }); + }; + +const initAnnouncementModal = (announcement?: Announcement) => + (dispatch: AppDispatch) => { + dispatch({ type: ADMIN_ANNOUNCEMENT_MODAL_INIT, announcement }); + dispatch(openModal('EDIT_ANNOUNCEMENT')); + }; + export { ADMIN_CONFIG_FETCH_REQUEST, ADMIN_CONFIG_FETCH_SUCCESS, @@ -554,8 +771,33 @@ export { ADMIN_USERS_UNSUGGEST_REQUEST, ADMIN_USERS_UNSUGGEST_SUCCESS, ADMIN_USERS_UNSUGGEST_FAIL, + ADMIN_USER_INDEX_EXPAND_FAIL, + ADMIN_USER_INDEX_EXPAND_REQUEST, + ADMIN_USER_INDEX_EXPAND_SUCCESS, + ADMIN_USER_INDEX_FETCH_FAIL, + ADMIN_USER_INDEX_FETCH_REQUEST, + ADMIN_USER_INDEX_FETCH_SUCCESS, + ADMIN_USER_INDEX_QUERY_SET, + ADMIN_ANNOUNCEMENTS_FETCH_FAIL, + ADMIN_ANNOUNCEMENTS_FETCH_REQUEST, + ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS, + ADMIN_ANNOUNCEMENTS_EXPAND_FAIL, + ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST, + ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS, + ADMIN_ANNOUNCEMENT_CHANGE_CONTENT, + ADMIN_ANNOUNCEMENT_CHANGE_START_TIME, + ADMIN_ANNOUNCEMENT_CHANGE_END_TIME, + ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY, + ADMIN_ANNOUNCEMENT_CREATE_FAIL, + ADMIN_ANNOUNCEMENT_CREATE_REQUEST, + ADMIN_ANNOUNCEMENT_CREATE_SUCCESS, + ADMIN_ANNOUNCEMENT_DELETE_FAIL, + ADMIN_ANNOUNCEMENT_DELETE_REQUEST, + ADMIN_ANNOUNCEMENT_DELETE_SUCCESS, + ADMIN_ANNOUNCEMENT_MODAL_INIT, fetchConfig, updateConfig, + updateSoapboxConfig, fetchReports, closeReports, fetchUsers, @@ -567,15 +809,28 @@ export { fetchModerationLog, tagUsers, untagUsers, + setTags, + setBadges, verifyUser, unverifyUser, - setDonor, - removeDonor, addPermission, removePermission, promoteToAdmin, promoteToModerator, demoteToUser, + setRole, suggestUsers, unsuggestUsers, + setUserIndexQuery, + fetchUserIndex, + expandUserIndex, + fetchAdminAnnouncements, + expandAdminAnnouncements, + changeAnnouncementContent, + changeAnnouncementStartTime, + changeAnnouncementEndTime, + changeAnnouncementAllDay, + handleCreateAnnouncement, + deleteAnnouncement, + initAnnouncementModal, }; diff --git a/app/soapbox/actions/alerts.ts b/app/soapbox/actions/alerts.ts deleted file mode 100644 index 8f200563a..000000000 --- a/app/soapbox/actions/alerts.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { defineMessages, MessageDescriptor } from 'react-intl'; - -import { httpErrorMessages } from 'soapbox/utils/errors'; - -import type { SnackbarActionSeverity } from './snackbar'; -import type { AnyAction } from '@reduxjs/toolkit'; -import type { AxiosError } from 'axios'; -import type { NotificationObject } from 'react-notification'; - -const messages = defineMessages({ - unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, - unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, -}); - -export const ALERT_SHOW = 'ALERT_SHOW'; -export const ALERT_DISMISS = 'ALERT_DISMISS'; -export const ALERT_CLEAR = 'ALERT_CLEAR'; - -const noOp = () => { }; - -function dismissAlert(alert: NotificationObject) { - return { - type: ALERT_DISMISS, - alert, - }; -} - -function showAlert( - title: MessageDescriptor | string = messages.unexpectedTitle, - message: MessageDescriptor | string = messages.unexpectedMessage, - severity: SnackbarActionSeverity = 'info', -) { - return { - type: ALERT_SHOW, - title, - message, - severity, - }; -} - -const showAlertForError = (error: AxiosError) => (dispatch: React.Dispatch, _getState: any) => { - if (error?.response) { - const { data, status, statusText } = error.response; - - if (status === 502) { - return dispatch(showAlert('', 'The server is down', 'error')); - } - - if (status === 404 || status === 410) { - // Skip these errors as they are reflected in the UI - return dispatch(noOp as any); - } - - let message: string | undefined = statusText; - - if (data?.error) { - message = data.error; - } - - if (!message) { - message = httpErrorMessages.find((httpError) => httpError.code === status)?.description; - } - - return dispatch(showAlert('', message, 'error')); - } else { - console.error(error); - return dispatch(showAlert(undefined, undefined, 'error')); - } -}; - -export { - dismissAlert, - showAlert, - showAlertForError, -}; diff --git a/app/soapbox/actions/aliases.ts b/app/soapbox/actions/aliases.ts index 8361e31ad..3a5b61163 100644 --- a/app/soapbox/actions/aliases.ts +++ b/app/soapbox/actions/aliases.ts @@ -1,14 +1,13 @@ import { defineMessages } from 'react-intl'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures } from 'soapbox/utils/features'; import api from '../api'; -import { showAlertForError } from './alerts'; import { importFetchedAccounts } from './importer'; import { patchMeSuccess } from './me'; -import snackbar from './snackbar'; import type { AxiosError } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; @@ -80,7 +79,7 @@ const fetchAliasesSuggestions = (q: string) => api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { dispatch(importFetchedAccounts(data)); dispatch(fetchAliasesSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); + }).catch(error => toast.showAlertForError(error)); }; const fetchAliasesSuggestionsReady = (query: string, accounts: APIEntity[]) => ({ @@ -114,7 +113,7 @@ const addToAliases = (account: Account) => api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, account.pleroma.get('ap_id')] }) .then((response => { - dispatch(snackbar.success(messages.createSuccess)); + toast.success(messages.createSuccess); dispatch(addToAliasesSuccess); dispatch(patchMeSuccess(response.data)); })) @@ -129,7 +128,7 @@ const addToAliases = (account: Account) => alias: account.acct, }) .then(() => { - dispatch(snackbar.success(messages.createSuccess)); + toast.success(messages.createSuccess); dispatch(addToAliasesSuccess); dispatch(fetchAliases); }) @@ -165,7 +164,7 @@ const removeFromAliases = (account: string) => api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: alsoKnownAs.filter((id: string) => id !== account) }) .then(response => { - dispatch(snackbar.success(messages.removeSuccess)); + toast.success(messages.removeSuccess); dispatch(removeFromAliasesSuccess); dispatch(patchMeSuccess(response.data)); }) @@ -182,7 +181,7 @@ const removeFromAliases = (account: string) => }, }) .then(response => { - dispatch(snackbar.success(messages.removeSuccess)); + toast.success(messages.removeSuccess); dispatch(removeFromAliasesSuccess); dispatch(fetchAliases); }) diff --git a/app/soapbox/actions/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/auth.ts b/app/soapbox/actions/auth.ts index e21974116..06fe848e2 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -14,12 +14,14 @@ import { createApp } from 'soapbox/actions/apps'; import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me'; import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth'; import { startOnboarding } from 'soapbox/actions/onboarding'; -import snackbar from 'soapbox/actions/snackbar'; import { custom } from 'soapbox/custom'; -import KVStore from 'soapbox/storage/kv_store'; +import { queryClient } from 'soapbox/queries/client'; +import KVStore from 'soapbox/storage/kv-store'; +import toast from 'soapbox/toast'; import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth'; import sourceCode from 'soapbox/utils/code'; -import { getFeatures } from 'soapbox/utils/features'; +import { normalizeUsername } from 'soapbox/utils/input'; +import { getScopes } from 'soapbox/utils/scopes'; import { isStandalone } from 'soapbox/utils/state'; import api, { baseClient } from '../api'; @@ -27,7 +29,6 @@ import api, { baseClient } from '../api'; import { importFetchedAccount } from './importer'; import type { AxiosError } from 'axios'; -import type { Map as ImmutableMap } from 'immutable'; import type { AppDispatch, RootState } from 'soapbox/store'; export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT'; @@ -49,17 +50,12 @@ const customApp = custom('app'); export const messages = defineMessages({ loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' }, + awaitingApproval: { id: 'auth.awaiting_approval', defaultMessage: 'Your account is awaiting approval' }, invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' }, }); const noOp = () => new Promise(f => f(undefined)); -const getScopes = (state: RootState) => { - const instance = state.instance; - const { scopes } = getFeatures(instance); - return scopes; -}; - const createAppAndToken = () => (dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => @@ -92,11 +88,11 @@ const createAuthApp = () => const createAppToken = () => (dispatch: AppDispatch, getState: () => RootState) => { - const app = getState().auth.get('app'); + const app = getState().auth.app; const params = { - client_id: app.get('client_id'), - client_secret: app.get('client_secret'), + client_id: app.client_id!, + client_secret: app.client_secret!, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', grant_type: 'client_credentials', scope: getScopes(getState()), @@ -109,11 +105,11 @@ const createAppToken = () => const createUserToken = (username: string, password: string) => (dispatch: AppDispatch, getState: () => RootState) => { - const app = getState().auth.get('app'); + const app = getState().auth.app; const params = { - client_id: app.get('client_id'), - client_secret: app.get('client_secret'), + client_id: app.client_id!, + client_secret: app.client_secret!, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', grant_type: 'password', username: username, @@ -125,32 +121,12 @@ const createUserToken = (username: string, password: string) => .then((token: Record) => dispatch(authLoggedIn(token))); }; -export const refreshUserToken = () => - (dispatch: AppDispatch, getState: () => RootState) => { - const refreshToken = getState().auth.getIn(['user', 'refresh_token']); - const app = getState().auth.get('app'); - - if (!refreshToken) return dispatch(noOp); - - const params = { - client_id: app.get('client_id'), - client_secret: app.get('client_secret'), - refresh_token: refreshToken, - redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', - grant_type: 'refresh_token', - scope: getScopes(getState()), - }; - - return dispatch(obtainOAuthToken(params)) - .then((token: Record) => dispatch(authLoggedIn(token))); - }; - export const otpVerify = (code: string, mfa_token: string) => (dispatch: AppDispatch, getState: () => RootState) => { - const app = getState().auth.get('app'); + const app = getState().auth.app; return api(getState, 'app').post('/oauth/mfa/challenge', { - client_id: app.get('client_id'), - client_secret: app.get('client_secret'), + client_id: app.client_id, + client_secret: app.client_secret, mfa_token: mfa_token, code: code, challenge_type: 'totp', @@ -202,21 +178,21 @@ export const rememberAuthAccount = (accountUrl: string) => export const loadCredentials = (token: string, accountUrl: string) => (dispatch: AppDispatch) => dispatch(rememberAuthAccount(accountUrl)) - .then(() => { - dispatch(verifyCredentials(token, accountUrl)); - }) + .then(() => dispatch(verifyCredentials(token, accountUrl))) .catch(() => dispatch(verifyCredentials(token, accountUrl))); export const logIn = (username: string, password: string) => (dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => { - return dispatch(createUserToken(username, password)); + return dispatch(createUserToken(normalizeUsername(username), password)); }).catch((error: AxiosError) => { - if ((error.response?.data as any).error === 'mfa_required') { + if ((error.response?.data as any)?.error === 'mfa_required') { // If MFA is required, throw the error and handle it in the component. throw error; + } else if ((error.response?.data as any)?.identifier === 'awaiting_approval') { + toast.error(messages.awaitingApproval); } else { // Return "wrong password" message. - dispatch(snackbar.error(messages.invalidCredentials)); + toast.error(messages.invalidCredentials); } throw error; }); @@ -233,30 +209,40 @@ export const logOut = () => if (!account) return dispatch(noOp); const params = { - client_id: state.auth.getIn(['app', 'client_id']), - client_secret: state.auth.getIn(['app', 'client_secret']), - token: state.auth.getIn(['users', account.url, 'access_token']), + client_id: state.auth.app.client_id!, + client_secret: state.auth.app.client_secret!, + token: state.auth.users.get(account.url)!.access_token, }; - return dispatch(revokeOAuthToken(params)).finally(() => { - dispatch({ type: AUTH_LOGGED_OUT, account, standalone }); - return dispatch(snackbar.success(messages.loggedOut)); - }); + return dispatch(revokeOAuthToken(params)) + .finally(() => { + // Clear all stored cache from React Query + queryClient.invalidateQueries(); + queryClient.clear(); + + dispatch({ type: AUTH_LOGGED_OUT, account, standalone }); + + toast.success(messages.loggedOut); + }); }; export const switchAccount = (accountId: string, background = false) => (dispatch: AppDispatch, getState: () => RootState) => { const account = getState().accounts.get(accountId); + // Clear all stored cache from React Query + queryClient.invalidateQueries(); + queryClient.clear(); + return dispatch({ type: SWITCH_ACCOUNT, account, background }); }; export const fetchOwnAccounts = () => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - return state.auth.get('users').forEach((user: ImmutableMap) => { - const account = state.accounts.get(user.get('id')); + return state.auth.users.forEach((user) => { + const account = state.accounts.get(user.id); if (!account) { - dispatch(verifyCredentials(user.get('access_token')!, user.get('url'))); + dispatch(verifyCredentials(user.access_token, user.url)); } }); }; diff --git a/app/soapbox/actions/carousels.ts b/app/soapbox/actions/carousels.ts deleted file mode 100644 index 7935536c4..000000000 --- a/app/soapbox/actions/carousels.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AxiosResponse } from 'axios'; - -import { AppDispatch, RootState } from 'soapbox/store'; - -import api from '../api'; - -const CAROUSEL_AVATAR_REQUEST = 'CAROUSEL_AVATAR_REQUEST'; -const CAROUSEL_AVATAR_SUCCESS = 'CAROUSEL_AVATAR_SUCCESS'; -const CAROUSEL_AVATAR_FAIL = 'CAROUSEL_AVATAR_FAIL'; - -const fetchCarouselAvatars = () => (dispatch: AppDispatch, getState: () => RootState) => { - dispatch({ type: CAROUSEL_AVATAR_REQUEST }); - - return api(getState) - .get('/api/v1/truth/carousels/avatars') - .then((response: AxiosResponse) => dispatch({ type: CAROUSEL_AVATAR_SUCCESS, payload: response.data })) - .catch(() => dispatch({ type: CAROUSEL_AVATAR_FAIL })); -}; - -export { - CAROUSEL_AVATAR_REQUEST, - CAROUSEL_AVATAR_SUCCESS, - CAROUSEL_AVATAR_FAIL, - fetchCarouselAvatars, -}; diff --git a/app/soapbox/actions/chats.ts b/app/soapbox/actions/chats.ts index 67b796408..f4ca85abe 100644 --- a/app/soapbox/actions/chats.ts +++ b/app/soapbox/actions/chats.ts @@ -6,8 +6,8 @@ import { getFeatures } from 'soapbox/utils/features'; import api, { getLinks } from '../api'; -import type { History } from 'history'; import type { AppDispatch, RootState } from 'soapbox/store'; +import type { History } from 'soapbox/types/history'; const CHATS_FETCH_REQUEST = 'CHATS_FETCH_REQUEST'; const CHATS_FETCH_SUCCESS = 'CHATS_FETCH_SUCCESS'; diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index 0a1b71f13..8be60bfc7 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -1,18 +1,18 @@ import axios, { AxiosError, Canceler } from 'axios'; +import { List as ImmutableList } from 'immutable'; import throttle from 'lodash/throttle'; import { defineMessages, IntlShape } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; import api from 'soapbox/api'; import { isNativeEmoji } from 'soapbox/features/emoji'; import emojiSearch from 'soapbox/features/emoji/search'; import { tagHistory } from 'soapbox/settings'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures, parseVersion } from 'soapbox/utils/features'; import { formatBytes, getVideoDuration } from 'soapbox/utils/media'; -import resizeImage from 'soapbox/utils/resize_image'; +import resizeImage from 'soapbox/utils/resize-image'; -import { showAlert, showAlertForError } from './alerts'; import { useEmoji } from './emojis'; import { importFetchedAccounts } from './importer'; import { uploadMedia, fetchMedia, updateMedia } from './media'; @@ -20,11 +20,11 @@ import { openModal, closeModal } from './modals'; import { getSettings } from './settings'; import { createStatus } from './statuses'; -import type { History } from 'history'; -import type { AutoSuggestion } from 'soapbox/components/autosuggest_input'; +import type { AutoSuggestion } from 'soapbox/components/autosuggest-input'; import type { Emoji } from 'soapbox/features/emoji'; import type { AppDispatch, RootState } from 'soapbox/store'; -import type { Account, APIEntity, Status } from 'soapbox/types/entities'; +import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities'; +import type { History } from 'soapbox/types/history'; const { CancelToken, isCancel } = axios; @@ -35,6 +35,7 @@ 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_EVENT_REPLY = 'COMPOSE_EVENT_REPLY'; const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; const COMPOSE_QUOTE = 'COMPOSE_QUOTE'; const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL'; @@ -46,6 +47,7 @@ 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_GROUP_POST = 'COMPOSE_GROUP_POST'; const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; @@ -54,10 +56,6 @@ 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'; @@ -90,22 +88,17 @@ const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; const messages = defineMessages({ exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' }, exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' }, - exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit} seconds)' }, + exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit, plural, one {# second} other {# seconds}})' }, scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' }, success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' }, + editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' }, uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, - view: { id: 'snackbar.view', defaultMessage: 'View' }, + view: { id: 'toast.view', defaultMessage: 'View' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, }); -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(); @@ -113,6 +106,7 @@ const setComposeToStatus = (status: Status, rawText: string, spoilerText?: strin dispatch({ type: COMPOSE_SET_STATUS, + id: 'compose-modal', status, rawText, explicitAddressing, @@ -123,8 +117,9 @@ const setComposeToStatus = (status: Status, rawText: string, spoilerText?: strin }); }; -const changeCompose = (text: string) => ({ +const changeCompose = (composeId: string, text: string) => ({ type: COMPOSE_CHANGE, + id: composeId, text: text, }); @@ -136,6 +131,7 @@ const replyCompose = (status: Status) => dispatch({ type: COMPOSE_REPLY, + id: 'compose-modal', status: status, account: state.accounts.get(state.me), explicitAddressing, @@ -146,6 +142,7 @@ const replyCompose = (status: Status) => const cancelReplyCompose = () => ({ type: COMPOSE_REPLY_CANCEL, + id: 'compose-modal', }); const quoteCompose = (status: Status) => @@ -156,6 +153,7 @@ const quoteCompose = (status: Status) => dispatch({ type: COMPOSE_QUOTE, + id: 'compose-modal', status: status, account: state.accounts.get(state.me), explicitAddressing, @@ -166,16 +164,19 @@ const quoteCompose = (status: Status) => const cancelQuoteCompose = () => ({ type: COMPOSE_QUOTE_CANCEL, + id: 'compose-modal', }); -const resetCompose = () => ({ +const resetCompose = (composeId = 'compose-modal') => ({ type: COMPOSE_RESET, + id: composeId, }); const mentionCompose = (account: Account) => (dispatch: AppDispatch) => { dispatch({ type: COMPOSE_MENTION, + id: 'compose-modal', account: account, }); @@ -186,6 +187,7 @@ const directCompose = (account: Account) => (dispatch: AppDispatch) => { dispatch({ type: COMPOSE_DIRECT, + id: 'compose-modal', account: account, }); @@ -198,22 +200,26 @@ const directComposeById = (accountId: string) => dispatch({ type: COMPOSE_DIRECT, + id: 'compose-modal', account: account, }); dispatch(openModal('COMPOSE')); }; -const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, data: APIEntity, status: string) => { +const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, composeId: string, data: APIEntity, status: string, edit?: boolean) => { 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}`)); + dispatch(insertIntoTagHistory(composeId, data.tags || [], status)); + dispatch(submitComposeSuccess(composeId, { ...data })); + toast.success(edit ? messages.editSuccess : messages.success, { + actionLabel: messages.view, + actionLink: `/@${data.account.acct}/posts/${data.id}`, + }); }; -const needsDescriptions = (state: RootState) => { - const media = state.compose.media_attachments; +const needsDescriptions = (state: RootState, composeId: string) => { + const media = state.compose.get(composeId)!.media_attachments; const missingDescriptionModal = getSettings(state).get('missingDescriptionModal'); const hasMissing = media.filter(item => !item.description).size > 0; @@ -221,8 +227,8 @@ const needsDescriptions = (state: RootState) => { return missingDescriptionModal && hasMissing; }; -const validateSchedule = (state: RootState) => { - const schedule = state.compose.schedule; +const validateSchedule = (state: RootState, composeId: string) => { + const schedule = state.compose.get(composeId)?.schedule; if (!schedule) return true; const fiveMinutesFromNow = new Date(new Date().getTime() + 300000); @@ -230,18 +236,20 @@ const validateSchedule = (state: RootState) => { return schedule.getTime() > fiveMinutesFromNow.getTime(); }; -const submitCompose = (routerHistory: History, force = false) => +const submitCompose = (composeId: string, 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; + const compose = state.compose.get(composeId)!; - if (!validateSchedule(state)) { - dispatch(snackbar.error(messages.scheduleError)); + const status = compose.text; + const media = compose.media_attachments; + const statusId = compose.id; + let to = compose.to; + + if (!validateSchedule(state, composeId)) { + toast.error(messages.scheduleError); return; } @@ -249,67 +257,71 @@ const submitCompose = (routerHistory: History, force = false) => return; } - if (!force && needsDescriptions(state)) { + if (!force && needsDescriptions(state, composeId)) { dispatch(openModal('MISSING_DESCRIPTION', { onContinue: () => { dispatch(closeModal('MISSING_DESCRIPTION')); - dispatch(submitCompose(routerHistory, true)); + dispatch(submitCompose(composeId, routerHistory, true)); }, })); return; } - if (to && status) { - const mentions: string[] | null = status.match(/(?:^|\s|\.)@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/gi); // not a perfect regex + const mentions: string[] | null = status.match(/(?:^|\s)@([a-z\d_-]+(?:@[^@\s]+)?)/gi); - if (mentions) - to = to.union(mentions.map(mention => mention.trim().slice(1))); + if (mentions) { + to = to.union(mentions.map(mention => mention.trim().slice(1))); } - dispatch(submitComposeRequest()); + dispatch(submitComposeRequest(composeId)); dispatch(closeModal()); - const idempotencyKey = state.compose.idempotencyKey; + const idempotencyKey = compose.idempotencyKey; - const params = { + const params: Record = { status, - in_reply_to_id: state.compose.in_reply_to, - quote_id: state.compose.quote, + in_reply_to_id: compose.in_reply_to, + quote_id: 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, + sensitive: compose.sensitive, + spoiler_text: compose.spoiler_text, + visibility: compose.privacy, + content_type: compose.content_type, + poll: compose.poll, + scheduled_at: compose.schedule, to, }; + if (compose.privacy === 'group') params.group_id = compose.group_id; + 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); + handleComposeSubmit(dispatch, getState, composeId, data, status, !!statusId); }).catch(function(error) { - dispatch(submitComposeFail(error)); + dispatch(submitComposeFail(composeId, error)); }); }; -const submitComposeRequest = () => ({ +const submitComposeRequest = (composeId: string) => ({ type: COMPOSE_SUBMIT_REQUEST, + id: composeId, }); -const submitComposeSuccess = (status: APIEntity) => ({ +const submitComposeSuccess = (composeId: string, status: APIEntity) => ({ type: COMPOSE_SUBMIT_SUCCESS, + id: composeId, status: status, }); -const submitComposeFail = (error: AxiosError) => ({ +const submitComposeFail = (composeId: string, error: AxiosError) => ({ type: COMPOSE_SUBMIT_FAIL, + id: composeId, error: error, }); -const uploadCompose = (files: FileList, intl: IntlShape) => +const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; const attachmentLimit = getState().instance.configuration.getIn(['statuses', 'max_media_attachments']) as number; @@ -317,19 +329,21 @@ const uploadCompose = (files: FileList, intl: IntlShape) => 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 media = getState().compose.get(composeId)?.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')); + const mediaCount = media ? media.size : 0; + + if (files.length + mediaCount > attachmentLimit) { + toast.error(messages.uploadErrorLimit); return; } - dispatch(uploadComposeRequest()); + dispatch(uploadComposeRequest(composeId)); Array.from(files).forEach(async(f, i) => { - if (media.size + i > attachmentLimit - 1) return; + if (mediaCount + i > attachmentLimit - 1) return; const isImage = f.type.match(/image.*/); const isVideo = f.type.match(/video.*/); @@ -338,19 +352,19 @@ const uploadCompose = (files: FileList, intl: IntlShape) => if (isImage && maxImageSize && (f.size > maxImageSize)) { const limit = formatBytes(maxImageSize); const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); - dispatch(snackbar.error(message)); - dispatch(uploadComposeFail(true)); + toast.error(message); + dispatch(uploadComposeFail(composeId, true)); return; } else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) { const limit = formatBytes(maxVideoSize); const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit }); - dispatch(snackbar.error(message)); - dispatch(uploadComposeFail(true)); + toast.error(message); + dispatch(uploadComposeFail(composeId, true)); return; } else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) { const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration }); - dispatch(snackbar.error(message)); - dispatch(uploadComposeFail(true)); + toast.error(message); + dispatch(uploadComposeFail(composeId, true)); return; } @@ -364,7 +378,7 @@ const uploadCompose = (files: FileList, intl: IntlShape) => const onUploadProgress = ({ loaded }: any) => { progress[i] = loaded; - dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); + dispatch(uploadComposeProgress(composeId, progress.reduce((a, v) => a + v, 0), total)); }; return dispatch(uploadMedia(data, onUploadProgress)) @@ -372,98 +386,116 @@ const uploadCompose = (files: FileList, intl: IntlShape) => // 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)); + dispatch(uploadComposeSuccess(composeId, data, f)); } else if (status === 202) { const poll = () => { dispatch(fetchMedia(data.id)).then(({ status, data }) => { if (status === 200) { - dispatch(uploadComposeSuccess(data, f)); + dispatch(uploadComposeSuccess(composeId, data, f)); } else if (status === 206) { setTimeout(() => poll(), 1000); } - }).catch(error => dispatch(uploadComposeFail(error))); + }).catch(error => dispatch(uploadComposeFail(composeId, error))); }; poll(); } }); - }).catch(error => dispatch(uploadComposeFail(error))); + }).catch(error => dispatch(uploadComposeFail(composeId, error))); /* eslint-enable no-loop-func */ }); }; -const changeUploadCompose = (id: string, params: Record) => +const changeUploadCompose = (composeId: string, id: string, params: Record) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; - dispatch(changeUploadComposeRequest()); + dispatch(changeUploadComposeRequest(composeId)); dispatch(updateMedia(id, params)).then(response => { - dispatch(changeUploadComposeSuccess(response.data)); + dispatch(changeUploadComposeSuccess(composeId, response.data)); }).catch(error => { - dispatch(changeUploadComposeFail(id, error)); + dispatch(changeUploadComposeFail(composeId, id, error)); }); }; -const changeUploadComposeRequest = () => ({ +const changeUploadComposeRequest = (composeId: string) => ({ type: COMPOSE_UPLOAD_CHANGE_REQUEST, + id: composeId, skipLoading: true, }); -const changeUploadComposeSuccess = (media: APIEntity) => ({ +const changeUploadComposeSuccess = (composeId: string, media: APIEntity) => ({ type: COMPOSE_UPLOAD_CHANGE_SUCCESS, + id: composeId, media: media, skipLoading: true, }); -const changeUploadComposeFail = (id: string, error: AxiosError) => ({ +const changeUploadComposeFail = (composeId: string, id: string, error: AxiosError) => ({ type: COMPOSE_UPLOAD_CHANGE_FAIL, + composeId, id, error: error, skipLoading: true, }); -const uploadComposeRequest = () => ({ +const uploadComposeRequest = (composeId: string) => ({ type: COMPOSE_UPLOAD_REQUEST, + id: composeId, skipLoading: true, }); -const uploadComposeProgress = (loaded: number, total: number) => ({ +const uploadComposeProgress = (composeId: string, loaded: number, total: number) => ({ type: COMPOSE_UPLOAD_PROGRESS, + id: composeId, loaded: loaded, total: total, }); -const uploadComposeSuccess = (media: APIEntity, file: File) => ({ +const uploadComposeSuccess = (composeId: string, media: APIEntity, file: File) => ({ type: COMPOSE_UPLOAD_SUCCESS, + id: composeId, media: media, file, skipLoading: true, }); -const uploadComposeFail = (error: AxiosError | true) => ({ +const uploadComposeFail = (composeId: string, error: AxiosError | true) => ({ type: COMPOSE_UPLOAD_FAIL, + id: composeId, error: error, skipLoading: true, }); -const undoUploadCompose = (media_id: string) => ({ +const undoUploadCompose = (composeId: string, media_id: string) => ({ type: COMPOSE_UPLOAD_UNDO, + id: composeId, media_id: media_id, }); -const clearComposeSuggestions = () => { +const groupCompose = (composeId: string, groupId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: COMPOSE_GROUP_POST, + id: composeId, + group_id: groupId, + }); + }; + +const clearComposeSuggestions = (composeId: string) => { if (cancelFetchComposeSuggestionsAccounts) { cancelFetchComposeSuggestionsAccounts(); } return { type: COMPOSE_SUGGESTIONS_CLEAR, + id: composeId, }; }; -const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { +const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => { if (cancelFetchComposeSuggestionsAccounts) { - cancelFetchComposeSuggestionsAccounts(); + cancelFetchComposeSuggestionsAccounts(composeId); } api(getState).get('/api/v1/accounts/search', { cancelToken: new CancelToken(cancel => { @@ -476,53 +508,58 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => }, }).then(response => { dispatch(importFetchedAccounts(response.data)); - dispatch(readyComposeSuggestionsAccounts(token, response.data)); + dispatch(readyComposeSuggestionsAccounts(composeId, token, response.data)); }).catch(error => { if (!isCancel(error)) { - dispatch(showAlertForError(error)); + toast.showAlertForError(error); } }); }, 200, { leading: true, trailing: true }); -const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, token: string) => { +const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => { const state = getState(); const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }, state.custom_emojis); - dispatch(readyComposeSuggestionsEmojis(token, results)); + dispatch(readyComposeSuggestionsEmojis(composeId, token, results)); }; -const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, token: string) => { - dispatch(updateSuggestionTags(token)); +const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => { + const state = getState(); + const currentTrends = state.trends.items; + + dispatch(updateSuggestionTags(composeId, token, currentTrends)); }; -const fetchComposeSuggestions = (token: string) => +const fetchComposeSuggestions = (composeId: string, token: string) => (dispatch: AppDispatch, getState: () => RootState) => { switch (token[0]) { case ':': - fetchComposeSuggestionsEmojis(dispatch, getState, token); + fetchComposeSuggestionsEmojis(dispatch, getState, composeId, token); break; case '#': - fetchComposeSuggestionsTags(dispatch, getState, token); + fetchComposeSuggestionsTags(dispatch, getState, composeId, token); break; default: - fetchComposeSuggestionsAccounts(dispatch, getState, token); + fetchComposeSuggestionsAccounts(dispatch, getState, composeId, token); break; } }; -const readyComposeSuggestionsEmojis = (token: string, emojis: Emoji[]) => ({ +const readyComposeSuggestionsEmojis = (composeId: string, token: string, emojis: Emoji[]) => ({ type: COMPOSE_SUGGESTIONS_READY, + id: composeId, token, emojis, }); -const readyComposeSuggestionsAccounts = (token: string, accounts: APIEntity[]) => ({ +const readyComposeSuggestionsAccounts = (composeId: string, token: string, accounts: APIEntity[]) => ({ type: COMPOSE_SUGGESTIONS_READY, + id: composeId, token, accounts, }); -const selectComposeSuggestion = (position: number, token: string | null, suggestion: AutoSuggestion, path: Array) => +const selectComposeSuggestion = (composeId: string, position: number, token: string | null, suggestion: AutoSuggestion, path: Array) => (dispatch: AppDispatch, getState: () => RootState) => { let completion, startPosition; @@ -541,6 +578,7 @@ const selectComposeSuggestion = (position: number, token: string | null, suggest dispatch({ type: COMPOSE_SUGGESTION_SELECT, + id: composeId, position: startPosition, token, completion, @@ -548,20 +586,23 @@ const selectComposeSuggestion = (position: number, token: string | null, suggest }); }; -const updateSuggestionTags = (token: string) => ({ +const updateSuggestionTags = (composeId: string, token: string, currentTrends: ImmutableList) => ({ type: COMPOSE_SUGGESTION_TAGS_UPDATE, + id: composeId, token, + currentTrends, }); -const updateTagHistory = (tags: string[]) => ({ +const updateTagHistory = (composeId: string, tags: string[]) => ({ type: COMPOSE_TAG_HISTORY_UPDATE, + id: composeId, tags, }); -const insertIntoTagHistory = (recognizedTags: APIEntity[], text: string) => +const insertIntoTagHistory = (composeId: string, recognizedTags: APIEntity[], text: string) => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - const oldHistory = state.compose.tagHistory; + const oldHistory = state.compose.get(composeId)!.tagHistory; const me = state.me; const names = recognizedTags .filter(tag => text.match(new RegExp(`#${tag.name}`, 'i'))) @@ -573,124 +614,138 @@ const insertIntoTagHistory = (recognizedTags: APIEntity[], text: string) => const newHistory = names.slice(0, 1000); tagHistory.set(me as string, newHistory); - dispatch(updateTagHistory(newHistory)); + dispatch(updateTagHistory(composeId, newHistory)); }; -const mountCompose = () => ({ - type: COMPOSE_MOUNT, -}); - -const unmountCompose = () => ({ - type: COMPOSE_UNMOUNT, -}); - -const changeComposeSensitivity = () => ({ - type: COMPOSE_SENSITIVITY_CHANGE, -}); - -const changeComposeSpoilerness = () => ({ +const changeComposeSpoilerness = (composeId: string) => ({ type: COMPOSE_SPOILERNESS_CHANGE, + id: composeId, }); -const changeComposeContentType = (value: string) => ({ +const changeComposeContentType = (composeId: string, value: string) => ({ type: COMPOSE_TYPE_CHANGE, + id: composeId, value, }); -const changeComposeSpoilerText = (text: string) => ({ +const changeComposeSpoilerText = (composeId: string, text: string) => ({ type: COMPOSE_SPOILER_TEXT_CHANGE, + id: composeId, text, }); -const changeComposeVisibility = (value: string) => ({ +const changeComposeVisibility = (composeId: string, value: string) => ({ type: COMPOSE_VISIBILITY_CHANGE, + id: composeId, value, }); -const insertEmojiCompose = (position: number, emoji: string, needsSpace: boolean) => ({ +const insertEmojiCompose = (composeId: string, position: number, emoji: Emoji, needsSpace: boolean) => ({ type: COMPOSE_EMOJI_INSERT, + id: composeId, position, emoji, needsSpace, }); -const changeComposing = (value: string) => ({ - type: COMPOSE_COMPOSING_CHANGE, - value, -}); - -const addPoll = () => ({ +const addPoll = (composeId: string) => ({ type: COMPOSE_POLL_ADD, + id: composeId, }); -const removePoll = () => ({ +const removePoll = (composeId: string) => ({ type: COMPOSE_POLL_REMOVE, + id: composeId, }); -const addSchedule = () => ({ +const addSchedule = (composeId: string) => ({ type: COMPOSE_SCHEDULE_ADD, + id: composeId, }); -const setSchedule = (date: Date) => ({ +const setSchedule = (composeId: string, date: Date) => ({ type: COMPOSE_SCHEDULE_SET, + id: composeId, date: date, }); -const removeSchedule = () => ({ +const removeSchedule = (composeId: string) => ({ type: COMPOSE_SCHEDULE_REMOVE, + id: composeId, }); -const addPollOption = (title: string) => ({ +const addPollOption = (composeId: string, title: string) => ({ type: COMPOSE_POLL_OPTION_ADD, + id: composeId, title, }); -const changePollOption = (index: number, title: string) => ({ +const changePollOption = (composeId: string, index: number, title: string) => ({ type: COMPOSE_POLL_OPTION_CHANGE, + id: composeId, index, title, }); -const removePollOption = (index: number) => ({ +const removePollOption = (composeId: string, index: number) => ({ type: COMPOSE_POLL_OPTION_REMOVE, + id: composeId, index, }); -const changePollSettings = (expiresIn?: string | number, isMultiple?: boolean) => ({ +const changePollSettings = (composeId: string, expiresIn?: string | number, isMultiple?: boolean) => ({ type: COMPOSE_POLL_SETTINGS_CHANGE, + id: composeId, expiresIn, isMultiple, }); -const openComposeWithText = (text = '') => +const openComposeWithText = (composeId: string, text = '') => (dispatch: AppDispatch) => { - dispatch(resetCompose()); + dispatch(resetCompose(composeId)); dispatch(openModal('COMPOSE')); - dispatch(changeCompose(text)); + dispatch(changeCompose(composeId, text)); }; -const addToMentions = (accountId: string) => +const addToMentions = (composeId: string, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); const acct = state.accounts.get(accountId)!.acct; return dispatch({ type: COMPOSE_ADD_TO_MENTIONS, + id: composeId, account: acct, }); }; -const removeFromMentions = (accountId: string) => +const removeFromMentions = (composeId: string, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); const acct = state.accounts.get(accountId)!.acct; return dispatch({ type: COMPOSE_REMOVE_FROM_MENTIONS, + id: composeId, account: acct, }); }; +const eventDiscussionCompose = (composeId: string, status: Status) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const { explicitAddressing } = getFeatures(instance); + + return dispatch({ + type: COMPOSE_EVENT_REPLY, + id: composeId, + status: status, + account: state.accounts.get(state.me), + explicitAddressing, + }); + }; + export { COMPOSE_CHANGE, COMPOSE_SUBMIT_REQUEST, @@ -698,6 +753,7 @@ export { COMPOSE_SUBMIT_FAIL, COMPOSE_REPLY, COMPOSE_REPLY_CANCEL, + COMPOSE_EVENT_REPLY, COMPOSE_QUOTE, COMPOSE_QUOTE_CANCEL, COMPOSE_DIRECT, @@ -708,14 +764,12 @@ export { COMPOSE_UPLOAD_FAIL, COMPOSE_UPLOAD_PROGRESS, COMPOSE_UPLOAD_UNDO, + COMPOSE_GROUP_POST, 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, @@ -738,7 +792,6 @@ export { COMPOSE_ADD_TO_MENTIONS, COMPOSE_REMOVE_FROM_MENTIONS, COMPOSE_SET_STATUS, - ensureComposeIsVisible, setComposeToStatus, changeCompose, replyCompose, @@ -764,6 +817,7 @@ export { uploadComposeSuccess, uploadComposeFail, undoUploadCompose, + groupCompose, clearComposeSuggestions, fetchComposeSuggestions, readyComposeSuggestionsEmojis, @@ -771,15 +825,11 @@ export { selectComposeSuggestion, updateSuggestionTags, updateTagHistory, - mountCompose, - unmountCompose, - changeComposeSensitivity, changeComposeSpoilerness, changeComposeContentType, changeComposeSpoilerText, changeComposeVisibility, insertEmojiCompose, - changeComposing, addPoll, removePoll, addSchedule, @@ -792,4 +842,5 @@ export { openComposeWithText, addToMentions, removeFromMentions, + eventDiscussionCompose, }; diff --git a/app/soapbox/actions/consumer-auth.ts b/app/soapbox/actions/consumer-auth.ts new file mode 100644 index 000000000..72c928dba --- /dev/null +++ b/app/soapbox/actions/consumer-auth.ts @@ -0,0 +1,53 @@ +import axios from 'axios'; + +import * as BuildConfig from 'soapbox/build-config'; +import { isURL } from 'soapbox/utils/auth'; +import sourceCode from 'soapbox/utils/code'; +import { getScopes } from 'soapbox/utils/scopes'; + +import { createApp } from './apps'; + +import type { AppDispatch, RootState } from 'soapbox/store'; + +const createProviderApp = () => { + return async(dispatch: AppDispatch, getState: () => RootState) => { + const scopes = getScopes(getState()); + + const params = { + client_name: sourceCode.displayName, + redirect_uris: `${window.location.origin}/login/external`, + website: sourceCode.homepage, + scopes, + }; + + return dispatch(createApp(params)); + }; +}; + +export const prepareRequest = (provider: string) => { + return async(dispatch: AppDispatch, getState: () => RootState) => { + const baseURL = isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : ''; + + const scopes = getScopes(getState()); + const app = await dispatch(createProviderApp()); + const { client_id, redirect_uri } = app; + + localStorage.setItem('soapbox:external:app', JSON.stringify(app)); + localStorage.setItem('soapbox:external:baseurl', baseURL); + localStorage.setItem('soapbox:external:scopes', scopes); + + const params = { + provider, + authorization: { + client_id, + redirect_uri, + scope: scopes, + }, + }; + + const formdata = axios.toFormData(params); + const query = new URLSearchParams(formdata as any); + + location.href = `${baseURL}/oauth/prepare_request?${query.toString()}`; + }; +}; diff --git a/app/soapbox/actions/custom_emojis.ts b/app/soapbox/actions/custom-emojis.ts similarity index 100% rename from app/soapbox/actions/custom_emojis.ts rename to app/soapbox/actions/custom-emojis.ts diff --git a/app/soapbox/actions/domain_blocks.ts b/app/soapbox/actions/domain-blocks.ts similarity index 100% rename from app/soapbox/actions/domain_blocks.ts rename to app/soapbox/actions/domain-blocks.ts diff --git a/app/soapbox/actions/dropdown-menu.ts b/app/soapbox/actions/dropdown-menu.ts new file mode 100644 index 000000000..cad73f364 --- /dev/null +++ b/app/soapbox/actions/dropdown-menu.ts @@ -0,0 +1,12 @@ +const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; +const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; + +const openDropdownMenu = () => ({ type: DROPDOWN_MENU_OPEN }); +const closeDropdownMenu = () => ({ type: DROPDOWN_MENU_CLOSE }); + +export { + DROPDOWN_MENU_OPEN, + DROPDOWN_MENU_CLOSE, + openDropdownMenu, + closeDropdownMenu, +}; diff --git a/app/soapbox/actions/dropdown_menu.ts b/app/soapbox/actions/dropdown_menu.ts deleted file mode 100644 index 2c19735a1..000000000 --- a/app/soapbox/actions/dropdown_menu.ts +++ /dev/null @@ -1,17 +0,0 @@ -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.ts b/app/soapbox/actions/email-list.ts similarity index 100% rename from app/soapbox/actions/email_list.ts rename to app/soapbox/actions/email-list.ts diff --git a/app/soapbox/actions/emoji_reacts.ts b/app/soapbox/actions/emoji-reacts.ts similarity index 100% rename from app/soapbox/actions/emoji_reacts.ts rename to app/soapbox/actions/emoji-reacts.ts diff --git a/app/soapbox/actions/events.ts b/app/soapbox/actions/events.ts new file mode 100644 index 000000000..d4ec49491 --- /dev/null +++ b/app/soapbox/actions/events.ts @@ -0,0 +1,746 @@ +import { defineMessages, IntlShape } from 'react-intl'; + +import api, { getLinks } from 'soapbox/api'; +import toast from 'soapbox/toast'; +import { formatBytes } from 'soapbox/utils/media'; +import resizeImage from 'soapbox/utils/resize-image'; + +import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer'; +import { fetchMedia, uploadMedia } from './media'; +import { closeModal, openModal } from './modals'; +import { + STATUS_FETCH_SOURCE_FAIL, + STATUS_FETCH_SOURCE_REQUEST, + STATUS_FETCH_SOURCE_SUCCESS, +} from './statuses'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities'; + +const LOCATION_SEARCH_REQUEST = 'LOCATION_SEARCH_REQUEST'; +const LOCATION_SEARCH_SUCCESS = 'LOCATION_SEARCH_SUCCESS'; +const LOCATION_SEARCH_FAIL = 'LOCATION_SEARCH_FAIL'; + +const EDIT_EVENT_NAME_CHANGE = 'EDIT_EVENT_NAME_CHANGE'; +const EDIT_EVENT_DESCRIPTION_CHANGE = 'EDIT_EVENT_DESCRIPTION_CHANGE'; +const EDIT_EVENT_START_TIME_CHANGE = 'EDIT_EVENT_START_TIME_CHANGE'; +const EDIT_EVENT_HAS_END_TIME_CHANGE = 'EDIT_EVENT_HAS_END_TIME_CHANGE'; +const EDIT_EVENT_END_TIME_CHANGE = 'EDIT_EVENT_END_TIME_CHANGE'; +const EDIT_EVENT_APPROVAL_REQUIRED_CHANGE = 'EDIT_EVENT_APPROVAL_REQUIRED_CHANGE'; +const EDIT_EVENT_LOCATION_CHANGE = 'EDIT_EVENT_LOCATION_CHANGE'; + +const EVENT_BANNER_UPLOAD_REQUEST = 'EVENT_BANNER_UPLOAD_REQUEST'; +const EVENT_BANNER_UPLOAD_PROGRESS = 'EVENT_BANNER_UPLOAD_PROGRESS'; +const EVENT_BANNER_UPLOAD_SUCCESS = 'EVENT_BANNER_UPLOAD_SUCCESS'; +const EVENT_BANNER_UPLOAD_FAIL = 'EVENT_BANNER_UPLOAD_FAIL'; +const EVENT_BANNER_UPLOAD_UNDO = 'EVENT_BANNER_UPLOAD_UNDO'; + +const EVENT_SUBMIT_REQUEST = 'EVENT_SUBMIT_REQUEST'; +const EVENT_SUBMIT_SUCCESS = 'EVENT_SUBMIT_SUCCESS'; +const EVENT_SUBMIT_FAIL = 'EVENT_SUBMIT_FAIL'; + +const EVENT_JOIN_REQUEST = 'EVENT_JOIN_REQUEST'; +const EVENT_JOIN_SUCCESS = 'EVENT_JOIN_SUCCESS'; +const EVENT_JOIN_FAIL = 'EVENT_JOIN_FAIL'; + +const EVENT_LEAVE_REQUEST = 'EVENT_LEAVE_REQUEST'; +const EVENT_LEAVE_SUCCESS = 'EVENT_LEAVE_SUCCESS'; +const EVENT_LEAVE_FAIL = 'EVENT_LEAVE_FAIL'; + +const EVENT_PARTICIPATIONS_FETCH_REQUEST = 'EVENT_PARTICIPATIONS_FETCH_REQUEST'; +const EVENT_PARTICIPATIONS_FETCH_SUCCESS = 'EVENT_PARTICIPATIONS_FETCH_SUCCESS'; +const EVENT_PARTICIPATIONS_FETCH_FAIL = 'EVENT_PARTICIPATIONS_FETCH_FAIL'; + +const EVENT_PARTICIPATIONS_EXPAND_REQUEST = 'EVENT_PARTICIPATIONS_EXPAND_REQUEST'; +const EVENT_PARTICIPATIONS_EXPAND_SUCCESS = 'EVENT_PARTICIPATIONS_EXPAND_SUCCESS'; +const EVENT_PARTICIPATIONS_EXPAND_FAIL = 'EVENT_PARTICIPATIONS_EXPAND_FAIL'; + +const EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST'; +const EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS'; +const EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL = 'EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL'; + +const EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST'; +const EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS'; +const EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL'; + +const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST'; +const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS'; +const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL'; + +const EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST = 'EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST'; +const EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS'; +const EVENT_PARTICIPATION_REQUEST_REJECT_FAIL = 'EVENT_PARTICIPATION_REQUEST_REJECT_FAIL'; + +const EVENT_COMPOSE_CANCEL = 'EVENT_COMPOSE_CANCEL'; + +const EVENT_FORM_SET = 'EVENT_FORM_SET'; + +const RECENT_EVENTS_FETCH_REQUEST = 'RECENT_EVENTS_FETCH_REQUEST'; +const RECENT_EVENTS_FETCH_SUCCESS = 'RECENT_EVENTS_FETCH_SUCCESS'; +const RECENT_EVENTS_FETCH_FAIL = 'RECENT_EVENTS_FETCH_FAIL'; +const JOINED_EVENTS_FETCH_REQUEST = 'JOINED_EVENTS_FETCH_REQUEST'; +const JOINED_EVENTS_FETCH_SUCCESS = 'JOINED_EVENTS_FETCH_SUCCESS'; +const JOINED_EVENTS_FETCH_FAIL = 'JOINED_EVENTS_FETCH_FAIL'; + +const noOp = () => new Promise(f => f(undefined)); + +const messages = defineMessages({ + exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' }, + success: { id: 'compose_event.submit_success', defaultMessage: 'Your event was created' }, + editSuccess: { id: 'compose_event.edit_success', defaultMessage: 'Your event was edited' }, + joinSuccess: { id: 'join_event.success', defaultMessage: 'Joined the event' }, + joinRequestSuccess: { id: 'join_event.request_success', defaultMessage: 'Requested to join the event' }, + view: { id: 'toast.view', defaultMessage: 'View' }, + authorized: { id: 'compose_event.participation_requests.authorize_success', defaultMessage: 'User accepted' }, + rejected: { id: 'compose_event.participation_requests.reject_success', defaultMessage: 'User rejected' }, +}); + +const locationSearch = (query: string, signal?: AbortSignal) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: LOCATION_SEARCH_REQUEST, query }); + return api(getState).get('/api/v1/pleroma/search/location', { params: { q: query }, signal }).then(({ data: locations }) => { + dispatch({ type: LOCATION_SEARCH_SUCCESS, locations }); + return locations; + }).catch(error => { + dispatch({ type: LOCATION_SEARCH_FAIL }); + throw error; + }); + }; + +const changeEditEventName = (value: string) => ({ + type: EDIT_EVENT_NAME_CHANGE, + value, +}); + +const changeEditEventDescription = (value: string) => ({ + type: EDIT_EVENT_DESCRIPTION_CHANGE, + value, +}); + +const changeEditEventStartTime = (value: Date) => ({ + type: EDIT_EVENT_START_TIME_CHANGE, + value, +}); + +const changeEditEventEndTime = (value: Date) => ({ + type: EDIT_EVENT_END_TIME_CHANGE, + value, +}); + +const changeEditEventHasEndTime = (value: boolean) => ({ + type: EDIT_EVENT_HAS_END_TIME_CHANGE, + value, +}); + +const changeEditEventApprovalRequired = (value: boolean) => ({ + type: EDIT_EVENT_APPROVAL_REQUIRED_CHANGE, + value, +}); + +const changeEditEventLocation = (value: string | null) => + (dispatch: AppDispatch, getState: () => RootState) => { + let location = null; + + if (value) { + location = getState().locations.get(value); + } + + dispatch({ + type: EDIT_EVENT_LOCATION_CHANGE, + value: location, + }); + }; + +const uploadEventBanner = (file: File, intl: IntlShape) => + (dispatch: AppDispatch, getState: () => RootState) => { + const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined; + + let progress = 0; + + dispatch(uploadEventBannerRequest()); + + if (maxImageSize && (file.size > maxImageSize)) { + const limit = formatBytes(maxImageSize); + const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); + toast.error(message); + dispatch(uploadEventBannerFail(true)); + return; + } + + resizeImage(file).then(file => { + const data = new FormData(); + data.append('file', file); + // Account for disparity in size of original image and resized data + + const onUploadProgress = ({ loaded }: any) => { + progress = loaded; + dispatch(uploadEventBannerProgress(progress)); + }; + + 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(uploadEventBannerSuccess(data, file)); + } else if (status === 202) { + const poll = () => { + dispatch(fetchMedia(data.id)).then(({ status, data }) => { + if (status === 200) { + dispatch(uploadEventBannerSuccess(data, file)); + } else if (status === 206) { + setTimeout(() => poll(), 1000); + } + }).catch(error => dispatch(uploadEventBannerFail(error))); + }; + + poll(); + } + }); + }).catch(error => dispatch(uploadEventBannerFail(error))); + }; + +const uploadEventBannerRequest = () => ({ + type: EVENT_BANNER_UPLOAD_REQUEST, +}); + +const uploadEventBannerProgress = (loaded: number) => ({ + type: EVENT_BANNER_UPLOAD_PROGRESS, + loaded, +}); + +const uploadEventBannerSuccess = (media: APIEntity, file: File) => ({ + type: EVENT_BANNER_UPLOAD_SUCCESS, + media, + file, +}); + +const uploadEventBannerFail = (error: AxiosError | true) => ({ + type: EVENT_BANNER_UPLOAD_FAIL, + error, +}); + +const undoUploadEventBanner = () => ({ + type: EVENT_BANNER_UPLOAD_UNDO, +}); + +const submitEvent = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + + const id = state.compose_event.id; + const name = state.compose_event.name; + const status = state.compose_event.status; + const banner = state.compose_event.banner; + const startTime = state.compose_event.start_time; + const endTime = state.compose_event.end_time; + const joinMode = state.compose_event.approval_required ? 'restricted' : 'free'; + const location = state.compose_event.location; + + if (!name || !name.length) { + return; + } + + dispatch(submitEventRequest()); + + const params: Record = { + name, + status, + start_time: startTime, + join_mode: joinMode, + content_type: 'text/markdown', + }; + + if (endTime) params.end_time = endTime; + if (banner) params.banner_id = banner.id; + if (location) params.location_id = location.origin_id; + + return api(getState).request({ + url: id === null ? '/api/v1/pleroma/events' : `/api/v1/pleroma/events/${id}`, + method: id === null ? 'post' : 'put', + data: params, + }).then(({ data }) => { + dispatch(closeModal('COMPOSE_EVENT')); + dispatch(importFetchedStatus(data)); + dispatch(submitEventSuccess(data)); + toast.success( + id ? messages.editSuccess : messages.success, + { + actionLabel: messages.view, + actionLink: `/@${data.account.acct}/events/${data.id}`, + }, + ); + }).catch(function(error) { + dispatch(submitEventFail(error)); + }); + }; + +const submitEventRequest = () => ({ + type: EVENT_SUBMIT_REQUEST, +}); + +const submitEventSuccess = (status: APIEntity) => ({ + type: EVENT_SUBMIT_SUCCESS, + status, +}); + +const submitEventFail = (error: AxiosError) => ({ + type: EVENT_SUBMIT_FAIL, + error, +}); + +const joinEvent = (id: string, participationMessage?: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const status = getState().statuses.get(id); + + if (!status || !status.event || status.event.join_state) { + return dispatch(noOp); + } + + dispatch(joinEventRequest(status)); + + return api(getState).post(`/api/v1/pleroma/events/${id}/join`, { + participation_message: participationMessage, + }).then(({ data }) => { + dispatch(importFetchedStatus(data)); + dispatch(joinEventSuccess(data)); + toast.success( + data.pleroma.event?.join_state === 'pending' ? messages.joinRequestSuccess : messages.joinSuccess, + { + actionLabel: messages.view, + actionLink: `/@${data.account.acct}/events/${data.id}`, + }, + ); + }).catch(function(error) { + dispatch(joinEventFail(error, status, status?.event?.join_state || null)); + }); + }; + +const joinEventRequest = (status: StatusEntity) => ({ + type: EVENT_JOIN_REQUEST, + id: status.id, +}); + +const joinEventSuccess = (status: APIEntity) => ({ + type: EVENT_JOIN_SUCCESS, + id: status.id, +}); + +const joinEventFail = (error: AxiosError, status: StatusEntity, previousState: string | null) => ({ + type: EVENT_JOIN_FAIL, + error, + id: status.id, + previousState, +}); + +const leaveEvent = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const status = getState().statuses.get(id); + + if (!status || !status.event || !status.event.join_state) { + return dispatch(noOp); + } + + dispatch(leaveEventRequest(status)); + + return api(getState).post(`/api/v1/pleroma/events/${id}/leave`).then(({ data }) => { + dispatch(importFetchedStatus(data)); + dispatch(leaveEventSuccess(data)); + }).catch(function(error) { + dispatch(leaveEventFail(error, status)); + }); + }; + +const leaveEventRequest = (status: StatusEntity) => ({ + type: EVENT_LEAVE_REQUEST, + id: status.id, +}); + +const leaveEventSuccess = (status: APIEntity) => ({ + type: EVENT_LEAVE_SUCCESS, + id: status.id, +}); + +const leaveEventFail = (error: AxiosError, status: StatusEntity) => ({ + type: EVENT_LEAVE_FAIL, + id: status.id, + error, +}); + +const fetchEventParticipations = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchEventParticipationsRequest(id)); + + return api(getState).get(`/api/v1/pleroma/events/${id}/participations`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + return dispatch(fetchEventParticipationsSuccess(id, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchEventParticipationsFail(id, error)); + }); + }; + +const fetchEventParticipationsRequest = (id: string) => ({ + type: EVENT_PARTICIPATIONS_FETCH_REQUEST, + id, +}); + +const fetchEventParticipationsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: EVENT_PARTICIPATIONS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchEventParticipationsFail = (id: string, error: AxiosError) => ({ + type: EVENT_PARTICIPATIONS_FETCH_FAIL, + id, + error, +}); + +const expandEventParticipations = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().user_lists.event_participations.get(id)?.next || null; + + if (url === null) { + return dispatch(noOp); + } + + dispatch(expandEventParticipationsRequest(id)); + + return api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + return dispatch(expandEventParticipationsSuccess(id, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandEventParticipationsFail(id, error)); + }); + }; + +const expandEventParticipationsRequest = (id: string) => ({ + type: EVENT_PARTICIPATIONS_EXPAND_REQUEST, + id, +}); + +const expandEventParticipationsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: EVENT_PARTICIPATIONS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandEventParticipationsFail = (id: string, error: AxiosError) => ({ + type: EVENT_PARTICIPATIONS_EXPAND_FAIL, + id, + error, +}); + +const fetchEventParticipationRequests = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchEventParticipationRequestsRequest(id)); + + return api(getState).get(`/api/v1/pleroma/events/${id}/participation_requests`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data.map(({ account }: APIEntity) => account))); + return dispatch(fetchEventParticipationRequestsSuccess(id, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchEventParticipationRequestsFail(id, error)); + }); + }; + +const fetchEventParticipationRequestsRequest = (id: string) => ({ + type: EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST, + id, +}); + +const fetchEventParticipationRequestsSuccess = (id: string, participations: APIEntity[], next: string | null) => ({ + type: EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS, + id, + participations, + next, +}); + +const fetchEventParticipationRequestsFail = (id: string, error: AxiosError) => ({ + type: EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL, + id, + error, +}); + +const expandEventParticipationRequests = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().user_lists.event_participations.get(id)?.next || null; + + if (url === null) { + return dispatch(noOp); + } + + dispatch(expandEventParticipationRequestsRequest(id)); + + return api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data.map(({ account }: APIEntity) => account))); + return dispatch(expandEventParticipationRequestsSuccess(id, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandEventParticipationRequestsFail(id, error)); + }); + }; + +const expandEventParticipationRequestsRequest = (id: string) => ({ + type: EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST, + id, +}); + +const expandEventParticipationRequestsSuccess = (id: string, participations: APIEntity[], next: string | null) => ({ + type: EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS, + id, + participations, + next, +}); + +const expandEventParticipationRequestsFail = (id: string, error: AxiosError) => ({ + type: EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL, + id, + error, +}); + +const authorizeEventParticipationRequest = (id: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(authorizeEventParticipationRequestRequest(id, accountId)); + + return api(getState) + .post(`/api/v1/pleroma/events/${id}/participation_requests/${accountId}/authorize`) + .then(() => { + dispatch(authorizeEventParticipationRequestSuccess(id, accountId)); + toast.success(messages.authorized); + }) + .catch(error => dispatch(authorizeEventParticipationRequestFail(id, accountId, error))); + }; + +const authorizeEventParticipationRequestRequest = (id: string, accountId: string) => ({ + type: EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST, + id, + accountId, +}); + +const authorizeEventParticipationRequestSuccess = (id: string, accountId: string) => ({ + type: EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS, + id, + accountId, +}); + +const authorizeEventParticipationRequestFail = (id: string, accountId: string, error: AxiosError) => ({ + type: EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL, + id, + accountId, + error, +}); + +const rejectEventParticipationRequest = (id: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(rejectEventParticipationRequestRequest(id, accountId)); + + return api(getState) + .post(`/api/v1/pleroma/events/${id}/participation_requests/${accountId}/reject`) + .then(() => { + dispatch(rejectEventParticipationRequestSuccess(id, accountId)); + toast.success(messages.rejected); + }) + .catch(error => dispatch(rejectEventParticipationRequestFail(id, accountId, error))); + }; + +const rejectEventParticipationRequestRequest = (id: string, accountId: string) => ({ + type: EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST, + id, + accountId, +}); + +const rejectEventParticipationRequestSuccess = (id: string, accountId: string) => ({ + type: EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS, + id, + accountId, +}); + +const rejectEventParticipationRequestFail = (id: string, accountId: string, error: AxiosError) => ({ + type: EVENT_PARTICIPATION_REQUEST_REJECT_FAIL, + id, + accountId, + error, +}); + +const fetchEventIcs = (id: string) => + (dispatch: any, getState: () => RootState) => + api(getState).get(`/api/v1/pleroma/events/${id}/ics`); + +const cancelEventCompose = () => ({ + type: EVENT_COMPOSE_CANCEL, +}); + +const editEvent = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { + const status = getState().statuses.get(id)!; + + dispatch({ type: STATUS_FETCH_SOURCE_REQUEST }); + + api(getState).get(`/api/v1/statuses/${id}/source`).then(response => { + dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS }); + dispatch({ + type: EVENT_FORM_SET, + status, + text: response.data.text, + location: response.data.location, + }); + dispatch(openModal('COMPOSE_EVENT')); + }).catch(error => { + dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error }); + }); +}; + +const fetchRecentEvents = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (getState().status_lists.get('recent_events')?.isLoading) { + return; + } + + dispatch({ type: RECENT_EVENTS_FETCH_REQUEST }); + + api(getState).get('/api/v1/timelines/public?only_events=true').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch({ + type: RECENT_EVENTS_FETCH_SUCCESS, + statuses: response.data, + next: next ? next.uri : null, + }); + }).catch(error => { + dispatch({ type: RECENT_EVENTS_FETCH_FAIL, error }); + }); + }; + +const fetchJoinedEvents = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (getState().status_lists.get('joined_events')?.isLoading) { + return; + } + + dispatch({ type: JOINED_EVENTS_FETCH_REQUEST }); + + api(getState).get('/api/v1/pleroma/events/joined_events').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch({ + type: JOINED_EVENTS_FETCH_SUCCESS, + statuses: response.data, + next: next ? next.uri : null, + }); + }).catch(error => { + dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error }); + }); + }; + +export { + LOCATION_SEARCH_REQUEST, + LOCATION_SEARCH_SUCCESS, + LOCATION_SEARCH_FAIL, + EDIT_EVENT_NAME_CHANGE, + EDIT_EVENT_DESCRIPTION_CHANGE, + EDIT_EVENT_START_TIME_CHANGE, + EDIT_EVENT_END_TIME_CHANGE, + EDIT_EVENT_HAS_END_TIME_CHANGE, + EDIT_EVENT_APPROVAL_REQUIRED_CHANGE, + EDIT_EVENT_LOCATION_CHANGE, + EVENT_BANNER_UPLOAD_REQUEST, + EVENT_BANNER_UPLOAD_PROGRESS, + EVENT_BANNER_UPLOAD_SUCCESS, + EVENT_BANNER_UPLOAD_FAIL, + EVENT_BANNER_UPLOAD_UNDO, + EVENT_SUBMIT_REQUEST, + EVENT_SUBMIT_SUCCESS, + EVENT_SUBMIT_FAIL, + EVENT_JOIN_REQUEST, + EVENT_JOIN_SUCCESS, + EVENT_JOIN_FAIL, + EVENT_LEAVE_REQUEST, + EVENT_LEAVE_SUCCESS, + EVENT_LEAVE_FAIL, + EVENT_PARTICIPATIONS_FETCH_REQUEST, + EVENT_PARTICIPATIONS_FETCH_SUCCESS, + EVENT_PARTICIPATIONS_FETCH_FAIL, + EVENT_PARTICIPATIONS_EXPAND_REQUEST, + EVENT_PARTICIPATIONS_EXPAND_SUCCESS, + EVENT_PARTICIPATIONS_EXPAND_FAIL, + EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST, + EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS, + EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL, + EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST, + EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS, + EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL, + EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST, + EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS, + EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL, + EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST, + EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS, + EVENT_PARTICIPATION_REQUEST_REJECT_FAIL, + EVENT_COMPOSE_CANCEL, + EVENT_FORM_SET, + RECENT_EVENTS_FETCH_REQUEST, + RECENT_EVENTS_FETCH_SUCCESS, + RECENT_EVENTS_FETCH_FAIL, + JOINED_EVENTS_FETCH_REQUEST, + JOINED_EVENTS_FETCH_SUCCESS, + JOINED_EVENTS_FETCH_FAIL, + locationSearch, + changeEditEventName, + changeEditEventDescription, + changeEditEventStartTime, + changeEditEventEndTime, + changeEditEventHasEndTime, + changeEditEventApprovalRequired, + changeEditEventLocation, + uploadEventBanner, + uploadEventBannerRequest, + uploadEventBannerProgress, + uploadEventBannerSuccess, + uploadEventBannerFail, + undoUploadEventBanner, + submitEvent, + submitEventRequest, + submitEventSuccess, + submitEventFail, + joinEvent, + joinEventRequest, + joinEventSuccess, + joinEventFail, + leaveEvent, + leaveEventRequest, + leaveEventSuccess, + leaveEventFail, + fetchEventParticipations, + fetchEventParticipationsRequest, + fetchEventParticipationsSuccess, + fetchEventParticipationsFail, + expandEventParticipations, + expandEventParticipationsRequest, + expandEventParticipationsSuccess, + expandEventParticipationsFail, + fetchEventParticipationRequests, + fetchEventParticipationRequestsRequest, + fetchEventParticipationRequestsSuccess, + fetchEventParticipationRequestsFail, + expandEventParticipationRequests, + expandEventParticipationRequestsRequest, + expandEventParticipationRequestsSuccess, + expandEventParticipationRequestsFail, + authorizeEventParticipationRequest, + authorizeEventParticipationRequestRequest, + authorizeEventParticipationRequestSuccess, + authorizeEventParticipationRequestFail, + rejectEventParticipationRequest, + rejectEventParticipationRequestRequest, + rejectEventParticipationRequestSuccess, + rejectEventParticipationRequestFail, + fetchEventIcs, + cancelEventCompose, + editEvent, + fetchRecentEvents, + fetchJoinedEvents, +}; diff --git a/app/soapbox/actions/export_data.ts b/app/soapbox/actions/export-data.ts similarity index 92% rename from app/soapbox/actions/export_data.ts rename to app/soapbox/actions/export-data.ts index b558c9e6e..519725ea2 100644 --- a/app/soapbox/actions/export_data.ts +++ b/app/soapbox/actions/export-data.ts @@ -1,10 +1,9 @@ import { defineMessages } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; import api, { getLinks } from 'soapbox/api'; import { normalizeAccount } from 'soapbox/normalizers'; +import toast from 'soapbox/toast'; -import type { SnackbarAction } from './snackbar'; import type { AxiosResponse } from 'axios'; import type { RootState } from 'soapbox/store'; @@ -35,9 +34,9 @@ type ExportDataActions = { | typeof EXPORT_BLOCKS_FAIL | typeof EXPORT_MUTES_REQUEST | typeof EXPORT_MUTES_SUCCESS - | typeof EXPORT_MUTES_FAIL, - error?: any, -} | SnackbarAction + | typeof EXPORT_MUTES_FAIL + error?: any +} function fileExport(content: string, fileName: string) { const fileToDownload = document.createElement('a'); @@ -75,7 +74,7 @@ export const exportFollows = () => (dispatch: React.Dispatch, followings.unshift('Account address,Show boosts'); fileExport(followings.join('\n'), 'export_followings.csv'); - dispatch(snackbar.success(messages.followersSuccess)); + toast.success(messages.followersSuccess); dispatch({ type: EXPORT_FOLLOWS_SUCCESS }); }).catch(error => { dispatch({ type: EXPORT_FOLLOWS_FAIL, error }); @@ -90,7 +89,7 @@ export const exportBlocks = () => (dispatch: React.Dispatch, .then((blocks) => { fileExport(blocks.join('\n'), 'export_block.csv'); - dispatch(snackbar.success(messages.blocksSuccess)); + toast.success(messages.blocksSuccess); dispatch({ type: EXPORT_BLOCKS_SUCCESS }); }).catch(error => { dispatch({ type: EXPORT_BLOCKS_FAIL, error }); @@ -105,7 +104,7 @@ export const exportMutes = () => (dispatch: React.Dispatch, g .then((mutes) => { fileExport(mutes.join('\n'), 'export_mutes.csv'); - dispatch(snackbar.success(messages.mutesSuccess)); + toast.success(messages.mutesSuccess); dispatch({ type: EXPORT_MUTES_SUCCESS }); }).catch(error => { dispatch({ type: EXPORT_MUTES_FAIL, error }); diff --git a/app/soapbox/actions/external_auth.ts b/app/soapbox/actions/external-auth.ts similarity index 90% rename from app/soapbox/actions/external_auth.ts rename to app/soapbox/actions/external-auth.ts index 064e100c9..87840cfe7 100644 --- a/app/soapbox/actions/external_auth.ts +++ b/app/soapbox/actions/external-auth.ts @@ -15,10 +15,11 @@ import sourceCode from 'soapbox/utils/code'; import { getWalletAndSign } from 'soapbox/utils/ethereum'; import { getFeatures } from 'soapbox/utils/features'; import { getQuirks } from 'soapbox/utils/quirks'; +import { getInstanceScopes } from 'soapbox/utils/scopes'; import { baseClient } from '../api'; -import type { AppDispatch } from 'soapbox/store'; +import type { AppDispatch, RootState } from 'soapbox/store'; import type { Instance } from 'soapbox/types/entities'; const fetchExternalInstance = (baseURL?: string) => { @@ -37,25 +38,23 @@ const fetchExternalInstance = (baseURL?: string) => { }; const createExternalApp = (instance: Instance, baseURL?: string) => - (dispatch: AppDispatch) => { + (dispatch: AppDispatch, _getState: () => RootState) => { // Mitra: skip creating the auth app if (getQuirks(instance).noApps) return new Promise(f => f({})); - const { scopes } = getFeatures(instance); - const params = { - client_name: sourceCode.displayName, + client_name: sourceCode.displayName, redirect_uris: `${window.location.origin}/login/external`, - website: sourceCode.homepage, - scopes, + website: sourceCode.homepage, + scopes: getInstanceScopes(instance), }; return dispatch(createApp(params, baseURL)); }; const externalAuthorize = (instance: Instance, baseURL: string) => - (dispatch: AppDispatch) => { - const { scopes } = getFeatures(instance); + (dispatch: AppDispatch, _getState: () => RootState) => { + const scopes = getInstanceScopes(instance); return dispatch(createExternalApp(instance, baseURL)).then((app) => { const { client_id, redirect_uri } = app as Record; @@ -76,7 +75,7 @@ const externalAuthorize = (instance: Instance, baseURL: string) => }; const externalEthereumLogin = (instance: Instance, baseURL?: string) => - (dispatch: AppDispatch) => { + (dispatch: AppDispatch, getState: () => RootState) => { const loginMessage = instance.login_message; return getWalletAndSign(loginMessage).then(({ wallet, signature }) => { @@ -89,7 +88,7 @@ const externalEthereumLogin = (instance: Instance, baseURL?: string) => client_secret: client_secret, password: signature as string, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', - scope: getFeatures(instance).scopes, + scope: getInstanceScopes(instance), }; return dispatch(obtainOAuthToken(params, baseURL)) diff --git a/app/soapbox/actions/familiar_followers.ts b/app/soapbox/actions/familiar-followers.ts similarity index 84% rename from app/soapbox/actions/familiar_followers.ts rename to app/soapbox/actions/familiar-followers.ts index ec6eca6d8..2d8aa6786 100644 --- a/app/soapbox/actions/familiar_followers.ts +++ b/app/soapbox/actions/familiar-followers.ts @@ -11,25 +11,25 @@ export const FAMILIAR_FOLLOWERS_FETCH_SUCCESS = 'FAMILIAR_FOLLOWERS_FETCH_SUCCES export const FAMILIAR_FOLLOWERS_FETCH_FAIL = 'FAMILIAR_FOLLOWERS_FETCH_FAIL'; type FamiliarFollowersFetchRequestAction = { - type: typeof FAMILIAR_FOLLOWERS_FETCH_REQUEST, - id: string, + type: typeof FAMILIAR_FOLLOWERS_FETCH_REQUEST + id: string } type FamiliarFollowersFetchRequestSuccessAction = { - type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS, - id: string, - accounts: Array, + type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS + id: string + accounts: Array } type FamiliarFollowersFetchRequestFailAction = { - type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL, - id: string, - error: any, + type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL + id: string + error: any } type AccountsImportAction = { - type: typeof ACCOUNTS_IMPORT, - accounts: Array, + type: typeof ACCOUNTS_IMPORT + accounts: Array } export type FamiliarFollowersActions = FamiliarFollowersFetchRequestAction | FamiliarFollowersFetchRequestSuccessAction | FamiliarFollowersFetchRequestFailAction | AccountsImportAction diff --git a/app/soapbox/actions/filters.ts b/app/soapbox/actions/filters.ts index 61b4e9b63..7e663f88d 100644 --- a/app/soapbox/actions/filters.ts +++ b/app/soapbox/actions/filters.ts @@ -1,7 +1,8 @@ import { defineMessages } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; +import { getFeatures } from 'soapbox/utils/features'; import api from '../api'; @@ -28,6 +29,12 @@ 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, @@ -59,7 +66,7 @@ const createFilter = (phrase: string, expires_at: string, context: Array expires_at, }).then(response => { dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); - dispatch(snackbar.success(messages.added)); + toast.success(messages.added); }).catch(error => { dispatch({ type: FILTERS_CREATE_FAIL, error }); }); @@ -70,7 +77,7 @@ const deleteFilter = (id: string) => dispatch({ type: FILTERS_DELETE_REQUEST }); return api(getState).delete(`/api/v1/filters/${id}`).then(response => { dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data }); - dispatch(snackbar.success(messages.removed)); + toast.success(messages.removed); }).catch(error => { dispatch({ type: FILTERS_DELETE_FAIL, error }); }); diff --git a/app/soapbox/actions/group_editor.ts b/app/soapbox/actions/group_editor.ts deleted file mode 100644 index 23f3491ad..000000000 --- a/app/soapbox/actions/group_editor.ts +++ /dev/null @@ -1,143 +0,0 @@ -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.ts b/app/soapbox/actions/groups.ts index 808cc3204..9715396f3 100644 --- a/app/soapbox/actions/groups.ts +++ b/app/soapbox/actions/groups.ts @@ -1,27 +1,45 @@ -import { AxiosError } from 'axios'; +import { defineMessages } from 'react-intl'; -import { isLoggedIn } from 'soapbox/utils/auth'; +import toast from 'soapbox/toast'; import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; -import { importFetchedAccounts } from './importer'; +import { importFetchedGroups, importFetchedAccounts } from './importer'; +import { closeModal, openModal } from './modals'; +import { deleteFromTimelines } from './timelines'; +import type { AxiosError } from 'axios'; +import type { GroupRole } from 'soapbox/reducers/group-memberships'; import type { AppDispatch, RootState } from 'soapbox/store'; -import type { APIEntity } from 'soapbox/types/entities'; +import type { APIEntity, Group } from 'soapbox/types/entities'; + +const GROUP_EDITOR_SET = 'GROUP_EDITOR_SET'; + +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_DELETE_REQUEST = 'GROUP_DELETE_REQUEST'; +const GROUP_DELETE_SUCCESS = 'GROUP_DELETE_SUCCESS'; +const GROUP_DELETE_FAIL = 'GROUP_DELETE_FAIL'; 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_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 GROUP_JOIN_REQUEST = 'GROUP_JOIN_REQUEST'; const GROUP_JOIN_SUCCESS = 'GROUP_JOIN_SUCCESS'; const GROUP_JOIN_FAIL = 'GROUP_JOIN_FAIL'; @@ -30,47 +48,188 @@ 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_DELETE_STATUS_REQUEST = 'GROUP_DELETE_STATUS_REQUEST'; +const GROUP_DELETE_STATUS_SUCCESS = 'GROUP_DELETE_STATUS_SUCCESS'; +const GROUP_DELETE_STATUS_FAIL = 'GROUP_DELETE_STATUS_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_KICK_REQUEST = 'GROUP_KICK_REQUEST'; +const GROUP_KICK_SUCCESS = 'GROUP_KICK_SUCCESS'; +const GROUP_KICK_FAIL = 'GROUP_KICK_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_BLOCKS_FETCH_REQUEST = 'GROUP_BLOCKS_FETCH_REQUEST'; +const GROUP_BLOCKS_FETCH_SUCCESS = 'GROUP_BLOCKS_FETCH_SUCCESS'; +const GROUP_BLOCKS_FETCH_FAIL = 'GROUP_BLOCKS_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_BLOCKS_EXPAND_REQUEST = 'GROUP_BLOCKS_EXPAND_REQUEST'; +const GROUP_BLOCKS_EXPAND_SUCCESS = 'GROUP_BLOCKS_EXPAND_SUCCESS'; +const GROUP_BLOCKS_EXPAND_FAIL = 'GROUP_BLOCKS_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_BLOCK_REQUEST = 'GROUP_BLOCK_REQUEST'; +const GROUP_BLOCK_SUCCESS = 'GROUP_BLOCK_SUCCESS'; +const GROUP_BLOCK_FAIL = 'GROUP_BLOCK_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_UNBLOCK_REQUEST = 'GROUP_UNBLOCK_REQUEST'; +const GROUP_UNBLOCK_SUCCESS = 'GROUP_UNBLOCK_SUCCESS'; +const GROUP_UNBLOCK_FAIL = 'GROUP_UNBLOCK_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 GROUP_PROMOTE_REQUEST = 'GROUP_PROMOTE_REQUEST'; +const GROUP_PROMOTE_SUCCESS = 'GROUP_PROMOTE_SUCCESS'; +const GROUP_PROMOTE_FAIL = 'GROUP_PROMOTE_FAIL'; + +const GROUP_DEMOTE_REQUEST = 'GROUP_DEMOTE_REQUEST'; +const GROUP_DEMOTE_SUCCESS = 'GROUP_DEMOTE_SUCCESS'; +const GROUP_DEMOTE_FAIL = 'GROUP_DEMOTE_FAIL'; + +const GROUP_MEMBERSHIPS_FETCH_REQUEST = 'GROUP_MEMBERSHIPS_FETCH_REQUEST'; +const GROUP_MEMBERSHIPS_FETCH_SUCCESS = 'GROUP_MEMBERSHIPS_FETCH_SUCCESS'; +const GROUP_MEMBERSHIPS_FETCH_FAIL = 'GROUP_MEMBERSHIPS_FETCH_FAIL'; + +const GROUP_MEMBERSHIPS_EXPAND_REQUEST = 'GROUP_MEMBERSHIPS_EXPAND_REQUEST'; +const GROUP_MEMBERSHIPS_EXPAND_SUCCESS = 'GROUP_MEMBERSHIPS_EXPAND_SUCCESS'; +const GROUP_MEMBERSHIPS_EXPAND_FAIL = 'GROUP_MEMBERSHIPS_EXPAND_FAIL'; + +const GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST = 'GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST'; +const GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS = 'GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS'; +const GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL = 'GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL'; + +const GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST = 'GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST'; +const GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS = 'GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS'; +const GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL = 'GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL'; + +const GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST = 'GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST'; +const GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS'; +const GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL = 'GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL'; + +const GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST = 'GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST'; +const GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS'; +const GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL = 'GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL'; + +const GROUP_EDITOR_TITLE_CHANGE = 'GROUP_EDITOR_TITLE_CHANGE'; +const GROUP_EDITOR_DESCRIPTION_CHANGE = 'GROUP_EDITOR_DESCRIPTION_CHANGE'; +const GROUP_EDITOR_PRIVACY_CHANGE = 'GROUP_EDITOR_PRIVACY_CHANGE'; +const GROUP_EDITOR_MEDIA_CHANGE = 'GROUP_EDITOR_MEDIA_CHANGE'; + +const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET'; + +const messages = defineMessages({ + success: { id: 'manage_group.submit_success', defaultMessage: 'The group was created' }, + editSuccess: { id: 'manage_group.edit_success', defaultMessage: 'The group was edited' }, + joinSuccess: { id: 'group.join.success', defaultMessage: 'Joined the group' }, + joinRequestSuccess: { id: 'group.join.request_success', defaultMessage: 'Requested to join the group' }, + leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' }, + view: { id: 'toast.view', defaultMessage: 'View' }, +}); + +const editGroup = (group: Group) => (dispatch: AppDispatch) => { + dispatch({ + type: GROUP_EDITOR_SET, + group, + }); + dispatch(openModal('MANAGE_GROUP')); +}; + +const createGroup = (params: Record, shouldReset?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(createGroupRequest()); + + return api(getState).post('/api/v1/groups', params, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + .then(({ data }) => { + dispatch(importFetchedGroups([data])); + dispatch(createGroupSuccess(data)); + toast.success(messages.success, { + actionLabel: messages.view, + actionLink: `/groups/${data.id}`, + }); + + if (shouldReset) { + dispatch(resetGroupEditor()); + } + dispatch(closeModal('MANAGE_GROUP')); + }).catch(err => dispatch(createGroupFail(err))); + }; + +const createGroupRequest = () => ({ + type: GROUP_CREATE_REQUEST, +}); + +const createGroupSuccess = (group: APIEntity) => ({ + type: GROUP_CREATE_SUCCESS, + group, +}); + +const createGroupFail = (error: AxiosError) => ({ + type: GROUP_CREATE_FAIL, + error, +}); + +const updateGroup = (id: string, params: Record, shouldReset?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(updateGroupRequest()); + + return api(getState).put(`/api/v1/groups/${id}`, params) + .then(({ data }) => { + dispatch(importFetchedGroups([data])); + dispatch(updateGroupSuccess(data)); + toast.success(messages.editSuccess); + + if (shouldReset) { + dispatch(resetGroupEditor()); + } + dispatch(closeModal('MANAGE_GROUP')); + }).catch(err => dispatch(updateGroupFail(err))); + }; + +const updateGroupRequest = () => ({ + type: GROUP_UPDATE_REQUEST, +}); + +const updateGroupSuccess = (group: APIEntity) => ({ + type: GROUP_UPDATE_SUCCESS, + group, +}); + +const updateGroupFail = (error: AxiosError) => ({ + type: GROUP_UPDATE_FAIL, + error, +}); + +const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(deleteGroupRequest(id)); + + return api(getState).delete(`/api/v1/groups/${id}`) + .then(() => dispatch(deleteGroupSuccess(id))) + .catch(err => dispatch(deleteGroupFail(id, err))); +}; + +const deleteGroupRequest = (id: string) => ({ + type: GROUP_DELETE_REQUEST, + id, +}); + +const deleteGroupSuccess = (id: string) => ({ + type: GROUP_DELETE_SUCCESS, + id, +}); + +const deleteGroupFail = (id: string, error: AxiosError) => ({ + type: GROUP_DELETE_FAIL, + id, + error, +}); 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))) + return api(getState).get(`/api/v1/groups/${id}`) + .then(({ data }) => { + dispatch(importFetchedGroups([data])); + dispatch(fetchGroupSuccess(data)); + }) .catch(err => dispatch(fetchGroupFail(id, err))); }; @@ -90,20 +249,44 @@ const fetchGroupFail = (id: string, error: AxiosError) => ({ error, }); +const fetchGroups = () => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchGroupsRequest()); + + return api(getState).get('/api/v1/groups') + .then(({ data }) => { + dispatch(importFetchedGroups(data)); + dispatch(fetchGroupsSuccess(data)); + dispatch(fetchGroupRelationships(data.map((item: APIEntity) => item.id))); + }).catch(err => dispatch(fetchGroupsFail(err))); +}; + +const fetchGroupsRequest = () => ({ + type: GROUPS_FETCH_REQUEST, +}); + +const fetchGroupsSuccess = (groups: APIEntity[]) => ({ + type: GROUPS_FETCH_SUCCESS, + groups, +}); + +const fetchGroupsFail = (error: AxiosError) => ({ + type: GROUPS_FETCH_FAIL, + error, +}); + const fetchGroupRelationships = (groupIds: string[]) => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; - - const loadedRelationships = getState().group_relationships; + const state = getState(); + const loadedRelationships = state.group_relationships; const newGroupIds = groupIds.filter(id => loadedRelationships.get(id, null) === null); - if (newGroupIds.length === 0) { + if (!state.me || newGroupIds.length === 0) { return; } dispatch(fetchGroupRelationshipsRequest(newGroupIds)); - api(getState).get(`/api/v1/groups/${newGroupIds[0]}/relationships?${newGroupIds.map(id => `id[]=${id}`).join('&')}`).then(response => { + return api(getState).get(`/api/v1/groups/relationships?${newGroupIds.map(id => `id[]=${id}`).join('&')}`).then(response => { dispatch(fetchGroupRelationshipsSuccess(response.data)); }).catch(error => { dispatch(fetchGroupRelationshipsFail(error)); @@ -126,391 +309,670 @@ 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, + skipNotFound: true, }); const joinGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; + const locked = (getState().groups.items.get(id) as any).locked || false; - dispatch(joinGroupRequest(id)); + dispatch(joinGroupRequest(id, locked)); - api(getState).post(`/api/v1/groups/${id}/accounts`).then(response => { + return api(getState).post(`/api/v1/groups/${id}/join`).then(response => { dispatch(joinGroupSuccess(response.data)); + toast.success(locked ? messages.joinRequestSuccess : messages.joinSuccess); }).catch(error => { - dispatch(joinGroupFail(id, error)); + dispatch(joinGroupFail(error, locked)); }); }; 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 => { + return api(getState).post(`/api/v1/groups/${id}/leave`).then(response => { dispatch(leaveGroupSuccess(response.data)); + toast.success(messages.leaveSuccess); }).catch(error => { - dispatch(leaveGroupFail(id, error)); + dispatch(leaveGroupFail(error)); }); }; -const joinGroupRequest = (id: string) => ({ +const joinGroupRequest = (id: string, locked: boolean) => ({ type: GROUP_JOIN_REQUEST, id, + locked, + skipLoading: true, }); const joinGroupSuccess = (relationship: APIEntity) => ({ type: GROUP_JOIN_SUCCESS, relationship, + skipLoading: true, }); -const joinGroupFail = (id: string, error: AxiosError) => ({ +const joinGroupFail = (error: AxiosError, locked: boolean) => ({ type: GROUP_JOIN_FAIL, - id, error, + locked, + skipLoading: true, }); const leaveGroupRequest = (id: string) => ({ type: GROUP_LEAVE_REQUEST, id, + skipLoading: true, }); const leaveGroupSuccess = (relationship: APIEntity) => ({ type: GROUP_LEAVE_SUCCESS, relationship, + skipLoading: true, }); -const leaveGroupFail = (id: string, error: AxiosError) => ({ +const leaveGroupFail = (error: AxiosError) => ({ type: GROUP_LEAVE_FAIL, - id, + error, + skipLoading: true, +}); + +const groupDeleteStatus = (groupId: string, statusId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupDeleteStatusRequest(groupId, statusId)); + + return api(getState).delete(`/api/v1/groups/${groupId}/statuses/${statusId}`) + .then(() => { + dispatch(deleteFromTimelines(statusId)); + dispatch(groupDeleteStatusSuccess(groupId, statusId)); + }).catch(err => dispatch(groupDeleteStatusFail(groupId, statusId, err))); + }; + +const groupDeleteStatusRequest = (groupId: string, statusId: string) => ({ + type: GROUP_DELETE_STATUS_REQUEST, + groupId, + statusId, +}); + +const groupDeleteStatusSuccess = (groupId: string, statusId: string) => ({ + type: GROUP_DELETE_STATUS_SUCCESS, + groupId, + statusId, +}); + +const groupDeleteStatusFail = (groupId: string, statusId: string, error: AxiosError) => ({ + type: GROUP_DELETE_STATUS_SUCCESS, + groupId, + statusId, error, }); -const fetchMembers = (id: string) => +const groupKick = (groupId: string, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; + dispatch(groupKickRequest(groupId, accountId)); - dispatch(fetchMembersRequest(id)); + return api(getState).post(`/api/v1/groups/${groupId}/kick`, { account_ids: [accountId] }) + .then(() => dispatch(groupKickSuccess(groupId, accountId))) + .catch(err => dispatch(groupKickFail(groupId, accountId, err))); + }; - api(getState).get(`/api/v1/groups/${id}/accounts`).then(response => { +const groupKickRequest = (groupId: string, accountId: string) => ({ + type: GROUP_KICK_REQUEST, + groupId, + accountId, +}); + +const groupKickSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_KICK_SUCCESS, + groupId, + accountId, +}); + +const groupKickFail = (groupId: string, accountId: string, error: AxiosError) => ({ + type: GROUP_KICK_SUCCESS, + groupId, + accountId, + error, +}); + +const fetchGroupBlocks = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchGroupBlocksRequest(id)); + + return api(getState).get(`/api/v1/groups/${id}/blocks`).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))); + dispatch(fetchGroupBlocksSuccess(id, response.data, next ? next.uri : null)); }).catch(error => { - dispatch(fetchMembersFail(id, error)); + dispatch(fetchGroupBlocksFail(id, error)); }); }; -const fetchMembersRequest = (id: string) => ({ - type: GROUP_MEMBERS_FETCH_REQUEST, +const fetchGroupBlocksRequest = (id: string) => ({ + type: GROUP_BLOCKS_FETCH_REQUEST, id, }); -const fetchMembersSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ - type: GROUP_MEMBERS_FETCH_SUCCESS, +const fetchGroupBlocksSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_BLOCKS_FETCH_SUCCESS, id, accounts, next, }); -const fetchMembersFail = (id: string, error: AxiosError) => ({ - type: GROUP_MEMBERS_FETCH_FAIL, +const fetchGroupBlocksFail = (id: string, error: AxiosError) => ({ + type: GROUP_BLOCKS_FETCH_FAIL, id, error, + skipNotFound: true, }); -const expandMembers = (id: string) => +const expandGroupBlocks = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; - - const url = getState().user_lists.groups.get(id)!.next; + const url = getState().user_lists.group_blocks.get(id)?.next || null; if (url === null) { return; } - dispatch(expandMembersRequest(id)); + dispatch(expandGroupBlocksRequest(id)); - api(getState).get(url).then(response => { + return 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(expandGroupBlocksSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); }).catch(error => { - dispatch(expandMembersFail(id, error)); + dispatch(expandGroupBlocksFail(id, error)); }); }; -const expandMembersRequest = (id: string) => ({ - type: GROUP_MEMBERS_EXPAND_REQUEST, +const expandGroupBlocksRequest = (id: string) => ({ + type: GROUP_BLOCKS_EXPAND_REQUEST, id, }); -const expandMembersSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ - type: GROUP_MEMBERS_EXPAND_SUCCESS, +const expandGroupBlocksSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_BLOCKS_EXPAND_SUCCESS, id, accounts, next, }); -const expandMembersFail = (id: string, error: AxiosError) => ({ - type: GROUP_MEMBERS_EXPAND_FAIL, +const expandGroupBlocksFail = (id: string, error: AxiosError) => ({ + type: GROUP_BLOCKS_EXPAND_FAIL, id, error, }); -const fetchRemovedAccounts = (id: string) => +const groupBlock = (groupId: string, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; + dispatch(groupBlockRequest(groupId, accountId)); - dispatch(fetchRemovedAccountsRequest(id)); + return api(getState).post(`/api/v1/groups/${groupId}/blocks`, { account_ids: [accountId] }) + .then(() => dispatch(groupBlockSuccess(groupId, accountId))) + .catch(err => dispatch(groupBlockFail(groupId, accountId, err))); + }; - api(getState).get(`/api/v1/groups/${id}/removed_accounts`).then(response => { +const groupBlockRequest = (groupId: string, accountId: string) => ({ + type: GROUP_BLOCK_REQUEST, + groupId, + accountId, +}); + +const groupBlockSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_BLOCK_SUCCESS, + groupId, + accountId, +}); + +const groupBlockFail = (groupId: string, accountId: string, error: AxiosError) => ({ + type: GROUP_BLOCK_FAIL, + groupId, + accountId, + error, +}); + +const groupUnblock = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupUnblockRequest(groupId, accountId)); + + return api(getState).delete(`/api/v1/groups/${groupId}/blocks?account_ids[]=${accountId}`) + .then(() => dispatch(groupUnblockSuccess(groupId, accountId))) + .catch(err => dispatch(groupUnblockFail(groupId, accountId, err))); + }; + +const groupUnblockRequest = (groupId: string, accountId: string) => ({ + type: GROUP_UNBLOCK_REQUEST, + groupId, + accountId, +}); + +const groupUnblockSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_UNBLOCK_SUCCESS, + groupId, + accountId, +}); + +const groupUnblockFail = (groupId: string, accountId: string, error: AxiosError) => ({ + type: GROUP_UNBLOCK_FAIL, + groupId, + accountId, + error, +}); + +const groupPromoteAccount = (groupId: string, accountId: string, role: GroupRole) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupPromoteAccountRequest(groupId, accountId)); + + return api(getState).post(`/api/v1/groups/${groupId}/promote`, { account_ids: [accountId], role: role }) + .then((response) => dispatch(groupPromoteAccountSuccess(groupId, accountId, response.data))) + .catch(err => dispatch(groupPromoteAccountFail(groupId, accountId, err))); + }; + +const groupPromoteAccountRequest = (groupId: string, accountId: string) => ({ + type: GROUP_PROMOTE_REQUEST, + groupId, + accountId, +}); + +const groupPromoteAccountSuccess = (groupId: string, accountId: string, memberships: APIEntity[]) => ({ + type: GROUP_PROMOTE_SUCCESS, + groupId, + accountId, + memberships, +}); + +const groupPromoteAccountFail = (groupId: string, accountId: string, error: AxiosError) => ({ + type: GROUP_PROMOTE_FAIL, + groupId, + accountId, + error, +}); + +const groupDemoteAccount = (groupId: string, accountId: string, role: GroupRole) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupDemoteAccountRequest(groupId, accountId)); + + return api(getState).post(`/api/v1/groups/${groupId}/demote`, { account_ids: [accountId], role: role }) + .then((response) => dispatch(groupDemoteAccountSuccess(groupId, accountId, response.data))) + .catch(err => dispatch(groupDemoteAccountFail(groupId, accountId, err))); + }; + +const groupDemoteAccountRequest = (groupId: string, accountId: string) => ({ + type: GROUP_DEMOTE_REQUEST, + groupId, + accountId, +}); + +const groupDemoteAccountSuccess = (groupId: string, accountId: string, memberships: APIEntity[]) => ({ + type: GROUP_DEMOTE_SUCCESS, + groupId, + accountId, + memberships, +}); + +const groupDemoteAccountFail = (groupId: string, accountId: string, error: AxiosError) => ({ + type: GROUP_DEMOTE_FAIL, + groupId, + accountId, + error, +}); + +const fetchGroupMemberships = (id: string, role: GroupRole) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchGroupMembershipsRequest(id, role)); + + return api(getState).get(`/api/v1/groups/${id}/memberships`, { params: { role } }).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))); + dispatch(importFetchedAccounts(response.data.map((membership: APIEntity) => membership.account))); + dispatch(fetchGroupMembershipsSuccess(id, role, response.data, next ? next.uri : null)); }).catch(error => { - dispatch(fetchRemovedAccountsFail(id, error)); + dispatch(fetchGroupMembershipsFail(id, role, error)); }); }; -const fetchRemovedAccountsRequest = (id: string) => ({ - type: GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST, +const fetchGroupMembershipsRequest = (id: string, role: GroupRole) => ({ + type: GROUP_MEMBERSHIPS_FETCH_REQUEST, id, + role, }); -const fetchRemovedAccountsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ - type: GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS, +const fetchGroupMembershipsSuccess = (id: string, role: GroupRole, memberships: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIPS_FETCH_SUCCESS, id, - accounts, + role, + memberships, next, }); -const fetchRemovedAccountsFail = (id: string, error: AxiosError) => ({ - type: GROUP_REMOVED_ACCOUNTS_FETCH_FAIL, +const fetchGroupMembershipsFail = (id: string, role: GroupRole, error: AxiosError) => ({ + type: GROUP_MEMBERSHIPS_FETCH_FAIL, id, + role, error, + skipNotFound: true, }); -const expandRemovedAccounts = (id: string) => +const expandGroupMemberships = (id: string, role: GroupRole) => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; - - const url = getState().user_lists.groups_removed_accounts.get(id)!.next; + const url = getState().group_memberships.get(role).get(id)?.next || null; if (url === null) { return; } - dispatch(expandRemovedAccountsRequest(id)); + dispatch(expandGroupMembershipsRequest(id, role)); - api(getState).get(url).then(response => { + return 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(importFetchedAccounts(response.data.map((membership: APIEntity) => membership.account))); + dispatch(expandGroupMembershipsSuccess(id, role, response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); }).catch(error => { - dispatch(expandRemovedAccountsFail(id, error)); + dispatch(expandGroupMembershipsFail(id, role, error)); }); }; -const expandRemovedAccountsRequest = (id: string) => ({ - type: GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST, +const expandGroupMembershipsRequest = (id: string, role: GroupRole) => ({ + type: GROUP_MEMBERSHIPS_EXPAND_REQUEST, + id, + role, +}); + +const expandGroupMembershipsSuccess = (id: string, role: GroupRole, memberships: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIPS_EXPAND_SUCCESS, + id, + role, + memberships, + next, +}); + +const expandGroupMembershipsFail = (id: string, role: GroupRole, error: AxiosError) => ({ + type: GROUP_MEMBERSHIPS_EXPAND_FAIL, + id, + role, + error, +}); + +const fetchGroupMembershipRequests = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchGroupMembershipRequestsRequest(id)); + + return api(getState).get(`/api/v1/groups/${id}/membership_requests`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchGroupMembershipRequestsSuccess(id, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchGroupMembershipRequestsFail(id, error)); + }); + }; + +const fetchGroupMembershipRequestsRequest = (id: string) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST, id, }); -const expandRemovedAccountsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ - type: GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS, +const fetchGroupMembershipRequestsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS, id, accounts, next, }); -const expandRemovedAccountsFail = (id: string, error: AxiosError) => ({ - type: GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL, +const fetchGroupMembershipRequestsFail = (id: string, error: AxiosError) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL, id, error, + skipNotFound: true, }); -const removeRemovedAccount = (groupId: string, id: string) => +const expandGroupMembershipRequests = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; + const url = getState().user_lists.membership_requests.get(id)?.next || null; - dispatch(removeRemovedAccountRequest(groupId, id)); + if (url === null) { + return; + } - api(getState).delete(`/api/v1/groups/${groupId}/removed_accounts?account_id=${id}`).then(response => { - dispatch(removeRemovedAccountSuccess(groupId, id)); + dispatch(expandGroupMembershipRequestsRequest(id)); + + return api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandGroupMembershipRequestsSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); }).catch(error => { - dispatch(removeRemovedAccountFail(groupId, id, error)); + dispatch(expandGroupMembershipRequestsFail(id, error)); }); }; -const removeRemovedAccountRequest = (groupId: string, id: string) => ({ - type: GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST, - groupId, +const expandGroupMembershipRequestsRequest = (id: string) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST, id, }); -const removeRemovedAccountSuccess = (groupId: string, id: string) => ({ - type: GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS, - groupId, +const expandGroupMembershipRequestsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS, id, + accounts, + next, }); -const removeRemovedAccountFail = (groupId: string, id: string, error: AxiosError) => ({ - type: GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL, - groupId, +const expandGroupMembershipRequestsFail = (id: string, error: AxiosError) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL, id, error, }); -const createRemovedAccount = (groupId: string, id: string) => +const authorizeGroupMembershipRequest = (groupId: string, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; + dispatch(authorizeGroupMembershipRequestRequest(groupId, accountId)); - 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)); - }); + return api(getState) + .post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`) + .then(() => dispatch(authorizeGroupMembershipRequestSuccess(groupId, accountId))) + .catch(error => dispatch(authorizeGroupMembershipRequestFail(groupId, accountId, error))); }; -const createRemovedAccountRequest = (groupId: string, id: string) => ({ - type: GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST, +const authorizeGroupMembershipRequestRequest = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST, groupId, - id, + accountId, }); -const createRemovedAccountSuccess = (groupId: string, id: string) => ({ - type: GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS, +const authorizeGroupMembershipRequestSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS, groupId, - id, + accountId, }); -const createRemovedAccountFail = (groupId: string, id: string, error: AxiosError) => ({ - type: GROUP_REMOVED_ACCOUNTS_CREATE_FAIL, +const authorizeGroupMembershipRequestFail = (groupId: string, accountId: string, error: AxiosError) => ({ + type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL, groupId, - id, + accountId, error, }); -const groupRemoveStatus = (groupId: string, id: string) => +const rejectGroupMembershipRequest = (groupId: string, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; + dispatch(rejectGroupMembershipRequestRequest(groupId, accountId)); - 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)); - }); + return api(getState) + .post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`) + .then(() => dispatch(rejectGroupMembershipRequestSuccess(groupId, accountId))) + .catch(error => dispatch(rejectGroupMembershipRequestFail(groupId, accountId, error))); }; -const groupRemoveStatusRequest = (groupId: string, id: string) => ({ - type: GROUP_REMOVE_STATUS_REQUEST, +const rejectGroupMembershipRequestRequest = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST, groupId, - id, + accountId, }); -const groupRemoveStatusSuccess = (groupId: string, id: string) => ({ - type: GROUP_REMOVE_STATUS_SUCCESS, +const rejectGroupMembershipRequestSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS, groupId, - id, + accountId, }); -const groupRemoveStatusFail = (groupId: string, id: string, error: AxiosError) => ({ - type: GROUP_REMOVE_STATUS_FAIL, +const rejectGroupMembershipRequestFail = (groupId: string, accountId: string, error?: AxiosError) => ({ + type: GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL, groupId, - id, + accountId, error, }); +const changeGroupEditorTitle = (value: string) => ({ + type: GROUP_EDITOR_TITLE_CHANGE, + value, +}); + +const changeGroupEditorDescription = (value: string) => ({ + type: GROUP_EDITOR_DESCRIPTION_CHANGE, + value, +}); + +const changeGroupEditorPrivacy = (value: boolean) => ({ + type: GROUP_EDITOR_PRIVACY_CHANGE, + value, +}); + +const changeGroupEditorMedia = (mediaType: 'header' | 'avatar', file: File) => ({ + type: GROUP_EDITOR_MEDIA_CHANGE, + mediaType, + value: file, +}); + +const resetGroupEditor = () => ({ + type: GROUP_EDITOR_RESET, +}); + +const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => { + const groupId = getState().group_editor.groupId; + const displayName = getState().group_editor.displayName; + const note = getState().group_editor.note; + const avatar = getState().group_editor.avatar; + const header = getState().group_editor.header; + + const params: Record = { + display_name: displayName, + note, + }; + + if (avatar) params.avatar = avatar; + if (header) params.header = header; + + if (groupId === null) { + dispatch(createGroup(params, shouldReset)); + } else { + dispatch(updateGroup(groupId, params, shouldReset)); + } +}; + export { + GROUP_EDITOR_SET, + GROUP_CREATE_REQUEST, + GROUP_CREATE_SUCCESS, + GROUP_CREATE_FAIL, + GROUP_UPDATE_REQUEST, + GROUP_UPDATE_SUCCESS, + GROUP_UPDATE_FAIL, + GROUP_DELETE_REQUEST, + GROUP_DELETE_SUCCESS, + GROUP_DELETE_FAIL, 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_RELATIONSHIPS_FETCH_REQUEST, + GROUP_RELATIONSHIPS_FETCH_SUCCESS, + GROUP_RELATIONSHIPS_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, + GROUP_DELETE_STATUS_REQUEST, + GROUP_DELETE_STATUS_SUCCESS, + GROUP_DELETE_STATUS_FAIL, + GROUP_KICK_REQUEST, + GROUP_KICK_SUCCESS, + GROUP_KICK_FAIL, + GROUP_BLOCKS_FETCH_REQUEST, + GROUP_BLOCKS_FETCH_SUCCESS, + GROUP_BLOCKS_FETCH_FAIL, + GROUP_BLOCKS_EXPAND_REQUEST, + GROUP_BLOCKS_EXPAND_SUCCESS, + GROUP_BLOCKS_EXPAND_FAIL, + GROUP_BLOCK_REQUEST, + GROUP_BLOCK_SUCCESS, + GROUP_BLOCK_FAIL, + GROUP_UNBLOCK_REQUEST, + GROUP_UNBLOCK_SUCCESS, + GROUP_UNBLOCK_FAIL, + GROUP_PROMOTE_REQUEST, + GROUP_PROMOTE_SUCCESS, + GROUP_PROMOTE_FAIL, + GROUP_DEMOTE_REQUEST, + GROUP_DEMOTE_SUCCESS, + GROUP_DEMOTE_FAIL, + GROUP_MEMBERSHIPS_FETCH_REQUEST, + GROUP_MEMBERSHIPS_FETCH_SUCCESS, + GROUP_MEMBERSHIPS_FETCH_FAIL, + GROUP_MEMBERSHIPS_EXPAND_REQUEST, + GROUP_MEMBERSHIPS_EXPAND_SUCCESS, + GROUP_MEMBERSHIPS_EXPAND_FAIL, + GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST, + GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS, + GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL, + GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST, + GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS, + GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL, + GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST, + GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS, + GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL, + GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST, + GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS, + GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL, + GROUP_EDITOR_TITLE_CHANGE, + GROUP_EDITOR_DESCRIPTION_CHANGE, + GROUP_EDITOR_PRIVACY_CHANGE, + GROUP_EDITOR_MEDIA_CHANGE, + GROUP_EDITOR_RESET, + editGroup, + createGroup, + createGroupRequest, + createGroupSuccess, + createGroupFail, + updateGroup, + updateGroupRequest, + updateGroupSuccess, + updateGroupFail, + deleteGroup, + deleteGroupRequest, + deleteGroupSuccess, + deleteGroupFail, fetchGroup, fetchGroupRequest, fetchGroupSuccess, fetchGroupFail, - fetchGroupRelationships, - fetchGroupRelationshipsRequest, - fetchGroupRelationshipsSuccess, - fetchGroupRelationshipsFail, fetchGroups, fetchGroupsRequest, fetchGroupsSuccess, fetchGroupsFail, + fetchGroupRelationships, + fetchGroupRelationshipsRequest, + fetchGroupRelationshipsSuccess, + fetchGroupRelationshipsFail, joinGroup, leaveGroup, joinGroupRequest, @@ -519,32 +981,66 @@ export { 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, + groupDeleteStatus, + groupDeleteStatusRequest, + groupDeleteStatusSuccess, + groupDeleteStatusFail, + groupKick, + groupKickRequest, + groupKickSuccess, + groupKickFail, + fetchGroupBlocks, + fetchGroupBlocksRequest, + fetchGroupBlocksSuccess, + fetchGroupBlocksFail, + expandGroupBlocks, + expandGroupBlocksRequest, + expandGroupBlocksSuccess, + expandGroupBlocksFail, + groupBlock, + groupBlockRequest, + groupBlockSuccess, + groupBlockFail, + groupUnblock, + groupUnblockRequest, + groupUnblockSuccess, + groupUnblockFail, + groupPromoteAccount, + groupPromoteAccountRequest, + groupPromoteAccountSuccess, + groupPromoteAccountFail, + groupDemoteAccount, + groupDemoteAccountRequest, + groupDemoteAccountSuccess, + groupDemoteAccountFail, + fetchGroupMemberships, + fetchGroupMembershipsRequest, + fetchGroupMembershipsSuccess, + fetchGroupMembershipsFail, + expandGroupMemberships, + expandGroupMembershipsRequest, + expandGroupMembershipsSuccess, + expandGroupMembershipsFail, + fetchGroupMembershipRequests, + fetchGroupMembershipRequestsRequest, + fetchGroupMembershipRequestsSuccess, + fetchGroupMembershipRequestsFail, + expandGroupMembershipRequests, + expandGroupMembershipRequestsRequest, + expandGroupMembershipRequestsSuccess, + expandGroupMembershipRequestsFail, + authorizeGroupMembershipRequest, + authorizeGroupMembershipRequestRequest, + authorizeGroupMembershipRequestSuccess, + authorizeGroupMembershipRequestFail, + rejectGroupMembershipRequest, + rejectGroupMembershipRequestRequest, + rejectGroupMembershipRequestSuccess, + rejectGroupMembershipRequestFail, + changeGroupEditorTitle, + changeGroupEditorDescription, + changeGroupEditorPrivacy, + changeGroupEditorMedia, + resetGroupEditor, + submitGroupEditor, }; diff --git a/app/soapbox/actions/import_data.ts b/app/soapbox/actions/import-data.ts similarity index 88% rename from app/soapbox/actions/import_data.ts rename to app/soapbox/actions/import-data.ts index 43de9f85c..023529453 100644 --- a/app/soapbox/actions/import_data.ts +++ b/app/soapbox/actions/import-data.ts @@ -1,10 +1,9 @@ import { defineMessages } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; +import toast from 'soapbox/toast'; import api from '../api'; -import type { SnackbarAction } from './snackbar'; import type { RootState } from 'soapbox/store'; export const IMPORT_FOLLOWS_REQUEST = 'IMPORT_FOLLOWS_REQUEST'; @@ -28,10 +27,10 @@ type ImportDataActions = { | typeof IMPORT_BLOCKS_FAIL | typeof IMPORT_MUTES_REQUEST | typeof IMPORT_MUTES_SUCCESS - | typeof IMPORT_MUTES_FAIL, - error?: any, + | typeof IMPORT_MUTES_FAIL + error?: any config?: string -} | SnackbarAction +} const messages = defineMessages({ blocksSuccess: { id: 'import_data.success.blocks', defaultMessage: 'Blocks imported successfully' }, @@ -45,7 +44,7 @@ export const importFollows = (params: FormData) => return api(getState) .post('/api/pleroma/follow_import', params) .then(response => { - dispatch(snackbar.success(messages.followersSuccess)); + toast.success(messages.followersSuccess); dispatch({ type: IMPORT_FOLLOWS_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_FOLLOWS_FAIL, error }); @@ -58,7 +57,7 @@ export const importBlocks = (params: FormData) => return api(getState) .post('/api/pleroma/blocks_import', params) .then(response => { - dispatch(snackbar.success(messages.blocksSuccess)); + toast.success(messages.blocksSuccess); dispatch({ type: IMPORT_BLOCKS_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_BLOCKS_FAIL, error }); @@ -71,7 +70,7 @@ export const importMutes = (params: FormData) => return api(getState) .post('/api/pleroma/mutes_import', params) .then(response => { - dispatch(snackbar.success(messages.mutesSuccess)); + toast.success(messages.mutesSuccess); dispatch({ type: IMPORT_MUTES_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_MUTES_FAIL, error }); diff --git a/app/soapbox/actions/importer/index.ts b/app/soapbox/actions/importer/index.ts index 87c6cca85..8750a5d61 100644 --- a/app/soapbox/actions/importer/index.ts +++ b/app/soapbox/actions/importer/index.ts @@ -5,47 +5,54 @@ import type { APIEntity } from 'soapbox/types/entities'; const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; +const GROUP_IMPORT = 'GROUP_IMPORT'; +const GROUPS_IMPORT = 'GROUPS_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 }; -} +const importAccount = (account: APIEntity) => + ({ type: ACCOUNT_IMPORT, account }); -export function importAccounts(accounts: APIEntity[]) { - return { type: ACCOUNTS_IMPORT, accounts }; -} +const importAccounts = (accounts: APIEntity[]) => + ({ type: ACCOUNTS_IMPORT, accounts }); -export function importStatus(status: APIEntity, idempotencyKey?: string) { - return (dispatch: AppDispatch, getState: () => RootState) => { +const importGroup = (group: APIEntity) => + ({ type: GROUP_IMPORT, group }); + +const importGroups = (groups: APIEntity[]) => + ({ type: GROUPS_IMPORT, groups }); + +const importStatus = (status: APIEntity, idempotencyKey?: string) => + (dispatch: AppDispatch, getState: () => RootState) => { const expandSpoilers = getSettings(getState()).get('expandSpoilers'); return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers }); }; -} -export function importStatuses(statuses: APIEntity[]) { - return (dispatch: AppDispatch, getState: () => RootState) => { +const importStatuses = (statuses: APIEntity[]) => + (dispatch: AppDispatch, getState: () => RootState) => { const expandSpoilers = getSettings(getState()).get('expandSpoilers'); return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers }); }; -} -export function importPolls(polls: APIEntity[]) { - return { type: POLLS_IMPORT, polls }; -} +const importPolls = (polls: APIEntity[]) => + ({ type: POLLS_IMPORT, polls }); -export function importFetchedAccount(account: APIEntity) { - return importFetchedAccounts([account]); -} +const importFetchedAccount = (account: APIEntity) => + importFetchedAccounts([account]); -export function importFetchedAccounts(accounts: APIEntity[]) { +const importFetchedAccounts = (accounts: APIEntity[], args = { should_refetch: false }) => { + const { should_refetch } = args; const normalAccounts: APIEntity[] = []; const processAccount = (account: APIEntity) => { if (!account.id) return; + if (should_refetch) { + account.should_refetch = true; + } + normalAccounts.push(account); if (account.moved) { @@ -56,10 +63,27 @@ export function importFetchedAccounts(accounts: APIEntity[]) { accounts.forEach(processAccount); return importAccounts(normalAccounts); -} +}; -export function importFetchedStatus(status: APIEntity, idempotencyKey?: string) { - return (dispatch: AppDispatch) => { +const importFetchedGroup = (group: APIEntity) => + importFetchedGroups([group]); + +const importFetchedGroups = (groups: APIEntity[]) => { + const normalGroups: APIEntity[] = []; + + const processGroup = (group: APIEntity) => { + if (!group.id) return; + + normalGroups.push(group); + }; + + groups.forEach(processGroup); + + return importGroups(normalGroups); +}; + +const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) => + (dispatch: AppDispatch) => { // Skip broken statuses if (isBroken(status)) return; @@ -91,20 +115,23 @@ export function importFetchedStatus(status: APIEntity, idempotencyKey?: string) dispatch(importFetchedPoll(status.poll)); } + if (status.group?.id) { + dispatch(importFetchedGroup(status.group)); + } + dispatch(importFetchedAccount(status.account)); dispatch(importStatus(status, idempotencyKey)); }; -} // Sometimes Pleroma can return an empty account, // or a repost can appear of a deleted account. Skip these statuses. const isBroken = (status: APIEntity) => { try { // Skip empty accounts - // https://gitlab.com/soapbox-pub/soapbox-fe/-/issues/424 + // https://gitlab.com/soapbox-pub/soapbox/-/issues/424 if (!status.account.id) return true; // Skip broken reposts - // https://gitlab.com/soapbox-pub/soapbox/-/issues/28 + // https://gitlab.com/soapbox-pub/rebased/-/issues/28 if (status.reblog && !status.reblog.account.id) return true; return false; } catch (e) { @@ -112,8 +139,8 @@ const isBroken = (status: APIEntity) => { } }; -export function importFetchedStatuses(statuses: APIEntity[]) { - return (dispatch: AppDispatch, getState: () => RootState) => { +const importFetchedStatuses = (statuses: APIEntity[]) => + (dispatch: AppDispatch, getState: () => RootState) => { const accounts: APIEntity[] = []; const normalStatuses: APIEntity[] = []; const polls: APIEntity[] = []; @@ -141,6 +168,10 @@ export function importFetchedStatuses(statuses: APIEntity[]) { if (status.poll?.id) { polls.push(status.poll); } + + if (status.group?.id) { + dispatch(importFetchedGroup(status.group)); + } } statuses.forEach(processStatus); @@ -149,23 +180,37 @@ export function importFetchedStatuses(statuses: APIEntity[]) { dispatch(importFetchedAccounts(accounts)); dispatch(importStatuses(normalStatuses)); }; -} -export function importFetchedPoll(poll: APIEntity) { - return (dispatch: AppDispatch) => { +const importFetchedPoll = (poll: APIEntity) => + (dispatch: AppDispatch) => { dispatch(importPolls([poll])); }; -} -export function importErrorWhileFetchingAccountByUsername(username: string) { - return { type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username }; -} +const importErrorWhileFetchingAccountByUsername = (username: string) => + ({ type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username }); export { ACCOUNT_IMPORT, ACCOUNTS_IMPORT, + GROUP_IMPORT, + GROUPS_IMPORT, STATUS_IMPORT, STATUSES_IMPORT, POLLS_IMPORT, ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, + importAccount, + importAccounts, + importGroup, + importGroups, + importStatus, + importStatuses, + importPolls, + importFetchedAccount, + importFetchedAccounts, + importFetchedGroup, + importFetchedGroups, + importFetchedStatus, + importFetchedStatuses, + importFetchedPoll, + importErrorWhileFetchingAccountByUsername, }; diff --git a/app/soapbox/actions/instance.ts b/app/soapbox/actions/instance.ts index 60a6b2e89..9738718b0 100644 --- a/app/soapbox/actions/instance.ts +++ b/app/soapbox/actions/instance.ts @@ -1,7 +1,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import get from 'lodash/get'; -import KVStore from 'soapbox/storage/kv_store'; +import KVStore from 'soapbox/storage/kv-store'; import { RootState } from 'soapbox/store'; import { getAuthUserUrl } from 'soapbox/utils/auth'; import { parseVersion } from 'soapbox/utils/features'; @@ -10,12 +10,12 @@ import api from '../api'; const getMeUrl = (state: RootState) => { const me = state.me; - return state.accounts.getIn([me, 'url']); + return state.accounts.get(me)?.url; }; /** Figure out the appropriate instance to fetch depending on the state */ export const getHost = (state: RootState) => { - const accountUrl = getMeUrl(state) || getAuthUserUrl(state); + const accountUrl = getMeUrl(state) || getAuthUserUrl(state) as string; try { return new URL(accountUrl).host; @@ -66,7 +66,5 @@ export const loadInstance = createAsyncThunk( export const fetchNodeinfo = createAsyncThunk( 'nodeinfo/fetch', - async(_arg, { getState }) => { - return await api(getState).get('/nodeinfo/2.1.json'); - }, + async(_arg, { getState }) => await api(getState).get('/nodeinfo/2.1.json'), ); diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index ff449e03c..9e43d0f40 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -1,10 +1,11 @@ import { defineMessages } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import api from '../api'; +import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; import type { AxiosError } from 'axios'; @@ -62,7 +63,7 @@ const REMOTE_INTERACTION_FAIL = 'REMOTE_INTERACTION_FAIL'; const messages = defineMessages({ bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' }, bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' }, - view: { id: 'snackbar.view', defaultMessage: 'View' }, + view: { id: 'toast.view', defaultMessage: 'View' }, }); const reblog = (status: StatusEntity) => @@ -94,6 +95,15 @@ const unreblog = (status: StatusEntity) => }); }; +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, @@ -158,6 +168,15 @@ const unfavourite = (status: StatusEntity) => }); }; +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, @@ -203,7 +222,10 @@ const bookmark = (status: StatusEntity) => api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function(response) { dispatch(importFetchedStatus(response.data)); dispatch(bookmarkSuccess(status, response.data)); - dispatch(snackbar.success(messages.bookmarkAdded, messages.view, '/bookmarks')); + toast.success(messages.bookmarkAdded, { + actionLabel: messages.view, + actionLink: '/bookmarks', + }); }).catch(function(error) { dispatch(bookmarkFail(status, error)); }); @@ -216,12 +238,21 @@ const unbookmark = (status: StatusEntity) => api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unbookmarkSuccess(status, response.data)); - dispatch(snackbar.success(messages.bookmarkRemoved)); + toast.success(messages.bookmarkRemoved); }).catch(error => { dispatch(unbookmarkFail(status, error)); }); }; +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, @@ -264,6 +295,7 @@ const fetchReblogs = (id: string) => api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { dispatch(importFetchedAccounts(response.data)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); dispatch(fetchReblogsSuccess(id, response.data)); }).catch(error => { dispatch(fetchReblogsFail(id, error)); @@ -295,6 +327,7 @@ const fetchFavourites = (id: string) => api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { dispatch(importFetchedAccounts(response.data)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); dispatch(fetchFavouritesSuccess(id, response.data)); }).catch(error => { dispatch(fetchFavouritesFail(id, error)); @@ -394,6 +427,15 @@ const unpin = (status: StatusEntity) => }); }; +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, @@ -488,6 +530,7 @@ export { REMOTE_INTERACTION_FAIL, reblog, unreblog, + toggleReblog, reblogRequest, reblogSuccess, reblogFail, @@ -496,6 +539,7 @@ export { unreblogFail, favourite, unfavourite, + toggleFavourite, favouriteRequest, favouriteSuccess, favouriteFail, @@ -504,6 +548,7 @@ export { unfavouriteFail, bookmark, unbookmark, + toggleBookmark, bookmarkRequest, bookmarkSuccess, bookmarkFail, @@ -530,6 +575,7 @@ export { unpinRequest, unpinSuccess, unpinFail, + togglePin, remoteInteraction, remoteInteractionRequest, remoteInteractionSuccess, diff --git a/app/soapbox/actions/lists.ts b/app/soapbox/actions/lists.ts index bf7dba8ba..216fae669 100644 --- a/app/soapbox/actions/lists.ts +++ b/app/soapbox/actions/lists.ts @@ -1,8 +1,8 @@ +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import api from '../api'; -import { showAlertForError } from './alerts'; import { importFetchedAccounts } from './importer'; import type { AxiosError } from 'axios'; @@ -265,7 +265,7 @@ const fetchListSuggestions = (q: string) => (dispatch: AppDispatch, getState: () api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { dispatch(importFetchedAccounts(data)); dispatch(fetchListSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); + }).catch(error => toast.showAlertForError(error)); }; const fetchListSuggestionsReady = (query: string, accounts: APIEntity[]) => ({ diff --git a/app/soapbox/actions/me.ts b/app/soapbox/actions/me.ts index 074f5d4cc..a8b275200 100644 --- a/app/soapbox/actions/me.ts +++ b/app/soapbox/actions/me.ts @@ -1,4 +1,4 @@ -import KVStore from 'soapbox/storage/kv_store'; +import KVStore from 'soapbox/storage/kv-store'; import { getAuthUserId, getAuthUserUrl } from 'soapbox/utils/auth'; import api from '../api'; @@ -6,7 +6,7 @@ import api from '../api'; import { loadCredentials } from './auth'; import { importFetchedAccount } from './importer'; -import type { AxiosError, AxiosRequestHeaders } from 'axios'; +import type { AxiosError, RawAxiosRequestHeaders } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { APIEntity } from 'soapbox/types/entities'; @@ -30,8 +30,8 @@ const getMeUrl = (state: RootState) => { const getMeToken = (state: RootState) => { // Fallback for upgrading IDs to URLs - const accountUrl = getMeUrl(state) || state.auth.get('me'); - return state.auth.getIn(['users', accountUrl, 'access_token']); + const accountUrl = getMeUrl(state) || state.auth.me; + return state.auth.users.get(accountUrl!)?.access_token; }; const fetchMe = () => @@ -41,13 +41,13 @@ const fetchMe = () => const accountUrl = getMeUrl(state); if (!token) { - dispatch({ type: ME_FETCH_SKIP }); return noOp(); + dispatch({ type: ME_FETCH_SKIP }); + return noOp(); } dispatch(fetchMeRequest()); - return dispatch(loadCredentials(token, accountUrl)).catch(error => { - dispatch(fetchMeFail(error)); - }); + return dispatch(loadCredentials(token, accountUrl!)) + .catch(error => dispatch(fetchMeFail(error))); }; /** Update the auth account in IndexedDB for Mastodon, etc. */ @@ -66,7 +66,7 @@ const patchMe = (params: Record, isFormData = false) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(patchMeRequest()); - const headers: AxiosRequestHeaders = isFormData ? { + const headers: RawAxiosRequestHeaders = isFormData ? { 'Content-Type': 'multipart/form-data', } : {}; diff --git a/app/soapbox/actions/mobile.ts b/app/soapbox/actions/mobile.ts deleted file mode 100644 index 1e11f473d..000000000 --- a/app/soapbox/actions/mobile.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { staticClient } from '../api'; - -import type { AppDispatch } from 'soapbox/store'; - -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 }) => { - dispatch({ type: FETCH_MOBILE_PAGE_SUCCESS, slug, locale, html }); - return html; - }).catch(error => { - dispatch({ type: FETCH_MOBILE_PAGE_FAIL, slug, locale, error }); - 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 3e1a106cf..83b52cb3e 100644 --- a/app/soapbox/actions/modals.ts +++ b/app/soapbox/actions/modals.ts @@ -1,8 +1,10 @@ +import type { ModalType } from 'soapbox/features/ui/components/modal-root'; + export const MODAL_OPEN = 'MODAL_OPEN'; export const MODAL_CLOSE = 'MODAL_CLOSE'; /** Open a modal of the given type */ -export function openModal(type: string, props?: any) { +export function openModal(type: ModalType, props?: any) { return { type: MODAL_OPEN, modalType: type, @@ -11,7 +13,7 @@ export function openModal(type: string, props?: any) { } /** Close the modal */ -export function closeModal(type?: string) { +export function closeModal(type?: ModalType) { return { type: MODAL_CLOSE, modalType: type, diff --git a/app/soapbox/actions/moderation.tsx b/app/soapbox/actions/moderation.tsx index ea5861eca..5b0a4a5f2 100644 --- a/app/soapbox/actions/moderation.tsx +++ b/app/soapbox/actions/moderation.tsx @@ -4,8 +4,10 @@ import { defineMessages, IntlShape } from 'react-intl'; import { fetchAccountByUsername } from 'soapbox/actions/accounts'; import { deactivateUsers, deleteUsers, deleteStatus, toggleStatusSensitivity } from 'soapbox/actions/admin'; import { openModal } from 'soapbox/actions/modals'; -import snackbar from 'soapbox/actions/snackbar'; -import AccountContainer from 'soapbox/containers/account_container'; +import OutlineBox from 'soapbox/components/outline-box'; +import { Stack, Text } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account-container'; +import toast from 'soapbox/toast'; import { isLocal } from 'soapbox/utils/accounts'; import type { AppDispatch, RootState } from 'soapbox/store'; @@ -43,15 +45,27 @@ const deactivateUserModal = (intl: IntlShape, accountId: string, afterConfirm = const acct = state.accounts.get(accountId)!.acct; const name = state.accounts.get(accountId)!.username; + const message = ( + + + + + + + {intl.formatMessage(messages.deactivateUserPrompt, { acct })} + + + ); + dispatch(openModal('CONFIRM', { icon: require('@tabler/icons/user-off.svg'), heading: intl.formatMessage(messages.deactivateUserHeading, { acct }), - message: intl.formatMessage(messages.deactivateUserPrompt, { acct }), + message, confirm: intl.formatMessage(messages.deactivateUserConfirm, { name }), onConfirm: () => { dispatch(deactivateUsers([accountId])).then(() => { const message = intl.formatMessage(messages.userDeactivated, { acct }); - dispatch(snackbar.success(message)); + toast.success(message); afterConfirm(); }).catch(() => {}); }, @@ -64,22 +78,21 @@ const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () = 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 = (<> - - {intl.formatMessage(messages.deleteUserPrompt, { acct })} - ); + const message = ( + + + + - const confirm = (<> - {favicon && -
- -
} - {intl.formatMessage(messages.deleteUserConfirm, { name })} - ); + + {intl.formatMessage(messages.deleteUserPrompt, { acct })} + +
+ ); + const confirm = intl.formatMessage(messages.deleteUserConfirm, { name }); const checkbox = local ? intl.formatMessage(messages.deleteLocalUserCheckbox) : false; dispatch(openModal('CONFIRM', { @@ -92,7 +105,7 @@ const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () = dispatch(deleteUsers([accountId])).then(() => { const message = intl.formatMessage(messages.userDeleted, { acct }); dispatch(fetchAccountByUsername(acct)); - dispatch(snackbar.success(message)); + toast.success(message); afterConfirm(); }).catch(() => {}); }, @@ -134,7 +147,7 @@ const toggleStatusSensitivityModal = (intl: IntlShape, statusId: string, sensiti onConfirm: () => { dispatch(toggleStatusSensitivity(statusId, sensitive)).then(() => { const message = intl.formatMessage(sensitive === false ? messages.statusMarkedSensitive : messages.statusMarkedNotSensitive, { acct }); - dispatch(snackbar.success(message)); + toast.success(message); }).catch(() => {}); afterConfirm(); }, @@ -155,7 +168,7 @@ const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = () onConfirm: () => { dispatch(deleteStatus(statusId)).then(() => { const message = intl.formatMessage(messages.statusDeleted, { acct }); - dispatch(snackbar.success(message)); + toast.success(message); }).catch(() => {}); afterConfirm(); }, diff --git a/app/soapbox/actions/mrf.ts b/app/soapbox/actions/mrf.ts index e2cef5938..1b9cbad93 100644 --- a/app/soapbox/actions/mrf.ts +++ b/app/soapbox/actions/mrf.ts @@ -1,11 +1,11 @@ import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable'; -import ConfigDB from 'soapbox/utils/config_db'; +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'; +import type { Policy } from 'soapbox/utils/config-db'; const simplePolicyMerge = (simplePolicy: Policy, host: string, restrictions: ImmutableMap) => { return simplePolicy.map((hosts, key) => { diff --git a/app/soapbox/actions/mutes.ts b/app/soapbox/actions/mutes.ts index 050a513f0..bb684b0d6 100644 --- a/app/soapbox/actions/mutes.ts +++ b/app/soapbox/actions/mutes.ts @@ -21,6 +21,7 @@ const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; +const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; const fetchMutes = () => (dispatch: AppDispatch, getState: () => RootState) => { @@ -103,6 +104,14 @@ const toggleHideNotifications = () => dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); }; +const changeMuteDuration = (duration: number) => + (dispatch: AppDispatch) => { + dispatch({ + type: MUTES_CHANGE_DURATION, + duration, + }); + }; + export { MUTES_FETCH_REQUEST, MUTES_FETCH_SUCCESS, @@ -112,6 +121,7 @@ export { MUTES_EXPAND_FAIL, MUTES_INIT_MODAL, MUTES_TOGGLE_HIDE_NOTIFICATIONS, + MUTES_CHANGE_DURATION, fetchMutes, fetchMutesRequest, fetchMutesSuccess, @@ -122,4 +132,5 @@ export { expandMutesFail, initMuteModal, toggleHideNotifications, + changeMuteDuration, }; diff --git a/app/soapbox/actions/notifications.ts b/app/soapbox/actions/notifications.ts index adeb46bf9..7b91b64d8 100644 --- a/app/soapbox/actions/notifications.ts +++ b/app/soapbox/actions/notifications.ts @@ -1,7 +1,3 @@ -import { - List as ImmutableList, - Map as ImmutableMap, -} from 'immutable'; import IntlMessageFormat from 'intl-messageformat'; import 'intl-pluralrules'; import { defineMessages } from 'react-intl'; @@ -9,8 +5,10 @@ import { defineMessages } from 'react-intl'; import api, { getLinks } from 'soapbox/api'; import { getFilters, regexFromFilters } from 'soapbox/selectors'; import { isLoggedIn } from 'soapbox/utils/auth'; -import { parseVersion, PLEROMA } from 'soapbox/utils/features'; +import { compareId } from 'soapbox/utils/comparators'; +import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features'; import { unescapeHTML } from 'soapbox/utils/html'; +import { EXCLUDE_TYPES, NOTIFICATION_TYPES } from 'soapbox/utils/notification'; import { joinPublicPath } from 'soapbox/utils/static'; import { fetchRelationships } from './accounts'; @@ -49,7 +47,7 @@ const MAX_QUEUED_NOTIFICATIONS = 40; defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, - group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, + group: { id: 'notifications.group', defaultMessage: '{count, plural, one {# notification} other {# notifications}}' }, }); const fetchRelatedRelationships = (dispatch: AppDispatch, notifications: APIEntity[]) => { @@ -91,6 +89,7 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record< (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 + if (notification.type === 'chat') return; // Drop Truth Social chat notifications. const showAlert = getSettings(getState()).getIn(['notifications', 'alerts', notification.type]); const filters = getFilters(getState(), { contextType: 'notifications' }); @@ -108,7 +107,10 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record< // Desktop notifications try { - if (showAlert && !filtered) { + // eslint-disable-next-line compat/compat + const isNotificationsEnabled = window.Notification?.permission === 'granted'; + + if (showAlert && !filtered && isNotificationsEnabled) { const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : ''); @@ -148,13 +150,13 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record< const dequeueNotifications = () => (dispatch: AppDispatch, getState: () => RootState) => { - const queuedNotifications = getState().notifications.get('queuedNotifications'); - const totalQueuedNotificationsCount = getState().notifications.get('totalQueuedNotificationsCount'); + const queuedNotifications = getState().notifications.queuedNotifications; + const totalQueuedNotificationsCount = getState().notifications.totalQueuedNotificationsCount; if (totalQueuedNotificationsCount === 0) { return; } else if (totalQueuedNotificationsCount > 0 && totalQueuedNotificationsCount <= MAX_QUEUED_NOTIFICATIONS) { - queuedNotifications.forEach((block: APIEntity) => { + queuedNotifications.forEach((block) => { dispatch(updateNotifications(block.notification)); }); } else { @@ -167,36 +169,46 @@ const dequeueNotifications = () => dispatch(markReadNotifications()); }; -const excludeTypesFromSettings = (getState: () => RootState) => (getSettings(getState()).getIn(['notifications', 'shows']) as ImmutableMap).filter(enabled => !enabled).keySeq().toJS(); - 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(); + return NOTIFICATION_TYPES.filter(item => item !== filter); }; -const noOp = () => {}; +const noOp = () => new Promise(f => f(undefined)); -const expandNotifications = ({ maxId }: Record = {}, done = noOp) => +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']) as string; - const notifications = getState().notifications; + const state = getState(); + const features = getFeatures(state.instance); + const activeFilter = getSettings(state).getIn(['notifications', 'quickFilter', 'active']) as string; + const notifications = state.notifications; const isLoadingMore = !!maxId; - if (notifications.get('isLoading')) { + if (notifications.isLoading) { done(); return dispatch(noOp); } const params: Record = { max_id: maxId, - exclude_types: activeFilter === 'all' - ? excludeTypesFromSettings(getState) - : excludeTypesFromFilter(activeFilter), }; - if (!maxId && notifications.get('items').size > 0) { + if (activeFilter === 'all') { + if (features.notificationsIncludeTypes) { + params.types = NOTIFICATION_TYPES.filter(type => !EXCLUDE_TYPES.includes(type as any)); + } else { + params.exclude_types = EXCLUDE_TYPES; + } + } else { + if (features.notificationsIncludeTypes) { + params.types = [activeFilter]; + } else { + params.exclude_types = excludeTypesFromFilter(activeFilter); + } + } + + if (!maxId && notifications.items.size > 0) { params.since_id = notifications.getIn(['items', 0, 'id']); } @@ -295,23 +307,22 @@ const markReadNotifications = () => if (!isLoggedIn(getState)) return; const state = getState(); - const instance = state.instance; - const topNotificationId = state.notifications.get('items').first(ImmutableMap()).get('id'); - const lastReadId = state.notifications.get('lastRead'); - const v = parseVersion(instance.version); + const topNotificationId = state.notifications.items.first()?.id; + const lastReadId = state.notifications.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)); + } } }; diff --git a/app/soapbox/actions/oauth.ts b/app/soapbox/actions/oauth.ts index aefef7b6f..55df6f1ae 100644 --- a/app/soapbox/actions/oauth.ts +++ b/app/soapbox/actions/oauth.ts @@ -18,7 +18,7 @@ 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 const obtainOAuthToken = (params: Record, baseURL?: string) => +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 }) => { diff --git a/app/soapbox/actions/pin_statuses.ts b/app/soapbox/actions/pin-statuses.ts similarity index 100% rename from app/soapbox/actions/pin_statuses.ts rename to app/soapbox/actions/pin-statuses.ts diff --git a/app/soapbox/actions/profile_hover_card.ts b/app/soapbox/actions/profile-hover-card.ts similarity index 100% rename from app/soapbox/actions/profile_hover_card.ts rename to app/soapbox/actions/profile-hover-card.ts diff --git a/app/soapbox/actions/push_notifications/index.ts b/app/soapbox/actions/push-notifications/index.ts similarity index 100% rename from app/soapbox/actions/push_notifications/index.ts rename to app/soapbox/actions/push-notifications/index.ts diff --git a/app/soapbox/actions/push_notifications/registerer.ts b/app/soapbox/actions/push-notifications/registerer.ts similarity index 92% rename from app/soapbox/actions/push_notifications/registerer.ts rename to app/soapbox/actions/push-notifications/registerer.ts index 893814b02..2ea7fbab8 100644 --- a/app/soapbox/actions/push_notifications/registerer.ts +++ b/app/soapbox/actions/push-notifications/registerer.ts @@ -1,4 +1,4 @@ -import { createPushSubscription, 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 { decode as decodeBase64 } from 'soapbox/utils/base64'; @@ -12,13 +12,19 @@ import type { Me } from 'soapbox/types/soapbox'; 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: ServiceWorkerRegistration) => registration.pushManager.getSubscription() @@ -31,8 +37,8 @@ const subscribe = (registration: ServiceWorkerRegistration, getState: () => Root }); const unsubscribe = ({ registration, subscription }: { - registration: ServiceWorkerRegistration, - subscription: PushSubscription | null, + registration: ServiceWorkerRegistration + subscription: PushSubscription | null }) => subscription ? subscription.unsubscribe().then(() => registration) : new Promise(r => r(registration)); @@ -52,6 +58,7 @@ const sendSubscriptionToBackend = (subscription: PushSubscription, me: Me) => }; // 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); const register = () => @@ -75,8 +82,8 @@ const register = () => .then(getPushSubscription) // @ts-ignore .then(({ registration, subscription }: { - registration: ServiceWorkerRegistration, - subscription: PushSubscription | null, + registration: ServiceWorkerRegistration + subscription: PushSubscription | null }) => { if (subscription !== null) { // We have a subscription, check if it is still valid @@ -112,7 +119,6 @@ const register = () => } }) .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') { diff --git a/app/soapbox/actions/push_notifications/setter.ts b/app/soapbox/actions/push-notifications/setter.ts similarity index 100% rename from app/soapbox/actions/push_notifications/setter.ts rename to app/soapbox/actions/push-notifications/setter.ts diff --git a/app/soapbox/actions/push_subscriptions.ts b/app/soapbox/actions/push-subscriptions.ts similarity index 100% rename from app/soapbox/actions/push_subscriptions.ts rename to app/soapbox/actions/push-subscriptions.ts diff --git a/app/soapbox/actions/remote_timeline.ts b/app/soapbox/actions/remote-timeline.ts similarity index 100% rename from app/soapbox/actions/remote_timeline.ts rename to app/soapbox/actions/remote-timeline.ts diff --git a/app/soapbox/actions/reports.ts b/app/soapbox/actions/reports.ts index 40b685ba4..d6a24a8c8 100644 --- a/app/soapbox/actions/reports.ts +++ b/app/soapbox/actions/reports.ts @@ -4,7 +4,7 @@ import { openModal } from './modals'; import type { AxiosError } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; -import type { Account, Status } from 'soapbox/types/entities'; +import type { Account, ChatMessage, Status } from 'soapbox/types/entities'; const REPORT_INIT = 'REPORT_INIT'; const REPORT_CANCEL = 'REPORT_CANCEL'; @@ -20,26 +20,23 @@ 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, - }); +type ReportedEntity = { + status?: Status + chatMessage?: ChatMessage +} - return dispatch(openModal('REPORT')); - }; +const initReport = (account: Account, entities?: ReportedEntity) => (dispatch: AppDispatch) => { + const { status, chatMessage } = entities || {}; -const initReportById = (accountId: string) => - (dispatch: AppDispatch, getState: () => RootState) => { - dispatch({ - type: REPORT_INIT, - account: getState().accounts.get(accountId), - }); + dispatch({ + type: REPORT_INIT, + account, + status, + chatMessage, + }); - dispatch(openModal('REPORT')); - }; + return dispatch(openModal('REPORT')); +}; const cancelReport = () => ({ type: REPORT_CANCEL, @@ -59,6 +56,7 @@ const submitReport = () => return api(getState).post('/api/v1/reports', { account_id: reports.getIn(['new', 'account_id']), status_ids: reports.getIn(['new', 'status_ids']), + message_ids: [reports.getIn(['new', 'chat_message', 'id'])], rule_ids: reports.getIn(['new', 'rule_ids']), comment: reports.getIn(['new', 'comment']), forward: reports.getIn(['new', 'forward']), @@ -110,7 +108,6 @@ export { REPORT_BLOCK_CHANGE, REPORT_RULE_CHANGE, initReport, - initReportById, cancelReport, toggleStatusReport, submitReport, @@ -121,4 +118,4 @@ export { changeReportForward, changeReportBlock, changeReportRule, -}; \ No newline at end of file +}; diff --git a/app/soapbox/actions/scheduled_statuses.ts b/app/soapbox/actions/scheduled-statuses.ts similarity index 93% rename from app/soapbox/actions/scheduled_statuses.ts rename to app/soapbox/actions/scheduled-statuses.ts index ddc550105..33e763701 100644 --- a/app/soapbox/actions/scheduled_statuses.ts +++ b/app/soapbox/actions/scheduled-statuses.ts @@ -1,3 +1,5 @@ +import { getFeatures } from 'soapbox/utils/features'; + import api, { getLinks } from '../api'; import type { AxiosError } from 'axios'; @@ -18,10 +20,17 @@ const SCHEDULED_STATUS_CANCEL_FAIL = 'SCHEDULED_STATUS_CANCEL_FAIL'; const fetchScheduledStatuses = () => (dispatch: AppDispatch, getState: () => RootState) => { - if (getState().status_lists.get('scheduled_statuses')?.isLoading) { + const state = getState(); + + if (state.status_lists.get('scheduled_statuses')?.isLoading) { return; } + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.scheduledStatuses) return; + dispatch(fetchScheduledStatusesRequest()); api(getState).get('/api/v1/scheduled_statuses').then(response => { diff --git a/app/soapbox/actions/search.ts b/app/soapbox/actions/search.ts index 1eb749e78..6d64b6534 100644 --- a/app/soapbox/actions/search.ts +++ b/app/soapbox/actions/search.ts @@ -1,16 +1,17 @@ import api from '../api'; import { fetchRelationships } from './accounts'; -import { importFetchedAccounts, importFetchedStatuses } from './importer'; +import { importFetchedAccounts, importFetchedGroups, 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_CHANGE = 'SEARCH_CHANGE'; +const SEARCH_CLEAR = 'SEARCH_CLEAR'; +const SEARCH_SHOW = 'SEARCH_SHOW'; +const SEARCH_RESULTS_CLEAR = 'SEARCH_RESULTS_CLEAR'; const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; @@ -22,11 +23,17 @@ 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()); + dispatch(clearSearchResults()); + return dispatch({ + type: SEARCH_CHANGE, + value, + }); } else { return dispatch({ type: SEARCH_CHANGE, @@ -39,10 +46,15 @@ const clearSearch = () => ({ type: SEARCH_CLEAR, }); +const clearSearchResults = () => ({ + type: SEARCH_RESULTS_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) { @@ -51,13 +63,17 @@ const submitSearch = (filter?: SearchFilter) => 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: { - q: value, - resolve: true, - limit: 20, - type, - }, + params, }).then(response => { if (response.data.accounts) { dispatch(importFetchedAccounts(response.data.accounts)); @@ -67,6 +83,10 @@ const submitSearch = (filter?: SearchFilter) => dispatch(importFetchedStatuses(response.data.statuses)); } + if (response.data.groups) { + dispatch(importFetchedGroups(response.data.groups)); + } + dispatch(fetchSearchSuccess(response.data, value, type)); dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id))); }).catch(error => { @@ -123,6 +143,10 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: ( dispatch(importFetchedStatuses(data.statuses)); } + if (data.groups) { + dispatch(importFetchedGroups(data.groups)); + } + dispatch(expandSearchSuccess(data, value, type)); dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id))); }).catch(error => { @@ -151,10 +175,16 @@ const showSearch = () => ({ type: SEARCH_SHOW, }); +const setSearchAccount = (accountId: string | null) => ({ + type: SEARCH_ACCOUNT_SET, + accountId, +}); + export { SEARCH_CHANGE, SEARCH_CLEAR, SEARCH_SHOW, + SEARCH_RESULTS_CLEAR, SEARCH_FETCH_REQUEST, SEARCH_FETCH_SUCCESS, SEARCH_FETCH_FAIL, @@ -162,8 +192,10 @@ export { SEARCH_EXPAND_REQUEST, SEARCH_EXPAND_SUCCESS, SEARCH_EXPAND_FAIL, + SEARCH_ACCOUNT_SET, changeSearch, clearSearch, + clearSearchResults, submitSearch, fetchSearchRequest, fetchSearchSuccess, @@ -174,4 +206,5 @@ export { expandSearchSuccess, expandSearchFail, showSearch, + setSearchAccount, }; diff --git a/app/soapbox/actions/security.ts b/app/soapbox/actions/security.ts index 430691a06..4448104b7 100644 --- a/app/soapbox/actions/security.ts +++ b/app/soapbox/actions/security.ts @@ -4,9 +4,10 @@ * @see module:soapbox/actions/auth */ -import snackbar from 'soapbox/actions/snackbar'; +import toast from 'soapbox/toast'; import { getLoggedInAccount } from 'soapbox/utils/auth'; import { parseVersion, TRUTHSOCIAL } from 'soapbox/utils/features'; +import { normalizeUsername } from 'soapbox/utils/input'; import api from '../api'; @@ -49,7 +50,7 @@ const MOVE_ACCOUNT_FAIL = 'MOVE_ACCOUNT_FAIL'; const fetchOAuthTokens = () => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: FETCH_TOKENS_REQUEST }); - return api(getState).get('/api/oauth_tokens.json').then(({ data: tokens }) => { + return api(getState).get('/api/oauth_tokens').then(({ data: tokens }) => { dispatch({ type: FETCH_TOKENS_SUCCESS, tokens }); }).catch(() => { dispatch({ type: FETCH_TOKENS_FAIL }); @@ -84,15 +85,16 @@ const changePassword = (oldPassword: string, newPassword: string, confirmation: const resetPassword = (usernameOrEmail: string) => (dispatch: AppDispatch, getState: () => RootState) => { + const input = normalizeUsername(usernameOrEmail); const state = getState(); const v = parseVersion(state.instance.version); dispatch({ type: RESET_PASSWORD_REQUEST }); const params = - usernameOrEmail.includes('@') - ? { email: usernameOrEmail } - : { nickname: usernameOrEmail, username: usernameOrEmail }; + input.includes('@') + ? { email: input } + : { nickname: input, username: input }; const endpoint = v.software === TRUTHSOCIAL @@ -150,7 +152,7 @@ const deleteAccount = (password: string) => if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure dispatch({ type: DELETE_ACCOUNT_SUCCESS, response }); dispatch({ type: AUTH_LOGGED_OUT, account }); - dispatch(snackbar.success(messages.loggedOut)); + toast.success(messages.loggedOut); }).catch(error => { dispatch({ type: DELETE_ACCOUNT_FAIL, error, skipAlert: true }); throw error; diff --git a/app/soapbox/actions/settings.ts b/app/soapbox/actions/settings.ts index 44a22f666..79ffe1975 100644 --- a/app/soapbox/actions/settings.ts +++ b/app/soapbox/actions/settings.ts @@ -4,11 +4,9 @@ import { createSelector } from 'reselect'; import { v4 as uuid } from 'uuid'; import { patchMe } from 'soapbox/actions/me'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; -import { showAlertForError } from './alerts'; -import snackbar from './snackbar'; - import type { AppDispatch, RootState } from 'soapbox/store'; const SETTING_CHANGE = 'SETTING_CHANGE'; @@ -20,7 +18,7 @@ const FE_NAME = 'soapbox_fe'; /** Options when changing/saving settings. */ type SettingOpts = { /** Whether to display an alert when settings are saved. */ - showAlert?: boolean, + showAlert?: boolean } const messages = defineMessages({ @@ -49,7 +47,6 @@ const defaultSettings = ImmutableMap({ autoloadMore: true, systemFont: false, - dyslexicFont: false, demetricator: false, isDeveloper: false, @@ -159,6 +156,8 @@ const defaultSettings = ImmutableMap({ }), }), + groups: ImmutableMap({}), + trends: ImmutableMap({ show: true, }), @@ -222,10 +221,10 @@ const saveSettingsImmediate = (opts?: SettingOpts) => dispatch({ type: SETTING_SAVE }); if (opts?.showAlert) { - dispatch(snackbar.success(messages.saveSuccess)); + toast.success(messages.saveSuccess); } }).catch(error => { - dispatch(showAlertForError(error)); + toast.showAlertForError(error); }); }; diff --git a/app/soapbox/actions/snackbar.ts b/app/soapbox/actions/snackbar.ts deleted file mode 100644 index 57d23b64b..000000000 --- a/app/soapbox/actions/snackbar.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ALERT_SHOW } from './alerts'; - -import type { MessageDescriptor } from 'react-intl'; - -export type SnackbarActionSeverity = 'info' | 'success' | 'error'; - -type SnackbarMessage = string | MessageDescriptor; - -export type SnackbarAction = { - type: typeof ALERT_SHOW, - message: SnackbarMessage, - actionLabel?: SnackbarMessage, - actionLink?: string, - action?: () => void, - severity: SnackbarActionSeverity, -}; - -type SnackbarOpts = { - actionLabel?: SnackbarMessage, - actionLink?: string, - action?: () => void, - dismissAfter?: number | false, -}; - -export const show = ( - severity: SnackbarActionSeverity, - message: SnackbarMessage, - opts?: SnackbarOpts, -): SnackbarAction => ({ - type: ALERT_SHOW, - message, - severity, - ...opts, -}); - -export const info = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) => - show('info', message, { actionLabel, actionLink }); - -export const success = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) => - show('success', message, { actionLabel, actionLink }); - -export const error = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) => - show('error', message, { actionLabel, actionLink }); - -export default { - info, - success, - error, - show, -}; diff --git a/app/soapbox/actions/soapbox.ts b/app/soapbox/actions/soapbox.ts index 89d3080d8..790997199 100644 --- a/app/soapbox/actions/soapbox.ts +++ b/app/soapbox/actions/soapbox.ts @@ -2,7 +2,7 @@ import { createSelector } from 'reselect'; import { getHost } from 'soapbox/actions/instance'; import { normalizeSoapboxConfig } from 'soapbox/normalizers'; -import KVStore from 'soapbox/storage/kv_store'; +import KVStore from 'soapbox/storage/kv-store'; import { removeVS16s } from 'soapbox/utils/emoji'; import { getFeatures } from 'soapbox/utils/features'; @@ -32,8 +32,8 @@ const getSoapboxConfig = createSelector([ } // If RGI reacts aren't supported, strip VS16s - // // https://git.pleroma.social/pleroma/pleroma/-/issues/2355 - if (!features.emojiReactsRGI) { + // https://git.pleroma.social/pleroma/pleroma/-/issues/2355 + if (features.emojiReactsNonRGI) { soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s)); } }); diff --git a/app/soapbox/actions/status-quotes.ts b/app/soapbox/actions/status-quotes.ts new file mode 100644 index 000000000..9dab8df46 --- /dev/null +++ b/app/soapbox/actions/status-quotes.ts @@ -0,0 +1,75 @@ +import api, { getLinks } from '../api'; + +import { importFetchedStatuses } from './importer'; + +import type { AppDispatch, RootState } from 'soapbox/store'; + +export const STATUS_QUOTES_FETCH_REQUEST = 'STATUS_QUOTES_FETCH_REQUEST'; +export const STATUS_QUOTES_FETCH_SUCCESS = 'STATUS_QUOTES_FETCH_SUCCESS'; +export const STATUS_QUOTES_FETCH_FAIL = 'STATUS_QUOTES_FETCH_FAIL'; + +export const STATUS_QUOTES_EXPAND_REQUEST = 'STATUS_QUOTES_EXPAND_REQUEST'; +export const STATUS_QUOTES_EXPAND_SUCCESS = 'STATUS_QUOTES_EXPAND_SUCCESS'; +export const STATUS_QUOTES_EXPAND_FAIL = 'STATUS_QUOTES_EXPAND_FAIL'; + +const noOp = () => new Promise(f => f(null)); + +export const fetchStatusQuotes = (statusId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (getState().status_lists.getIn([`quotes:${statusId}`, 'isLoading'])) { + return dispatch(noOp); + } + + dispatch({ + statusId, + type: STATUS_QUOTES_FETCH_REQUEST, + }); + + return api(getState).get(`/api/v1/pleroma/statuses/${statusId}/quotes`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + return dispatch({ + type: STATUS_QUOTES_FETCH_SUCCESS, + statusId, + statuses: response.data, + next: next ? next.uri : null, + }); + }).catch(error => { + dispatch({ + type: STATUS_QUOTES_FETCH_FAIL, + statusId, + error, + }); + }); + }; + +export const expandStatusQuotes = (statusId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().status_lists.getIn([`quotes:${statusId}`, 'next'], null) as string | null; + + if (url === null || getState().status_lists.getIn([`quotes:${statusId}`, 'isLoading'])) { + return dispatch(noOp); + } + + dispatch({ + type: STATUS_QUOTES_EXPAND_REQUEST, + statusId, + }); + + return api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch({ + type: STATUS_QUOTES_EXPAND_SUCCESS, + statusId, + statuses: response.data, + next: next ? next.uri : null, + }); + }).catch(error => { + dispatch({ + type: STATUS_QUOTES_EXPAND_FAIL, + statusId, + error, + }); + }); + }; diff --git a/app/soapbox/actions/statuses.ts b/app/soapbox/actions/statuses.ts index 5cf534446..047d61d71 100644 --- a/app/soapbox/actions/statuses.ts +++ b/app/soapbox/actions/statuses.ts @@ -10,7 +10,7 @@ import { openModal } from './modals'; import { deleteFromTimelines } from './timelines'; import type { AppDispatch, RootState } from 'soapbox/store'; -import type { APIEntity } from 'soapbox/types/entities'; +import type { APIEntity, Status } from 'soapbox/types/entities'; const STATUS_CREATE_REQUEST = 'STATUS_CREATE_REQUEST'; const STATUS_CREATE_SUCCESS = 'STATUS_CREATE_SUCCESS'; @@ -43,13 +43,18 @@ const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; const STATUS_REVEAL = 'STATUS_REVEAL'; const STATUS_HIDE = 'STATUS_HIDE'; +const STATUS_TRANSLATE_REQUEST = 'STATUS_TRANSLATE_REQUEST'; +const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS'; +const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL'; +const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; + 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 }); + dispatch({ type: STATUS_CREATE_REQUEST, params, idempotencyKey, editing: !!statusId }); return api(getState).request({ url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, @@ -63,7 +68,7 @@ const createStatus = (params: Record, idempotencyKey: string, statu } dispatch(importFetchedStatus(status, idempotencyKey)); - dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey }); + dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey, editing: !!statusId }); // Poll the backend for the updated card if (status.expectsCard) { @@ -84,7 +89,7 @@ const createStatus = (params: Record, idempotencyKey: string, statu return status; }).catch(error => { - dispatch({ type: STATUS_CREATE_FAIL, error, params, idempotencyKey }); + dispatch({ type: STATUS_CREATE_FAIL, error, params, idempotencyKey, editing: !!statusId }); throw error; }); }; @@ -101,7 +106,7 @@ const editStatus = (id: string) => (dispatch: AppDispatch, getState: () => RootS 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(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.content_type, false)); dispatch(openModal('COMPOSE')); }).catch(error => { dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error }); @@ -266,6 +271,15 @@ const unmuteStatus = (id: string) => }); }; +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]; @@ -288,6 +302,39 @@ const revealStatus = (ids: string[] | string) => { }; }; +const toggleStatusHidden = (status: Status) => { + if (status.hidden) { + return revealStatus(status.id); + } else { + return hideStatus(status.id); + } +}; + +const translateStatus = (id: string, targetLanguage?: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: STATUS_TRANSLATE_REQUEST, id }); + + api(getState).post(`/api/v1/statuses/${id}/translate`, { + target_language: targetLanguage, + }).then(response => { + dispatch({ + type: STATUS_TRANSLATE_SUCCESS, + id, + translation: response.data, + }); + }).catch(error => { + dispatch({ + type: STATUS_TRANSLATE_FAIL, + id, + error, + }); + }); +}; + +const undoStatusTranslation = (id: string) => ({ + type: STATUS_TRANSLATE_UNDO, + id, +}); + export { STATUS_CREATE_REQUEST, STATUS_CREATE_SUCCESS, @@ -312,6 +359,10 @@ export { STATUS_UNMUTE_FAIL, STATUS_REVEAL, STATUS_HIDE, + STATUS_TRANSLATE_REQUEST, + STATUS_TRANSLATE_SUCCESS, + STATUS_TRANSLATE_FAIL, + STATUS_TRANSLATE_UNDO, createStatus, editStatus, fetchStatus, @@ -324,6 +375,10 @@ export { fetchStatusWithContext, muteStatus, unmuteStatus, + toggleMuteStatus, hideStatus, revealStatus, + toggleStatusHidden, + translateStatus, + undoStatusTranslation, }; diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts index c47667197..c9095e021 100644 --- a/app/soapbox/actions/streaming.ts +++ b/app/soapbox/actions/streaming.ts @@ -1,10 +1,22 @@ import { getSettings } from 'soapbox/actions/settings'; import messages from 'soapbox/locales/messages'; +import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats'; +import { queryClient } from 'soapbox/queries/client'; +import { getUnreadChatsCount, updateChatListItem, updateChatMessage } from 'soapbox/utils/chats'; +import { removePageItem } from 'soapbox/utils/queries'; +import { play, soundCache } from 'soapbox/utils/sounds'; 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 { @@ -15,8 +27,9 @@ import { processTimelineUpdate, } from './timelines'; +import type { IStatContext } from 'soapbox/contexts/stat-context'; import type { AppDispatch, RootState } from 'soapbox/store'; -import type { APIEntity } from 'soapbox/types/entities'; +import type { APIEntity, Chat } from 'soapbox/types/entities'; const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE'; const STREAMING_FOLLOW_RELATIONSHIPS_UPDATE = 'STREAMING_FOLLOW_RELATIONSHIPS_UPDATE'; @@ -38,11 +51,45 @@ const updateFollowRelationships = (relationships: APIEntity) => }); }; +const removeChatMessage = (payload: string) => { + const data = JSON.parse(payload); + const chatId = data.chat_id; + const chatMessageId = data.deleted_message_id; + + // If the user just deleted the "last_message", then let's invalidate + // the Chat Search query so the Chat List will show the new "last_message". + if (isLastMessage(chatMessageId)) { + queryClient.invalidateQueries(ChatKeys.chatSearch()); + } + + removePageItem(ChatKeys.chatMessages(chatId), chatMessageId, (o: any, n: any) => String(o.id) === String(n)); +}; + +// Update the specific Chat query data. +const updateChatQuery = (chat: IChat) => { + const cachedChat = queryClient.getQueryData(ChatKeys.chat(chat.id)); + if (!cachedChat) { + return; + } + + const newChat = { + ...cachedChat, + latest_read_message_by_account: chat.latest_read_message_by_account, + latest_read_message_created_at: chat.latest_read_message_created_at, + }; + queryClient.setQueryData(ChatKeys.chat(chat.id), newChat as any); +}; + +interface StreamOpts { + statContext?: IStatContext +} + const connectTimelineStream = ( timelineId: string, path: string, pollingRefresh: ((dispatch: AppDispatch, done?: () => void) => void) | null = null, accept: ((status: APIEntity) => boolean) | null = null, + opts?: StreamOpts, ) => connectStream(path, pollingRefresh, (dispatch: AppDispatch, getState: () => RootState) => { const locale = getLocale(getState()); @@ -71,7 +118,14 @@ const connectTimelineStream = ( // break; case 'notification': messages[locale]().then(messages => { - dispatch(updateNotificationsQueue(JSON.parse(data.payload), messages, locale, window.location.pathname)); + dispatch( + updateNotificationsQueue( + JSON.parse(data.payload), + messages, + locale, + window.location.pathname, + ), + ); }).catch(error => { console.error(error); }); @@ -83,33 +137,69 @@ const connectTimelineStream = ( dispatch(fetchFilters()); break; case 'pleroma:chat_update': - dispatch((dispatch: AppDispatch, getState: () => RootState) => { + case 'chat_message.created': // TruthSocial + 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); + const messageOwned = chat.last_message?.account_id === me; + const settings = getSettings(getState()); - dispatch({ - type: STREAMING_CHAT_UPDATE, - chat, - me, - // Only play sounds for recipient messages - meta: !messageOwned && getSettings(getState()).getIn(['chats', 'sound']) && { sound: 'chat' }, - }); + // Don't update own messages from streaming + if (!messageOwned) { + updateChatListItem(chat); + + if (settings.getIn(['chats', 'sound'])) { + play(soundCache.chat); + } + + // Increment unread counter + opts?.statContext?.setUnreadChatsCount(getUnreadChatsCount()); + } }); break; + case 'chat_message.deleted': // TruthSocial + removeChatMessage(data.payload); + break; + case 'chat_message.read': // TruthSocial + dispatch((_dispatch: AppDispatch, getState: () => RootState) => { + const chat = JSON.parse(data.payload); + const me = getState().me; + const isFromOtherUser = chat.account.id !== me; + if (isFromOtherUser) { + updateChatQuery(JSON.parse(data.payload)); + } + }); + break; + case 'chat_message.reaction': // TruthSocial + updateChatMessage(JSON.parse(data.payload)); + 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({} as any, done)))); + dispatch(expandHomeTimeline({}, () => + dispatch(expandNotifications({}, () => + dispatch(fetchAnnouncements(done)))))); -const connectUserStream = () => - connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); +const connectUserStream = (opts?: StreamOpts) => + connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification, null, opts); const connectCommunityStream = ({ onlyMedia }: Record = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); diff --git a/app/soapbox/actions/suggestions.ts b/app/soapbox/actions/suggestions.ts index d82f81f40..600dffb7d 100644 --- a/app/soapbox/actions/suggestions.ts +++ b/app/soapbox/actions/suggestions.ts @@ -1,3 +1,5 @@ +import { AxiosResponse } from 'axios'; + import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures } from 'soapbox/utils/features'; @@ -5,6 +7,7 @@ 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'; @@ -19,6 +22,10 @@ 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 }); @@ -52,6 +59,48 @@ const fetchSuggestionsV2 = (params: Record = {}) => }); }; +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(); @@ -59,17 +108,24 @@ const fetchSuggestions = (params: Record = { limit: 50 }) => const instance = state.instance; const features = getFeatures(instance); - if (!me) return; + if (!me) return null; - if (features.suggestionsV2) { - dispatch(fetchSuggestionsV2(params)) + 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) { - dispatch(fetchSuggestionsV1(params)) + return dispatch(fetchSuggestionsV1(params)) .then((accounts: APIEntity[]) => { const accountIds = accounts.map(({ id }) => id); dispatch(fetchRelationships(accountIds)); @@ -77,9 +133,14 @@ const fetchSuggestions = (params: Record = { limit: 50 }) => .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; @@ -100,8 +161,12 @@ export { 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/timelines.ts b/app/soapbox/actions/timelines.ts index a962b068c..7ae023338 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -12,21 +12,23 @@ 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 = '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_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; -const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; +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; @@ -110,9 +112,9 @@ const dequeueTimeline = (timelineId: string, expandFunc?: (lastStatusId: string) const deleteFromTimelines = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { - const accountId = getState().statuses.get(id)?.account; + 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); + const reblogOf = getState().statuses.getIn([id, 'reblog'], null); dispatch({ type: TIMELINE_DELETE, @@ -127,7 +129,7 @@ const clearTimeline = (timeline: string) => (dispatch: AppDispatch) => dispatch({ type: TIMELINE_CLEAR, timeline }); -const noOp = () => {}; +const noOp = () => { }; const noOpAsync = () => () => new Promise(f => f(undefined)); const parseTags = (tags: Record = {}, mode: 'any' | 'all' | 'none') => { @@ -139,9 +141,15 @@ const parseTags = (tags: Record = {}, mode: 'any' | 'all' | 'none const replaceHomeTimeline = ( accountId: string | null, { maxId }: Record = {}, + done?: () => void, ) => (dispatch: AppDispatch, _getState: () => RootState) => { dispatch({ type: TIMELINE_REPLACE, accountId }); - dispatch(expandHomeTimeline({ accountId, maxId })); + dispatch(expandHomeTimeline({ accountId, maxId }, () => { + dispatch(insertSuggestionsIntoTimeline()); + if (done) { + done(); + } + })); }; const expandTimeline = (timelineId: string, path: string, params: Record = {}, done = noOp) => @@ -211,12 +219,15 @@ const expandListTimeline = (id: string, { maxId }: Record = {}, don const expandGroupTimeline = (id: string, { maxId }: Record = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done); +const expandGroupMediaTimeline = (id: string | number, { maxId }: Record = {}) => + expandTimeline(`group:${id}:media`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: true, limit: 40, with_muted: true }); + 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'), + any: parseTags(tags, 'any'), + all: parseTags(tags, 'all'), + none: parseTags(tags, 'none'), }, done); }; @@ -259,6 +270,14 @@ const scrollTopTimeline = (timeline: string, top: boolean) => ({ 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, @@ -272,6 +291,8 @@ export { TIMELINE_CONNECT, TIMELINE_DISCONNECT, TIMELINE_REPLACE, + TIMELINE_CLEAR_FEED_ACCOUNT_ID, + TIMELINE_INSERT, MAX_QUEUED_ITEMS, processTimelineUpdate, updateTimeline, @@ -291,6 +312,7 @@ export { expandAccountMediaTimeline, expandListTimeline, expandGroupTimeline, + expandGroupMediaTimeline, expandHashtagTimeline, expandTimelineRequest, expandTimelineSuccess, @@ -298,4 +320,6 @@ export { connectTimeline, disconnectTimeline, scrollTopTimeline, + insertSuggestionsIntoTimeline, + clearFeedAccountId, }; diff --git a/app/soapbox/actions/trending_statuses.ts b/app/soapbox/actions/trending-statuses.ts similarity index 94% rename from app/soapbox/actions/trending_statuses.ts rename to app/soapbox/actions/trending-statuses.ts index 435fcf6df..7ccab27ab 100644 --- a/app/soapbox/actions/trending_statuses.ts +++ b/app/soapbox/actions/trending-statuses.ts @@ -17,6 +17,8 @@ const fetchTrendingStatuses = () => const instance = state.instance; const features = getFeatures(instance); + if (!features.trendingStatuses && !features.trendingTruths) return; + dispatch({ type: TRENDING_STATUSES_FETCH_REQUEST }); return api(getState).get(features.trendingTruths ? '/api/v1/truth/trending/truths' : '/api/v1/trends/statuses').then(({ data: statuses }) => { dispatch(importFetchedStatuses(statuses)); diff --git a/app/soapbox/actions/verification.ts b/app/soapbox/actions/verification.ts index ce3d27009..2273bb499 100644 --- a/app/soapbox/actions/verification.ts +++ b/app/soapbox/actions/verification.ts @@ -31,14 +31,15 @@ const AGE: Challenge = 'age'; export type Challenge = 'age' | 'sms' | 'email' type Challenges = { - email?: 0 | 1, - sms?: number, - age?: number, + email?: 0 | 1 + sms?: 0 | 1 + age?: 0 | 1 } type Verification = { - token?: string, - challenges?: Challenges, + token?: string + challenges?: Challenges + challengeTypes?: Array<'age' | 'sms' | 'email'> }; /** @@ -83,6 +84,18 @@ const fetchStoredChallenges = () => { } }; +/** + * Fetch and return the state of the verification challenge types. + */ +const fetchStoredChallengeTypes = () => { + try { + const verification: Verification | null = fetchStoredVerification(); + return verification!.challengeTypes; + } catch { + return null; + } +}; + /** * Update the verification object in local storage. * @@ -131,7 +144,10 @@ function saveChallenges(challenges: Array<'age' | 'sms' | 'email'>) { } } - updateStorage({ challenges: currentChallenges }); + updateStorage({ + challenges: currentChallenges, + challengeTypes: challenges, + }); } /** @@ -267,13 +283,29 @@ const confirmEmailVerification = (emailToken: string) => return api(getState).post('/api/v1/pepe/verify_email/confirm', { token: emailToken }, { headers: { Authorization: `Bearer ${token}` }, }) - .then(() => { - finishChallenge(EMAIL); - dispatchNextChallenge(dispatch); + .then((response) => { + updateStorageFromEmailConfirmation(dispatch, response.data.token); }) .finally(() => dispatch({ type: SET_LOADING, value: false })); }; +const updateStorageFromEmailConfirmation = (dispatch: AppDispatch, token: string) => { + const challengeTypes = fetchStoredChallengeTypes(); + if (!challengeTypes) { + return; + } + + const indexOfEmail = challengeTypes.indexOf('email'); + const challenges: Challenges = {}; + challengeTypes?.forEach((challengeType, idx) => { + const value = idx <= indexOfEmail ? 1 : 0; + challenges[challengeType] = value; + }); + + updateStorage({ token, challengeTypes, challenges }); + dispatchNextChallenge(dispatch); +}; + const postEmailVerification = () => (dispatch: AppDispatch) => { finishChallenge(EMAIL); diff --git a/app/soapbox/__mocks__/api.ts b/app/soapbox/api/__mocks__/index.ts similarity index 79% rename from app/soapbox/__mocks__/api.ts rename to app/soapbox/api/__mocks__/index.ts index 060846c94..92175d076 100644 --- a/app/soapbox/__mocks__/api.ts +++ b/app/soapbox/api/__mocks__/index.ts @@ -4,7 +4,7 @@ import LinkHeader from 'http-link-header'; import type { AxiosInstance, AxiosResponse } from 'axios'; -const api = jest.requireActual('../api') as Record; +const api = jest.requireActual('../index') as Record; let mocks: Array = []; export const __stub = (func: (mock: MockAdapter) => void) => mocks.push(func); @@ -21,6 +21,11 @@ export const getLinks = (response: AxiosResponse): LinkHeader => { return new LinkHeader(response.headers?.link); }; +export const getNextLink = (response: AxiosResponse) => { + const nextLink = new LinkHeader(response.headers?.link); + return nextLink.refs.find((ref) => ref.uri)?.uri; +}; + export const baseClient = (...params: any[]) => { const axios = api.baseClient(...params); setupMock(axios); diff --git a/app/soapbox/api.ts b/app/soapbox/api/index.ts similarity index 73% rename from app/soapbox/api.ts rename to app/soapbox/api/index.ts index bdcaf53d8..c7fcb6230 100644 --- a/app/soapbox/api.ts +++ b/app/soapbox/api/index.ts @@ -9,18 +9,18 @@ import axios, { AxiosInstance, AxiosResponse } from 'axios'; import LinkHeader from 'http-link-header'; import { createSelector } from 'reselect'; -import * as BuildConfig from 'soapbox/build_config'; +import * as BuildConfig from 'soapbox/build-config'; import { RootState } from 'soapbox/store'; import { getAccessToken, getAppToken, isURL, parseBaseURL } from 'soapbox/utils/auth'; import type MockAdapter from 'axios-mock-adapter'; /** - Parse Link headers, mostly for pagination. - @see {@link https://www.npmjs.com/package/http-link-header} - @param {object} response - Axios response object - @returns {object} Link object - */ + Parse Link headers, mostly for pagination. + @see {@link https://www.npmjs.com/package/http-link-header} + @param {object} response - Axios response object + @returns {object} Link object + */ export const getLinks = (response: AxiosResponse): LinkHeader => { return new LinkHeader(response.headers?.link); }; @@ -43,18 +43,18 @@ const maybeParseJSON = (data: string) => { const getAuthBaseURL = createSelector([ (state: RootState, me: string | false | null) => state.accounts.getIn([me, 'url']), - (state: RootState, _me: string | false | null) => state.auth.get('me'), + (state: RootState, _me: string | false | null) => state.auth.me, ], (accountUrl, authUserUrl) => { const baseURL = parseBaseURL(accountUrl) || parseBaseURL(authUserUrl); return baseURL !== window.location.origin ? baseURL : ''; }); /** - * Base client for HTTP requests. - * @param {string} accessToken - * @param {string} baseURL - * @returns {object} Axios instance - */ + * Base client for HTTP requests. + * @param {string} accessToken + * @param {string} baseURL + * @returns {object} Axios instance + */ export const baseClient = (accessToken?: string | null, baseURL: string = ''): AxiosInstance => { return axios.create({ // When BACKEND_URL is set, always use it. @@ -62,28 +62,27 @@ export const baseClient = (accessToken?: string | null, baseURL: string = ''): A headers: Object.assign(accessToken ? { 'Authorization': `Bearer ${accessToken}`, } : {}), - transformResponse: [maybeParseJSON], }); }; /** - * Dumb client for grabbing static files. - * It uses FE_SUBDIRECTORY and parses JSON if possible. - * No authorization is needed. - */ + * Dumb client for grabbing static files. + * It uses FE_SUBDIRECTORY and parses JSON if possible. + * No authorization is needed. + */ export const staticClient = axios.create({ baseURL: BuildConfig.FE_SUBDIRECTORY, transformResponse: [maybeParseJSON], }); /** - * Stateful API client. - * Uses credentials from the Redux store if available. - * @param {function} getState - Must return the Redux state - * @param {string} authType - Either 'user' or 'app' - * @returns {object} Axios instance - */ + * Stateful API client. + * Uses credentials from the Redux store if available. + * @param {function} getState - Must return the Redux state + * @param {string} authType - Either 'user' or 'app' + * @returns {object} Axios instance + */ export default (getState: () => RootState, authType: string = 'user'): AxiosInstance => { const state = getState(); const accessToken = getToken(state, authType); diff --git a/app/soapbox/base_polyfills.ts b/app/soapbox/base_polyfills.ts deleted file mode 100644 index 53146d222..000000000 --- a/app/soapbox/base_polyfills.ts +++ /dev/null @@ -1,50 +0,0 @@ -'use strict'; - -import 'intl'; -import 'intl/locale-data/jsonp/en'; -import 'es6-symbol/implement'; -// @ts-ignore: No types -import includes from 'array-includes'; -// @ts-ignore: No types -import isNaN from 'is-nan'; -import assign from 'object-assign'; -// @ts-ignore: No types -import values from 'object.values'; - -import { decode as decodeBase64 } from './utils/base64'; - -if (!Array.prototype.includes) { - includes.shim(); -} - -if (!Object.assign) { - Object.assign = assign; -} - -if (!Object.values) { - values.shim(); -} - -if (!Number.isNaN) { - Number.isNaN = isNaN; -} - -if (!HTMLCanvasElement.prototype.toBlob) { - const BASE64_MARKER = ';base64,'; - - Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { - value(callback: any, type = 'image/png', quality: any) { - const dataURL = this.toDataURL(type, quality); - let data; - - if (dataURL.indexOf(BASE64_MARKER) >= 0) { - const [, base64] = dataURL.split(BASE64_MARKER); - data = decodeBase64(base64); - } else { - [, data] = dataURL.split(','); - } - - callback(new Blob([data], { type })); - }, - }); -} diff --git a/app/soapbox/build_config.js b/app/soapbox/build-config.js similarity index 96% rename from app/soapbox/build_config.js rename to app/soapbox/build-config.js index 04b48bf78..a11faa0e8 100644 --- a/app/soapbox/build_config.js +++ b/app/soapbox/build-config.js @@ -1,7 +1,7 @@ // @preval /** * Build config: configuration set at build time. - * @module soapbox/build_config + * @module soapbox/build-config */ const trim = require('lodash/trim'); diff --git a/app/soapbox/compare_id.ts b/app/soapbox/compare_id.ts deleted file mode 100644 index e92d13ef5..000000000 --- a/app/soapbox/compare_id.ts +++ /dev/null @@ -1,12 +0,0 @@ -'use strict'; - -export default function compareId(id1: string, id2: string) { - if (id1 === id2) { - return 0; - } - if (id1.length === id2.length) { - return id1 > id2 ? 1 : -1; - } else { - return id1.length > id2.length ? 1 : -1; - } -} diff --git a/app/soapbox/components/__mocks__/react-inlinesvg.tsx b/app/soapbox/components/__mocks__/react-inlinesvg.tsx index 367ec0e33..1317dcbcb 100644 --- a/app/soapbox/components/__mocks__/react-inlinesvg.tsx +++ b/app/soapbox/components/__mocks__/react-inlinesvg.tsx @@ -1,7 +1,7 @@ -import * as React from 'react'; +import React from 'react'; interface IInlineSVG { - loader?: JSX.Element, + loader?: JSX.Element } const InlineSVG: React.FC = ({ loader }): JSX.Element => { diff --git a/app/soapbox/components/__tests__/autosuggest_emoji.test.tsx b/app/soapbox/components/__tests__/autosuggest-emoji.test.tsx similarity index 94% rename from app/soapbox/components/__tests__/autosuggest_emoji.test.tsx rename to app/soapbox/components/__tests__/autosuggest-emoji.test.tsx index 8fab0ef8b..e2f059ff7 100644 --- a/app/soapbox/components/__tests__/autosuggest_emoji.test.tsx +++ b/app/soapbox/components/__tests__/autosuggest-emoji.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen } from '../../jest/test-helpers'; -import AutosuggestEmoji from '../autosuggest_emoji'; +import AutosuggestEmoji from '../autosuggest-emoji'; describe('', () => { it('renders native emoji', () => { diff --git a/app/soapbox/components/__tests__/avatar.test.tsx b/app/soapbox/components/__tests__/avatar.test.tsx deleted file mode 100644 index 56f592925..000000000 --- a/app/soapbox/components/__tests__/avatar.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; - -import { normalizeAccount } from 'soapbox/normalizers'; - -import { render, screen } from '../../jest/test-helpers'; -import Avatar from '../avatar'; - -import type { ReducerAccount } from 'soapbox/reducers/accounts'; - -describe('', () => { - const account = normalizeAccount({ - username: 'alice', - acct: 'alice', - display_name: 'Alice', - avatar: '/animated/alice.gif', - avatar_static: '/static/alice.jpg', - }) as ReducerAccount; - - const size = 100; - - // describe('Autoplay', () => { - // it('renders an animated avatar', () => { - // render(); - - // expect(screen.getByRole('img').getAttribute('src')).toBe(account.get('avatar')); - // }); - // }); - - describe('Still', () => { - it('renders a still avatar', () => { - render(); - - expect(screen.getByRole('img').getAttribute('src')).toBe(account.get('avatar')); - }); - }); - - // TODO add autoplay test if possible -}); diff --git a/app/soapbox/components/__tests__/avatar_overlay.test.tsx b/app/soapbox/components/__tests__/avatar_overlay.test.tsx deleted file mode 100644 index 105828556..000000000 --- a/app/soapbox/components/__tests__/avatar_overlay.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; - -import { normalizeAccount } from 'soapbox/normalizers'; - -import { render, screen } from '../../jest/test-helpers'; -import AvatarOverlay from '../avatar_overlay'; - -import type { ReducerAccount } from 'soapbox/reducers/accounts'; - -describe(' { - const account = normalizeAccount({ - username: 'alice', - acct: 'alice', - display_name: 'Alice', - avatar: '/animated/alice.gif', - avatar_static: '/static/alice.jpg', - }) as ReducerAccount; - - const friend = normalizeAccount({ - username: 'eve', - acct: 'eve@blackhat.lair', - display_name: 'Evelyn', - avatar: '/animated/eve.gif', - avatar_static: '/static/eve.jpg', - }) as ReducerAccount; - - it('renders a overlay avatar', () => { - render(); - expect(screen.queryAllByRole('img')).toHaveLength(2); - }); -}); diff --git a/app/soapbox/components/__tests__/display_name.test.tsx b/app/soapbox/components/__tests__/display-name.test.tsx similarity index 100% rename from app/soapbox/components/__tests__/display_name.test.tsx rename to app/soapbox/components/__tests__/display-name.test.tsx diff --git a/app/soapbox/components/__tests__/emoji_selector.test.tsx b/app/soapbox/components/__tests__/emoji_selector.test.tsx deleted file mode 100644 index c680d156e..000000000 --- a/app/soapbox/components/__tests__/emoji_selector.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; - -import { render, screen } from '../../jest/test-helpers'; -import EmojiSelector from '../emoji_selector'; - -describe('', () => { - it('renders correctly', () => { - const children = ; - // @ts-ignore - children.__proto__.addEventListener = () => {}; - - render(children); - - expect(screen.queryAllByRole('button')).toHaveLength(6); - }); -}); diff --git a/app/soapbox/components/__tests__/status.test.tsx b/app/soapbox/components/__tests__/status.test.tsx new file mode 100644 index 000000000..ea9d04d98 --- /dev/null +++ b/app/soapbox/components/__tests__/status.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { render, screen, rootState } from '../../jest/test-helpers'; +import { normalizeStatus, normalizeAccount } from '../../normalizers'; +import Status from '../status'; + +import type { ReducerStatus } from 'soapbox/reducers/statuses'; + +const account = normalizeAccount({ + id: '1', + acct: 'alex', +}); + +const status = normalizeStatus({ + id: '1', + account, + content: 'hello world', + contentHtml: 'hello world', +}) as ReducerStatus; + +describe('', () => { + const state = rootState.setIn(['accounts', '1'], account); + + it('renders content', () => { + render(, undefined, state); + screen.getByText(/hello world/i); + expect(screen.getByTestId('status')).toHaveTextContent(/hello world/i); + }); + + describe('the Status Action Bar', () => { + it('is rendered', () => { + render(, undefined, state); + expect(screen.getByTestId('status-action-bar')).toBeInTheDocument(); + }); + + it('is not rendered if status is under review', () => { + const inReviewStatus = normalizeStatus({ ...status, visibility: 'self' }); + render(, undefined, state); + expect(screen.queryAllByTestId('status-action-bar')).toHaveLength(0); + }); + }); +}); diff --git a/app/soapbox/components/account_search.tsx b/app/soapbox/components/account-search.tsx similarity index 64% rename from app/soapbox/components/account_search.tsx rename to app/soapbox/components/account-search.tsx index adbb5501d..cbaab0f18 100644 --- a/app/soapbox/components/account_search.tsx +++ b/app/soapbox/components/account-search.tsx @@ -1,9 +1,10 @@ -import classNames from 'classnames'; +import clsx from 'clsx'; import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import AutosuggestAccountInput from 'soapbox/components/autosuggest_account_input'; -import Icon from 'soapbox/components/icon'; +import AutosuggestAccountInput from 'soapbox/components/autosuggest-account-input'; + +import SvgIcon from './ui/icon/svg-icon'; const messages = defineMessages({ placeholder: { id: 'account_search.placeholder', defaultMessage: 'Search for an account' }, @@ -11,11 +12,9 @@ const messages = defineMessages({ interface IAccountSearch { /** Callback when a searched account is chosen. */ - onSelected: (accountId: string) => void, + onSelected: (accountId: string) => void /** Override the default placeholder of the input. */ - placeholder?: string, - /** Position of results relative to the input. */ - resultsPosition?: 'above' | 'below', + placeholder?: string } /** Input to search for accounts. */ @@ -56,9 +55,10 @@ const AccountSearch: React.FC = ({ onSelected, ...rest }) => { }; return ( -
-