diff --git a/.eslintignore b/.eslintignore index 0c17e6907..1ab6f8d8c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,4 +5,4 @@ /tmp/** /coverage/** /custom/** -!.eslintrc.js +!.eslintrc.cjs diff --git a/.eslintrc.js b/.eslintrc.cjs similarity index 95% rename from .eslintrc.js rename to .eslintrc.cjs index 0ecb15a5b..e829eaa1f 100644 --- a/.eslintrc.js +++ b/.eslintrc.cjs @@ -18,7 +18,7 @@ module.exports = { ATTACHMENT_HOST: false, }, - parser: 'babel-eslint', + parser: '@babel/eslint-parser', plugins: [ 'react', @@ -43,7 +43,7 @@ module.exports = { react: { version: 'detect', }, - 'import/extensions': ['.js', '.jsx', '.ts', '.tsx'], + 'import/extensions': ['.js', '.jsx', '.cjs', '.mjs', '.ts', '.tsx'], 'import/ignore': [ 'node_modules', '\\.(css|scss|json)$', @@ -54,12 +54,12 @@ module.exports = { }, }, polyfills: [ - 'es:all', - 'fetch', - 'IntersectionObserver', - 'Promise', - 'URL', - 'URLSearchParams', + 'es:all', // core-js + 'IntersectionObserver', // npm:intersection-observer + 'Promise', // core-js + 'ResizeObserver', // npm:resize-observer-polyfill + 'URL', // core-js + 'URLSearchParams', // core-js ], }, diff --git a/.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 01c6314c9..36a3084ea 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,6 +3,9 @@ image: node:18 variables: NODE_ENV: test +default: + interruptible: true + cache: &cache key: files: @@ -15,6 +18,7 @@ stages: - deps - test - deploy + - release deps: stage: deps @@ -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,14 +144,14 @@ pages: paths: - public only: - refs: - - develop + variables: + - $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME docker: stage: deploy - image: docker:20.10.17 + image: docker:20.10.23 services: - - docker:20.10.17-dind + - docker:20.10.23-dind tags: - dind # https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df @@ -156,5 +160,17 @@ docker: - docker build -t $CI_REGISTRY_IMAGE . - docker push $CI_REGISTRY_IMAGE only: - refs: - - develop \ No newline at end of file + variables: + - $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME + +release: + stage: release + rules: + - if: $CI_COMMIT_TAG + script: + - npx ts-node ./scripts/do-release.ts + interruptible: false + +include: + - template: Jobs/Dependency-Scanning.gitlab-ci.yml + - template: Security/License-Scanning.gitlab-ci.yml \ No newline at end of file diff --git a/.gitlab/merge_request_templates/BeforeAndAfter.md b/.gitlab/merge_request_templates/BeforeAndAfter.md new file mode 100644 index 000000000..6e457a708 --- /dev/null +++ b/.gitlab/merge_request_templates/BeforeAndAfter.md @@ -0,0 +1,8 @@ +## Summary + + + +## Screenshots (if appropriate): +| Before | After | +| ------ | ----- | +| | | diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 5dcd5926a..97bad7f28 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,5 +1,8 @@ { "*.js": "eslint --cache", + "*.cjs": "eslint --cache", + "*.mjs": "eslint --cache", "*.ts": "eslint --cache", + "*.tsx": "eslint --cache", "app/styles/**/*.scss": "stylelint" } diff --git a/.stylelintrc.json b/.stylelintrc.json index c8a71a164..1a610d5eb 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,16 +1,22 @@ { - "extends": ["stylelint-config-standard"], - "ignoreFiles": ["app/styles/reset.scss"], - "plugins": ["stylelint-scss"], + "extends": ["stylelint-config-standard-scss"], "rules": { + "alpha-value-notation": null, "at-rule-no-unknown": null, "at-rule-empty-line-before": ["always", { "ignore": ["after-comment", "first-nested", "inside-block", "blockless-after-same-name-blockless", "blockless-after-blockless"] }], + "color-function-notation": null, + "custom-property-pattern": null, + "declaration-block-no-redundant-longhand-properties": null, "declaration-colon-newline-after": null, "declaration-empty-line-before": "never", - "font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free", "OpenDyslexic", "soapbox"] }], + "font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free"] }], + "max-line-length": null, "no-descending-specificity": null, "no-duplicate-selectors": null, - "scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["/tailwind/", "layer"]}], - "no-invalid-position-at-import-rule": null + "no-invalid-position-at-import-rule": null, + "scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["tailwind", "apply", "layer", "config"]}], + "scss/operator-no-unspaced": null, + "selector-class-pattern": null, + "string-quotes": "single" } } diff --git a/.tool-versions b/.tool-versions index f0c37ee48..efc600fbd 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs 18.2.0 +nodejs 18.13.0 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 57a35ab4f..d1762aa9a 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,6 +3,7 @@ "dbaeumer.vscode-eslint", "bradlc.vscode-tailwindcss", "stylelint.vscode-stylelint", - "wix.vscode-import-cost" + "wix.vscode-import-cost", + "redhat.vscode-yaml" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 4a7155a74..1b3f69961 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,21 @@ { + "css.validate": false, "editor.insertSpaces": true, "editor.tabSize": 2, "files.associations": { "*.conf.template": "properties" }, "files.eol": "\n", - "files.insertFinalNewline": false + "files.insertFinalNewline": false, + "json.schemas": [ + { + "fileMatch": [".lintstagedrc.json"], + "url": "https://json.schemastore.org/lintstagedrc.schema.json" + }, + { + "fileMatch": ["renovate.json"], + "url": "https://docs.renovatebot.com/renovate-schema.json" + } + ], + "scss.validate": false } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a3850599..619a1d207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,153 @@ 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 +- Admin: redirect the homepage to any URL. +- Compatibility: added compatibility with Friendica. +- Posts: bot badge on statuses from bot accounts. +- Compatibility: improved browser support for older browsers. +- Events: allow to repost events in event menu. +- Groups: Initial support for groups. + +### Changed +- Chats: improved display of media attachments. +- ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away. +- Posts: increased font size of focused status in threads. + +### Fixed +- Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load. +- Chats: don't display "copy" button for messages without text. +- Posts: don't have to click the play button twice for embedded videos. +- index.html: remove `referrer` meta tag so it doesn't conflict with backend's `Referrer-Policy` header. +- Modals: fix media modal automatically switching to video. + +### Removed +- Admin: single user mode. Now the homepage can be redirected to any URL. + +## [3.1.0] - 2023-01-13 + +### Added +- Compatibility: rudimentary support for Takahē. +- UI: added backdrop blur behind modals. +- Admin: let admins configure media preview for attachment thumbnails. +- Login: accept `?server` param in external login, eg `fe.soapbox.pub/login/external?server=gleasonator.com`. +- Backups: restored Pleroma backups functionality. +- Export: restored "Export data" to CSV. + +### Changed +- Posts: letterbox images to 19:6 again. +- Status Info: moved context (repost, pinned) to improve UX. +- Posts: remove file icon from empty link previews. +- Settings: moved "Import data" under settings. +- Composer: add more descriptive discard confirmation message. + +### Fixed +- Layout: use accent color for "floating action button" (mobile compose button). +- ServiceWorker: don't serve favicon, robots.txt, and others from ServiceWorker. +- Datepicker: correctly default to the current year. +- Scheduled posts: fix page crashing on deleting a scheduled post. +- Events: don't crash when searching for a location. +- Search: fixes an abort error when using the navbar search component. +- Posts: fix monospace font in Markdown code blocks. +- Modals: fix action buttons overflow +- Editing: don't insert edited posts to the top of the feed. +- Editing: don't display edited posts as pending posts. +- Modals: close modal when navigating to a different page. +- Modals: fix "View context" button in media modal. +- Posts: let unauthenticated users to translate posts if allowed by backend. +- Chats: fix jumpy scrollbar. +- Composer: fix alignment of icon in submit button. +- Login: add a border around QR codes. +- Composer: don't display action button in reply indicator. + +## [3.0.0] - 2022-12-25 + +### Added +- Editing: ability to edit posts and view edit history (on Rebased, Pleroma, and Mastodon). +- Events: ability to create, view, and comment on Events (on Rebased). +- 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. diff --git a/README.md b/README.md index 754e68d4c..2504de278 100644 --- a/README.md +++ b/README.md @@ -1,202 +1,81 @@ -# Soapbox - ![Soapbox Screenshot](soapbox-screenshot.png) -**Soapbox** is a frontend for Mastodon and Pleroma with a focus on custom branding and ease of use. +**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. -## Try it out +# On The Fediverse -Visit https://fe.soapbox.pub/ and point it to your favorite instance. +You may have heard of **Mastodon**. Soapbox builds upon what Mastodon made great to make something even better. -## :rocket: Deploy on Pleroma +You can run **Mastodon+Soapbox**, **Rebased+Soapbox**, and more. -Installing Soapbox on an existing Pleroma server is extremely easy. -Just ssh into the server and download a .zip of the latest build: +Soapbox is the **frontend** (what users see) while Mastodon is the **backend** (data, APIs). You can mix-and-match in the Fediverse ecosystem. -```sh -curl -L https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/develop/download?job=build-production -o soapbox.zip -``` +> 💡 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/) -Then unpack it into Pleroma's `instance` directory: +# Try It Out -```sh -busybox unzip soapbox.zip -o -d /opt/pleroma/instance -``` +Want to give Soapbox a shot? Here are some suggested servers: -**That's it!** :tada: -**Soapbox is installed.** -The change will take effect immediately, just refresh your browser tab. -It's not necessary to restart the Pleroma service. +- [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 -***For OTP releases,*** *unpack to /var/lib/pleroma instead.* +Want to use Soapbox against **any existing Mastodon/Pleroma server?** Try: -To remove Soapbox 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! -## :elephant: Deploy on Mastodon +# 🚀 Starting Your Own Server -See [Installing Soapbox over Mastodon](https://docs.soapbox.pub/frontend/administration/mastodon/). +Starting your own server is one of the best ways to have freedom online! We recommend installing **Rebased+Soapbox**. -## How does it work? +See here for a detailed setup guide: [Installing Rebased+Soapbox](https://soapbox.pub/install/) -Soapbox is a [single-page application (SPA)](https://en.wikipedia.org/wiki/Single-page_application) that runs entirely in the browser with JavaScript. +# Adding Soapbox to an Existing Server -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). +Already have a server? No problem — it is still possible to use Soapbox. -Here is a simplified example with Nginx: +- [Deploying on Pleroma](https://docs.soapbox.pub/frontend/installing/#install-soapbox) +- [Deploying on Mastodon](https://docs.soapbox.pub/frontend/administration/mastodon/) -```nginx -location /api { - proxy_pass http://backend; -} +> 💡 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. -location / { - root /opt/soapbox; - try_files $uri index.html; -} -``` +# Developing Soapbox -(See [`mastodon.conf`](https://gitlab.com/soapbox-pub/soapbox/-/blob/develop/installation/mastodon.conf) for a full example.) +tl;dr — `git clone`, `yarn`, and `yarn dev`. -Soapbox incorporates much of the [Mastodon API](https://docs.joinmastodon.org/methods/), [Pleroma API](https://api.pleroma.social/), and more. -It detects features supported by the backend to provide the right experience for the backend. +For detailed guides, see these pages: -# Running locally +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/) -To get it running, just clone the repo: - -```sh -git clone https://gitlab.com/soapbox-pub/soapbox.git -cd soapbox -``` - -Ensure that Node.js and Yarn are installed, then install dependencies: - -```sh -yarn -``` - -Finally, run the dev server: - -```sh -yarn dev -``` - -**That's it!** :tada: - -It will serve at `http://localhost:3036` by default. - -You should see an input box - just enter the domain name of your instance to log in. - -Tip: you can even enter a local instance like `http://localhost:3000`! - -### Troubleshooting: `ERROR: NODE_ENV must be set` - -Create a `.env` file if you haven't already. - -```sh -cp .env.example .env -``` - -And ensure that it contains `NODE_ENV=development`. -Try again. - -### Troubleshooting: it's not working! - -Run `node -V` and compare your Node.js version with the version in [`.tool-versions`](https://gitlab.com/soapbox-pub/soapbox/-/blob/develop/.tool-versions). -If they don't match, try installing [asdf](https://asdf-vm.com/). - -## 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 checks for the following options: - -- `development` - What you should use while developing Soapbox. -- `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 +## Contributing We welcome contributions to this project. To contribute, see [Contributing to Soapbox](docs/contributing.md). -# Customization +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! -Soapbox supports customization of the user interface, to allow per-instance branding and other features. -Some examples include: +# Project Philosophy -- 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 +Soapbox was born out of the need to build independent platforms with **a unique identity and brand**. -More details can be found in [Customizing Soapbox](docs/customization.md). +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. + +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 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. - -- `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. +© Alex Gleason & other Soapbox contributors +© Eugen Rochko & other Mastodon contributors +© Trump Media & Technology Group +© Gab AI, Inc. Soapbox is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by @@ -205,8 +84,8 @@ the Free Software Foundation, either version 3 of the License, or 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. If not, see . +along with Soapbox. If not, see . diff --git a/app/application.ts b/app/application.ts deleted file mode 100644 index 9610b4f9a..000000000 --- a/app/application.ts +++ /dev/null @@ -1,17 +0,0 @@ -import loadPolyfills from './soapbox/load-polyfills'; - -// Load iframe event listener -require('./soapbox/iframe'); - -// @ts-ignore -require.context('./assets/images/', true); - -// Load stylesheet -require('react-datepicker/dist/react-datepicker.css'); -require('./styles/application.scss'); - -loadPolyfills().then(() => { - require('./soapbox/main').default(); -}).catch(e => { - console.error(e); -}); diff --git a/app/assets/fonts/OpenDyslexic/LICENSE b/app/assets/fonts/OpenDyslexic/LICENSE deleted file mode 100644 index bb867823f..000000000 --- a/app/assets/fonts/OpenDyslexic/LICENSE +++ /dev/null @@ -1,94 +0,0 @@ -Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es), -with Reserved Font Name OpenDyslexic. -Copyright (c) 12/2012 - 2019 -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 b/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 deleted file mode 100644 index aa7bcdea9..000000000 Binary files a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 and /dev/null differ diff --git a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold.woff2 b/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold.woff2 deleted file mode 100644 index 2f04ad119..000000000 Binary files a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold.woff2 and /dev/null differ diff --git a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Italic.woff2 b/app/assets/fonts/OpenDyslexic/OpenDyslexic-Italic.woff2 deleted file mode 100644 index 00c19082d..000000000 Binary files a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Italic.woff2 and /dev/null differ diff --git a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Regular.woff2 b/app/assets/fonts/OpenDyslexic/OpenDyslexic-Regular.woff2 deleted file mode 100644 index 47e26d82a..000000000 Binary files a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Regular.woff2 and /dev/null differ diff --git a/app/assets/fonts/soapbox/soapbox.eot b/app/assets/fonts/soapbox/soapbox.eot deleted file mode 100644 index d66bb100a..000000000 Binary files a/app/assets/fonts/soapbox/soapbox.eot and /dev/null differ diff --git a/app/assets/fonts/soapbox/soapbox.svg b/app/assets/fonts/soapbox/soapbox.svg deleted file mode 100644 index 20d08a586..000000000 --- a/app/assets/fonts/soapbox/soapbox.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - -Generated by IcoMoon - - - - - - - - \ No newline at end of file diff --git a/app/assets/fonts/soapbox/soapbox.ttf b/app/assets/fonts/soapbox/soapbox.ttf deleted file mode 100644 index df64210fb..000000000 Binary files a/app/assets/fonts/soapbox/soapbox.ttf and /dev/null differ diff --git a/app/assets/fonts/soapbox/soapbox.woff b/app/assets/fonts/soapbox/soapbox.woff deleted file mode 100644 index 1902dbc36..000000000 Binary files a/app/assets/fonts/soapbox/soapbox.woff and /dev/null differ diff --git a/app/assets/icons/COPYING.md b/app/assets/icons/COPYING.md index 5e84c0b5c..1dcc928d9 100644 --- a/app/assets/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/assets/icons/fediverse.svg b/app/assets/icons/fediverse.svg deleted file mode 100644 index 4cd3cb938..000000000 --- a/app/assets/icons/fediverse.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - 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/index.ejs b/app/index.ejs index b0d109e1f..e7c9b7330 100644 --- a/app/index.ejs +++ b/app/index.ejs @@ -5,7 +5,6 @@ - <%= snippets %> diff --git a/app/soapbox/__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/__tests__/toast.test.tsx b/app/soapbox/__tests__/toast.test.tsx new file mode 100644 index 000000000..4c38755e2 --- /dev/null +++ b/app/soapbox/__tests__/toast.test.tsx @@ -0,0 +1,166 @@ +import { render } from '@testing-library/react'; +import { AxiosError } from 'axios'; +import React from 'react'; +import { IntlProvider } from 'react-intl'; + +import { act, screen } from 'soapbox/jest/test-helpers'; + +function renderApp() { + const { Toaster } = require('react-hot-toast'); + const toast = require('../toast').default; + + return { + toast, + ...render( + + , + , + ), + }; +} + +beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + (console.error as any).mockClear(); +}); + +afterAll(() => { + (console.error as any).mockRestore(); +}); + +describe('toasts', () =>{ + it('renders successfully', async() => { + const { toast } = renderApp(); + + act(() => { + toast.success('hello'); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent('hello'); + }); + + describe('actionable button', () => { + it('renders the button', async() => { + const { toast } = renderApp(); + + act(() => { + toast.success('hello', { action: () => null, actionLabel: 'click me' }); + }); + + expect(screen.getByTestId('toast-action')).toHaveTextContent('click me'); + }); + + it('does not render the button', async() => { + const { toast } = renderApp(); + + act(() => { + toast.success('hello'); + }); + + expect(screen.queryAllByTestId('toast-action')).toHaveLength(0); + }); + }); + + describe('showAlertForError()', () => { + const buildError = (message: string, status: number) => new AxiosError(message, String(status), undefined, null, { + data: { + error: message, + }, + statusText: String(status), + status, + headers: {}, + config: {}, + }); + + describe('with a 502 status code', () => { + it('renders the correct message', async() => { + const message = 'The server is down'; + const error = buildError(message, 502); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent('The server is down'); + }); + }); + + describe('with a 404 status code', () => { + it('renders the correct message', async() => { + const error = buildError('', 404); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.queryAllByTestId('toast')).toHaveLength(0); + }); + }); + + describe('with a 410 status code', () => { + it('renders the correct message', async() => { + const error = buildError('', 410); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.queryAllByTestId('toast')).toHaveLength(0); + }); + }); + + describe('with an accepted status code', () => { + describe('with a message from the server', () => { + it('renders the correct message', async() => { + const message = 'custom message'; + const error = buildError(message, 200); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent(message); + }); + }); + + describe('without a message from the server', () => { + it('renders the correct message', async() => { + const message = 'The request has been accepted for processing'; + const error = buildError(message, 202); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent(message); + }); + }); + }); + + describe('without a response', () => { + it('renders the default message', async() => { + const error = new AxiosError(); + const { toast } = renderApp(); + + act(() => { + toast.showAlertForError(error); + }); + + expect(screen.getByTestId('toast')).toBeInTheDocument(); + expect(screen.getByTestId('toast-message')).toHaveTextContent('An unexpected error occurred.'); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/actions/__tests__/alerts.test.ts b/app/soapbox/actions/__tests__/alerts.test.ts deleted file mode 100644 index 5f1f9f4d6..000000000 --- a/app/soapbox/actions/__tests__/alerts.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { AxiosError } from 'axios'; - -import { mockStore, rootState } from 'soapbox/jest/test-helpers'; - -import { dismissAlert, showAlert, showAlertForError } from '../alerts'; - -const buildError = (message: string, status: number) => new AxiosError(message, String(status), undefined, null, { - data: { - error: message, - }, - statusText: String(status), - status, - headers: {}, - config: {}, -}); - -let store: ReturnType; - -beforeEach(() => { - const state = rootState; - store = mockStore(state); -}); - -describe('dismissAlert()', () => { - it('dispatches the proper actions', async() => { - const alert = 'hello world'; - const expectedActions = [ - { type: 'ALERT_DISMISS', alert }, - ]; - await store.dispatch(dismissAlert(alert as any)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); -}); - -describe('showAlert()', () => { - it('dispatches the proper actions', async() => { - const title = 'title'; - const message = 'msg'; - const severity = 'info'; - const expectedActions = [ - { type: 'ALERT_SHOW', title, message, severity }, - ]; - await store.dispatch(showAlert(title, message, severity)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); -}); - -describe('showAlert()', () => { - describe('with a 502 status code', () => { - it('dispatches the proper actions', async() => { - const message = 'The server is down'; - const error = buildError(message, 502); - - const expectedActions = [ - { type: 'ALERT_SHOW', title: '', message, severity: 'error' }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('with a 404 status code', () => { - it('dispatches the proper actions', async() => { - const error = buildError('', 404); - - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual([]); - }); - }); - - describe('with a 410 status code', () => { - it('dispatches the proper actions', async() => { - const error = buildError('', 410); - - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual([]); - }); - }); - - describe('with an accepted status code', () => { - describe('with a message from the server', () => { - it('dispatches the proper actions', async() => { - const message = 'custom message'; - const error = buildError(message, 200); - - const expectedActions = [ - { type: 'ALERT_SHOW', title: '', message, severity: 'error' }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('without a message from the server', () => { - it('dispatches the proper actions', async() => { - const message = 'The request has been accepted for processing'; - const error = buildError(message, 202); - - const expectedActions = [ - { type: 'ALERT_SHOW', title: '', message, severity: 'error' }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - }); - - describe('without a response', () => { - it('dispatches the proper actions', async() => { - const error = new AxiosError(); - - const expectedActions = [ - { - type: 'ALERT_SHOW', - title: { - defaultMessage: 'Oops!', - id: 'alert.unexpected.title', - }, - message: { - defaultMessage: 'An unexpected error occurred.', - id: 'alert.unexpected.message', - }, - severity: 'error', - }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); diff --git a/app/soapbox/actions/__tests__/compose.test.ts b/app/soapbox/actions/__tests__/compose.test.ts index 1579d63c9..58f83e537 100644 --- a/app/soapbox/actions/__tests__/compose.test.ts +++ b/app/soapbox/actions/__tests__/compose.test.ts @@ -46,13 +46,6 @@ describe('uploadCompose()', () => { const expectedActions = [ { type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true }, - { - type: 'ALERT_SHOW', - message: 'Image exceeds the current file size limit (10 Bytes)', - actionLabel: undefined, - actionLink: undefined, - severity: 'error', - }, { type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true }, ]; @@ -99,13 +92,6 @@ describe('uploadCompose()', () => { const expectedActions = [ { type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true }, - { - type: 'ALERT_SHOW', - message: 'Video exceeds the current file size limit (10 Bytes)', - actionLabel: undefined, - actionLink: undefined, - severity: 'error', - }, { type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true }, ]; diff --git a/app/soapbox/actions/__tests__/me.test.ts b/app/soapbox/actions/__tests__/me.test.ts index c75d128f0..d4dc1d31f 100644 --- a/app/soapbox/actions/__tests__/me.test.ts +++ b/app/soapbox/actions/__tests__/me.test.ts @@ -2,7 +2,9 @@ import { Map as ImmutableMap } from 'immutable'; import { __stub } from 'soapbox/api'; import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { AccountRecord } from 'soapbox/normalizers'; +import { AuthUserRecord, ReducerRecord } from '../../reducers/auth'; import { fetchMe, patchMe, } from '../me'; @@ -38,18 +40,18 @@ describe('fetchMe()', () => { beforeEach(() => { const state = rootState - .set('auth', ImmutableMap({ + .set('auth', ReducerRecord({ me: accountUrl, users: ImmutableMap({ - [accountUrl]: ImmutableMap({ + [accountUrl]: AuthUserRecord({ 'access_token': token, }), }), })) .set('accounts', ImmutableMap({ - [accountUrl]: { + [accountUrl]: AccountRecord({ url: accountUrl, - }, + }), }) as any); store = mockStore(state); }); @@ -112,4 +114,4 @@ describe('patchMe()', () => { expect(actions).toEqual(expectedActions); }); }); -}); \ No newline at end of file +}); diff --git a/app/soapbox/actions/__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/accounts.ts b/app/soapbox/actions/accounts.ts index 14db17275..b4a54201f 100644 --- a/app/soapbox/actions/accounts.ts +++ b/app/soapbox/actions/accounts.ts @@ -10,10 +10,10 @@ import { } from './importer'; import type { AxiosError, CancelToken } from 'axios'; -import type { History } from 'history'; import type { Map as ImmutableMap } from 'immutable'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { APIEntity, Status } from 'soapbox/types/entities'; +import type { History } from 'soapbox/types/history'; const ACCOUNT_CREATE_REQUEST = 'ACCOUNT_CREATE_REQUEST'; const ACCOUNT_CREATE_SUCCESS = 'ACCOUNT_CREATE_SUCCESS'; diff --git a/app/soapbox/actions/admin.ts b/app/soapbox/actions/admin.ts index 02af5e87a..e24999aa1 100644 --- a/app/soapbox/actions/admin.ts +++ b/app/soapbox/actions/admin.ts @@ -77,6 +77,16 @@ const ADMIN_USERS_UNSUGGEST_REQUEST = 'ADMIN_USERS_UNSUGGEST_REQUEST'; const ADMIN_USERS_UNSUGGEST_SUCCESS = 'ADMIN_USERS_UNSUGGEST_SUCCESS'; const ADMIN_USERS_UNSUGGEST_FAIL = 'ADMIN_USERS_UNSUGGEST_FAIL'; +const ADMIN_USER_INDEX_EXPAND_FAIL = 'ADMIN_USER_INDEX_EXPAND_FAIL'; +const ADMIN_USER_INDEX_EXPAND_REQUEST = 'ADMIN_USER_INDEX_EXPAND_REQUEST'; +const ADMIN_USER_INDEX_EXPAND_SUCCESS = 'ADMIN_USER_INDEX_EXPAND_SUCCESS'; + +const ADMIN_USER_INDEX_FETCH_FAIL = 'ADMIN_USER_INDEX_FETCH_FAIL'; +const ADMIN_USER_INDEX_FETCH_REQUEST = 'ADMIN_USER_INDEX_FETCH_REQUEST'; +const ADMIN_USER_INDEX_FETCH_SUCCESS = 'ADMIN_USER_INDEX_FETCH_SUCCESS'; + +const ADMIN_USER_INDEX_QUERY_SET = 'ADMIN_USER_INDEX_QUERY_SET'; + const nicknamesFromIds = (getState: () => RootState, ids: string[]) => ids.map(id => getState().accounts.get(id)!.acct); const fetchConfig = () => @@ -103,6 +113,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) @@ -531,6 +554,50 @@ const unsuggestUsers = (accountIds: string[]) => }); }; +const setUserIndexQuery = (query: string) => ({ type: ADMIN_USER_INDEX_QUERY_SET, query }); + +const fetchUserIndex = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const { filters, page, query, pageSize, isLoading } = getState().admin_user_index; + + if (isLoading) return; + + dispatch({ type: ADMIN_USER_INDEX_FETCH_REQUEST }); + + dispatch(fetchUsers(filters.toJS() as string[], page + 1, query, pageSize)) + .then((data: any) => { + if (data.error) { + dispatch({ type: ADMIN_USER_INDEX_FETCH_FAIL }); + } else { + const { users, count, next } = (data); + dispatch({ type: ADMIN_USER_INDEX_FETCH_SUCCESS, users, count, next }); + } + }).catch(() => { + dispatch({ type: ADMIN_USER_INDEX_FETCH_FAIL }); + }); + }; + +const expandUserIndex = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const { filters, page, query, pageSize, isLoading, next, loaded } = getState().admin_user_index; + + if (!loaded || isLoading) return; + + dispatch({ type: ADMIN_USER_INDEX_EXPAND_REQUEST }); + + dispatch(fetchUsers(filters.toJS() as string[], page + 1, query, pageSize, next)) + .then((data: any) => { + if (data.error) { + dispatch({ type: ADMIN_USER_INDEX_EXPAND_FAIL }); + } else { + const { users, count, next } = (data); + dispatch({ type: ADMIN_USER_INDEX_EXPAND_SUCCESS, users, count, next }); + } + }).catch(() => { + dispatch({ type: ADMIN_USER_INDEX_EXPAND_FAIL }); + }); + }; + export { ADMIN_CONFIG_FETCH_REQUEST, ADMIN_CONFIG_FETCH_SUCCESS, @@ -583,8 +650,16 @@ export { ADMIN_USERS_UNSUGGEST_REQUEST, ADMIN_USERS_UNSUGGEST_SUCCESS, ADMIN_USERS_UNSUGGEST_FAIL, + ADMIN_USER_INDEX_EXPAND_FAIL, + ADMIN_USER_INDEX_EXPAND_REQUEST, + ADMIN_USER_INDEX_EXPAND_SUCCESS, + ADMIN_USER_INDEX_FETCH_FAIL, + ADMIN_USER_INDEX_FETCH_REQUEST, + ADMIN_USER_INDEX_FETCH_SUCCESS, + ADMIN_USER_INDEX_QUERY_SET, fetchConfig, updateConfig, + updateSoapboxConfig, fetchReports, closeReports, fetchUsers, @@ -608,4 +683,7 @@ export { setRole, suggestUsers, unsuggestUsers, + setUserIndexQuery, + fetchUserIndex, + expandUserIndex, }; diff --git a/app/soapbox/actions/alerts.ts b/app/soapbox/actions/alerts.ts deleted file mode 100644 index 8f200563a..000000000 --- a/app/soapbox/actions/alerts.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { defineMessages, MessageDescriptor } from 'react-intl'; - -import { httpErrorMessages } from 'soapbox/utils/errors'; - -import type { SnackbarActionSeverity } from './snackbar'; -import type { AnyAction } from '@reduxjs/toolkit'; -import type { AxiosError } from 'axios'; -import type { NotificationObject } from 'react-notification'; - -const messages = defineMessages({ - unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, - unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, -}); - -export const ALERT_SHOW = 'ALERT_SHOW'; -export const ALERT_DISMISS = 'ALERT_DISMISS'; -export const ALERT_CLEAR = 'ALERT_CLEAR'; - -const noOp = () => { }; - -function dismissAlert(alert: NotificationObject) { - return { - type: ALERT_DISMISS, - alert, - }; -} - -function showAlert( - title: MessageDescriptor | string = messages.unexpectedTitle, - message: MessageDescriptor | string = messages.unexpectedMessage, - severity: SnackbarActionSeverity = 'info', -) { - return { - type: ALERT_SHOW, - title, - message, - severity, - }; -} - -const showAlertForError = (error: AxiosError) => (dispatch: React.Dispatch, _getState: any) => { - if (error?.response) { - const { data, status, statusText } = error.response; - - if (status === 502) { - return dispatch(showAlert('', 'The server is down', 'error')); - } - - if (status === 404 || status === 410) { - // Skip these errors as they are reflected in the UI - return dispatch(noOp as any); - } - - let message: string | undefined = statusText; - - if (data?.error) { - message = data.error; - } - - if (!message) { - message = httpErrorMessages.find((httpError) => httpError.code === status)?.description; - } - - return dispatch(showAlert('', message, 'error')); - } else { - console.error(error); - return dispatch(showAlert(undefined, undefined, 'error')); - } -}; - -export { - dismissAlert, - showAlert, - showAlertForError, -}; diff --git a/app/soapbox/actions/aliases.ts b/app/soapbox/actions/aliases.ts index 8361e31ad..3a5b61163 100644 --- a/app/soapbox/actions/aliases.ts +++ b/app/soapbox/actions/aliases.ts @@ -1,14 +1,13 @@ import { defineMessages } from 'react-intl'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures } from 'soapbox/utils/features'; import api from '../api'; -import { showAlertForError } from './alerts'; import { importFetchedAccounts } from './importer'; import { patchMeSuccess } from './me'; -import snackbar from './snackbar'; import type { AxiosError } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; @@ -80,7 +79,7 @@ const fetchAliasesSuggestions = (q: string) => api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { dispatch(importFetchedAccounts(data)); dispatch(fetchAliasesSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); + }).catch(error => toast.showAlertForError(error)); }; const fetchAliasesSuggestionsReady = (query: string, accounts: APIEntity[]) => ({ @@ -114,7 +113,7 @@ const addToAliases = (account: Account) => api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, account.pleroma.get('ap_id')] }) .then((response => { - dispatch(snackbar.success(messages.createSuccess)); + toast.success(messages.createSuccess); dispatch(addToAliasesSuccess); dispatch(patchMeSuccess(response.data)); })) @@ -129,7 +128,7 @@ const addToAliases = (account: Account) => alias: account.acct, }) .then(() => { - dispatch(snackbar.success(messages.createSuccess)); + toast.success(messages.createSuccess); dispatch(addToAliasesSuccess); dispatch(fetchAliases); }) @@ -165,7 +164,7 @@ const removeFromAliases = (account: string) => api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: alsoKnownAs.filter((id: string) => id !== account) }) .then(response => { - dispatch(snackbar.success(messages.removeSuccess)); + toast.success(messages.removeSuccess); dispatch(removeFromAliasesSuccess); dispatch(patchMeSuccess(response.data)); }) @@ -182,7 +181,7 @@ const removeFromAliases = (account: string) => }, }) .then(response => { - dispatch(snackbar.success(messages.removeSuccess)); + toast.success(messages.removeSuccess); dispatch(removeFromAliasesSuccess); dispatch(fetchAliases); }) diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index d588db80a..06fe848e2 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -14,14 +14,14 @@ import { createApp } from 'soapbox/actions/apps'; import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me'; import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth'; import { startOnboarding } from 'soapbox/actions/onboarding'; -import snackbar from 'soapbox/actions/snackbar'; import { custom } from 'soapbox/custom'; import { queryClient } from 'soapbox/queries/client'; import KVStore from 'soapbox/storage/kv-store'; +import toast from 'soapbox/toast'; import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth'; import sourceCode from 'soapbox/utils/code'; -import { getFeatures } from 'soapbox/utils/features'; import { normalizeUsername } from 'soapbox/utils/input'; +import { getScopes } from 'soapbox/utils/scopes'; import { isStandalone } from 'soapbox/utils/state'; import api, { baseClient } from '../api'; @@ -29,7 +29,6 @@ import api, { baseClient } from '../api'; import { importFetchedAccount } from './importer'; import type { AxiosError } from 'axios'; -import type { Map as ImmutableMap } from 'immutable'; import type { AppDispatch, RootState } from 'soapbox/store'; export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT'; @@ -51,17 +50,12 @@ const customApp = custom('app'); export const messages = defineMessages({ loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' }, + awaitingApproval: { id: 'auth.awaiting_approval', defaultMessage: 'Your account is awaiting approval' }, invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' }, }); const noOp = () => new Promise(f => f(undefined)); -const getScopes = (state: RootState) => { - const instance = state.instance; - const { scopes } = getFeatures(instance); - return scopes; -}; - const createAppAndToken = () => (dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => @@ -94,11 +88,11 @@ const createAuthApp = () => const createAppToken = () => (dispatch: AppDispatch, getState: () => RootState) => { - const app = getState().auth.get('app'); + const app = getState().auth.app; const params = { - client_id: app.get('client_id'), - client_secret: app.get('client_secret'), + client_id: app.client_id!, + client_secret: app.client_secret!, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', grant_type: 'client_credentials', scope: getScopes(getState()), @@ -111,11 +105,11 @@ const createAppToken = () => const createUserToken = (username: string, password: string) => (dispatch: AppDispatch, getState: () => RootState) => { - const app = getState().auth.get('app'); + const app = getState().auth.app; const params = { - client_id: app.get('client_id'), - client_secret: app.get('client_secret'), + client_id: app.client_id!, + client_secret: app.client_secret!, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', grant_type: 'password', username: username, @@ -127,32 +121,12 @@ const createUserToken = (username: string, password: string) => .then((token: Record) => dispatch(authLoggedIn(token))); }; -export const refreshUserToken = () => - (dispatch: AppDispatch, getState: () => RootState) => { - const refreshToken = getState().auth.getIn(['user', 'refresh_token']); - const app = getState().auth.get('app'); - - if (!refreshToken) return dispatch(noOp); - - const params = { - client_id: app.get('client_id'), - client_secret: app.get('client_secret'), - refresh_token: refreshToken, - redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', - grant_type: 'refresh_token', - scope: getScopes(getState()), - }; - - return dispatch(obtainOAuthToken(params)) - .then((token: Record) => dispatch(authLoggedIn(token))); - }; - export const otpVerify = (code: string, mfa_token: string) => (dispatch: AppDispatch, getState: () => RootState) => { - const app = getState().auth.get('app'); + const app = getState().auth.app; return api(getState, 'app').post('/oauth/mfa/challenge', { - client_id: app.get('client_id'), - client_secret: app.get('client_secret'), + client_id: app.client_id, + client_secret: app.client_secret, mfa_token: mfa_token, code: code, challenge_type: 'totp', @@ -204,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(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; }); @@ -235,9 +209,9 @@ export const logOut = () => if (!account) return dispatch(noOp); const params = { - client_id: state.auth.getIn(['app', 'client_id']), - client_secret: state.auth.getIn(['app', 'client_secret']), - token: state.auth.getIn(['users', account.url, 'access_token']), + client_id: state.auth.app.client_id!, + client_secret: state.auth.app.client_secret!, + token: state.auth.users.get(account.url)!.access_token, }; return dispatch(revokeOAuthToken(params)) @@ -248,7 +222,7 @@ export const logOut = () => dispatch({ type: AUTH_LOGGED_OUT, account, standalone }); - return dispatch(snackbar.success(messages.loggedOut)); + toast.success(messages.loggedOut); }); }; @@ -265,10 +239,10 @@ export const switchAccount = (accountId: string, background = false) => export const fetchOwnAccounts = () => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - return state.auth.get('users').forEach((user: ImmutableMap) => { - const account = state.accounts.get(user.get('id')); + return state.auth.users.forEach((user) => { + const account = state.accounts.get(user.id); if (!account) { - dispatch(verifyCredentials(user.get('access_token')!, user.get('url'))); + dispatch(verifyCredentials(user.access_token, user.url)); } }); }; diff --git a/app/soapbox/actions/chats.ts b/app/soapbox/actions/chats.ts index 67b796408..f4ca85abe 100644 --- a/app/soapbox/actions/chats.ts +++ b/app/soapbox/actions/chats.ts @@ -6,8 +6,8 @@ import { getFeatures } from 'soapbox/utils/features'; import api, { getLinks } from '../api'; -import type { History } from 'history'; import type { AppDispatch, RootState } from 'soapbox/store'; +import type { History } from 'soapbox/types/history'; const CHATS_FETCH_REQUEST = 'CHATS_FETCH_REQUEST'; const CHATS_FETCH_SUCCESS = 'CHATS_FETCH_SUCCESS'; diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index 3010ae3fd..75ff84ae5 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -3,16 +3,15 @@ import { List as ImmutableList } from 'immutable'; import throttle from 'lodash/throttle'; import { defineMessages, IntlShape } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; import api from 'soapbox/api'; import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light'; import { tagHistory } from 'soapbox/settings'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures, parseVersion } from 'soapbox/utils/features'; import { formatBytes, getVideoDuration } from 'soapbox/utils/media'; import resizeImage from 'soapbox/utils/resize-image'; -import { showAlert, showAlertForError } from './alerts'; import { useEmoji } from './emojis'; import { importFetchedAccounts } from './importer'; import { uploadMedia, fetchMedia, updateMedia } from './media'; @@ -20,11 +19,11 @@ import { openModal, closeModal } from './modals'; import { getSettings } from './settings'; import { createStatus } from './statuses'; -import type { History } from 'history'; import type { Emoji } from 'soapbox/components/autosuggest-emoji'; import type { AutoSuggestion } from 'soapbox/components/autosuggest-input'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities'; +import type { History } from 'soapbox/types/history'; const { CancelToken, isCancel } = axios; @@ -35,6 +34,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 +46,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'; @@ -86,13 +87,13 @@ const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; const messages = defineMessages({ exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' }, exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' }, - exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit} seconds)' }, + exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit, plural, one {# second} other {# seconds}})' }, scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' }, success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' }, editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' }, uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, - view: { id: 'snackbar.view', defaultMessage: 'View' }, + view: { id: 'toast.view', defaultMessage: 'View' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, }); @@ -210,7 +211,10 @@ const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, c dispatch(insertIntoTagHistory(composeId, data.tags || [], status)); dispatch(submitComposeSuccess(composeId, { ...data })); - dispatch(snackbar.success(edit ? messages.editSuccess : messages.success, messages.view, `/@${data.account.acct}/posts/${data.id}`)); + toast.success(edit ? messages.editSuccess : messages.success, { + actionLabel: messages.view, + actionLink: `/@${data.account.acct}/posts/${data.id}`, + }); }; const needsDescriptions = (state: RootState, composeId: string) => { @@ -244,7 +248,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false let to = compose.to; if (!validateSchedule(state, composeId)) { - dispatch(snackbar.error(messages.scheduleError)); + toast.error(messages.scheduleError); return; } @@ -285,6 +289,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false poll: compose.poll, scheduled_at: compose.schedule, to, + group_id: compose.privacy === 'group' ? compose.group_id : null, }; dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) { @@ -329,7 +334,7 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => const mediaCount = media ? media.size : 0; if (files.length + mediaCount > attachmentLimit) { - dispatch(showAlert(undefined, messages.uploadErrorLimit, 'error')); + toast.error(messages.uploadErrorLimit); return; } @@ -345,18 +350,18 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => if (isImage && maxImageSize && (f.size > maxImageSize)) { const limit = formatBytes(maxImageSize); const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); - dispatch(snackbar.error(message)); + toast.error(message); dispatch(uploadComposeFail(composeId, true)); return; } else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) { const limit = formatBytes(maxVideoSize); const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit }); - dispatch(snackbar.error(message)); + toast.error(message); dispatch(uploadComposeFail(composeId, true)); return; } else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) { const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration }); - dispatch(snackbar.error(message)); + toast.error(message); dispatch(uploadComposeFail(composeId, true)); return; } @@ -467,6 +472,15 @@ const undoUploadCompose = (composeId: string, media_id: string) => ({ media_id: media_id, }); +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(); @@ -495,7 +509,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, dispatch(readyComposeSuggestionsAccounts(composeId, token, response.data)); }).catch(error => { if (!isCancel(error)) { - dispatch(showAlertForError(error)); + toast.showAlertForError(error); } }); }, 200, { leading: true, trailing: true }); @@ -713,6 +727,21 @@ const removeFromMentions = (composeId: string, accountId: string) => }); }; +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, @@ -720,6 +749,7 @@ export { COMPOSE_SUBMIT_FAIL, COMPOSE_REPLY, COMPOSE_REPLY_CANCEL, + COMPOSE_EVENT_REPLY, COMPOSE_QUOTE, COMPOSE_QUOTE_CANCEL, COMPOSE_DIRECT, @@ -730,6 +760,7 @@ export { COMPOSE_UPLOAD_FAIL, COMPOSE_UPLOAD_PROGRESS, COMPOSE_UPLOAD_UNDO, + COMPOSE_GROUP_POST, COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTION_SELECT, @@ -782,6 +813,7 @@ export { uploadComposeSuccess, uploadComposeFail, undoUploadCompose, + groupCompose, clearComposeSuggestions, fetchComposeSuggestions, readyComposeSuggestionsEmojis, @@ -806,4 +838,5 @@ export { openComposeWithText, addToMentions, removeFromMentions, + eventDiscussionCompose, }; diff --git a/app/soapbox/actions/consumer-auth.ts b/app/soapbox/actions/consumer-auth.ts index 171941577..72c928dba 100644 --- a/app/soapbox/actions/consumer-auth.ts +++ b/app/soapbox/actions/consumer-auth.ts @@ -3,7 +3,7 @@ import axios from 'axios'; import * as BuildConfig from 'soapbox/build-config'; import { isURL } from 'soapbox/utils/auth'; import sourceCode from 'soapbox/utils/code'; -import { getFeatures } from 'soapbox/utils/features'; +import { getScopes } from 'soapbox/utils/scopes'; import { createApp } from './apps'; @@ -11,8 +11,7 @@ import type { AppDispatch, RootState } from 'soapbox/store'; const createProviderApp = () => { return async(dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const { scopes } = getFeatures(state.instance); + const scopes = getScopes(getState()); const params = { client_name: sourceCode.displayName, @@ -29,8 +28,7 @@ export const prepareRequest = (provider: string) => { return async(dispatch: AppDispatch, getState: () => RootState) => { const baseURL = isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : ''; - const state = getState(); - const { scopes } = getFeatures(state.instance); + const scopes = getScopes(getState()); const app = await dispatch(createProviderApp()); const { client_id, redirect_uri } = app; diff --git a/app/soapbox/actions/events.ts b/app/soapbox/actions/events.ts 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 index b558c9e6e..1ddab9103 100644 --- a/app/soapbox/actions/export-data.ts +++ b/app/soapbox/actions/export-data.ts @@ -1,10 +1,9 @@ import { defineMessages } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; import api, { getLinks } from 'soapbox/api'; import { normalizeAccount } from 'soapbox/normalizers'; +import toast from 'soapbox/toast'; -import type { SnackbarAction } from './snackbar'; import type { AxiosResponse } from 'axios'; import type { RootState } from 'soapbox/store'; @@ -37,7 +36,7 @@ type ExportDataActions = { | typeof EXPORT_MUTES_SUCCESS | typeof EXPORT_MUTES_FAIL, error?: any, -} | SnackbarAction +} function fileExport(content: string, fileName: string) { const fileToDownload = document.createElement('a'); @@ -75,7 +74,7 @@ export const exportFollows = () => (dispatch: React.Dispatch, followings.unshift('Account address,Show boosts'); fileExport(followings.join('\n'), 'export_followings.csv'); - dispatch(snackbar.success(messages.followersSuccess)); + toast.success(messages.followersSuccess); dispatch({ type: EXPORT_FOLLOWS_SUCCESS }); }).catch(error => { dispatch({ type: EXPORT_FOLLOWS_FAIL, error }); @@ -90,7 +89,7 @@ export const exportBlocks = () => (dispatch: React.Dispatch, .then((blocks) => { fileExport(blocks.join('\n'), 'export_block.csv'); - dispatch(snackbar.success(messages.blocksSuccess)); + toast.success(messages.blocksSuccess); dispatch({ type: EXPORT_BLOCKS_SUCCESS }); }).catch(error => { dispatch({ type: EXPORT_BLOCKS_FAIL, error }); @@ -105,7 +104,7 @@ export const exportMutes = () => (dispatch: React.Dispatch, g .then((mutes) => { fileExport(mutes.join('\n'), 'export_mutes.csv'); - dispatch(snackbar.success(messages.mutesSuccess)); + toast.success(messages.mutesSuccess); dispatch({ type: EXPORT_MUTES_SUCCESS }); }).catch(error => { dispatch({ type: EXPORT_MUTES_FAIL, error }); diff --git a/app/soapbox/actions/external-auth.ts b/app/soapbox/actions/external-auth.ts index 064e100c9..87840cfe7 100644 --- a/app/soapbox/actions/external-auth.ts +++ b/app/soapbox/actions/external-auth.ts @@ -15,10 +15,11 @@ import sourceCode from 'soapbox/utils/code'; import { getWalletAndSign } from 'soapbox/utils/ethereum'; import { getFeatures } from 'soapbox/utils/features'; import { getQuirks } from 'soapbox/utils/quirks'; +import { getInstanceScopes } from 'soapbox/utils/scopes'; import { baseClient } from '../api'; -import type { AppDispatch } from 'soapbox/store'; +import type { AppDispatch, RootState } from 'soapbox/store'; import type { Instance } from 'soapbox/types/entities'; const fetchExternalInstance = (baseURL?: string) => { @@ -37,25 +38,23 @@ const fetchExternalInstance = (baseURL?: string) => { }; const createExternalApp = (instance: Instance, baseURL?: string) => - (dispatch: AppDispatch) => { + (dispatch: AppDispatch, _getState: () => RootState) => { // Mitra: skip creating the auth app if (getQuirks(instance).noApps) return new Promise(f => f({})); - const { scopes } = getFeatures(instance); - const params = { - client_name: sourceCode.displayName, + client_name: sourceCode.displayName, redirect_uris: `${window.location.origin}/login/external`, - website: sourceCode.homepage, - scopes, + website: sourceCode.homepage, + scopes: getInstanceScopes(instance), }; return dispatch(createApp(params, baseURL)); }; const externalAuthorize = (instance: Instance, baseURL: string) => - (dispatch: AppDispatch) => { - const { scopes } = getFeatures(instance); + (dispatch: AppDispatch, _getState: () => RootState) => { + const scopes = getInstanceScopes(instance); return dispatch(createExternalApp(instance, baseURL)).then((app) => { const { client_id, redirect_uri } = app as Record; @@ -76,7 +75,7 @@ const externalAuthorize = (instance: Instance, baseURL: string) => }; const externalEthereumLogin = (instance: Instance, baseURL?: string) => - (dispatch: AppDispatch) => { + (dispatch: AppDispatch, getState: () => RootState) => { const loginMessage = instance.login_message; return getWalletAndSign(loginMessage).then(({ wallet, signature }) => { @@ -89,7 +88,7 @@ const externalEthereumLogin = (instance: Instance, baseURL?: string) => client_secret: client_secret, password: signature as string, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', - scope: getFeatures(instance).scopes, + scope: getInstanceScopes(instance), }; return dispatch(obtainOAuthToken(params, baseURL)) diff --git a/app/soapbox/actions/filters.ts b/app/soapbox/actions/filters.ts index c0f79c6b8..7e663f88d 100644 --- a/app/soapbox/actions/filters.ts +++ b/app/soapbox/actions/filters.ts @@ -1,6 +1,6 @@ import { defineMessages } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures } from 'soapbox/utils/features'; @@ -66,7 +66,7 @@ const createFilter = (phrase: string, expires_at: string, context: Array expires_at, }).then(response => { dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); - dispatch(snackbar.success(messages.added)); + toast.success(messages.added); }).catch(error => { dispatch({ type: FILTERS_CREATE_FAIL, error }); }); @@ -77,7 +77,7 @@ const deleteFilter = (id: string) => dispatch({ type: FILTERS_DELETE_REQUEST }); return api(getState).delete(`/api/v1/filters/${id}`).then(response => { dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data }); - dispatch(snackbar.success(messages.removed)); + toast.success(messages.removed); }).catch(error => { dispatch({ type: FILTERS_DELETE_FAIL, error }); }); diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts new file mode 100644 index 000000000..9715396f3 --- /dev/null +++ b/app/soapbox/actions/groups.ts @@ -0,0 +1,1046 @@ +import { defineMessages } from 'react-intl'; + +import toast from 'soapbox/toast'; + +import api, { getLinks } from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedGroups, importFetchedAccounts } from './importer'; +import { closeModal, openModal } from './modals'; +import { 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, 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 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'; + +const GROUP_LEAVE_REQUEST = 'GROUP_LEAVE_REQUEST'; +const GROUP_LEAVE_SUCCESS = 'GROUP_LEAVE_SUCCESS'; +const GROUP_LEAVE_FAIL = 'GROUP_LEAVE_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_KICK_REQUEST = 'GROUP_KICK_REQUEST'; +const GROUP_KICK_SUCCESS = 'GROUP_KICK_SUCCESS'; +const GROUP_KICK_FAIL = 'GROUP_KICK_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_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_BLOCK_REQUEST = 'GROUP_BLOCK_REQUEST'; +const GROUP_BLOCK_SUCCESS = 'GROUP_BLOCK_SUCCESS'; +const GROUP_BLOCK_FAIL = 'GROUP_BLOCK_FAIL'; + +const GROUP_UNBLOCK_REQUEST = 'GROUP_UNBLOCK_REQUEST'; +const GROUP_UNBLOCK_SUCCESS = 'GROUP_UNBLOCK_SUCCESS'; +const GROUP_UNBLOCK_FAIL = 'GROUP_UNBLOCK_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) => { + dispatch(fetchGroupRelationships([id])); + dispatch(fetchGroupRequest(id)); + + return api(getState).get(`/api/v1/groups/${id}`) + .then(({ data }) => { + dispatch(importFetchedGroups([data])); + dispatch(fetchGroupSuccess(data)); + }) + .catch(err => dispatch(fetchGroupFail(id, err))); +}; + +const fetchGroupRequest = (id: string) => ({ + type: GROUP_FETCH_REQUEST, + id, +}); + +const fetchGroupSuccess = (group: APIEntity) => ({ + type: GROUP_FETCH_SUCCESS, + group, +}); + +const fetchGroupFail = (id: string, error: AxiosError) => ({ + type: GROUP_FETCH_FAIL, + id, + error, +}); + +const 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) => { + const state = getState(); + const loadedRelationships = state.group_relationships; + const newGroupIds = groupIds.filter(id => loadedRelationships.get(id, null) === null); + + if (!state.me || newGroupIds.length === 0) { + return; + } + + dispatch(fetchGroupRelationshipsRequest(newGroupIds)); + + 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)); + }); + }; + +const fetchGroupRelationshipsRequest = (ids: string[]) => ({ + type: GROUP_RELATIONSHIPS_FETCH_REQUEST, + ids, + skipLoading: true, +}); + +const fetchGroupRelationshipsSuccess = (relationships: APIEntity[]) => ({ + type: GROUP_RELATIONSHIPS_FETCH_SUCCESS, + relationships, + skipLoading: true, +}); + +const fetchGroupRelationshipsFail = (error: AxiosError) => ({ + type: GROUP_RELATIONSHIPS_FETCH_FAIL, + error, + skipLoading: true, + skipNotFound: true, +}); + +const joinGroup = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const locked = (getState().groups.items.get(id) as any).locked || false; + + dispatch(joinGroupRequest(id, locked)); + + 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(error, locked)); + }); + }; + +const leaveGroup = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(leaveGroupRequest(id)); + + return api(getState).post(`/api/v1/groups/${id}/leave`).then(response => { + dispatch(leaveGroupSuccess(response.data)); + toast.success(messages.leaveSuccess); + }).catch(error => { + dispatch(leaveGroupFail(error)); + }); + }; + +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 = (error: AxiosError, locked: boolean) => ({ + type: GROUP_JOIN_FAIL, + 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 = (error: AxiosError) => ({ + type: GROUP_LEAVE_FAIL, + 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 groupKick = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupKickRequest(groupId, accountId)); + + return api(getState).post(`/api/v1/groups/${groupId}/kick`, { account_ids: [accountId] }) + .then(() => dispatch(groupKickSuccess(groupId, accountId))) + .catch(err => dispatch(groupKickFail(groupId, accountId, err))); + }; + +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(fetchGroupBlocksSuccess(id, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchGroupBlocksFail(id, error)); + }); + }; + +const fetchGroupBlocksRequest = (id: string) => ({ + type: GROUP_BLOCKS_FETCH_REQUEST, + id, +}); + +const fetchGroupBlocksSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_BLOCKS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchGroupBlocksFail = (id: string, error: AxiosError) => ({ + type: GROUP_BLOCKS_FETCH_FAIL, + id, + error, + skipNotFound: true, +}); + +const expandGroupBlocks = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().user_lists.group_blocks.get(id)?.next || null; + + if (url === null) { + return; + } + + dispatch(expandGroupBlocksRequest(id)); + + return api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandGroupBlocksSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandGroupBlocksFail(id, error)); + }); + }; + +const expandGroupBlocksRequest = (id: string) => ({ + type: GROUP_BLOCKS_EXPAND_REQUEST, + id, +}); + +const expandGroupBlocksSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_BLOCKS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandGroupBlocksFail = (id: string, error: AxiosError) => ({ + type: GROUP_BLOCKS_EXPAND_FAIL, + id, + error, +}); + +const groupBlock = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupBlockRequest(groupId, accountId)); + + return api(getState).post(`/api/v1/groups/${groupId}/blocks`, { account_ids: [accountId] }) + .then(() => dispatch(groupBlockSuccess(groupId, accountId))) + .catch(err => dispatch(groupBlockFail(groupId, accountId, err))); + }; + +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.map((membership: APIEntity) => membership.account))); + dispatch(fetchGroupMembershipsSuccess(id, role, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchGroupMembershipsFail(id, role, error)); + }); + }; + +const fetchGroupMembershipsRequest = (id: string, role: GroupRole) => ({ + type: GROUP_MEMBERSHIPS_FETCH_REQUEST, + id, + role, +}); + +const fetchGroupMembershipsSuccess = (id: string, role: GroupRole, memberships: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIPS_FETCH_SUCCESS, + id, + role, + memberships, + next, +}); + +const fetchGroupMembershipsFail = (id: string, role: GroupRole, error: AxiosError) => ({ + type: GROUP_MEMBERSHIPS_FETCH_FAIL, + id, + role, + error, + skipNotFound: true, +}); + +const expandGroupMemberships = (id: string, role: GroupRole) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().group_memberships.get(role).get(id)?.next || null; + + if (url === null) { + return; + } + + dispatch(expandGroupMembershipsRequest(id, role)); + + return api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + 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(expandGroupMembershipsFail(id, role, error)); + }); + }; + +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 fetchGroupMembershipRequestsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchGroupMembershipRequestsFail = (id: string, error: AxiosError) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL, + id, + error, + skipNotFound: true, +}); + +const expandGroupMembershipRequests = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().user_lists.membership_requests.get(id)?.next || null; + + if (url === null) { + return; + } + + 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(expandGroupMembershipRequestsFail(id, error)); + }); + }; + +const expandGroupMembershipRequestsRequest = (id: string) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST, + id, +}); + +const expandGroupMembershipRequestsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandGroupMembershipRequestsFail = (id: string, error: AxiosError) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL, + id, + error, +}); + +const authorizeGroupMembershipRequest = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(authorizeGroupMembershipRequestRequest(groupId, accountId)); + + 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 authorizeGroupMembershipRequestRequest = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST, + groupId, + accountId, +}); + +const authorizeGroupMembershipRequestSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS, + groupId, + accountId, +}); + +const authorizeGroupMembershipRequestFail = (groupId: string, accountId: string, error: AxiosError) => ({ + type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL, + groupId, + accountId, + error, +}); + +const rejectGroupMembershipRequest = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(rejectGroupMembershipRequestRequest(groupId, accountId)); + + 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 rejectGroupMembershipRequestRequest = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST, + groupId, + accountId, +}); + +const rejectGroupMembershipRequestSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS, + groupId, + accountId, +}); + +const rejectGroupMembershipRequestFail = (groupId: string, accountId: string, error?: AxiosError) => ({ + type: GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL, + groupId, + 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, + 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_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, + fetchGroups, + fetchGroupsRequest, + fetchGroupsSuccess, + fetchGroupsFail, + fetchGroupRelationships, + fetchGroupRelationshipsRequest, + fetchGroupRelationshipsSuccess, + fetchGroupRelationshipsFail, + joinGroup, + leaveGroup, + joinGroupRequest, + joinGroupSuccess, + joinGroupFail, + leaveGroupRequest, + leaveGroupSuccess, + leaveGroupFail, + 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 index 43de9f85c..90f81e7e7 100644 --- a/app/soapbox/actions/import-data.ts +++ b/app/soapbox/actions/import-data.ts @@ -1,10 +1,9 @@ import { defineMessages } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; +import toast from 'soapbox/toast'; import api from '../api'; -import type { SnackbarAction } from './snackbar'; import type { RootState } from 'soapbox/store'; export const IMPORT_FOLLOWS_REQUEST = 'IMPORT_FOLLOWS_REQUEST'; @@ -31,7 +30,7 @@ type ImportDataActions = { | typeof IMPORT_MUTES_FAIL, error?: any, config?: string -} | SnackbarAction +} const messages = defineMessages({ blocksSuccess: { id: 'import_data.success.blocks', defaultMessage: 'Blocks imported successfully' }, @@ -45,7 +44,7 @@ export const importFollows = (params: FormData) => return api(getState) .post('/api/pleroma/follow_import', params) .then(response => { - dispatch(snackbar.success(messages.followersSuccess)); + toast.success(messages.followersSuccess); dispatch({ type: IMPORT_FOLLOWS_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_FOLLOWS_FAIL, error }); @@ -58,7 +57,7 @@ export const importBlocks = (params: FormData) => return api(getState) .post('/api/pleroma/blocks_import', params) .then(response => { - dispatch(snackbar.success(messages.blocksSuccess)); + toast.success(messages.blocksSuccess); dispatch({ type: IMPORT_BLOCKS_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_BLOCKS_FAIL, error }); @@ -71,7 +70,7 @@ export const importMutes = (params: FormData) => return api(getState) .post('/api/pleroma/mutes_import', params) .then(response => { - dispatch(snackbar.success(messages.mutesSuccess)); + toast.success(messages.mutesSuccess); dispatch({ type: IMPORT_MUTES_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_MUTES_FAIL, error }); diff --git a/app/soapbox/actions/importer/index.ts b/app/soapbox/actions/importer/index.ts index a5d86c9b0..8750a5d61 100644 --- a/app/soapbox/actions/importer/index.ts +++ b/app/soapbox/actions/importer/index.ts @@ -5,42 +5,44 @@ 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[], args = { should_refetch: false }) { +const importFetchedAccounts = (accounts: APIEntity[], args = { should_refetch: false }) => { const { should_refetch } = args; const normalAccounts: APIEntity[] = []; @@ -61,10 +63,27 @@ export function importFetchedAccounts(accounts: APIEntity[], args = { should_ref 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; @@ -96,10 +115,13 @@ 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. @@ -117,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[] = []; @@ -146,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); @@ -154,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 ca1fc3ef5..9738718b0 100644 --- a/app/soapbox/actions/instance.ts +++ b/app/soapbox/actions/instance.ts @@ -10,12 +10,12 @@ import api from '../api'; const getMeUrl = (state: RootState) => { const me = state.me; - return state.accounts.getIn([me, 'url']); + return state.accounts.get(me)?.url; }; /** Figure out the appropriate instance to fetch depending on the state */ export const getHost = (state: RootState) => { - const accountUrl = getMeUrl(state) || getAuthUserUrl(state); + const accountUrl = getMeUrl(state) || getAuthUserUrl(state) as string; try { return new URL(accountUrl).host; diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index cb23f3dae..9e43d0f40 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -1,6 +1,6 @@ import { defineMessages } from 'react-intl'; -import snackbar from 'soapbox/actions/snackbar'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import api from '../api'; @@ -63,7 +63,7 @@ const REMOTE_INTERACTION_FAIL = 'REMOTE_INTERACTION_FAIL'; const messages = defineMessages({ bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' }, bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' }, - view: { id: 'snackbar.view', defaultMessage: 'View' }, + view: { id: 'toast.view', defaultMessage: 'View' }, }); const reblog = (status: StatusEntity) => @@ -222,7 +222,10 @@ const bookmark = (status: StatusEntity) => api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function(response) { dispatch(importFetchedStatus(response.data)); dispatch(bookmarkSuccess(status, response.data)); - dispatch(snackbar.success(messages.bookmarkAdded, messages.view, '/bookmarks')); + toast.success(messages.bookmarkAdded, { + actionLabel: messages.view, + actionLink: '/bookmarks', + }); }).catch(function(error) { dispatch(bookmarkFail(status, error)); }); @@ -235,7 +238,7 @@ const unbookmark = (status: StatusEntity) => api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unbookmarkSuccess(status, response.data)); - dispatch(snackbar.success(messages.bookmarkRemoved)); + toast.success(messages.bookmarkRemoved); }).catch(error => { dispatch(unbookmarkFail(status, error)); }); diff --git a/app/soapbox/actions/lists.ts b/app/soapbox/actions/lists.ts index bf7dba8ba..216fae669 100644 --- a/app/soapbox/actions/lists.ts +++ b/app/soapbox/actions/lists.ts @@ -1,8 +1,8 @@ +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import api from '../api'; -import { showAlertForError } from './alerts'; import { importFetchedAccounts } from './importer'; import type { AxiosError } from 'axios'; @@ -265,7 +265,7 @@ const fetchListSuggestions = (q: string) => (dispatch: AppDispatch, getState: () api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { dispatch(importFetchedAccounts(data)); dispatch(fetchListSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); + }).catch(error => toast.showAlertForError(error)); }; const fetchListSuggestionsReady = (query: string, accounts: APIEntity[]) => ({ diff --git a/app/soapbox/actions/me.ts b/app/soapbox/actions/me.ts index 17beae21d..a8b275200 100644 --- a/app/soapbox/actions/me.ts +++ b/app/soapbox/actions/me.ts @@ -6,7 +6,7 @@ import api from '../api'; import { loadCredentials } from './auth'; import { importFetchedAccount } from './importer'; -import type { AxiosError, AxiosRequestHeaders } from 'axios'; +import type { AxiosError, RawAxiosRequestHeaders } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { APIEntity } from 'soapbox/types/entities'; @@ -30,8 +30,8 @@ const getMeUrl = (state: RootState) => { const getMeToken = (state: RootState) => { // Fallback for upgrading IDs to URLs - const accountUrl = getMeUrl(state) || state.auth.get('me'); - return state.auth.getIn(['users', accountUrl, 'access_token']); + const accountUrl = getMeUrl(state) || state.auth.me; + return state.auth.users.get(accountUrl!)?.access_token; }; const fetchMe = () => @@ -46,7 +46,7 @@ const fetchMe = () => } dispatch(fetchMeRequest()); - return dispatch(loadCredentials(token, accountUrl)) + return dispatch(loadCredentials(token, accountUrl!)) .catch(error => dispatch(fetchMeFail(error))); }; @@ -66,7 +66,7 @@ const patchMe = (params: Record, isFormData = false) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(patchMeRequest()); - const headers: AxiosRequestHeaders = isFormData ? { + const headers: RawAxiosRequestHeaders = isFormData ? { 'Content-Type': 'multipart/form-data', } : {}; diff --git a/app/soapbox/actions/moderation.tsx b/app/soapbox/actions/moderation.tsx index 1791500b5..5b0a4a5f2 100644 --- a/app/soapbox/actions/moderation.tsx +++ b/app/soapbox/actions/moderation.tsx @@ -4,10 +4,10 @@ import { defineMessages, IntlShape } from 'react-intl'; import { fetchAccountByUsername } from 'soapbox/actions/accounts'; import { deactivateUsers, deleteUsers, deleteStatus, toggleStatusSensitivity } from 'soapbox/actions/admin'; import { openModal } from 'soapbox/actions/modals'; -import snackbar from 'soapbox/actions/snackbar'; import OutlineBox from 'soapbox/components/outline-box'; import { Stack, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account-container'; +import toast from 'soapbox/toast'; import { isLocal } from 'soapbox/utils/accounts'; import type { AppDispatch, RootState } from 'soapbox/store'; @@ -65,7 +65,7 @@ const deactivateUserModal = (intl: IntlShape, accountId: string, afterConfirm = onConfirm: () => { dispatch(deactivateUsers([accountId])).then(() => { const message = intl.formatMessage(messages.userDeactivated, { acct }); - dispatch(snackbar.success(message)); + toast.success(message); afterConfirm(); }).catch(() => {}); }, @@ -105,7 +105,7 @@ const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () = dispatch(deleteUsers([accountId])).then(() => { const message = intl.formatMessage(messages.userDeleted, { acct }); dispatch(fetchAccountByUsername(acct)); - dispatch(snackbar.success(message)); + toast.success(message); afterConfirm(); }).catch(() => {}); }, @@ -147,7 +147,7 @@ const toggleStatusSensitivityModal = (intl: IntlShape, statusId: string, sensiti onConfirm: () => { dispatch(toggleStatusSensitivity(statusId, sensitive)).then(() => { const message = intl.formatMessage(sensitive === false ? messages.statusMarkedSensitive : messages.statusMarkedNotSensitive, { acct }); - dispatch(snackbar.success(message)); + toast.success(message); }).catch(() => {}); afterConfirm(); }, @@ -168,7 +168,7 @@ const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = () onConfirm: () => { dispatch(deleteStatus(statusId)).then(() => { const message = intl.formatMessage(messages.statusDeleted, { acct }); - dispatch(snackbar.success(message)); + toast.success(message); }).catch(() => {}); afterConfirm(); }, diff --git a/app/soapbox/actions/notifications.ts b/app/soapbox/actions/notifications.ts index 068e65dc4..7b91b64d8 100644 --- a/app/soapbox/actions/notifications.ts +++ b/app/soapbox/actions/notifications.ts @@ -47,7 +47,7 @@ const MAX_QUEUED_NOTIFICATIONS = 40; defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, - group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, + group: { id: 'notifications.group', defaultMessage: '{count, plural, one {# notification} other {# notifications}}' }, }); const fetchRelatedRelationships = (dispatch: AppDispatch, notifications: APIEntity[]) => { @@ -89,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' }); @@ -106,7 +107,10 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record< // Desktop notifications try { - if (showAlert && !filtered) { + // eslint-disable-next-line compat/compat + const isNotificationsEnabled = window.Notification?.permission === 'granted'; + + if (showAlert && !filtered && isNotificationsEnabled) { const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : ''); diff --git a/app/soapbox/actions/reports.ts b/app/soapbox/actions/reports.ts index dce162247..46b51cd55 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, diff --git a/app/soapbox/actions/scheduled-statuses.ts b/app/soapbox/actions/scheduled-statuses.ts index ddc550105..33e763701 100644 --- a/app/soapbox/actions/scheduled-statuses.ts +++ b/app/soapbox/actions/scheduled-statuses.ts @@ -1,3 +1,5 @@ +import { getFeatures } from 'soapbox/utils/features'; + import api, { getLinks } from '../api'; import type { AxiosError } from 'axios'; @@ -18,10 +20,17 @@ const SCHEDULED_STATUS_CANCEL_FAIL = 'SCHEDULED_STATUS_CANCEL_FAIL'; const fetchScheduledStatuses = () => (dispatch: AppDispatch, getState: () => RootState) => { - if (getState().status_lists.get('scheduled_statuses')?.isLoading) { + const state = getState(); + + if (state.status_lists.get('scheduled_statuses')?.isLoading) { return; } + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.scheduledStatuses) return; + dispatch(fetchScheduledStatusesRequest()); api(getState).get('/api/v1/scheduled_statuses').then(response => { diff --git a/app/soapbox/actions/search.ts b/app/soapbox/actions/search.ts index a2f165ac0..6d64b6534 100644 --- a/app/soapbox/actions/search.ts +++ b/app/soapbox/actions/search.ts @@ -1,7 +1,7 @@ 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'; @@ -83,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 => { @@ -139,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 => { diff --git a/app/soapbox/actions/security.ts b/app/soapbox/actions/security.ts index 196e54dcb..4448104b7 100644 --- a/app/soapbox/actions/security.ts +++ b/app/soapbox/actions/security.ts @@ -4,7 +4,7 @@ * @see module:soapbox/actions/auth */ -import snackbar from 'soapbox/actions/snackbar'; +import toast from 'soapbox/toast'; import { getLoggedInAccount } from 'soapbox/utils/auth'; import { parseVersion, TRUTHSOCIAL } from 'soapbox/utils/features'; import { normalizeUsername } from 'soapbox/utils/input'; @@ -50,7 +50,7 @@ const MOVE_ACCOUNT_FAIL = 'MOVE_ACCOUNT_FAIL'; const fetchOAuthTokens = () => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: FETCH_TOKENS_REQUEST }); - return api(getState).get('/api/oauth_tokens.json').then(({ data: tokens }) => { + return api(getState).get('/api/oauth_tokens').then(({ data: tokens }) => { dispatch({ type: FETCH_TOKENS_SUCCESS, tokens }); }).catch(() => { dispatch({ type: FETCH_TOKENS_FAIL }); @@ -152,7 +152,7 @@ const deleteAccount = (password: string) => if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure dispatch({ type: DELETE_ACCOUNT_SUCCESS, response }); dispatch({ type: AUTH_LOGGED_OUT, account }); - dispatch(snackbar.success(messages.loggedOut)); + toast.success(messages.loggedOut); }).catch(error => { dispatch({ type: DELETE_ACCOUNT_FAIL, error, skipAlert: true }); throw error; diff --git a/app/soapbox/actions/settings.ts b/app/soapbox/actions/settings.ts index 44a22f666..5f4a600d2 100644 --- a/app/soapbox/actions/settings.ts +++ b/app/soapbox/actions/settings.ts @@ -4,11 +4,9 @@ import { createSelector } from 'reselect'; import { v4 as uuid } from 'uuid'; import { patchMe } from 'soapbox/actions/me'; +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; -import { showAlertForError } from './alerts'; -import snackbar from './snackbar'; - import type { AppDispatch, RootState } from 'soapbox/store'; const SETTING_CHANGE = 'SETTING_CHANGE'; @@ -49,7 +47,6 @@ const defaultSettings = ImmutableMap({ autoloadMore: true, systemFont: false, - dyslexicFont: false, demetricator: false, isDeveloper: false, @@ -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/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 f9bfca3b0..047d61d71 100644 --- a/app/soapbox/actions/statuses.ts +++ b/app/soapbox/actions/statuses.ts @@ -68,7 +68,7 @@ const createStatus = (params: Record, idempotencyKey: string, statu } dispatch(importFetchedStatus(status, idempotencyKey)); - dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey }); + dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey, editing: !!statusId }); // Poll the backend for the updated card if (status.expectsCard) { diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts index dcb190f25..d0ceb6595 100644 --- a/app/soapbox/actions/streaming.ts +++ b/app/soapbox/actions/streaming.ts @@ -1,5 +1,10 @@ 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 } from 'soapbox/utils/chats'; +import { removePageItem } from 'soapbox/utils/queries'; +import { play, soundCache } from 'soapbox/utils/sounds'; import { connectStream } from '../stream'; @@ -22,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'; @@ -45,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()); @@ -78,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); }); @@ -90,18 +137,37 @@ 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 'pleroma:follow_relationships_update': @@ -129,8 +195,8 @@ const refreshHomeTimelineAndNotification = (dispatch: AppDispatch, done?: () => 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/timelines.ts b/app/soapbox/actions/timelines.ts index 5e4bd26d6..7ae023338 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -219,6 +219,9 @@ 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, @@ -309,6 +312,7 @@ export { expandAccountMediaTimeline, expandListTimeline, expandGroupTimeline, + expandGroupMediaTimeline, expandHashtagTimeline, expandTimelineRequest, expandTimelineSuccess, diff --git a/app/soapbox/actions/trending-statuses.ts b/app/soapbox/actions/trending-statuses.ts index 435fcf6df..7ccab27ab 100644 --- a/app/soapbox/actions/trending-statuses.ts +++ b/app/soapbox/actions/trending-statuses.ts @@ -17,6 +17,8 @@ const fetchTrendingStatuses = () => const instance = state.instance; const features = getFeatures(instance); + if (!features.trendingStatuses && !features.trendingTruths) return; + dispatch({ type: TRENDING_STATUSES_FETCH_REQUEST }); return api(getState).get(features.trendingTruths ? '/api/v1/truth/trending/truths' : '/api/v1/trends/statuses').then(({ data: statuses }) => { dispatch(importFetchedStatuses(statuses)); diff --git a/app/soapbox/actions/verification.ts b/app/soapbox/actions/verification.ts index ce3d27009..d038a79a9 100644 --- a/app/soapbox/actions/verification.ts +++ b/app/soapbox/actions/verification.ts @@ -32,13 +32,14 @@ export type Challenge = 'age' | 'sms' | 'email' type Challenges = { email?: 0 | 1, - sms?: number, - age?: number, + sms?: 0 | 1, + age?: 0 | 1, } type Verification = { token?: string, challenges?: Challenges, + challengeTypes?: Array<'age' | 'sms' | 'email'> }; /** @@ -83,6 +84,18 @@ const fetchStoredChallenges = () => { } }; +/** + * Fetch and return the state of the verification challenge types. + */ +const fetchStoredChallengeTypes = () => { + try { + const verification: Verification | null = fetchStoredVerification(); + return verification!.challengeTypes; + } catch { + return null; + } +}; + /** * Update the verification object in local storage. * @@ -131,7 +144,10 @@ function saveChallenges(challenges: Array<'age' | 'sms' | 'email'>) { } } - updateStorage({ challenges: currentChallenges }); + updateStorage({ + challenges: currentChallenges, + challengeTypes: challenges, + }); } /** @@ -267,13 +283,29 @@ const confirmEmailVerification = (emailToken: string) => return api(getState).post('/api/v1/pepe/verify_email/confirm', { token: emailToken }, { headers: { Authorization: `Bearer ${token}` }, }) - .then(() => { - finishChallenge(EMAIL); - dispatchNextChallenge(dispatch); + .then((response) => { + updateStorageFromEmailConfirmation(dispatch, response.data.token); }) .finally(() => dispatch({ type: SET_LOADING, value: false })); }; +const updateStorageFromEmailConfirmation = (dispatch: AppDispatch, token: string) => { + const challengeTypes = fetchStoredChallengeTypes(); + if (!challengeTypes) { + return; + } + + const indexOfEmail = challengeTypes.indexOf('email'); + const challenges: Challenges = {}; + challengeTypes?.forEach((challengeType, idx) => { + const value = idx <= indexOfEmail ? 1 : 0; + challenges[challengeType] = value; + }); + + updateStorage({ token, challengeTypes, challenges }); + dispatchNextChallenge(dispatch); +}; + const postEmailVerification = () => (dispatch: AppDispatch) => { finishChallenge(EMAIL); diff --git a/app/soapbox/api/__mocks__/index.ts b/app/soapbox/api/__mocks__/index.ts index dd2f1ec93..92175d076 100644 --- a/app/soapbox/api/__mocks__/index.ts +++ b/app/soapbox/api/__mocks__/index.ts @@ -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/index.ts b/app/soapbox/api/index.ts index 97d7d25d7..c7fcb6230 100644 --- a/app/soapbox/api/index.ts +++ b/app/soapbox/api/index.ts @@ -43,7 +43,7 @@ const maybeParseJSON = (data: string) => { const getAuthBaseURL = createSelector([ (state: RootState, me: string | false | null) => state.accounts.getIn([me, 'url']), - (state: RootState, _me: string | false | null) => state.auth.get('me'), + (state: RootState, _me: string | false | null) => state.auth.me, ], (accountUrl, authUserUrl) => { const baseURL = parseBaseURL(accountUrl) || parseBaseURL(authUserUrl); return baseURL !== window.location.origin ? baseURL : ''; @@ -62,7 +62,6 @@ export const baseClient = (accessToken?: string | null, baseURL: string = ''): A headers: Object.assign(accessToken ? { 'Authorization': `Bearer ${accessToken}`, } : {}), - transformResponse: [maybeParseJSON], }); }; diff --git a/app/soapbox/base-polyfills.ts b/app/soapbox/base-polyfills.ts deleted file mode 100644 index a6e92bb3c..000000000 --- a/app/soapbox/base-polyfills.ts +++ /dev/null @@ -1,50 +0,0 @@ -'use strict'; - -import 'intl'; -import 'intl/locale-data/jsonp/en'; -import 'es6-symbol/implement'; -// @ts-ignore: No types -import includes from 'array-includes'; -// @ts-ignore: No types -import isNaN from 'is-nan'; -import assign from 'object-assign'; -// @ts-ignore: No types -import values from 'object.values'; - -import { decode as decodeBase64 } from './utils/base64'; - -if (!Array.prototype.includes) { - includes.shim(); -} - -if (!Object.assign) { - Object.assign = assign; -} - -if (!Object.values) { - values.shim(); -} - -if (!Number.isNaN) { - Number.isNaN = isNaN; -} - -if (!HTMLCanvasElement.prototype.toBlob) { - const BASE64_MARKER = ';base64,'; - - Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { - value(callback: any, type = 'image/png', quality: any) { - const dataURL = this.toDataURL(type, quality); - let data; - - if (dataURL.includes(BASE64_MARKER)) { - const [, base64] = dataURL.split(BASE64_MARKER); - data = decodeBase64(base64); - } else { - [, data] = dataURL.split(','); - } - - callback(new Blob([data], { type })); - }, - }); -} diff --git a/app/soapbox/components/__mocks__/react-inlinesvg.tsx b/app/soapbox/components/__mocks__/react-inlinesvg.tsx index 367ec0e33..1d4fde154 100644 --- a/app/soapbox/components/__mocks__/react-inlinesvg.tsx +++ b/app/soapbox/components/__mocks__/react-inlinesvg.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import React from 'react'; interface IInlineSVG { loader?: JSX.Element, diff --git a/app/soapbox/components/__tests__/avatar-overlay.test.tsx b/app/soapbox/components/__tests__/avatar-overlay.test.tsx deleted file mode 100644 index 4e83dd071..000000000 --- a/app/soapbox/components/__tests__/avatar-overlay.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; - -import { normalizeAccount } from 'soapbox/normalizers'; - -import { render, screen } from '../../jest/test-helpers'; -import AvatarOverlay from '../avatar-overlay'; - -import type { ReducerAccount } from 'soapbox/reducers/accounts'; - -describe(' { - const account = normalizeAccount({ - username: 'alice', - acct: 'alice', - display_name: 'Alice', - avatar: '/animated/alice.gif', - avatar_static: '/static/alice.jpg', - }) as ReducerAccount; - - const friend = normalizeAccount({ - username: 'eve', - acct: 'eve@blackhat.lair', - display_name: 'Evelyn', - avatar: '/animated/eve.gif', - avatar_static: '/static/eve.jpg', - }) as ReducerAccount; - - it('renders a overlay avatar', () => { - render(); - expect(screen.queryAllByRole('img')).toHaveLength(2); - }); -}); diff --git a/app/soapbox/components/__tests__/avatar.test.tsx b/app/soapbox/components/__tests__/avatar.test.tsx deleted file mode 100644 index 56f592925..000000000 --- a/app/soapbox/components/__tests__/avatar.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; - -import { normalizeAccount } from 'soapbox/normalizers'; - -import { render, screen } from '../../jest/test-helpers'; -import Avatar from '../avatar'; - -import type { ReducerAccount } from 'soapbox/reducers/accounts'; - -describe('', () => { - const account = normalizeAccount({ - username: 'alice', - acct: 'alice', - display_name: 'Alice', - avatar: '/animated/alice.gif', - avatar_static: '/static/alice.jpg', - }) as ReducerAccount; - - const size = 100; - - // describe('Autoplay', () => { - // it('renders an animated avatar', () => { - // render(); - - // expect(screen.getByRole('img').getAttribute('src')).toBe(account.get('avatar')); - // }); - // }); - - describe('Still', () => { - it('renders a still avatar', () => { - render(); - - expect(screen.getByRole('img').getAttribute('src')).toBe(account.get('avatar')); - }); - }); - - // TODO add autoplay test if possible -}); diff --git a/app/soapbox/components/account-search.tsx b/app/soapbox/components/account-search.tsx index 883278bcf..c519b0243 100644 --- a/app/soapbox/components/account-search.tsx +++ b/app/soapbox/components/account-search.tsx @@ -3,7 +3,8 @@ 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 SvgIcon from './ui/icon/svg-icon'; const messages = defineMessages({ placeholder: { id: 'account_search.placeholder', defaultMessage: 'Search for an account' }, @@ -14,8 +15,6 @@ interface IAccountSearch { onSelected: (accountId: string) => void, /** Override the default placeholder of the input. */ placeholder?: string, - /** Position of results relative to the input. */ - resultsPosition?: 'above' | 'below', } /** Input to search for accounts. */ @@ -56,9 +55,10 @@ const AccountSearch: React.FC = ({ onSelected, ...rest }) => { }; return ( -
-