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..51f878342 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: @@ -32,6 +35,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: @@ -80,7 +86,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 +95,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 +115,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,8 +141,8 @@ pages: paths: - public only: - refs: - - develop + variables: + - $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME docker: stage: deploy @@ -156,5 +157,9 @@ 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 + +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/.vscode/extensions.json b/.vscode/extensions.json index 57a35ab4f..d1762aa9a 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,6 +3,7 @@ "dbaeumer.vscode-eslint", "bradlc.vscode-tailwindcss", "stylelint.vscode-stylelint", - "wix.vscode-import-cost" + "wix.vscode-import-cost", + "redhat.vscode-yaml" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 4a7155a74..d7ca13345 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,15 @@ "*.conf.template": "properties" }, "files.eol": "\n", - "files.insertFinalNewline": false + "files.insertFinalNewline": false, + "json.schemas": [ + { + "fileMatch": [".lintstagedrc.json"], + "url": "https://json.schemastore.org/lintstagedrc.schema.json" + }, + { + "fileMatch": ["renovate.json"], + "url": "https://docs.renovatebot.com/renovate-schema.json" + } + ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a3850599..05162696f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,105 @@ 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 +- Compatibility: rudimentary support for Takahē. + +### Changed +- Posts: letterbox images to 19:6 again. + +### Fixed +- Layout: use accent color for "floating action button" (mobile compose button). +- ServiceWorker: don't serve favicon, robots.txt, and others from ServiceWorker. +- Datepicker: correctly default to the current year. + +## [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/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/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/admin.ts b/app/soapbox/actions/admin.ts index 02af5e87a..3cd5a25ba 100644 --- a/app/soapbox/actions/admin.ts +++ b/app/soapbox/actions/admin.ts @@ -103,6 +103,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) @@ -585,6 +598,7 @@ export { ADMIN_USERS_UNSUGGEST_FAIL, fetchConfig, updateConfig, + updateSoapboxConfig, fetchReports, closeReports, fetchUsers, 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..8e7a00d02 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -14,10 +14,10 @@ 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'; @@ -204,9 +204,7 @@ 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) => @@ -218,7 +216,7 @@ export const logIn = (username: string, password: string) => throw error; } else { // Return "wrong password" message. - dispatch(snackbar.error(messages.invalidCredentials)); + toast.error(messages.invalidCredentials); } throw error; }); @@ -248,7 +246,7 @@ export const logOut = () => dispatch({ type: AUTH_LOGGED_OUT, account, standalone }); - return dispatch(snackbar.success(messages.loggedOut)); + toast.success(messages.loggedOut); }); }; diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index 3010ae3fd..38efc838e 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'; @@ -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'; @@ -92,7 +92,7 @@ const messages = defineMessages({ 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 +210,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 +247,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; } @@ -329,7 +332,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 +348,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; } @@ -495,7 +498,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 +716,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); + + dispatch({ + type: COMPOSE_EVENT_REPLY, + id: composeId, + status: status, + account: state.accounts.get(state.me), + explicitAddressing, + }); + }; + export { COMPOSE_CHANGE, COMPOSE_SUBMIT_REQUEST, @@ -720,6 +738,7 @@ export { COMPOSE_SUBMIT_FAIL, COMPOSE_REPLY, COMPOSE_REPLY_CANCEL, + COMPOSE_EVENT_REPLY, COMPOSE_QUOTE, COMPOSE_QUOTE_CANCEL, COMPOSE_DIRECT, @@ -806,4 +825,5 @@ export { openComposeWithText, addToMentions, removeFromMentions, + eventDiscussionCompose, }; 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/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/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/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/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..6ac655143 100644 --- a/app/soapbox/actions/notifications.ts +++ b/app/soapbox/actions/notifications.ts @@ -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' }); 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/security.ts b/app/soapbox/actions/security.ts index 196e54dcb..48304aa23 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'; @@ -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..a52ba2255 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'; @@ -222,10 +220,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/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/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..2a221d0d1 100644 --- a/app/soapbox/api/index.ts +++ b/app/soapbox/api/index.ts @@ -62,7 +62,6 @@ export const baseClient = (accessToken?: string | null, baseURL: string = ''): A headers: Object.assign(accessToken ? { 'Authorization': `Bearer ${accessToken}`, } : {}), - transformResponse: [maybeParseJSON], }); }; diff --git a/app/soapbox/components/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 ( -
-