Merge remote-tracking branch 'soapbox/develop' into lexical
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
CHANGELOG.md merge=union
|
||||
@ -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
|
||||
8
.gitlab/merge_request_templates/BeforeAndAfter.md
Normal file
8
.gitlab/merge_request_templates/BeforeAndAfter.md
Normal file
@ -0,0 +1,8 @@
|
||||
## Summary
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
|
||||
## Screenshots (if appropriate):
|
||||
| Before | After |
|
||||
| ------ | ----- |
|
||||
| | |
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@ -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
12
.vscode/settings.json
vendored
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
99
CHANGELOG.md
99
CHANGELOG.md
@ -4,6 +4,105 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Compatibility: rudimentary support for Takahē.
|
||||
|
||||
### Changed
|
||||
- Posts: letterbox images to 19:6 again.
|
||||
|
||||
### Fixed
|
||||
- Layout: use accent color for "floating action button" (mobile compose button).
|
||||
- ServiceWorker: don't serve favicon, robots.txt, and others from ServiceWorker.
|
||||
- Datepicker: correctly default to the current year.
|
||||
|
||||
## [3.0.0] - 2022-12-25
|
||||
|
||||
### Added
|
||||
- Editing: ability to edit posts and view edit history (on Rebased, Pleroma, and Mastodon).
|
||||
- Events: ability to create, view, and comment on Events (on Rebased).
|
||||
- Onboarding: display an introduction wizard to newly registered accounts.
|
||||
- Posts: translate foreign language posts into your native language (on Rebased, Mastodon; if configured by the admin).
|
||||
- Posts: ability to view quotes of a post (on Rebased).
|
||||
- Posts: hover the "replying to" line to see a preview card of the parent post.
|
||||
- Chats: ability to leave a chat (on Rebased, Truth Social).
|
||||
- Chats: ability to disable chats for yourself.
|
||||
- Layout: added right-to-left support for Arabic, Hebrew, Persian, and Central Kurdish languages.
|
||||
- Composer: support custom emoji categories.
|
||||
- Search: ability to search posts from a specific account (on Pleroma, Rebased).
|
||||
- Theme: auto-detect system theme by default.
|
||||
- Profile: remove a specific user from your followers (on Rebased, Mastodon).
|
||||
- Suggestions: ability to view all suggested profiles.
|
||||
- Feeds: display suggested accounts in Home feed (optional by admin).
|
||||
- Compatibility: added compatibility with Truth Social, Fedibird, Pixelfed, Akkoma, and Glitch.
|
||||
- Developers: added Test feed, Service Worker debugger, and Network Error preview.
|
||||
- Reports: display server rules in reports. Let users select rule violations when submitting a report.
|
||||
- Admin: added Theme Editor, a GUI for customizing the color scheme.
|
||||
- Admin: custom badges. Admins can add non-federating badges to any user's profile (on Rebased, Pleroma).
|
||||
- Admin: consolidated user dropdown actions (verify/suggest/etc) into a unified "Moderate User" modal.
|
||||
- i18n: updated translations for Italian, Polish, Arabic, Hebrew, and German.
|
||||
- Toast: added the ability to dismiss toast notifications.
|
||||
|
||||
### Changed
|
||||
- UI: the whole UI has been overhauled both inside and out. 97% of the codebase has been rewritten to TypeScript, and a new component library has been introduced with Tailwind CSS.
|
||||
- Chats: redesigned chats. Includes an improved desktop UI, unified chat widget, expanding textarea, and autosuggestions.
|
||||
- Lists: ability to edit and delete a list.
|
||||
- Settings: unified settings under one path with separate sections.
|
||||
- Posts: changed the thumbs-up icon to a heart.
|
||||
- Posts: move instance favicon beside username instead of post timestamp.
|
||||
- Posts: changed the behavior of content warnings. CWs and sensitive media are unified into one design.
|
||||
- Posts: redesigned interaction counters to use text instead of icons.
|
||||
- Posts: letterbox images taller than 1:1.
|
||||
- Profile: overhauled user profiles to be consistent with the rest of the UI.
|
||||
- Composer: move emoji button alongside other composer buttons, add numerical counter.
|
||||
- Birthdays: move today's birthdays out of notifications into right sidebar.
|
||||
- Performance: improve scrolling/navigation between feeds by using a virtual window library.
|
||||
- Admin: reorganize UI into 3-column layout.
|
||||
- Admin: include external link to frontend repo for the running commit.
|
||||
- Toast: redesigned toast notifications.
|
||||
|
||||
### Removed
|
||||
- Theme: Halloween theme.
|
||||
- Settings: advanced notification settings.
|
||||
- Settings: dyslexic mode.
|
||||
- Settings: demetricator.
|
||||
- Profile: ability to set and view private notes on an account.
|
||||
- Feeds: per-feed filters for replies, media, etc.
|
||||
- Backup and export functionality (for now).
|
||||
- Posts: hide non-emoji images embedded in post content.
|
||||
|
||||
### Security
|
||||
- Glitch Social: fixed XSS vulnerability on Glitch Social where custom emojis could be exploited to embed a script tag.
|
||||
|
||||
## [2.0.0] - 2022-05-01
|
||||
### Added
|
||||
- Quote Posting: repost with comment on Fedibird and Rebased.
|
||||
- Profile: ability to feature other users on your profile (on Rebased, Mastodon).
|
||||
- Profile: ability to add location to the user's profile (on Rebased, Truth Social).
|
||||
- Birthdays: ability to add a birthday to your profile (on Rebased, Pleroma).
|
||||
- Birthdays: support for age-gated registration if configured by the admin (on Rebased, Pleroma).
|
||||
- Birthdays: display today's birthdays in notifications.
|
||||
- Notifications: added unread badge to favicon when user has notifications.
|
||||
- Notifications: display full attachments in notifications instead of links.
|
||||
- Search: added a dedicated search page with prefilled suggestions.
|
||||
- Compatibility: improved support for Mastodon, added support for Mitra.
|
||||
- Ethereum: Metamask sign-in with Mitra.
|
||||
- i18n: added Shavian alphabet (`en-Shaw`) transliteration.
|
||||
- i18n: added Icelandic translation.
|
||||
|
||||
### Changed
|
||||
- Feeds: added gaps between posts in feeds.
|
||||
- Feeds: automatically load new posts when scrolled to the top of the feed.
|
||||
- Layout: improved design of top navigation bar.
|
||||
- Layout: add left sidebar navigation.
|
||||
- Icons: replaced Fork Awesome icons with Tabler icons.
|
||||
- Posts: moved mentions out of the post content into an area above the post for replies (on Pleroma and Rebased - Mastodon falls back to the old behavior).
|
||||
- Composer: use graphical ring counter for character count.
|
||||
|
||||
### Fixed
|
||||
- Multi-Account: fix switching between profiles on different servers with the same local username.
|
||||
|
||||
## [1.3.0] - 2021-07-02
|
||||
### Changed
|
||||
- Layout: show right sidebar on all pages.
|
||||
|
||||
213
README.md
213
README.md
@ -1,202 +1,81 @@
|
||||
# Soapbox
|
||||
|
||||

|
||||
|
||||
**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/>.
|
||||
|
||||
6
app/assets/sounds/LICENSE.md
Normal file
6
app/assets/sounds/LICENSE.md
Normal 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/).
|
||||
166
app/soapbox/__tests__/toast.test.tsx
Normal file
166
app/soapbox/__tests__/toast.test.tsx
Normal 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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -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);
|
||||
})
|
||||
|
||||
@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
746
app/soapbox/actions/events.ts
Normal file
746
app/soapbox/actions/events.ts
Normal 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,
|
||||
};
|
||||
@ -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 });
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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));
|
||||
});
|
||||
|
||||
@ -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[]) => ({
|
||||
|
||||
@ -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();
|
||||
},
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -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' : ''}`);
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -62,7 +62,6 @@ export const baseClient = (accessToken?: string | null, baseURL: string = ''): A
|
||||
headers: Object.assign(accessToken ? {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
} : {}),
|
||||
|
||||
transformResponse: [maybeParseJSON],
|
||||
});
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
|
||||
41
app/soapbox/components/autosuggest-location.tsx
Normal file
41
app/soapbox/components/autosuggest-location.tsx
Normal 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;
|
||||
@ -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';
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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))}
|
||||
|
||||
93
app/soapbox/components/event-preview.tsx
Normal file
93
app/soapbox/components/event-preview.tsx
Normal 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;
|
||||
@ -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]);
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
11
app/soapbox/components/link.tsx
Normal file
11
app/soapbox/components/link.tsx
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
110
app/soapbox/components/location-search.tsx
Normal file
110
app/soapbox/components/location-search.tsx
Normal 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;
|
||||
@ -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) */
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
43
app/soapbox/components/radio.tsx
Normal file
43
app/soapbox/components/radio.tsx
Normal 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,
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) && (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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>
|
||||
|
||||
31
app/soapbox/components/ui/combobox/combobox.css
Normal file
31
app/soapbox/components/ui/combobox/combobox.css
Normal 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;
|
||||
}
|
||||
10
app/soapbox/components/ui/combobox/combobox.tsx
Normal file
10
app/soapbox/components/ui/combobox/combobox.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import './combobox.css';
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxPopover,
|
||||
ComboboxList,
|
||||
ComboboxOption,
|
||||
ComboboxOptionText,
|
||||
} from '@reach/combobox';
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -14,6 +14,7 @@ const alignItemsOptions = {
|
||||
bottom: 'items-end',
|
||||
center: 'items-center',
|
||||
start: 'items-start',
|
||||
stretch: 'items-stretch',
|
||||
};
|
||||
|
||||
const spaces = {
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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] {
|
||||
|
||||
@ -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 />;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
124
app/soapbox/components/ui/slider/slider.tsx
Normal file
124
app/soapbox/components/ui/slider/slider.tsx
Normal 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;
|
||||
@ -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> {
|
||||
|
||||
@ -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;
|
||||
|
||||
145
app/soapbox/components/ui/toast/toast.tsx
Normal file
145
app/soapbox/components/ui/toast/toast.tsx
Normal 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;
|
||||
@ -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}
|
||||
|
||||
213
app/soapbox/components/upload.tsx
Normal file
213
app/soapbox/components/upload.tsx
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
|
||||
88
app/soapbox/contexts/chat-context.tsx
Normal file
88
app/soapbox/contexts/chat-context.tsx
Normal 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 };
|
||||
29
app/soapbox/contexts/stat-context.tsx
Normal file
29
app/soapbox/contexts/stat-context.tsx
Normal 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 };
|
||||
@ -2,6 +2,3 @@
|
||||
|
||||
import 'intersection-observer';
|
||||
import 'requestidlecallback';
|
||||
import objectFitImages from 'object-fit-images';
|
||||
|
||||
objectFitImages();
|
||||
|
||||
@ -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 }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
57
app/soapbox/features/admin/components/dashcounter.tsx
Normal file
57
app/soapbox/features/admin/components/dashcounter.tsx
Normal 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,
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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(() => {});
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
Reference in New Issue
Block a user