Merge remote-tracking branch 'soapbox/develop' into lexical

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2023-01-01 19:04:03 +01:00
409 changed files with 14637 additions and 13127 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
CHANGELOG.md merge=union

View File

@ -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
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
include:
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
- template: Security/License-Scanning.gitlab-ci.yml

View File

@ -0,0 +1,8 @@
## Summary
<!-- Describe your changes in detail -->
## Screenshots (if appropriate):
| Before | After |
| ------ | ----- |
| | |

View File

@ -3,6 +3,7 @@
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"stylelint.vscode-stylelint",
"wix.vscode-import-cost"
"wix.vscode-import-cost",
"redhat.vscode-yaml"
]
}

12
.vscode/settings.json vendored
View File

@ -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"
}
]
}

View File

@ -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.

213
README.md
View File

@ -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 <https://www.gnu.org/licenses/>.
along with Soapbox. If not, see <https://www.gnu.org/licenses/>.

View File

@ -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/).

View File

@ -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(
<IntlProvider locale='en'>
<Toaster />,
</IntlProvider>,
),
};
}
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<any>(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.');
});
});
});
});

View File

@ -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<any>(message, String(status), undefined, null, {
data: {
error: message,
},
statusText: String(status),
status,
headers: {},
config: {},
});
let store: ReturnType<typeof mockStore>;
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);
});
});
});

View File

@ -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 },
];

View File

@ -103,6 +103,19 @@ const updateConfig = (configs: Record<string, any>[]) =>
});
};
const updateSoapboxConfig = (data: Record<string, any>) =>
(dispatch: AppDispatch, _getState: () => RootState) => {
const params = [{
group: ':pleroma',
key: ':frontend_configurations',
value: [{
tuple: [':soapbox_fe', data],
}],
}];
return dispatch(updateConfig(params));
};
const fetchMastodonReports = (params: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) =>
api(getState)
@ -585,6 +598,7 @@ export {
ADMIN_USERS_UNSUGGEST_FAIL,
fetchConfig,
updateConfig,
updateSoapboxConfig,
fetchReports,
closeReports,
fetchUsers,

View File

@ -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<any>) => (dispatch: React.Dispatch<AnyAction>, _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,
};

View File

@ -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);
})

View File

@ -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);
});
};

View File

@ -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,
};

View File

@ -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<string, any> = {
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,
};

View File

@ -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<ExportDataActions>,
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<ExportDataActions>,
.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<ExportDataActions>, 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 });

View File

@ -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<string>
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 });
});

View File

@ -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 });

View File

@ -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));
});

View File

@ -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[]) => ({

View File

@ -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();
},

View File

@ -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' });

View File

@ -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,

View File

@ -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 => {

View File

@ -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;

View File

@ -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);
});
};

View File

@ -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,
};

View File

@ -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<IChat>(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<Chat>(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<string, any> = {}) =>
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);

View File

@ -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));

View File

@ -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);

View File

@ -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);

View File

@ -62,7 +62,6 @@ export const baseClient = (accessToken?: string | null, baseURL: string = ''): A
headers: Object.assign(accessToken ? {
'Authorization': `Bearer ${accessToken}`,
} : {}),
transformResponse: [maybeParseJSON],
});
};

View File

@ -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<IAccountSearch> = ({ onSelected, ...rest }) => {
};
return (
<div className='search search--account'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
<div className='w-full'>
<label className='sr-only'>{intl.formatMessage(messages.placeholder)}</label>
<div className='relative'>
<AutosuggestAccountInput
className='rounded-full'
placeholder={intl.formatMessage(messages.placeholder)}
@ -68,10 +68,24 @@ const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, ...rest }) => {
onKeyDown={handleKeyDown}
{...rest}
/>
</label>
<div role='button' tabIndex={0} className='search__icon' onClick={handleClear}>
<Icon src={require('@tabler/icons/search.svg')} className={classNames('svg-icon--search', { active: isEmpty() })} />
<Icon src={require('@tabler/icons/backspace.svg')} className={classNames('svg-icon--backspace', { active: !isEmpty() })} aria-label={intl.formatMessage(messages.placeholder)} />
<div
role='button'
tabIndex={0}
className='absolute inset-y-0 right-0 px-3 flex items-center cursor-pointer'
onClick={handleClear}
>
<SvgIcon
src={require('@tabler/icons/search.svg')}
className={classNames('h-4 w-4 text-gray-400', { hidden: !isEmpty() })}
/>
<SvgIcon
src={require('@tabler/icons/x.svg')}
className={classNames('h-4 w-4 text-gray-400', { hidden: isEmpty() })}
aria-label={intl.formatMessage(messages.placeholder)}
/>
</div>
</div>
</div>
);

View File

@ -69,6 +69,7 @@ interface IAccount {
withRelationship?: boolean,
showEdit?: boolean,
emoji?: string,
note?: string,
}
const Account = ({
@ -92,6 +93,7 @@ const Account = ({
withRelationship = true,
showEdit = false,
emoji,
note,
}: IAccount) => {
const overflowRef = React.useRef<HTMLDivElement>(null);
const actionRef = React.useRef<HTMLDivElement>(null);
@ -169,7 +171,7 @@ const Account = ({
return (
<div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}>
<HStack alignItems={actionAlignment} justifyContent='between'>
<HStack alignItems={withAccountNote ? 'top' : 'center'} space={3}>
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3}>
<ProfilePopper
condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
@ -212,9 +214,9 @@ const Account = ({
</LinkEl>
</ProfilePopper>
<Stack space={withAccountNote ? 1 : 0}>
<Stack space={withAccountNote || note ? 1 : 0}>
<HStack alignItems='center' space={1} style={style}>
<Text theme='muted' size='sm' truncate>@{username}</Text>
<Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text>
{account.favicon && (
<InstanceFavicon account={account} />
@ -251,7 +253,14 @@ const Account = ({
) : null}
</HStack>
{withAccountNote && (
{note ? (
<Text
size='sm'
className='mr-2'
>
{note}
</Text>
) : withAccountNote && (
<Text
size='sm'
dangerouslySetInnerHTML={{ __html: account.note_emojified }}

View File

@ -14,6 +14,7 @@ const noOp = () => { };
interface IAutosuggestAccountInput {
onChange: React.ChangeEventHandler<HTMLInputElement>,
onSelected: (accountId: string) => void,
autoFocus?: boolean,
value: string,
limit?: number,
className?: string,
@ -52,8 +53,7 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
setAccountIds(ImmutableOrderedSet(accountIds));
})
.catch(noOp);
}, 900, { leading: true, trailing: true }), [limit]);
}, 900, { leading: false, trailing: true }), [limit]);
const handleChange: React.ChangeEventHandler<HTMLInputElement> = e => {
refreshCancelToken();
@ -67,6 +67,12 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
}
};
useEffect(() => {
if (rest.autoFocus) {
handleAccountSearch('');
}
}, []);
useEffect(() => {
if (value === '') {
clearResults();

View File

@ -9,42 +9,13 @@ import Icon from 'soapbox/components/icon';
import { Input } from 'soapbox/components/ui';
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
import { isRtl } from 'soapbox/rtl';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu';
import type { InputThemes } from 'soapbox/components/ui/input/input';
type CursorMatch = [
tokenStart: number | null,
token: string | null,
];
export type AutoSuggestion = string | Emoji;
const textAtCursorMatchesToken = (str: string, caretPosition: number, searchTokens: string[]): CursorMatch => {
let word: string;
const left: number = str.slice(0, caretPosition).search(/\S+$/);
const right: number = str.slice(caretPosition).search(/\s/);
if (right < 0) {
word = str.slice(left);
} else {
word = str.slice(left, right + caretPosition);
}
if (!word || word.trim().length < 3 || !searchTokens.includes(word[0])) {
return [null, null];
}
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left + 1, word];
} else {
return [null, null];
}
};
export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputElement>, 'onChange' | 'onKeyUp' | 'onKeyDown'> {
value: string,
suggestions: ImmutableList<any>,
@ -60,7 +31,8 @@ export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputEl
searchTokens: string[],
maxLength?: number,
menu?: Menu,
resultsPosition: string,
renderSuggestion?: React.FC<{ id: string }>,
hidePortal?: boolean,
theme?: InputThemes,
}
@ -70,7 +42,6 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
autoFocus: false,
autoSelect: true,
searchTokens: ImmutableList(['@', ':', '#']),
resultsPosition: 'below',
};
getFirstIndex = () => {
@ -88,7 +59,11 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
input: HTMLInputElement | null = null;
onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart || 0, this.props.searchTokens);
const [tokenStart, token] = textAtCursorMatchesToken(
e.target.value,
e.target.selectionStart || 0,
this.props.searchTokens,
);
if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
@ -203,7 +178,11 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
const { selectedSuggestion } = this.state;
let inner, key;
if (typeof suggestion === 'object') {
if (this.props.renderSuggestion && typeof suggestion === 'string') {
const RenderSuggestion = this.props.renderSuggestion;
inner = <RenderSuggestion id={suggestion} />;
key = suggestion;
} else if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else if (suggestion[0] === '#') {
@ -279,11 +258,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
const { top, height, left, width } = this.input.getBoundingClientRect();
if (this.props.resultsPosition === 'below') {
return { left, width, top: top + height };
}
return { left, width, top, transform: 'translate(0, -100%)' };
return { left, width, top: top + height };
}
render() {
@ -293,7 +268,8 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
const visible = !suggestionsHidden && (!suggestions.isEmpty() || (menu && value));
if (isRtl(value)) {
// TODO: convert to functional component and use `useLocale()` hook instead of checking placeholder text.
if (isRtl(value) || (!value && placeholder && isRtl(placeholder))) {
style.direction = 'rtl';
}

View File

@ -0,0 +1,41 @@
import React from 'react';
import { useAppSelector } from 'soapbox/hooks';
import { HStack, Icon, Stack, Text } from './ui';
const buildingCommunityIcon = require('@tabler/icons/building-community.svg');
const homeIcon = require('@tabler/icons/home-2.svg');
const mapPinIcon = require('@tabler/icons/map-pin.svg');
const roadIcon = require('@tabler/icons/road.svg');
export const ADDRESS_ICONS: Record<string, string> = {
house: homeIcon,
street: roadIcon,
secondary: roadIcon,
zone: buildingCommunityIcon,
city: buildingCommunityIcon,
administrative: buildingCommunityIcon,
};
interface IAutosuggestLocation {
id: string,
}
const AutosuggestLocation: React.FC<IAutosuggestLocation> = ({ id }) => {
const location = useAppSelector((state) => state.locations.get(id));
if (!location) return null;
return (
<HStack alignItems='center' space={2}>
<Icon src={ADDRESS_ICONS[location.type] || mapPinIcon} />
<Stack>
<Text>{location.description}</Text>
<Text size='xs' theme='muted'>{[location.street, location.locality, location.country].filter(val => val.trim()).join(' · ')}</Text>
</Stack>
</HStack>
);
};
export default AutosuggestLocation;

View File

@ -4,6 +4,8 @@ import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import AutosuggestAccount from '../features/compose/components/autosuggest-account';
import { isRtl } from '../rtl';
@ -11,31 +13,6 @@ import AutosuggestEmoji, { Emoji } from './autosuggest-emoji';
import type { List as ImmutableList } from 'immutable';
const textAtCursorMatchesToken = (str: string, caretPosition: number) => {
let word;
const left = str.slice(0, caretPosition).search(/\S+$/);
const right = str.slice(caretPosition).search(/\s/);
if (right < 0) {
word = str.slice(left);
} else {
word = str.slice(left, right + caretPosition);
}
if (!word || word.trim().length < 3 || !['@', ':', '#'].includes(word[0])) {
return [null, null];
}
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left + 1, word];
} else {
return [null, null];
}
};
interface IAutosuggesteTextarea {
id?: string,
value: string,
@ -72,7 +49,11 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
};
onChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
const [tokenStart, token] = textAtCursorMatchesToken(
e.target.value,
e.target.selectionStart,
['@', ':', '#'],
);
if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
@ -248,7 +229,8 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr', minRows: 10 };
if (isRtl(value)) {
// TODO: convert to functional component and use `useLocale()` hook instead of checking placeholder text.
if (isRtl(value) || (!value && placeholder && isRtl(placeholder))) {
style.direction = 'rtl';
}

View File

@ -70,7 +70,7 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
<div className='flex flex-col gap-2'>
<div className='flex items-center justify-between'>
<IconButton
className='datepicker__button'
className='datepicker__button rtl:rotate-180'
src={require('@tabler/icons/chevron-left.svg')}
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
@ -79,7 +79,7 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
/>
{intl.formatDate(date, { month: 'long' })}
<IconButton
className='datepicker__button'
className='datepicker__button rtl:rotate-180'
src={require('@tabler/icons/chevron-right.svg')}
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
@ -89,7 +89,7 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
</div>
<div className='flex items-center justify-between'>
<IconButton
className='datepicker__button'
className='datepicker__button rtl:rotate-180'
src={require('@tabler/icons/chevron-left.svg')}
onClick={decreaseYear}
disabled={prevYearButtonDisabled}
@ -98,7 +98,7 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
/>
{intl.formatDate(date, { year: 'numeric' })}
<IconButton
className='datepicker__button'
className='datepicker__button rtl:rotate-180'
src={require('@tabler/icons/chevron-right.svg')}
onClick={increaseYear}
disabled={nextYearButtonDisabled}

View File

@ -219,7 +219,12 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }} ref={this.setRef}>
<div
className={`dropdown-menu ${placement}`}
style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }}
ref={this.setRef}
data-testid='dropdown-menu'
>
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
<ul>
{items.map((option, i) => this.renderItem(option, i))}

View File

@ -0,0 +1,93 @@
import classNames from 'clsx';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import EventActionButton from 'soapbox/features/event/components/event-action-button';
import EventDate from 'soapbox/features/event/components/event-date';
import { useAppSelector } from 'soapbox/hooks';
import Icon from './icon';
import { Button, HStack, Stack, Text } from './ui';
import VerificationBadge from './verification-badge';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
const messages = defineMessages({
bannerHeader: { id: 'event.banner', defaultMessage: 'Event banner' },
leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' },
leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' },
});
interface IEventPreview {
status: StatusEntity
className?: string
hideAction?: boolean
floatingAction?: boolean
}
const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction, floatingAction = true }) => {
const intl = useIntl();
const me = useAppSelector((state) => state.me);
const account = status.account as AccountEntity;
const event = status.event!;
const banner = event.banner;
const action = !hideAction && (account.id === me ? (
<Button
size='sm'
theme={floatingAction ? 'secondary' : 'primary'}
to={`/@${account.acct}/events/${status.id}`}
>
<FormattedMessage id='event.manage' defaultMessage='Manage' />
</Button>
) : (
<EventActionButton
status={status}
theme={floatingAction ? 'secondary' : 'primary'}
/>
));
return (
<div className={classNames('w-full rounded-lg bg-gray-100 dark:bg-primary-800 relative overflow-hidden', className)}>
<div className='absolute top-28 right-3'>
{floatingAction && action}
</div>
<div className='bg-primary-200 dark:bg-gray-600 h-40'>
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.bannerHeader)} />}
</div>
<Stack className='p-2.5' space={2}>
<HStack space={2} alignItems='center' justifyContent='between'>
<Text weight='semibold' truncate>{event.name}</Text>
{!floatingAction && action}
</HStack>
<div className='flex gap-y-1 gap-x-2 flex-wrap text-gray-700 dark:text-gray-600'>
<HStack alignItems='center' space={2}>
<Icon src={require('@tabler/icons/user.svg')} />
<HStack space={1} alignItems='center' grow>
<span dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
{account.verified && <VerificationBadge />}
</HStack>
</HStack>
<EventDate status={status} />
{event.location && (
<HStack alignItems='center' space={2}>
<Icon src={require('@tabler/icons/map-pin.svg')} />
<span>
{event.location.get('name')}
</span>
</HStack>
)}
</div>
</Stack>
</div>
);
};
export default EventPreview;

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Helmet as ReactHelmet } from 'react-helmet';
import { useStatContext } from 'soapbox/contexts/stat-context';
import { useAppSelector, useInstance, useSettings } from 'soapbox/hooks';
import { RootState } from 'soapbox/store';
import FaviconService from 'soapbox/utils/favicon-service';
@ -9,15 +10,15 @@ FaviconService.initFaviconService();
const getNotifTotals = (state: RootState): number => {
const notifications = state.notifications.unread || 0;
const chats = state.chats.items.reduce((acc: any, curr: any) => acc + Math.min(curr.get('unread', 0), 1), 0);
const reports = state.admin.openReports.count();
const approvals = state.admin.awaitingApproval.count();
return notifications + chats + reports + approvals;
return notifications + reports + approvals;
};
const Helmet: React.FC = ({ children }) => {
const instance = useInstance();
const unreadCount = useAppSelector((state) => getNotifTotals(state));
const { unreadChatsCount } = useStatContext();
const unreadCount = useAppSelector((state) => getNotifTotals(state) + unreadChatsCount);
const demetricator = useSettings().get('demetricator');
const hasUnreadNotifications = React.useMemo(() => !(unreadCount < 1 || demetricator), [unreadCount, demetricator]);

View File

@ -1,5 +1,5 @@
import classNames from 'clsx';
import { debounce } from 'lodash';
import debounce from 'lodash/debounce';
import React, { useRef } from 'react';
import { useDispatch } from 'react-redux';

View File

@ -5,18 +5,19 @@ import { Counter } from 'soapbox/components/ui';
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
count: number,
countMax?: number
icon?: string;
src?: string;
}
const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, ...rest }) => {
const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, countMax, ...rest }) => {
return (
<div className='relative'>
<Icon id={icon} {...rest as IIcon} />
{count > 0 && (
<span className='absolute -top-2 -right-2'>
<Counter count={count} />
<span className='absolute -top-2 -right-3'>
<Counter count={count} countMax={countMax} />
</span>
)}
</div>

View File

@ -0,0 +1,11 @@
import React from 'react';
import { Link as Comp, LinkProps } from 'react-router-dom';
const Link = (props: LinkProps) => (
<Comp
{...props}
className='text-primary-600 dark:text-accent-blue hover:underline'
/>
);
export default Link;

View File

@ -14,10 +14,12 @@ const List: React.FC = ({ children }) => (
interface IListItem {
label: React.ReactNode,
hint?: React.ReactNode,
onClick?: () => void,
onClick?(): void,
onSelect?(): void
isSelected?: boolean
}
const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelect, isSelected }) => {
const id = uuidv4();
const domId = `list-group-${id}`;
@ -28,8 +30,8 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
};
const Comp = onClick ? 'a' : 'div';
const LabelComp = onClick ? 'span' : 'label';
const linkProps = onClick ? { onClick, onKeyDown, tabIndex: 0, role: 'link' } : {};
const LabelComp = onClick || onSelect ? 'span' : 'label';
const linkProps = onClick || onSelect ? { onClick: onClick || onSelect, onKeyDown, tabIndex: 0, role: 'link' } : {};
const renderChildren = React.useCallback(() => {
return React.Children.map(children, (child) => {
@ -52,7 +54,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
<Comp
className={classNames({
'flex items-center justify-between px-3 py-2 first:rounded-t-lg last:rounded-b-lg bg-gradient-to-r from-gradient-start/10 to-gradient-end/10': true,
'cursor-pointer hover:from-gradient-start/20 hover:to-gradient-end/20 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof onClick !== 'undefined',
'cursor-pointer hover:from-gradient-start/20 hover:to-gradient-end/20 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof onClick !== 'undefined' || typeof onSelect !== 'undefined',
})}
{...linkProps}
>
@ -68,9 +70,21 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
<HStack space={1} alignItems='center' className='text-gray-700 dark:text-gray-600'>
{children}
<Icon src={require('@tabler/icons/chevron-right.svg')} className='ml-1' />
<Icon src={require('@tabler/icons/chevron-right.svg')} className='ml-1 rtl:rotate-180' />
</HStack>
) : renderChildren()}
) : null}
{onSelect ? (
<div className='flex flex-row items-center text-gray-700 dark:text-gray-600'>
{children}
{isSelected ? (
<Icon src={require('@tabler/icons/check.svg')} className='ml-1 text-primary-500 dark:text-primary-400' />
) : null}
</div>
) : null}
{typeof onClick === 'undefined' && typeof onSelect === 'undefined' ? renderChildren() : null}
</Comp>
);
};

View File

@ -0,0 +1,110 @@
import classNames from 'clsx';
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import throttle from 'lodash/throttle';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { locationSearch } from 'soapbox/actions/events';
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input';
import Icon from 'soapbox/components/icon';
import { useAppDispatch } from 'soapbox/hooks';
import AutosuggestLocation from './autosuggest-location';
const noOp = () => {};
const messages = defineMessages({
placeholder: { id: 'location_search.placeholder', defaultMessage: 'Find an address' },
});
interface ILocationSearch {
onSelected: (locationId: string) => void,
}
const LocationSearch: React.FC<ILocationSearch> = ({ onSelected }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [locationIds, setLocationIds] = useState(ImmutableOrderedSet<string>());
const controller = useRef(new AbortController());
const [value, setValue] = useState('');
const isEmpty = (): boolean => {
return !(value.length > 0);
};
const handleChange: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
refreshCancelToken();
handleLocationSearch(target.value);
setValue(target.value);
};
const handleSelected = (_tokenStart: number, _lastToken: string | null, suggestion: AutoSuggestion) => {
if (typeof suggestion === 'string') {
onSelected(suggestion);
}
};
const handleClear: React.MouseEventHandler = e => {
e.preventDefault();
if (!isEmpty()) {
setValue('');
}
};
const handleKeyDown: React.KeyboardEventHandler = e => {
if (e.key === 'Escape') {
document.querySelector('.ui')?.parentElement?.focus();
}
};
const refreshCancelToken = () => {
controller.current.abort();
controller.current = new AbortController();
};
const clearResults = () => {
setLocationIds(ImmutableOrderedSet());
};
const handleLocationSearch = useCallback(throttle(q => {
dispatch(locationSearch(q, controller.current.signal))
.then((locations: { origin_id: string }[]) => {
const locationIds = locations.map(location => location.origin_id);
setLocationIds(ImmutableOrderedSet(locationIds));
})
.catch(noOp);
}, 900, { leading: true, trailing: true }), []);
useEffect(() => {
if (value === '') {
clearResults();
}
}, [value]);
return (
<div className='search'>
<AutosuggestInput
className='rounded-full'
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={handleChange}
suggestions={locationIds.toList()}
onSuggestionsFetchRequested={noOp}
onSuggestionsClearRequested={noOp}
onSuggestionSelected={handleSelected}
searchTokens={[]}
onKeyDown={handleKeyDown}
renderSuggestion={AutosuggestLocation}
/>
<div role='button' tabIndex={0} className='search__icon' onClick={handleClear}>
<Icon src={require('@tabler/icons/search.svg')} className={classNames('svg-icon--search', { active: isEmpty() })} />
<Icon src={require('@tabler/icons/backspace.svg')} className={classNames('svg-icon--backspace', { active: !isEmpty() })} aria-label={intl.formatMessage(messages.placeholder)} />
</div>
</div>
);
};
export default LocationSearch;

View File

@ -1,3 +1,7 @@
[data-markup] {
@apply whitespace-pre-wrap;
}
[data-markup] p {
@apply mb-4 whitespace-pre-wrap;
}
@ -61,7 +65,7 @@
/* Emojis */
[data-markup] img.emojione {
@apply w-5 h-5;
@apply w-5 h-5 m-0;
}
/* Hide Markdown images (Pleroma) */

View File

@ -4,7 +4,7 @@ import React, { useState, useRef, useEffect } from 'react';
import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon';
import StillImage from 'soapbox/components/still-image';
import { MIMETYPE_ICONS } from 'soapbox/features/compose/components/upload';
import { MIMETYPE_ICONS } from 'soapbox/components/upload';
import { useSettings } from 'soapbox/hooks';
import { Attachment } from 'soapbox/types/entities';
import { truncateFilename } from 'soapbox/utils/media';
@ -262,7 +262,7 @@ const Item: React.FC<IItem> = ({
interface IMediaGallery {
sensitive?: boolean,
media: ImmutableList<Attachment>,
height: number,
height?: number,
onOpenMedia: (media: ImmutableList<Attachment>, index: number) => void,
defaultWidth?: number,
cacheWidth?: (width: number) => void,

View File

@ -5,15 +5,20 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { cancelReplyCompose } from 'soapbox/actions/compose';
import { cancelEventCompose } from 'soapbox/actions/events';
import { openModal, closeModal } from 'soapbox/actions/modals';
import { useAppDispatch, useAppSelector, usePrevious } from 'soapbox/hooks';
import { useAppDispatch, usePrevious } from 'soapbox/hooks';
import { queryClient } from 'soapbox/queries/client';
import { IPolicy, PolicyKeys } from 'soapbox/queries/policies';
import type { UnregisterCallback } from 'history';
import type { ModalType } from 'soapbox/features/ui/components/modal-root';
import type { ReducerCompose } from 'soapbox/reducers/compose';
import type { ReducerRecord as ReducerComposeEvent } from 'soapbox/reducers/compose-event';
const messages = defineMessages({
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' },
});
export const checkComposeContent = (compose?: ReturnType<typeof ReducerCompose>) => {
@ -25,6 +30,15 @@ export const checkComposeContent = (compose?: ReturnType<typeof ReducerCompose>)
].some(check => check === true);
};
export const checkEventComposeContent = (compose?: ReturnType<typeof ReducerComposeEvent>) => {
return !!compose && [
compose.name.length > 0,
compose.status.length > 0,
compose.location !== null,
compose.banner !== null,
].some(check => check === true);
};
interface IModalRoot {
onCancel?: () => void,
onClose: (type?: ModalType) => void,
@ -46,8 +60,6 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
const prevChildren = usePrevious(children);
const prevType = usePrevious(type);
const isEditing = useAppSelector(state => state.compose.get('compose-modal')?.id !== null);
const visible = !!children;
const handleKeyUp = (e: KeyboardEvent) => {
@ -58,13 +70,20 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
const handleOnClose = () => {
dispatch((_, getState) => {
const hasComposeContent = checkComposeContent(getState().compose.get('compose-modal'));
const compose = getState().compose.get('compose-modal');
const hasComposeContent = checkComposeContent(compose);
const hasEventComposeContent = checkEventComposeContent(getState().compose_event);
if (hasComposeContent && type === 'COMPOSE') {
const isEditing = compose!.id !== null;
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/trash.svg'),
heading: isEditing ? <FormattedMessage id='confirmations.cancel_editing.heading' defaultMessage='Cancel post editing' /> : <FormattedMessage id='confirmations.delete.heading' defaultMessage='Delete post' />,
message: isEditing ? <FormattedMessage id='confirmations.cancel_editing.message' defaultMessage='Are you sure you want to cancel editing this post? All changes will be lost.' /> : <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this post?' />,
heading: isEditing
? <FormattedMessage id='confirmations.cancel_editing.heading' defaultMessage='Cancel post editing' />
: <FormattedMessage id='confirmations.delete.heading' defaultMessage='Delete post' />,
message: isEditing
? <FormattedMessage id='confirmations.cancel_editing.message' defaultMessage='Are you sure you want to cancel editing this post? All changes will be lost.' />
: <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this post?' />,
confirm: intl.formatMessage(messages.confirm),
onConfirm: () => {
dispatch(closeModal('COMPOSE'));
@ -74,8 +93,36 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
dispatch(closeModal('CONFIRM'));
},
}));
} else if (hasComposeContent && type === 'CONFIRM') {
} else if (hasEventComposeContent && type === 'COMPOSE_EVENT') {
const isEditing = getState().compose_event.id !== null;
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/trash.svg'),
heading: isEditing
? <FormattedMessage id='confirmations.cancel_event_editing.heading' defaultMessage='Cancel event editing' />
: <FormattedMessage id='confirmations.delete_event.heading' defaultMessage='Delete event' />,
message: isEditing
? <FormattedMessage id='confirmations.cancel_event_editing.message' defaultMessage='Are you sure you want to cancel editing this event? All changes will be lost.' />
: <FormattedMessage id='confirmations.delete_event.message' defaultMessage='Are you sure you want to delete this event?' />,
confirm: intl.formatMessage(isEditing ? messages.cancelEditing : messages.confirm),
onConfirm: () => {
dispatch(closeModal('COMPOSE_EVENT'));
dispatch(cancelEventCompose());
},
onCancel: () => {
dispatch(closeModal('CONFIRM'));
},
}));
} else if ((hasComposeContent || hasEventComposeContent) && type === 'CONFIRM') {
dispatch(closeModal('CONFIRM'));
} else if (type === 'POLICY') {
// If the user has not accepted the Policy, prevent them
// from closing the Modal.
const pendingPolicy = queryClient.getQueryData(PolicyKeys.policy) as IPolicy;
if (pendingPolicy?.pending_policy_id) {
return;
}
onClose();
} else {
onClose();
}

View File

@ -9,6 +9,7 @@ import AccountContainer from 'soapbox/containers/account-container';
import { useSettings } from 'soapbox/hooks';
import { defaultMediaVisibility } from 'soapbox/utils/status';
import EventPreview from './event-preview';
import OutlineBox from './outline-box';
import StatusContent from './status-content';
import StatusReplyMentions from './status-reply-mentions';
@ -112,35 +113,37 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
<StatusReplyMentions status={status} hoverable={false} />
<Stack
className='relative z-0'
style={{ minHeight: status.hidden ? Math.max(minHeight, 208) + 12 : undefined }}
>
{(status.hidden) && (
<SensitiveContentOverlay
status={status}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
ref={overlay}
/>
)}
<Stack space={4}>
<StatusContent
status={status}
collapsable
/>
{(status.card || status.media_attachments.size > 0) && (
<StatusMedia
{status.event ? <EventPreview status={status} hideAction /> : (
<Stack
className='relative z-0'
style={{ minHeight: status.hidden ? Math.max(minHeight, 208) + 12 : undefined }}
>
{(status.hidden) && (
<SensitiveContentOverlay
status={status}
muted={compose}
showMedia={showMedia}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
ref={overlay}
/>
)}
<Stack space={4}>
<StatusContent
status={status}
collapsable
/>
{(status.card || status.media_attachments.size > 0) && (
<StatusMedia
status={status}
muted={compose}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
)}
</Stack>
</Stack>
</Stack>
)}
</Stack>
</OutlineBox>
);

View File

@ -0,0 +1,43 @@
import React from 'react';
import List, { ListItem } from './list';
interface IRadioGroup {
onChange: React.ChangeEventHandler
children: React.ReactElement<{ onChange: React.ChangeEventHandler }>[]
}
const RadioGroup = ({ onChange, children }: IRadioGroup) => {
const childrenWithProps = React.Children.map(children, child =>
React.cloneElement(child, { onChange }),
);
return <List>{childrenWithProps}</List>;
};
interface IRadioItem {
label: React.ReactNode,
hint?: React.ReactNode,
value: string,
checked: boolean,
onChange?: React.ChangeEventHandler,
}
const RadioItem: React.FC<IRadioItem> = ({ label, hint, checked = false, onChange, value }) => {
return (
<ListItem label={label} hint={hint}>
<input
type='radio'
checked={checked}
onChange={onChange}
value={value}
className='h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500'
/>
</ListItem>
);
};
export {
RadioGroup,
RadioItem,
};

View File

@ -1,3 +1,4 @@
/* eslint-disable jsx-a11y/interactive-supports-focus */
import classNames from 'clsx';
import React from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
@ -34,6 +35,7 @@ const messages = defineMessages({
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'column.lists', defaultMessage: 'Lists' },
events: { id: 'column.events', defaultMessage: 'Events' },
invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' },
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
addAccount: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' },
@ -135,209 +137,234 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
return (
<div
className={classNames('sidebar-menu__root', {
'sidebar-menu__root--visible': sidebarOpen,
})}
aria-expanded={sidebarOpen}
className={
classNames({
'z-[1000]': sidebarOpen,
hidden: !sidebarOpen,
})
}
>
<div
className={classNames({
'fixed inset-0 bg-gray-500/90 dark:bg-gray-700/90 z-1000': true,
'hidden': !sidebarOpen,
})}
className='fixed inset-0 bg-gray-500/90 dark:bg-gray-700/90'
role='button'
onClick={handleClose}
>
<IconButton
title={intl.formatMessage(messages.close)}
onClick={handleClose}
src={require('@tabler/icons/x.svg')}
ref={closeButtonRef}
iconClassName='h-6 w-6'
className='fixed top-5 right-5 text-gray-600 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
/>
</div>
/>
<div className='sidebar-menu'>
<div className='relative overflow-y-scroll overflow-auto h-full w-full'>
<div className='p-4'>
<Stack space={4}>
<Link to={`/@${account.acct}`} onClick={onClose}>
<Account account={account} showProfileHoverCard={false} withLinkToProfile={false} />
</Link>
<ProfileStats
account={account}
onClickHandler={handleClose}
/>
<div className='fixed inset-0 z-[1000] flex'>
<div
className={
classNames({
'flex flex-col flex-1 bg-white dark:bg-primary-900 -translate-x-full rtl:translate-x-full w-full max-w-xs': true,
'!translate-x-0': sidebarOpen,
})
}
>
<IconButton
title={intl.formatMessage(messages.close)}
onClick={handleClose}
src={require('@tabler/icons/x.svg')}
ref={closeButtonRef}
iconClassName='h-6 w-6'
className='absolute top-0 right-0 -mr-11 mt-2 text-gray-600 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
/>
<div className='relative overflow-y-scroll overflow-auto h-full w-full'>
<div className='p-4'>
<Stack space={4}>
<Divider />
<Link to={`/@${account.acct}`} onClick={onClose}>
<Account account={account} showProfileHoverCard={false} withLinkToProfile={false} />
</Link>
<SidebarLink
to={`/@${account.acct}`}
icon={require('@tabler/icons/user.svg')}
text={intl.formatMessage(messages.profile)}
onClick={onClose}
<ProfileStats
account={account}
onClickHandler={handleClose}
/>
{(account.locked || followRequestsCount > 0) && (
<SidebarLink
to='/follow_requests'
icon={require('@tabler/icons/user-plus.svg')}
text={intl.formatMessage(messages.followRequests)}
onClick={onClose}
/>
)}
{features.bookmarks && (
<SidebarLink
to='/bookmarks'
icon={require('@tabler/icons/bookmark.svg')}
text={intl.formatMessage(messages.bookmarks)}
onClick={onClose}
/>
)}
{features.lists && (
<SidebarLink
to='/lists'
icon={require('@tabler/icons/list.svg')}
text={intl.formatMessage(messages.lists)}
onClick={onClose}
/>
)}
{settings.get('isDeveloper') && (
<SidebarLink
to='/developers'
icon={require('@tabler/icons/code.svg')}
text={intl.formatMessage(messages.developers)}
onClick={onClose}
/>
)}
{features.publicTimeline && <>
<Stack space={4}>
<Divider />
<SidebarLink
to='/timeline/local'
icon={features.federating ? require('@tabler/icons/affiliate.svg') : require('@tabler/icons/world.svg')}
text={features.federating ? <FormattedMessage id='tabs_bar.local' defaultMessage='Local' /> : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />}
to={`/@${account.acct}`}
icon={require('@tabler/icons/user.svg')}
text={intl.formatMessage(messages.profile)}
onClick={onClose}
/>
{(account.locked || followRequestsCount > 0) && (
<SidebarLink
to='/follow_requests'
icon={require('@tabler/icons/user-plus.svg')}
text={intl.formatMessage(messages.followRequests)}
onClick={onClose}
/>
)}
{features.bookmarks && (
<SidebarLink
to='/bookmarks'
icon={require('@tabler/icons/bookmark.svg')}
text={intl.formatMessage(messages.bookmarks)}
onClick={onClose}
/>
)}
{features.lists && (
<SidebarLink
to='/lists'
icon={require('@tabler/icons/list.svg')}
text={intl.formatMessage(messages.lists)}
onClick={onClose}
/>
)}
{features.events && (
<SidebarLink
to='/events'
icon={require('@tabler/icons/calendar-event.svg')}
text={intl.formatMessage(messages.events)}
onClick={onClose}
/>
)}
{settings.get('isDeveloper') && (
<SidebarLink
to='/developers'
icon={require('@tabler/icons/code.svg')}
text={intl.formatMessage(messages.developers)}
onClick={onClose}
/>
)}
{features.publicTimeline && <>
<Divider />
<SidebarLink
to='/timeline/local'
icon={features.federating ? require('@tabler/icons/affiliate.svg') : require('@tabler/icons/world.svg')}
text={features.federating ? <FormattedMessage id='tabs_bar.local' defaultMessage='Local' /> : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />}
onClick={onClose}
/>
{features.federating && (
<SidebarLink
to='/timeline/fediverse'
icon={require('@tabler/icons/topology-star-ring-3.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />}
onClick={onClose}
/>
)}
</>}
<Divider />
<SidebarLink
to='/blocks'
icon={require('@tabler/icons/ban.svg')}
text={intl.formatMessage(messages.blocks)}
onClick={onClose}
/>
<SidebarLink
to='/mutes'
icon={require('@tabler/icons/circle-x.svg')}
text={intl.formatMessage(messages.mutes)}
onClick={onClose}
/>
<SidebarLink
to='/settings/preferences'
icon={require('@tabler/icons/settings.svg')}
text={intl.formatMessage(messages.preferences)}
onClick={onClose}
/>
{features.federating && (
<SidebarLink
to='/timeline/fediverse'
icon={require('@tabler/icons/topology-star-ring-3.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />}
to='/domain_blocks'
icon={require('@tabler/icons/ban.svg')}
text={intl.formatMessage(messages.domainBlocks)}
onClick={onClose}
/>
)}
</>}
<Divider />
<SidebarLink
to='/blocks'
icon={require('@tabler/icons/ban.svg')}
text={intl.formatMessage(messages.blocks)}
onClick={onClose}
/>
<SidebarLink
to='/mutes'
icon={require('@tabler/icons/circle-x.svg')}
text={intl.formatMessage(messages.mutes)}
onClick={onClose}
/>
<SidebarLink
to='/settings/preferences'
icon={require('@tabler/icons/settings.svg')}
text={intl.formatMessage(messages.preferences)}
onClick={onClose}
/>
{features.federating && (
<SidebarLink
to='/domain_blocks'
icon={require('@tabler/icons/ban.svg')}
text={intl.formatMessage(messages.domainBlocks)}
onClick={onClose}
/>
)}
{features.filters && (
<SidebarLink
to='/filters'
icon={require('@tabler/icons/filter.svg')}
text={intl.formatMessage(messages.filters)}
onClick={onClose}
/>
)}
{account.admin && (
<SidebarLink
to='/soapbox/config'
icon={require('@tabler/icons/settings.svg')}
text={intl.formatMessage(messages.soapboxConfig)}
onClick={onClose}
/>
)}
{features.import && (
<SidebarLink
to='/settings/import'
icon={require('@tabler/icons/cloud-upload.svg')}
text={intl.formatMessage(messages.importData)}
onClick={onClose}
/>
)}
<Divider />
<SidebarLink
to='/logout'
icon={require('@tabler/icons/logout.svg')}
text={intl.formatMessage(messages.logout)}
onClick={onClickLogOut}
/>
<Divider />
<Stack space={4}>
<button type='button' onClick={handleSwitcherClick} className='py-1'>
<HStack alignItems='center' justifyContent='between'>
<Text tag='span'>
<FormattedMessage id='profile_dropdown.switch_account' defaultMessage='Switch accounts' />
</Text>
<Icon
src={require('@tabler/icons/chevron-down.svg')}
className={classNames('w-4 h-4 text-gray-900 dark:text-gray-100 transition-transform', {
'rotate-180': switcher,
})}
/>
</HStack>
</button>
{switcher && (
<div className='border-t-2 border-gray-100 dark:border-gray-800 border-solid'>
{otherAccounts.map(account => renderAccount(account))}
<NavLink className='flex items-center py-2 space-x-1' to='/login/add' onClick={handleClose}>
<Icon className='text-primary-500 w-4 h-4' src={require('@tabler/icons/plus.svg')} />
<Text size='sm' weight='medium'>{intl.formatMessage(messages.addAccount)}</Text>
</NavLink>
</div>
{features.filters && (
<SidebarLink
to='/filters'
icon={require('@tabler/icons/filter.svg')}
text={intl.formatMessage(messages.filters)}
onClick={onClose}
/>
)}
{account.admin && (
<SidebarLink
to='/soapbox/config'
icon={require('@tabler/icons/settings.svg')}
text={intl.formatMessage(messages.soapboxConfig)}
onClick={onClose}
/>
)}
{features.import && (
<SidebarLink
to='/settings/import'
icon={require('@tabler/icons/cloud-upload.svg')}
text={intl.formatMessage(messages.importData)}
onClick={onClose}
/>
)}
<Divider />
<SidebarLink
to='/logout'
icon={require('@tabler/icons/logout.svg')}
text={intl.formatMessage(messages.logout)}
onClick={onClickLogOut}
/>
<Divider />
<Stack space={4}>
<button type='button' onClick={handleSwitcherClick} className='py-1'>
<HStack alignItems='center' justifyContent='between'>
<Text tag='span'>
<FormattedMessage id='profile_dropdown.switch_account' defaultMessage='Switch accounts' />
</Text>
<Icon
src={require('@tabler/icons/chevron-down.svg')}
className={classNames('w-4 h-4 text-gray-900 dark:text-gray-100 transition-transform', {
'rotate-180': switcher,
})}
/>
</HStack>
</button>
{switcher && (
<div className='border-t-2 border-gray-100 dark:border-gray-800 border-solid'>
{otherAccounts.map(account => renderAccount(account))}
<NavLink className='flex items-center py-2 space-x-1' to='/login/add' onClick={handleClose}>
<Icon className='text-primary-500 w-4 h-4' src={require('@tabler/icons/plus.svg')} />
<Text size='sm' weight='medium'>{intl.formatMessage(messages.addAccount)}</Text>
</NavLink>
</div>
)}
</Stack>
</Stack>
</Stack>
</Stack>
</div>
</div>
</div>
{/* Dummy element to keep Close Icon visible */}
<div
aria-hidden
className='w-14 flex-shrink-0'
onClick={handleClose}
/>
</div>
</div>
);

View File

@ -7,6 +7,8 @@ import { Icon, Text } from './ui';
interface ISidebarNavigationLink {
/** Notification count, if any. */
count?: number,
/** Optional max to cap count (ie: N+) */
countMax?: number
/** URL to an SVG icon. */
icon: string,
/** Link label. */
@ -19,7 +21,7 @@ interface ISidebarNavigationLink {
/** Desktop sidebar navigation link. */
const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, ref: React.ForwardedRef<HTMLAnchorElement>): JSX.Element => {
const { icon, text, to = '', count, onClick } = props;
const { icon, text, to = '', count, countMax, onClick } = props;
const isActive = location.pathname === to;
const handleClick: React.EventHandler<React.MouseEvent> = (e) => {
@ -45,6 +47,7 @@ const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, r
<Icon
src={icon}
count={count}
countMax={countMax}
className={classNames('h-5 w-5', {
'text-gray-600 dark:text-gray-500 group-hover:text-primary-500 dark:group-hover:text-primary-400': !isActive,
'text-primary-500 dark:text-primary-400': isActive,

View File

@ -1,7 +1,9 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Stack } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import { useStatContext } from 'soapbox/contexts/stat-context';
import ComposeButton from 'soapbox/features/ui/components/compose-button';
import { useAppSelector, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks';
@ -13,18 +15,19 @@ const messages = defineMessages({
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'column.lists', defaultMessage: 'Lists' },
events: { id: 'column.events', defaultMessage: 'Events' },
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
});
/** Desktop sidebar with links to different views in the app. */
const SidebarNavigation = () => {
const intl = useIntl();
const { unreadChatsCount } = useStatContext();
const features = useFeatures();
const settings = useSettings();
const account = useOwnAccount();
const notificationCount = useAppSelector((state) => state.notifications.unread);
const chatsCount = useAppSelector((state) => state.chats.items.reduce((acc, curr) => acc + Math.min(curr.unread || 0, 1), 0));
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
@ -57,6 +60,14 @@ const SidebarNavigation = () => {
});
}
if (features.events) {
menu.push({
to: '/events',
text: intl.formatMessage(messages.events),
icon: require('@tabler/icons/calendar-event.svg'),
});
}
if (settings.get('isDeveloper')) {
menu.push({
to: '/developers',
@ -78,8 +89,9 @@ const SidebarNavigation = () => {
<SidebarNavigationLink
to='/chats'
icon={require('@tabler/icons/messages.svg')}
count={chatsCount}
text={<FormattedMessage id='tabs_bar.chats' defaultMessage='Chats' />}
count={unreadChatsCount}
countMax={9}
text={<FormattedMessage id='navigation.chats' defaultMessage='Chats' />}
/>
);
}
@ -98,8 +110,8 @@ const SidebarNavigation = () => {
};
return (
<div>
<div className='flex flex-col space-y-2'>
<Stack space={4}>
<Stack space={2}>
<SidebarNavigationLink
to='/'
icon={require('@tabler/icons/home.svg')}
@ -172,12 +184,12 @@ const SidebarNavigation = () => {
/>
</DropdownMenu>
)}
</div>
</Stack>
{account && (
<ComposeButton />
)}
</div>
</Stack>
);
};

View File

@ -4,9 +4,9 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { blockAccount } from 'soapbox/actions/accounts';
import { showAlertForError } from 'soapbox/actions/alerts';
import { launchChat } from 'soapbox/actions/chats';
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
import { editEvent } from 'soapbox/actions/events';
import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals';
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
@ -18,7 +18,9 @@ import StatusActionButton from 'soapbox/components/status-action-button';
import { HStack } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import { isLocal, isRemote } from 'soapbox/utils/accounts';
import copy from 'soapbox/utils/copy';
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts';
import type { Menu } from 'soapbox/components/dropdown-menu';
@ -203,7 +205,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const handleEditClick: React.EventHandler<React.MouseEvent> = () => {
dispatch(editStatus(status.id));
if (status.event) dispatch(editEvent(status.id));
else dispatch(editStatus(status.id));
};
const handlePinClick: React.EventHandler<React.MouseEvent> = (e) => {
@ -239,7 +242,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
secondary: intl.formatMessage(messages.blockAndReport),
onSecondary: () => {
dispatch(blockAccount(account.id));
dispatch(initReport(account, status));
dispatch(initReport(account, { status }));
},
}));
};
@ -251,12 +254,12 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const handleEmbed = () => {
dispatch(openModal('EMBED', {
url: status.get('url'),
onError: (error: any) => dispatch(showAlertForError(error)),
onError: (error: any) => toast.showAlertForError(error),
}));
};
const handleReport: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(initReport(status.account as Account, status));
dispatch(initReport(status.account as Account, { status }));
};
const handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
@ -265,21 +268,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const handleCopy: React.EventHandler<React.MouseEvent> = (e) => {
const { uri } = status;
const textarea = document.createElement('textarea');
textarea.textContent = uri;
textarea.style.position = 'fixed';
document.body.appendChild(textarea);
try {
textarea.select();
document.execCommand('copy');
} catch {
// Do nothing
} finally {
document.body.removeChild(textarea);
}
copy(uri);
};
const onModerate: React.MouseEventHandler = (e) => {

View File

@ -79,7 +79,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
ref={ref}
type='button'
className={classNames(
'flex items-center p-1 rounded-full',
'flex items-center p-1 rounded-full rtl:space-x-reverse',
'text-gray-600 hover:text-gray-600 dark:hover:text-white',
'bg-white dark:bg-transparent',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:ring-offset-0',

View File

@ -43,7 +43,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
const size = status.media_attachments.size;
const firstAttachment = status.media_attachments.first();
let media = null;
let media: JSX.Element | null = null;
const setRef = (c: HTMLDivElement): void => {
if (c) {
@ -122,7 +122,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
const attachment = firstAttachment;
media = (
<Bundle fetchComponent={Audio} loading={renderLoadingAudioPlayer} >
<Bundle fetchComponent={Audio} loading={renderLoadingAudioPlayer}>
{(Component: any) => (
<Component
src={attachment.url}

View File

@ -15,6 +15,7 @@ import QuotedStatus from 'soapbox/features/status/containers/quoted-status-conta
import { useAppDispatch, useSettings } from 'soapbox/hooks';
import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status';
import EventPreview from './event-preview';
import StatusActionBar from './status-action-bar';
import StatusContent from './status-content';
import StatusMedia from './status-media';
@ -28,7 +29,7 @@ import type {
Status as StatusEntity,
} from 'soapbox/types/entities';
// Defined in components/scrollable_list
// Defined in components/scrollable-list
export type ScrollPosition = { height: number, top: number };
const messages = defineMessages({
@ -236,7 +237,7 @@ const Status: React.FC<IStatus> = (props) => {
<NavLink
to={`/@${status.getIn(['account', 'acct'])}`}
onClick={(event) => event.stopPropagation()}
className='hidden sm:flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline'
className='hidden sm:flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 rtl:space-x-reverse hover:underline'
>
<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />
@ -245,7 +246,7 @@ const Status: React.FC<IStatus> = (props) => {
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: <bdi className='max-w-[100px] truncate pr-1'>
name: <bdi className='max-w-[100px] truncate pr-1 rtl:px-1'>
<strong className='text-gray-800 dark:text-gray-200' dangerouslySetInnerHTML={displayNameHtml} />
</bdi>,
}}
@ -383,30 +384,32 @@ const Status: React.FC<IStatus> = (props) => {
/>
)}
<Stack space={4}>
<StatusContent
status={actualStatus}
onClick={handleClick}
collapsable
translatable
/>
{actualStatus.event ? <EventPreview className='shadow-xl' status={actualStatus} /> : (
<Stack space={4}>
<StatusContent
status={actualStatus}
onClick={handleClick}
collapsable
translatable
/>
<TranslateButton status={actualStatus} />
<TranslateButton status={actualStatus} />
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
<Stack space={4}>
<StatusMedia
status={actualStatus}
muted={muted}
onClick={handleClick}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
<Stack space={4}>
<StatusMedia
status={actualStatus}
muted={muted}
onClick={handleClick}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
{quote}
</Stack>
)}
</Stack>
{quote}
</Stack>
)}
</Stack>
)}
</Stack>
{(!hideActionBar && !isUnderReview) && (

View File

@ -7,6 +7,7 @@ import { Icon, Text } from 'soapbox/components/ui';
interface IThumbNavigationLink {
count?: number,
countMax?: number,
src: string,
text: string | React.ReactElement,
to: string,
@ -14,7 +15,7 @@ interface IThumbNavigationLink {
paths?: Array<string>,
}
const ThumbNavigationLink: React.FC<IThumbNavigationLink> = ({ count, src, text, to, exact, paths }): JSX.Element => {
const ThumbNavigationLink: React.FC<IThumbNavigationLink> = ({ count, countMax, src, text, to, exact, paths }): JSX.Element => {
const { pathname } = useLocation();
const isActive = (): boolean => {
@ -38,6 +39,7 @@ const ThumbNavigationLink: React.FC<IThumbNavigationLink> = ({ count, src, text,
'text-primary-500': active,
})}
count={count}
countMax={countMax}
/>
) : (
<Icon

View File

@ -2,12 +2,14 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import ThumbNavigationLink from 'soapbox/components/thumb-navigation-link';
import { useStatContext } from 'soapbox/contexts/stat-context';
import { useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
const ThumbNavigation: React.FC = (): JSX.Element => {
const account = useOwnAccount();
const { unreadChatsCount } = useStatContext();
const notificationCount = useAppSelector((state) => state.notifications.unread);
const chatsCount = useAppSelector((state) => state.chats.items.reduce((acc, curr) => acc + Math.min(curr.unread || 0, 1), 0));
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
const features = useFeatures();
@ -20,7 +22,8 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
text={<FormattedMessage id='navigation.chats' defaultMessage='Chats' />}
to='/chats'
exact
count={chatsCount}
count={unreadChatsCount}
countMax={9}
/>
);
}

View File

@ -1,8 +1,9 @@
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { translateStatus, undoStatusTranslation } from 'soapbox/actions/statuses';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector, useFeatures, useInstance } from 'soapbox/hooks';
import { Stack } from './ui';
@ -16,11 +17,17 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const features = useFeatures();
const instance = useInstance();
const me = useAppSelector((state) => state.me);
const sourceLanguages = instance.pleroma.getIn(['metadata', 'translation', 'source_languages']) as ImmutableList<string>;
const targetLanguages = instance.pleroma.getIn(['metadata', 'translation', 'target_languages']) as ImmutableList<string>;
const renderTranslate = me && ['public', 'unlisted'].includes(status.visibility) && status.contentHtml.length > 0 && status.language !== null && intl.locale !== status.language;
const supportsLanguages = (!sourceLanguages || sourceLanguages.includes(status.language!)) && (!targetLanguages || targetLanguages.includes(intl.locale));
const handleTranslate: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
@ -31,7 +38,7 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
}
};
if (!features.translations || !renderTranslate) return null;
if (!features.translations || !renderTranslate || !supportsLanguages) return null;
if (status.translation) {
const languageNames = new Intl.DisplayNames([intl.locale], { type: 'language' });

View File

@ -2,9 +2,12 @@ import classNames from 'clsx';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { HStack, Icon, Text } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import HStack from '../hstack/hstack';
import Icon from '../icon/icon';
import Text from '../text/text';
import type { Menu } from 'soapbox/components/dropdown-menu';
const messages = defineMessages({

View File

@ -49,6 +49,8 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
className,
} = props;
const body = text || children;
const themeClass = useButtonStyles({
theme,
block,
@ -61,7 +63,7 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
return null;
}
return <Icon src={icon} className='mr-2 w-4 h-4' />;
return <Icon src={icon} className='w-4 h-4' />;
};
const handleClick = React.useCallback((event) => {
@ -72,7 +74,7 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
const renderButton = () => (
<button
className={classNames(themeClass, className)}
className={classNames('space-x-2 rtl:space-x-reverse', themeClass, className)}
disabled={disabled}
onClick={handleClick}
ref={ref}
@ -80,7 +82,10 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
data-testid='button'
>
{renderIcon()}
{text || children}
{body && (
<span>{body}</span>
)}
</button>
);

View File

@ -64,7 +64,7 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
return (
<Comp {...backAttributes} className='text-gray-900 dark:text-gray-100 focus:ring-primary-500 focus:ring-2' aria-label={intl.formatMessage(messages.back)}>
<SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6' />
<SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6 rtl:rotate-180' />
<span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
</Comp>
);
@ -88,9 +88,14 @@ const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
<Text size='xl' weight='bold' tag='h1' data-testid='card-title' truncate>{title}</Text>
);
interface ICardBody {
/** Classnames for the <div> element. */
className?: string
}
/** A card's body. */
const CardBody: React.FC = ({ children }): JSX.Element => (
<div data-testid='card-body'>{children}</div>
const CardBody: React.FC<ICardBody> = ({ className, children }): JSX.Element => (
<div data-testid='card-body' className={className}>{children}</div>
);
export { Card, CardHeader, CardTitle, CardBody };

View File

@ -7,10 +7,10 @@ import { useSoapboxConfig } from 'soapbox/hooks';
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' |'transparent'>;
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' |'className'>;
/** Contains the column title with optional back button. */
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, transparent }) => {
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) => {
const history = useHistory();
const handleBackClick = () => {
@ -27,10 +27,7 @@ const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, transparent })
};
return (
<CardHeader
className={classNames({ 'px-4 pt-4 sm:p-0': transparent })}
onBackClick={handleBackClick}
>
<CardHeader className={className} onBackClick={handleBackClick}>
<CardTitle title={label} />
</CardHeader>
);
@ -72,7 +69,11 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
<Card variant={transparent ? undefined : 'rounded'} className={className}>
{withHeader && (
<ColumnHeader label={label} backHref={backHref} transparent={transparent} />
<ColumnHeader
label={label}
backHref={backHref}
className={classNames({ 'px-4 pt-4 sm:p-0': transparent })}
/>
)}
<CardBody>

View File

@ -0,0 +1,31 @@
:root {
--reach-combobox: 1;
}
[data-reach-combobox-popover] {
@apply rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700 z-[100];
}
[data-reach-combobox-list] {
@apply list-none m-0 py-1 px-0 select-none;
}
[data-reach-combobox-option] {
@apply block px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 cursor-pointer;
}
[data-reach-combobox-option][aria-selected="true"] {
@apply bg-gray-100 dark:bg-gray-800;
}
[data-reach-combobox-option]:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
[data-reach-combobox-option][aria-selected="true"]:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
[data-suggested-value] {
@apply font-bold;
}

View File

@ -0,0 +1,10 @@
import './combobox.css';
export {
Combobox,
ComboboxInput,
ComboboxPopover,
ComboboxList,
ComboboxOption,
ComboboxOptionText,
} from '@reach/combobox';

View File

@ -5,13 +5,15 @@ import { shortNumberFormat } from 'soapbox/utils/numbers';
interface ICounter {
/** Number this counter should display. */
count: number,
/** Optional max number (ie: N+) */
countMax?: number
}
/** A simple counter for notifications, etc. */
const Counter: React.FC<ICounter> = ({ count }) => {
const Counter: React.FC<ICounter> = ({ count, countMax }) => {
return (
<span className='block px-1.5 py-0.5 bg-secondary-500 text-xs text-white rounded-full ring-2 ring-white dark:ring-gray-800'>
{shortNumberFormat(count)}
<span className='h-5 min-w-[20px] max-w-[26px] flex items-center justify-center bg-secondary-500 text-xs font-medium text-white rounded-full ring-2 ring-white dark:ring-gray-800'>
{shortNumberFormat(count, countMax)}
</span>
);
};

View File

@ -20,7 +20,7 @@ const Datepicker = ({ onChange }: IDatepicker) => {
const [month, setMonth] = useState<number>(new Date().getMonth());
const [day, setDay] = useState<number>(new Date().getDate());
const [year, setYear] = useState<number>(2022);
const [year, setYear] = useState<number>(new Date().getFullYear());
const numberOfDays = useMemo(() => {
return getDaysInMonth(month, year);

View File

@ -18,7 +18,7 @@ const Divider = ({ text, textSize = 'md' }: IDivider) => (
{text && (
<div className='relative flex justify-center'>
<span className='px-2 bg-white dark:bg-gray-900 text-gray-400' data-testid='divider-text'>
<span className='px-2 bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-600' data-testid='divider-text'>
<Text size={textSize} tag='span' theme='inherit'>{text}</Text>
</span>
</div>

View File

@ -14,6 +14,7 @@ const alignItemsOptions = {
bottom: 'items-end',
center: 'items-center',
start: 'items-start',
stretch: 'items-stretch',
};
const spaces = {

View File

@ -9,6 +9,8 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
className?: string,
/** Number to display a counter over the icon. */
count?: number,
/** Optional max to cap count (ie: N+) */
countMax?: number,
/** Tooltip text for the icon. */
alt?: string,
/** URL to the svg icon. */
@ -18,11 +20,11 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
}
/** Renders and SVG icon with optional counter. */
const Icon: React.FC<IIcon> = ({ src, alt, count, size, ...filteredProps }): JSX.Element => (
<div className='relative' data-testid='icon'>
const Icon: React.FC<IIcon> = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => (
<div className='flex flex-col flex-shrink-0 relative' data-testid='icon'>
{count ? (
<span className='absolute -top-2 -right-3'>
<Counter count={count} />
<span className='absolute -top-2 -right-3 min-w-[20px] h-5 flex-shrink-0 whitespace-nowrap flex items-center justify-center break-words'>
<Counter count={count} countMax={countMax} />
</span>
) : null}

View File

@ -5,6 +5,14 @@ export { default as Button } from './button/button';
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
export { default as Checkbox } from './checkbox/checkbox';
export { Column, ColumnHeader } from './column/column';
export {
Combobox,
ComboboxInput,
ComboboxPopover,
ComboboxList,
ComboboxOption,
ComboboxOptionText,
} from './combobox/combobox';
export { default as Counter } from './counter/counter';
export { default as Datepicker } from './datepicker/datepicker';
export { default as Divider } from './divider/divider';
@ -33,6 +41,7 @@ export { default as PhoneInput } from './phone-input/phone-input';
export { default as ProgressBar } from './progress-bar/progress-bar';
export { default as RadioButton } from './radio-button/radio-button';
export { default as Select } from './select/select';
export { default as Slider } from './slider/slider';
export { default as Spinner } from './spinner/spinner';
export { default as Stack } from './stack/stack';
export { default as Streamfield } from './streamfield/streamfield';
@ -40,6 +49,7 @@ export { default as Tabs } from './tabs/tabs';
export { default as TagInput } from './tag-input/tag-input';
export { default as Text } from './text/text';
export { default as Textarea } from './textarea/textarea';
export { default as Toast } from './toast/toast';
export { default as Toggle } from './toggle/toggle';
export { default as Tooltip } from './tooltip/tooltip';
export { default as Widget } from './widget/widget';

View File

@ -12,7 +12,7 @@ const messages = defineMessages({
});
/** Possible theme names for an Input. */
type InputThemes = 'normal' | 'search' | 'transparent';
type InputThemes = 'normal' | 'search'
interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxLength' | 'onChange' | 'onBlur' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled' | 'onClick' | 'readOnly' | 'min' | 'pattern' | 'onKeyDown' | 'onKeyUp' | 'onFocus' | 'style' | 'id'> {
/** Put the cursor into the input on mount. */
@ -61,9 +61,11 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
return (
<div
className={
classNames('mt-1 relative shadow-sm', outerClassName, {
classNames('relative', {
'rounded-md': theme !== 'search',
'rounded-full': theme === 'search',
'mt-1': !String(outerClassName).includes('mt-'),
[String(outerClassName)]: typeof outerClassName !== 'undefined',
})
}
>
@ -83,12 +85,11 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
{...filteredProps}
type={revealed ? 'text' : type}
ref={ref}
className={classNames({
'text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
className={classNames('text-base placeholder:text-gray-600 dark:placeholder:text-gray-600', {
'text-gray-900 dark:text-gray-100 block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
['normal', 'search'].includes(theme),
'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal',
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search',
'bg-transparent border-none': theme === 'transparent',
'pr-7 rtl:pl-7 rtl:pr-3': isPassword || append,
'text-red-600 border-red-600': hasError,
'pl-8': typeof icon !== 'undefined',

View File

@ -40,7 +40,7 @@ const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, classN
/** Right sidebar container in the UI. */
const Aside: React.FC = ({ children }) => (
<aside className='hidden xl:block xl:col-span-3'>
<StickyBox offsetTop={80} className='space-y-6 pb-12' >
<StickyBox offsetTop={80} className='space-y-6 pb-12'>
{children}
</StickyBox>
</aside>

View File

@ -1,7 +1,5 @@
[data-reach-menu-popover] {
@apply origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700 focus:outline-none;
z-index: 1003;
@apply origin-top-right rtl:origin-top-left absolute mt-2 rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700 focus:outline-none z-[1003];
}
[data-reach-menu-button] {

View File

@ -5,28 +5,36 @@ import {
MenuItems,
MenuPopover,
MenuLink,
MenuPopoverProps,
MenuListProps,
} from '@reach/menu-button';
import { positionDefault, positionRight } from '@reach/popover';
import classNames from 'clsx';
import React from 'react';
import './menu.css';
interface IMenuList extends Omit<MenuPopoverProps, 'position'> {
interface IMenuList extends Omit<MenuListProps, 'position'> {
/** Position of the dropdown menu. */
position?: 'left' | 'right'
className?: string
}
/** Renders children as a dropdown menu. */
const MenuList: React.FC<IMenuList> = (props) => (
<MenuPopover position={props.position === 'left' ? positionDefault : positionRight}>
<MenuItems
onKeyDown={(event) => event.nativeEvent.stopImmediatePropagation()}
className='py-1 bg-white dark:bg-primary-900 rounded-lg shadow-menu'
{...props}
/>
</MenuPopover>
);
const MenuList: React.FC<IMenuList> = (props) => {
const { position, className, ...filteredProps } = props;
return (
<MenuPopover position={props.position === 'left' ? positionDefault : positionRight}>
<MenuItems
onKeyDown={(event) => event.nativeEvent.stopImmediatePropagation()}
className={
classNames(className, 'py-1 bg-white dark:bg-primary-900 rounded-lg shadow-menu')
}
{...filteredProps}
/>
</MenuPopover>
);
};
/** Divides menu items. */
const MenuDivider = () => <hr />;

View File

@ -3,8 +3,9 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Button from '../button/button';
import { ButtonThemes } from '../button/useButtonStyles';
import HStack from '../hstack/hstack';
import IconButton from '../icon-button/icon-button';
import Stack from '../stack/stack';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -38,7 +39,7 @@ interface IModal {
/** Confirmation button text. */
confirmationText?: React.ReactNode,
/** Confirmation button theme. */
confirmationTheme?: 'danger',
confirmationTheme?: ButtonThemes,
/** Callback when the modal is closed. */
onClose?: () => void,
/** Callback when the secondary action is chosen. */
@ -100,7 +101,7 @@ const Modal: React.FC<IModal> = ({
src={closeIcon}
title={intl.formatMessage(messages.close)}
onClick={onClose}
className='text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-200'
className='text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-200 rtl:rotate-180'
/>
)}
</div>
@ -115,7 +116,7 @@ const Modal: React.FC<IModal> = ({
</div>
{confirmationAction && (
<div className='mt-5 flex flex-row justify-between' data-testid='modal-actions'>
<HStack className='mt-5' justifyContent='between' data-testid='modal-actions'>
<div className='flex-grow'>
{cancelAction && (
<Button
@ -127,7 +128,7 @@ const Modal: React.FC<IModal> = ({
)}
</div>
<Stack space={2}>
<HStack space={2}>
{secondaryAction && (
<Button
theme='secondary'
@ -146,8 +147,8 @@ const Modal: React.FC<IModal> = ({
>
{confirmationText}
</Button>
</Stack>
</div>
</HStack>
</HStack>
)}
</div>
);

View File

@ -11,7 +11,7 @@ const Select = React.forwardRef<HTMLSelectElement, ISelect>((props, ref) => {
return (
<select
ref={ref}
className={`w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-800 focus:outline-none focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-900 dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:focus:ring-primary-500 dark:focus:border-primary-500 sm:text-sm rounded-md disabled:opacity-50 ${className}`}
className={`w-full pl-3 pr-10 py-2 text-base truncate border-gray-300 dark:border-gray-800 focus:outline-none focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-900 dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:focus:ring-primary-500 dark:focus:border-primary-500 sm:text-sm rounded-md disabled:opacity-50 ${className}`}
{...filteredProps}
>
{children}

View File

@ -0,0 +1,124 @@
import throttle from 'lodash/throttle';
import React, { useRef } from 'react';
type Point = { x: number, y: number };
interface ISlider {
/** Value between 0 and 1. */
value: number
/** Callback when the value changes. */
onChange(value: number): void
}
/** Draggable slider component. */
const Slider: React.FC<ISlider> = ({ value, onChange }) => {
const node = useRef<HTMLDivElement>(null);
const handleMouseDown: React.MouseEventHandler = e => {
document.addEventListener('mousemove', handleMouseSlide, true);
document.addEventListener('mouseup', handleMouseUp, true);
document.addEventListener('touchmove', handleMouseSlide, true);
document.addEventListener('touchend', handleMouseUp, true);
handleMouseSlide(e);
e.preventDefault();
e.stopPropagation();
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseSlide, true);
document.removeEventListener('mouseup', handleMouseUp, true);
document.removeEventListener('touchmove', handleMouseSlide, true);
document.removeEventListener('touchend', handleMouseUp, true);
};
const handleMouseSlide = throttle(e => {
if (node.current) {
const { x } = getPointerPosition(node.current, e);
if (!isNaN(x)) {
let slideamt = x;
if (x > 1) {
slideamt = 1;
} else if (x < 0) {
slideamt = 0;
}
onChange(slideamt);
}
}
}, 60);
return (
<div
className='inline-flex cursor-pointer h-6 relative transition'
onMouseDown={handleMouseDown}
ref={node}
>
<div className='w-full h-1 bg-primary-200 dark:bg-primary-700 absolute top-1/2 -translate-y-1/2 rounded-full' />
<div className='h-1 bg-accent-500 absolute top-1/2 -translate-y-1/2 rounded-full' style={{ width: `${value * 100}%` }} />
<span
className='bg-accent-500 absolute rounded-full w-3 h-3 -ml-1.5 top-1/2 -translate-y-1/2 z-10 shadow'
tabIndex={0}
style={{ left: `${value * 100}%` }}
/>
</div>
);
};
const findElementPosition = (el: HTMLElement) => {
let box;
if (el.getBoundingClientRect && el.parentNode) {
box = el.getBoundingClientRect();
}
if (!box) {
return {
left: 0,
top: 0,
};
}
const docEl = document.documentElement;
const body = document.body;
const clientLeft = docEl.clientLeft || body.clientLeft || 0;
const scrollLeft = window.pageXOffset || body.scrollLeft;
const left = (box.left + scrollLeft) - clientLeft;
const clientTop = docEl.clientTop || body.clientTop || 0;
const scrollTop = window.pageYOffset || body.scrollTop;
const top = (box.top + scrollTop) - clientTop;
return {
left: Math.round(left),
top: Math.round(top),
};
};
const getPointerPosition = (el: HTMLElement, event: MouseEvent & TouchEvent): Point => {
const box = findElementPosition(el);
const boxW = el.offsetWidth;
const boxH = el.offsetHeight;
const boxY = box.top;
const boxX = box.left;
let pageY = event.pageY;
let pageX = event.pageX;
if (event.changedTouches) {
pageX = event.changedTouches[0].pageX;
pageY = event.changedTouches[0].pageY;
}
return {
y: Math.max(0, Math.min(1, (pageY - boxY) / boxH)),
x: Math.max(0, Math.min(1, (pageX - boxX) / boxW)),
};
};
export default Slider;

View File

@ -10,6 +10,7 @@ const spaces = {
3: 'space-y-3',
4: 'space-y-4',
5: 'space-y-5',
6: 'space-y-6',
10: 'space-y-10',
};
@ -23,6 +24,7 @@ const alignItemsOptions = {
bottom: 'items-end',
center: 'items-center',
start: 'items-start',
end: 'items-end',
};
interface IStack extends React.HTMLAttributes<HTMLDivElement> {

View File

@ -1,9 +1,15 @@
import classNames from 'clsx';
import React from 'react';
import React, { useState } from 'react';
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'required' | 'disabled' | 'rows' | 'readOnly' | 'onKeyDown' | 'onPaste'> {
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
/** Put the cursor into the input on mount. */
autoFocus?: boolean,
/** Allows the textarea height to grow while typing */
autoGrow?: boolean,
/** Used with "autoGrow". Sets a max number of rows. */
maxRows?: number,
/** Used with "autoGrow". Sets a min number of rows. */
minRows?: number,
/** The initial text in the input. */
defaultValue?: string,
/** Internal input name. */
@ -18,24 +24,64 @@ interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElemen
autoComplete?: string,
/** Whether to display the textarea in red. */
hasError?: boolean,
/** Whether or not you can resize the teztarea */
isResizeable?: boolean,
}
/** Textarea with custom styles. */
const Textarea = React.forwardRef(
({ isCodeEditor = false, hasError = false, ...props }: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
return (
<textarea
{...props}
ref={ref}
className={classNames({
'bg-white dark:bg-transparent shadow-sm block w-full sm:text-sm rounded-md text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
true,
'font-mono': isCodeEditor,
'text-red-600 border-red-600': hasError,
})}
/>
);
},
const Textarea = React.forwardRef(({
isCodeEditor = false,
hasError = false,
isResizeable = true,
onChange,
autoGrow = false,
maxRows = 10,
minRows = 1,
...props
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
const [rows, setRows] = useState<number>(autoGrow ? 1 : 4);
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
if (autoGrow) {
const textareaLineHeight = 20;
const previousRows = event.target.rows;
event.target.rows = minRows;
const currentRows = ~~(event.target.scrollHeight / textareaLineHeight);
if (currentRows === previousRows) {
event.target.rows = currentRows;
}
if (currentRows >= maxRows) {
event.target.rows = maxRows;
event.target.scrollTop = event.target.scrollHeight;
}
setRows(currentRows < maxRows ? currentRows : maxRows);
}
if (onChange) {
onChange(event);
}
};
return (
<textarea
{...props}
ref={ref}
rows={rows}
onChange={handleChange}
className={classNames({
'bg-white dark:bg-transparent shadow-sm block w-full sm:text-sm rounded-md text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
true,
'font-mono': isCodeEditor,
'text-red-600 border-red-600': hasError,
'resize-none': !isResizeable,
})}
/>
);
},
);
export default Textarea;

View File

@ -0,0 +1,145 @@
import classNames from 'clsx';
import React from 'react';
import toast, { Toast as RHToast } from 'react-hot-toast';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { ToastText, ToastType } from 'soapbox/toast';
import HStack from '../hstack/hstack';
import Icon from '../icon/icon';
const renderText = (text: ToastText) => {
if (typeof text === 'string') {
return text;
} else {
return <FormattedMessage {...text} />;
}
};
interface IToast {
t: RHToast
message: ToastText
type: ToastType
action?(): void
actionLink?: string
actionLabel?: ToastText
}
/**
* Customizable Toasts for in-app notifications.
*/
const Toast = (props: IToast) => {
const { t, message, type, action, actionLink, actionLabel } = props;
const dismissToast = () => toast.dismiss(t.id);
const renderIcon = () => {
switch (type) {
case 'success':
return (
<Icon
src={require('@tabler/icons/circle-check.svg')}
className='w-6 h-6 text-success-500 dark:text-success-400'
aria-hidden
/>
);
case 'info':
return (
<Icon
src={require('@tabler/icons/info-circle.svg')}
className='w-6 h-6 text-primary-600 dark:text-accent-blue'
aria-hidden
/>
);
case 'error':
return (
<Icon
src={require('@tabler/icons/alert-circle.svg')}
className='w-6 h-6 text-danger-600'
aria-hidden
/>
);
}
};
const renderAction = () => {
const classNames = 'mt-0.5 flex-shrink-0 rounded-full text-sm font-medium text-primary-600 dark:text-accent-blue hover:underline focus:outline-none';
if (action && actionLabel) {
return (
<button
type='button'
className={classNames}
onClick={() => {
dismissToast();
action();
}}
data-testid='toast-action'
>
{renderText(actionLabel)}
</button>
);
}
if (actionLink && actionLabel) {
return (
<Link
to={actionLink}
onClick={dismissToast}
className={classNames}
data-testid='toast-action-link'
>
{renderText(actionLabel)}
</Link>
);
}
return null;
};
return (
<div
data-testid='toast'
className={
classNames({
'p-4 pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white dark:bg-gray-900 shadow-lg dark:ring-2 dark:ring-gray-800': true,
'animate-enter': t.visible,
'animate-leave': !t.visible,
})
}
>
<HStack space={4} alignItems='start'>
<HStack space={3} justifyContent='between' alignItems='start' className='w-0 flex-1'>
<HStack space={3} alignItems='start' className='w-0 flex-1'>
<div className='flex-shrink-0'>
{renderIcon()}
</div>
<p className='pt-0.5 text-sm text-gray-900 dark:text-gray-100' data-testid='toast-message'>
{renderText(message)}
</p>
</HStack>
{/* Action */}
{renderAction()}
</HStack>
{/* Dismiss Button */}
<div className='flex flex-shrink-0 pt-0.5'>
<button
type='button'
className='inline-flex rounded-md text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
onClick={dismissToast}
data-testid='toast-dismiss'
>
<span className='sr-only'>Close</span>
<Icon src={require('@tabler/icons/x.svg')} className='w-5 h-5' />
</button>
</div>
</HStack>
</div>
);
};
export default Toast;

View File

@ -44,7 +44,7 @@ const Widget: React.FC<IWidget> = ({
<WidgetTitle title={title} />
{action || (onActionClick && (
<IconButton
className='w-6 h-6 ml-2 text-black dark:text-white'
className='w-6 h-6 ml-2 text-black dark:text-white rtl:rotate-180'
src={actionIcon}
onClick={onActionClick}
title={actionTitle}

View File

@ -0,0 +1,213 @@
import classNames from 'clsx';
import { List as ImmutableList } from 'immutable';
import React, { useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { spring } from 'react-motion';
import { openModal } from 'soapbox/actions/modals';
import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon';
import IconButton from 'soapbox/components/icon-button';
import Motion from 'soapbox/features/ui/util/optional-motion';
import { useAppDispatch } from 'soapbox/hooks';
import { Attachment } from 'soapbox/types/entities';
const bookIcon = require('@tabler/icons/book.svg');
const fileCodeIcon = require('@tabler/icons/file-code.svg');
const fileSpreadsheetIcon = require('@tabler/icons/file-spreadsheet.svg');
const fileTextIcon = require('@tabler/icons/file-text.svg');
const fileZipIcon = require('@tabler/icons/file-zip.svg');
const defaultIcon = require('@tabler/icons/paperclip.svg');
const presentationIcon = require('@tabler/icons/presentation.svg');
export const MIMETYPE_ICONS: Record<string, string> = {
'application/x-freearc': fileZipIcon,
'application/x-bzip': fileZipIcon,
'application/x-bzip2': fileZipIcon,
'application/gzip': fileZipIcon,
'application/vnd.rar': fileZipIcon,
'application/x-tar': fileZipIcon,
'application/zip': fileZipIcon,
'application/x-7z-compressed': fileZipIcon,
'application/x-csh': fileCodeIcon,
'application/html': fileCodeIcon,
'text/javascript': fileCodeIcon,
'application/json': fileCodeIcon,
'application/ld+json': fileCodeIcon,
'application/x-httpd-php': fileCodeIcon,
'application/x-sh': fileCodeIcon,
'application/xhtml+xml': fileCodeIcon,
'application/xml': fileCodeIcon,
'application/epub+zip': bookIcon,
'application/vnd.oasis.opendocument.spreadsheet': fileSpreadsheetIcon,
'application/vnd.ms-excel': fileSpreadsheetIcon,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': fileSpreadsheetIcon,
'application/pdf': fileTextIcon,
'application/vnd.oasis.opendocument.presentation': presentationIcon,
'application/vnd.ms-powerpoint': presentationIcon,
'application/vnd.openxmlformats-officedocument.presentationml.presentation': presentationIcon,
'text/plain': fileTextIcon,
'application/rtf': fileTextIcon,
'application/msword': fileTextIcon,
'application/x-abiword': fileTextIcon,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': fileTextIcon,
'application/vnd.oasis.opendocument.text': fileTextIcon,
};
const messages = defineMessages({
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
delete: { id: 'upload_form.undo', defaultMessage: 'Delete' },
});
interface IUpload {
media: Attachment,
onSubmit?(): void,
onDelete?(): void,
onDescriptionChange?(description: string): void,
descriptionLimit?: number,
withPreview?: boolean,
}
const Upload: React.FC<IUpload> = ({
media,
onSubmit,
onDelete,
onDescriptionChange,
descriptionLimit,
withPreview = true,
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [dirtyDescription, setDirtyDescription] = useState<string | null>(null);
const handleKeyDown: React.KeyboardEventHandler = (e) => {
if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
handleInputBlur();
onSubmit();
}
};
const handleUndoClick: React.MouseEventHandler = e => {
if (onDelete) {
e.stopPropagation();
onDelete();
}
};
const handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
setDirtyDescription(e.target.value);
};
const handleMouseEnter = () => {
setHovered(true);
};
const handleMouseLeave = () => {
setHovered(false);
};
const handleInputFocus = () => {
setFocused(true);
};
const handleClick = () => {
setFocused(true);
};
const handleInputBlur = () => {
setFocused(false);
setDirtyDescription(null);
if (dirtyDescription !== null && onDescriptionChange) {
onDescriptionChange(dirtyDescription);
}
};
const handleOpenModal = () => {
dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0 }));
};
const active = hovered || focused;
const description = dirtyDescription || (dirtyDescription !== '' && media.description) || '';
const focusX = media.meta.getIn(['focus', 'x']) as number | undefined;
const focusY = media.meta.getIn(['focus', 'y']) as number | undefined;
const x = focusX ? ((focusX / 2) + .5) * 100 : undefined;
const y = focusY ? ((focusY / -2) + .5) * 100 : undefined;
const mediaType = media.type;
const mimeType = media.pleroma.get('mime_type') as string | undefined;
const uploadIcon = mediaType === 'unknown' && (
<Icon
className='h-16 w-16 mx-auto my-12 text-gray-800 dark:text-gray-200'
src={MIMETYPE_ICONS[mimeType || ''] || defaultIcon}
/>
);
return (
<div className='compose-form__upload' tabIndex={0} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={handleClick} role='button'>
<Blurhash hash={media.blurhash} className='media-gallery__preview' />
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => (
<div
className={classNames('compose-form__upload-thumbnail', mediaType)}
style={{
transform: `scale(${scale})`,
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined }}
>
<div className={classNames('compose-form__upload__actions', { active })}>
{onDelete && (
<IconButton
onClick={handleUndoClick}
src={require('@tabler/icons/x.svg')}
text={<FormattedMessage id='upload_form.undo' defaultMessage='Delete' />}
/>
)}
{/* Only display the "Preview" button for a valid attachment with a URL */}
{(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && (
<IconButton
onClick={handleOpenModal}
src={require('@tabler/icons/zoom-in.svg')}
text={<FormattedMessage id='upload_form.preview' defaultMessage='Preview' />}
/>
)}
</div>
{onDescriptionChange && (
<div className={classNames('compose-form__upload-description', { active })}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
<textarea
placeholder={intl.formatMessage(messages.description)}
value={description}
maxLength={descriptionLimit}
onFocus={handleInputFocus}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
/>
</label>
</div>
)}
<div className='compose-form__upload-preview'>
{mediaType === 'video' && (
<video autoPlay playsInline muted loop>
<source src={media.preview_url} />
</video>
)}
{uploadIcon}
</div>
</div>
)}
</Motion>
</div>
);
};
export default Upload;

View File

@ -3,12 +3,14 @@
import { QueryClientProvider } from '@tanstack/react-query';
import classNames from 'clsx';
import React, { useState, useEffect } from 'react';
import { Toaster } from 'react-hot-toast';
import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux';
import { BrowserRouter, Switch, Redirect, Route } from 'react-router-dom';
// @ts-ignore: it doesn't have types
import { ScrollContext } from 'react-router-scroll-4';
import { loadInstance } from 'soapbox/actions/instance';
import { fetchMe } from 'soapbox/actions/me';
import { loadSoapboxConfig, getSoapboxConfig } from 'soapbox/actions/soapbox';
@ -17,13 +19,13 @@ import * as BuildConfig from 'soapbox/build-config';
import GdprBanner from 'soapbox/components/gdpr-banner';
import Helmet from 'soapbox/components/helmet';
import LoadingScreen from 'soapbox/components/loading-screen';
import { StatProvider } from 'soapbox/contexts/stat-context';
import AuthLayout from 'soapbox/features/auth-layout';
import EmbeddedStatus from 'soapbox/features/embedded-status';
import PublicLayout from 'soapbox/features/public-layout';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import {
ModalContainer,
NotificationsContainer,
OnboardingWizard,
WaitlistPage,
} from 'soapbox/features/ui/util/async-components';
@ -40,6 +42,7 @@ import {
useInstance,
} from 'soapbox/hooks';
import MESSAGES from 'soapbox/locales/messages';
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
import { queryClient } from 'soapbox/queries/client';
import { useCachedLocationHandler } from 'soapbox/utils/redirect';
import { generateThemeCss } from 'soapbox/utils/theme';
@ -50,8 +53,6 @@ import ErrorBoundary from '../components/error-boundary';
import UI from '../features/ui';
import { store } from '../store';
const RTL_LOCALES = ['ar', 'ckb', 'fa', 'he'];
// Configure global functions for developers
createGlobals(store);
@ -85,6 +86,7 @@ const loadInitial = () => {
/** Highest level node with the Redux store. */
const SoapboxMount = () => {
useCachedLocationHandler();
const me = useAppSelector(state => state.me);
const instance = useInstance();
const account = useOwnAccount();
@ -182,15 +184,12 @@ const SoapboxMount = () => {
<Route>
{renderBody()}
<BundleContainer fetchComponent={NotificationsContainer}>
{(Component) => <Component />}
</BundleContainer>
<BundleContainer fetchComponent={ModalContainer}>
{Component => <Component />}
</BundleContainer>
<GdprBanner />
<Toaster position='top-right' containerClassName='top-10' containerStyle={{ top: 75 }} />
</Route>
</Switch>
</ScrollContext>
@ -210,7 +209,7 @@ const SoapboxLoad: React.FC<ISoapboxLoad> = ({ children }) => {
const me = useAppSelector(state => state.me);
const account = useOwnAccount();
const swUpdating = useAppSelector(state => state.meta.swUpdating);
const locale = useLocale();
const { locale } = useLocale();
const [messages, setMessages] = useState<Record<string, string>>({});
const [localeLoading, setLocaleLoading] = useState(true);
@ -261,12 +260,13 @@ interface ISoapboxHead {
/** Injects metadata into site head with Helmet. */
const SoapboxHead: React.FC<ISoapboxHead> = ({ children }) => {
const locale = useLocale();
const { locale, direction } = useLocale();
const settings = useSettings();
const soapboxConfig = useSoapboxConfig();
const demo = !!settings.get('demo');
const darkMode = useTheme() === 'dark';
const themeCss = generateThemeCss(soapboxConfig);
const themeCss = generateThemeCss(demo ? normalizeSoapboxConfig({ brandColor: '#0482d8' }) : soapboxConfig);
const bodyClass = classNames('bg-white dark:bg-gray-800 text-base h-full', {
'no-reduce-motion': !settings.get('reduceMotion'),
@ -279,7 +279,7 @@ const SoapboxHead: React.FC<ISoapboxHead> = ({ children }) => {
<>
<Helmet>
<html lang={locale} className={classNames('h-full', { dark: darkMode })} />
<body className={bodyClass} dir={RTL_LOCALES.includes(locale) ? 'rtl' : undefined} />
<body className={bodyClass} dir={direction} />
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
{darkMode && <style type='text/css'>{':root { color-scheme: dark; }'}</style>}
<meta name='theme-color' content={soapboxConfig.brandColor} />
@ -295,11 +295,13 @@ const Soapbox: React.FC = () => {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<SoapboxHead>
<SoapboxLoad>
<SoapboxMount />
</SoapboxLoad>
</SoapboxHead>
<StatProvider>
<SoapboxHead>
<SoapboxLoad>
<SoapboxMount />
</SoapboxLoad>
</SoapboxHead>
</StatProvider>
</QueryClientProvider>
</Provider>
);

View File

@ -0,0 +1,88 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useHistory, useParams } from 'react-router-dom';
import { toggleMainWindow } from 'soapbox/actions/chats';
import { useOwnAccount, useSettings } from 'soapbox/hooks';
import { IChat, useChat } from 'soapbox/queries/chats';
type WindowState = 'open' | 'minimized';
const ChatContext = createContext<any>({
isOpen: false,
needsAcceptance: false,
});
enum ChatWidgetScreens {
INBOX = 'INBOX',
SEARCH = 'SEARCH',
CHAT = 'CHAT',
CHAT_SETTINGS = 'CHAT_SETTINGS'
}
const ChatProvider: React.FC = ({ children }) => {
const history = useHistory();
const dispatch = useDispatch();
const settings = useSettings();
const account = useOwnAccount();
const path = history.location.pathname;
const isUsingMainChatPage = Boolean(path.match(/^\/chats/));
const { chatId } = useParams<{ chatId: string }>();
const [screen, setScreen] = useState<ChatWidgetScreens>(ChatWidgetScreens.INBOX);
const [currentChatId, setCurrentChatId] = useState<null | string>(chatId);
const { data: chat } = useChat(currentChatId as string);
const mainWindowState = settings.getIn(['chats', 'mainWindow']) as WindowState;
const needsAcceptance = !chat?.accepted && chat?.created_by_account !== account?.id;
const isOpen = mainWindowState === 'open';
const changeScreen = (screen: ChatWidgetScreens, currentChatId?: string | null) => {
setCurrentChatId(currentChatId || null);
setScreen(screen);
};
const toggleChatPane = () => dispatch(toggleMainWindow());
const value = useMemo(() => ({
chat,
needsAcceptance,
isOpen,
isUsingMainChatPage,
toggleChatPane,
screen,
changeScreen,
currentChatId,
}), [chat, currentChatId, needsAcceptance, isUsingMainChatPage, isOpen, screen, changeScreen]);
useEffect(() => {
if (chatId) {
setCurrentChatId(chatId);
} else {
setCurrentChatId(null);
}
}, [chatId]);
return (
<ChatContext.Provider value={value}>
{children}
</ChatContext.Provider>
);
};
interface IChatContext {
chat: IChat | null
isOpen: boolean
isUsingMainChatPage?: boolean
needsAcceptance: boolean
toggleChatPane(): void
screen: ChatWidgetScreens
currentChatId: string | null
changeScreen(screen: ChatWidgetScreens, currentChatId?: string | null): void
}
const useChatContext = (): IChatContext => useContext(ChatContext);
export { ChatContext, ChatProvider, useChatContext, ChatWidgetScreens };

View File

@ -0,0 +1,29 @@
import React, { createContext, useContext, useMemo, useState } from 'react';
type IStatContext = {
unreadChatsCount: number,
setUnreadChatsCount: React.Dispatch<React.SetStateAction<number>>
}
const StatContext = createContext<any>({
unreadChatsCount: 0,
});
const StatProvider: React.FC = ({ children }) => {
const [unreadChatsCount, setUnreadChatsCount] = useState<number>(0);
const value = useMemo(() => ({
unreadChatsCount,
setUnreadChatsCount,
}), [unreadChatsCount]);
return (
<StatContext.Provider value={value}>
{children}
</StatContext.Provider>
);
};
const useStatContext = (): IStatContext => useContext(StatContext);
export { StatProvider, useStatContext, IStatContext };

View File

@ -2,6 +2,3 @@
import 'intersection-observer';
import 'requestidlecallback';
import objectFitImages from 'object-fit-images';
objectFitImages();

View File

@ -95,7 +95,7 @@ const AccountGallery = () => {
const media = (attachment.status as Status).media_attachments;
const index = media.findIndex((x) => x.id === attachment.id);
dispatch(openModal('MEDIA', { media, index, status: attachment.status, account: attachment.account }));
dispatch(openModal('MEDIA', { media, index, status: attachment.status }));
}
};

View File

@ -1,12 +1,13 @@
'use strict';
import { useMutation } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import { blockAccount, followAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts';
import { launchChat } from 'soapbox/actions/chats';
import { mentionCompose, directCompose } from 'soapbox/actions/compose';
import { blockDomain, unblockDomain } from 'soapbox/actions/domain-blocks';
import { openModal } from 'soapbox/actions/modals';
@ -14,7 +15,6 @@ import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport } from 'soapbox/actions/reports';
import { setSearchAccount } from 'soapbox/actions/search';
import { getSettings } from 'soapbox/actions/settings';
import snackbar from 'soapbox/actions/snackbar';
import Badge from 'soapbox/components/badge';
import StillImage from 'soapbox/components/still-image';
import { Avatar, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
@ -24,8 +24,11 @@ import ActionButton from 'soapbox/features/ui/components/action-button';
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button';
import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { normalizeAttachment } from 'soapbox/normalizers';
import { ChatKeys, useChats } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
import toast from 'soapbox/toast';
import { Account } from 'soapbox/types/entities';
import { isRemote } from 'soapbox/utils/accounts';
import { isDefaultHeader, isRemote } from 'soapbox/utils/accounts';
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
@ -82,6 +85,21 @@ const Header: React.FC<IHeader> = ({ account }) => {
const features = useFeatures();
const ownAccount = useOwnAccount();
const { getOrCreateChatByAccountId } = useChats();
const createAndNavigateToChat = useMutation((accountId: string) => {
return getOrCreateChatByAccountId(accountId);
}, {
onError: (error: AxiosError) => {
const data = error.response?.data as any;
toast.error(data?.error);
},
onSuccess: (response) => {
history.push(`/chats/${response.data.id}`);
queryClient.invalidateQueries(ChatKeys.chatSearch());
},
});
if (!account) {
return (
<div className='-mt-4 -mx-4'>
@ -140,12 +158,12 @@ const Header: React.FC<IHeader> = ({ account }) => {
const onEndorseToggle = () => {
if (account.relationship?.endorsed) {
dispatch(unpinAccount(account.id))
.then(() => dispatch(snackbar.success(intl.formatMessage(messages.userUnendorsed, { acct: account.acct }))))
.catch(() => {});
.then(() => toast.success(intl.formatMessage(messages.userUnendorsed, { acct: account.acct })))
.catch(() => { });
} else {
dispatch(pinAccount(account.id))
.then(() => dispatch(snackbar.success(intl.formatMessage(messages.userEndorsed, { acct: account.acct }))))
.catch(() => {});
.then(() => toast.success(intl.formatMessage(messages.userEndorsed, { acct: account.acct })))
.catch(() => { });
}
};
@ -185,10 +203,6 @@ const Header: React.FC<IHeader> = ({ account }) => {
}));
};
const onChat = () => {
dispatch(launchChat(account.id, history));
};
const onModerate = () => {
dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id }));
};
@ -304,13 +318,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
icon: require('@tabler/icons/at.svg'),
});
if (account.getIn(['pleroma', 'accepts_chat_messages']) === true) {
menu.push({
text: intl.formatMessage(messages.chat, { name: account.username }),
action: onChat,
icon: require('@tabler/icons/messages.svg'),
});
} else if (features.privacyScopes) {
if (features.privacyScopes) {
menu.push({
text: intl.formatMessage(messages.direct, { name: account.username }),
action: onDirect,
@ -494,34 +502,66 @@ const Header: React.FC<IHeader> = ({ account }) => {
return info;
};
// const renderMessageButton = () => {
// if (!ownAccount || !account || account.id === ownAccount?.id) {
// return null;
// }
const renderHeader = () => {
let header: React.ReactNode;
// const canChat = account.getIn(['pleroma', 'accepts_chat_messages']) === true;
if (account.header) {
header = (
<StillImage
src={account.header}
alt={intl.formatMessage(messages.header)}
/>
);
// if (canChat) {
// return (
// <IconButton
// src={require('@tabler/icons/messages.svg')}
// onClick={onChat}
// title={intl.formatMessage(messages.chat, { name: account.username })}
// />
// );
// } else {
// return (
// <IconButton
// src={require('@tabler/icons/mail.svg')}
// onClick={onDirect}
// title={intl.formatMessage(messages.direct, { name: account.username })}
// theme='outlined'
// className='px-2'
// iconClassName='w-4 h-4'
// />
// );
// }
// };
if (!isDefaultHeader(account.header)) {
header = (
<a href={account.header} onClick={handleHeaderClick} target='_blank'>
{header}
</a>
);
}
}
return header;
};
const renderMessageButton = () => {
if (!ownAccount || !account || account.id === ownAccount?.id) {
return null;
}
if (features.chatsWithFollowers) { // Truth Social
const canChat = account.relationship?.followed_by;
if (!canChat) {
return null;
}
return (
<IconButton
src={require('@tabler/icons/messages.svg')}
onClick={() => createAndNavigateToChat.mutate(account.id)}
title={intl.formatMessage(messages.chat, { name: account.username })}
theme='outlined'
className='px-2'
iconClassName='w-4 h-4'
disabled={createAndNavigateToChat.isLoading}
/>
);
} else if (account.getIn(['pleroma', 'accepts_chat_messages']) === true) {
return (
<IconButton
src={require('@tabler/icons/messages.svg')}
onClick={() => createAndNavigateToChat.mutate(account.id)}
title={intl.formatMessage(messages.chat, { name: account.username })}
theme='outlined'
className='px-2'
iconClassName='w-4 h-4'
/>
);
} else {
return null;
}
};
const renderShareButton = () => {
const canShare = 'share' in navigator;
@ -553,14 +593,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
<div>
<div className='relative flex flex-col justify-center h-32 w-full lg:h-48 md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50 overflow-hidden isolate'>
{account.header && (
<a href={account.header} onClick={handleHeaderClick} target='_blank'>
<StillImage
src={account.header}
alt={intl.formatMessage(messages.header)}
/>
</a>
)}
{renderHeader()}
<div className='absolute top-2 left-2'>
<HStack alignItems='center' space={1}>
@ -577,7 +610,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
<Avatar
src={account.avatar}
size={96}
className='relative h-24 w-24 rounded-full ring-4 ring-white dark:ring-primary-900'
className='relative h-24 w-24 rounded-full ring-4 ring-white dark:ring-primary-900 bg-white dark:bg-primary-900'
/>
</a>
</div>
@ -585,6 +618,8 @@ const Header: React.FC<IHeader> = ({ account }) => {
<div className='mt-6 flex justify-end w-full sm:pb-1'>
<HStack space={2} className='mt-10'>
<SubscriptionButton account={account} />
{renderMessageButton()}
{renderShareButton()}
{ownAccount && (
<Menu>
@ -597,7 +632,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
children={null}
/>
<MenuList>
<MenuList className='w-56'>
{menu.map((menuItem, idx) => {
if (typeof menuItem?.text === 'undefined') {
return <MenuDivider key={idx} />;
@ -622,9 +657,6 @@ const Header: React.FC<IHeader> = ({ account }) => {
</Menu>
)}
{renderShareButton()}
{/* {renderMessageButton()} */}
<ActionButton account={account} />
</HStack>
</div>

View File

@ -0,0 +1,57 @@
import React from 'react';
import { FormattedNumber } from 'react-intl';
import { Link } from 'react-router-dom';
import { Text } from 'soapbox/components/ui';
import { isNumber } from 'soapbox/utils/numbers';
interface IDashCounter {
count: number | undefined
label: React.ReactNode
to?: string
percent?: boolean
}
/** Displays a (potentially clickable) dashboard statistic. */
const DashCounter: React.FC<IDashCounter> = ({ count, label, to = '#', percent = false }) => {
if (!isNumber(count)) {
return null;
}
return (
<Link
className='bg-gray-200 dark:bg-gray-800 p-4 rounded flex flex-col items-center space-y-2 hover:-translate-y-1 transition-transform cursor-pointer'
to={to}
>
<Text align='center' size='2xl' weight='medium'>
<FormattedNumber
value={count}
style={percent ? 'unit' : undefined}
unit={percent ? 'percent' : undefined}
/>
</Text>
<Text align='center'>
{label}
</Text>
</Link>
);
};
interface IDashCounters {
children: React.ReactNode
}
/** Wrapper container for dash counters. */
const DashCounters: React.FC<IDashCounters> = ({ children }) => {
return (
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2'>
{children}
</div>
);
};
export {
DashCounter,
DashCounters,
};

View File

@ -2,14 +2,9 @@ import React from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { updateConfig } from 'soapbox/actions/admin';
import snackbar from 'soapbox/actions/snackbar';
import {
SimpleForm,
FieldsGroup,
RadioGroup,
RadioItem,
} from 'soapbox/features/forms';
import { RadioGroup, RadioItem } from 'soapbox/components/radio';
import { useAppDispatch, useInstance } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import type { Instance } from 'soapbox/types/entities';
@ -49,38 +44,31 @@ const RegistrationModePicker: React.FC = () => {
const onChange: React.ChangeEventHandler<HTMLInputElement> = e => {
const config = generateConfig(e.target.value as RegistrationMode);
dispatch(updateConfig(config)).then(() => {
dispatch(snackbar.success(intl.formatMessage(messages.saved)));
toast.success(intl.formatMessage(messages.saved));
}).catch(() => {});
};
return (
<SimpleForm>
<FieldsGroup>
<RadioGroup
label={<FormattedMessage id='admin.dashboard.registration_mode_label' defaultMessage='Registrations' />}
onChange={onChange}
>
<RadioItem
label={<FormattedMessage id='admin.dashboard.registration_mode.open_label' defaultMessage='Open' />}
hint={<FormattedMessage id='admin.dashboard.registration_mode.open_hint' defaultMessage='Anyone can join.' />}
checked={mode === 'open'}
value='open'
/>
<RadioItem
label={<FormattedMessage id='admin.dashboard.registration_mode.approval_label' defaultMessage='Approval Required' />}
hint={<FormattedMessage id='admin.dashboard.registration_mode.approval_hint' defaultMessage='Users can sign up, but their account only gets activated when an admin approves it.' />}
checked={mode === 'approval'}
value='approval'
/>
<RadioItem
label={<FormattedMessage id='admin.dashboard.registration_mode.closed_label' defaultMessage='Closed' />}
hint={<FormattedMessage id='admin.dashboard.registration_mode.closed_hint' defaultMessage='Nobody can sign up. You can still invite people.' />}
checked={mode === 'closed'}
value='closed'
/>
</RadioGroup>
</FieldsGroup>
</SimpleForm>
<RadioGroup onChange={onChange}>
<RadioItem
label={<FormattedMessage id='admin.dashboard.registration_mode.open_label' defaultMessage='Open' />}
hint={<FormattedMessage id='admin.dashboard.registration_mode.open_hint' defaultMessage='Anyone can join.' />}
checked={mode === 'open'}
value='open'
/>
<RadioItem
label={<FormattedMessage id='admin.dashboard.registration_mode.approval_label' defaultMessage='Approval Required' />}
hint={<FormattedMessage id='admin.dashboard.registration_mode.approval_hint' defaultMessage='Users can sign up, but their account only gets activated when an admin approves it.' />}
checked={mode === 'approval'}
value='approval'
/>
<RadioItem
label={<FormattedMessage id='admin.dashboard.registration_mode.closed_label' defaultMessage='Closed' />}
hint={<FormattedMessage id='admin.dashboard.registration_mode.closed_hint' defaultMessage='Nobody can sign up. You can still invite people.' />}
checked={mode === 'closed'}
value='closed'
/>
</RadioGroup>
);
};

View File

@ -4,13 +4,13 @@ import { Link } from 'react-router-dom';
import { closeReports } from 'soapbox/actions/admin';
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
import snackbar from 'soapbox/actions/snackbar';
import Avatar from 'soapbox/components/avatar';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
import { Accordion, Button, Stack, HStack, Text } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetReport } from 'soapbox/selectors';
import toast from 'soapbox/toast';
import ReportStatus from './report-status';
@ -58,7 +58,7 @@ const Report: React.FC<IReport> = ({ id }) => {
const handleCloseReport = () => {
dispatch(closeReports([report.id])).then(() => {
const message = intl.formatMessage(messages.reportClosed, { name: targetAccount.username as string });
dispatch(snackbar.success(message));
toast.success(message);
}).catch(() => {});
};

View File

@ -3,10 +3,10 @@ import { defineMessages, useIntl } from 'react-intl';
import { approveUsers } from 'soapbox/actions/admin';
import { rejectUserModal } from 'soapbox/actions/moderation';
import snackbar from 'soapbox/actions/snackbar';
import IconButton from 'soapbox/components/icon-button';
import { Stack, HStack, Text, IconButton } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
import toast from 'soapbox/toast';
const messages = defineMessages({
approved: { id: 'admin.awaiting_approval.approved_message', defaultMessage: '{acct} was approved!' },
@ -32,7 +32,7 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
dispatch(approveUsers([account.id]))
.then(() => {
const message = intl.formatMessage(messages.approved, { acct: `@${account.acct}` });
dispatch(snackbar.success(message));
toast.success(message);
})
.catch(() => {});
};
@ -40,21 +40,36 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
const handleReject = () => {
dispatch(rejectUserModal(intl, account.id, () => {
const message = intl.formatMessage(messages.rejected, { acct: `@${account.acct}` });
dispatch(snackbar.info(message));
toast.info(message);
}));
};
return (
<div className='unapproved-account'>
<div className='unapproved-account__bio'>
<div className='unapproved-account__nickname'>@{account.get('acct')}</div>
<blockquote className='md'>{adminAccount?.invite_request || ''}</blockquote>
</div>
<div className='unapproved-account__actions'>
<IconButton src={require('@tabler/icons/check.svg')} onClick={handleApprove} />
<IconButton src={require('@tabler/icons/x.svg')} onClick={handleReject} />
</div>
</div>
<HStack space={4} justifyContent='between'>
<Stack space={1}>
<Text weight='semibold'>
@{account.get('acct')}
</Text>
<Text tag='blockquote' size='sm'>
{adminAccount?.invite_request || ''}
</Text>
</Stack>
<HStack space={2} alignItems='center'>
<IconButton
src={require('@tabler/icons/check.svg')}
onClick={handleApprove}
theme='outlined'
iconClassName='p-1 text-gray-600 dark:text-gray-400'
/>
<IconButton
src={require('@tabler/icons/x.svg')}
onClick={handleReject}
theme='outlined'
iconClassName='p-1 text-gray-600 dark:text-gray-400'
/>
</HStack>
</HStack>
);
};

View File

@ -3,8 +3,9 @@ import { defineMessages, FormattedDate, useIntl } from 'react-intl';
import { fetchModerationLog } from 'soapbox/actions/admin';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Column } from 'soapbox/components/ui';
import { Column, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { AdminLog } from 'soapbox/types/entities';
const messages = defineMessages({
heading: { id: 'column.admin.moderation_log', defaultMessage: 'Moderation Log' },
@ -18,6 +19,7 @@ const ModerationLog = () => {
const items = useAppSelector((state) => {
return state.admin_log.index.map((i) => state.admin_log.items.get(String(i)));
});
const hasMore = useAppSelector((state) => state.admin_log.total - state.admin_log.index.count() > 0);
const [isLoading, setIsLoading] = useState(true);
@ -54,26 +56,38 @@ const ModerationLog = () => {
emptyMessage={intl.formatMessage(messages.emptyMessage)}
hasMore={hasMore}
onLoadMore={handleLoadMore}
className='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
>
{items.map((item) => item && (
<div className='logentry' key={item.id}>
<div className='logentry__message'>{item.message}</div>
<div className='logentry__timestamp'>
<FormattedDate
value={new Date(item.time * 1000)}
hour12
year='numeric'
month='short'
day='2-digit'
hour='numeric'
minute='2-digit'
/>
</div>
</div>
{items.map(item => item && (
<LogItem key={item.id} log={item} />
))}
</ScrollableList>
</Column>
);
};
interface ILogItem {
log: AdminLog
}
const LogItem: React.FC<ILogItem> = ({ log }) => {
return (
<Stack space={2} className='p-4'>
<Text>{log.message}</Text>
<Text theme='muted' size='xs'>
<FormattedDate
value={new Date(log.time * 1000)}
hour12
year='numeric'
month='short'
day='2-digit'
hour='numeric'
minute='2-digit'
/>
</Text>
</Stack>
);
};
export default ModerationLog;

View File

@ -33,9 +33,12 @@ const AwaitingApproval: React.FC = () => {
showLoading={showLoading}
scrollKey='awaiting-approval'
emptyMessage={intl.formatMessage(messages.emptyMessage)}
className='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
>
{accountIds.map(id => (
<UnapprovedAccount accountId={id} key={id} />
<div key={id} className='py-4 px-5'>
<UnapprovedAccount accountId={id} />
</div>
))}
</ScrollableList>
);

Some files were not shown because too many files have changed in this diff Show More