Compare commits

..

1 commit

Author SHA1 Message Date
clovis
da79aeb5cb [FIX] Fix crash when deleting scheduled post 2022-12-21 23:20:19 +01:00
205 changed files with 9057 additions and 8280 deletions

13
.github/FUNDING.yml vendored
View file

@ -1,13 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: 'bdxtown'
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View file

@ -1,20 +0,0 @@
name: Github Pages
on:
push:
branches: [ "gh-pages" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Find and Replace
uses: jacobtomlinson/gha-find-replace@v3
with:
find: "/packs/"
replace: "/Mangane/packs/"
regex: false
- name: Push changes
uses: ad-m/github-push-action@v0.6.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.ref }}

View file

@ -32,16 +32,12 @@ jobs:
- name: Build
run: |
# Build one time for release
export NODE_ENV=production
yarn
yarn build
mv static dist
zip -r static.zip dist
# Build one time for showcase
export NODE_ENV=development
export FE_SUBDIRECTORY=Mangane
yarn build
- name: Create Draft Release
id: create_release
@ -68,8 +64,3 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
release_id: ${{ steps.create_release.outputs.id }}
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@v4
with:
folder: static/Mangane

View file

@ -1,2 +0,0 @@
{
}

View file

@ -4,123 +4,6 @@ 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ē.
- UI: added backdrop blur behind modals.
- Admin: let admins configure media preview for attachment thumbnails.
- Login: accept `?server` param in external login, eg `fe.soapbox.pub/login/external?server=gleasonator.com`.
- Backups: restored Pleroma backups functionality.
- Export: restored "Export data" to CSV.
### Changed
- Posts: letterbox images to 19:6 again.
- Status Info: moved context (repost, pinned) to improve UX.
- Posts: remove file icon from empty link previews.
- Settings: moved "Import data" under settings.
### Fixed
- Layout: use accent color for "floating action button" (mobile compose button).
- ServiceWorker: don't serve favicon, robots.txt, and others from ServiceWorker.
- Datepicker: correctly default to the current year.
- Scheduled posts: fix page crashing on deleting a scheduled post.
- Events: don't crash when searching for a location.
- Search: fixes an abort error when using the navbar search component.
- Posts: fix monospace font in Markdown code blocks.
- Modals: fix action buttons overflow
- Editing: don't insert edited posts to the top of the feed.
- Modals: close modal when navigating to a different page.
- Modals: fix "View context" button in media modal.
- Posts: let unauthenticated users to translate posts if allowed by backend.
- Chats: fix jumpy scrollbar.
## [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.

49
COFE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,49 @@
```
o$$$$$$oo
o$" "$oo
$ o""""$o "$o
"$ o "o "o $
"$ $o $ $ o$
"$ o$"$ o$
"$ooooo$$ $ o$
o$ """ $ " $$$ " $
o$ $o $$" " "
$$ $ " $ $$$o"$ o o$"
$" o "" $ $" " o" $$
$o " " $ o$" o" o$"
"$o $$ $ o" o$$"
""o$o"$" $oo" o$"
o$$ $ $$$ o$$
o" o oo"" "" "$o
o$o" "" $
$" " o" " " " "o
$$ " " o$ o$o " $
o$ $ $ o$$ " " ""
o $ $" " "o o$
$ o $o$oo$""
$o $ o o o"$$
$o o $ $ "$o
$o $ o $ $ "o
$ $ "o $ "o"$o
$ " o $ o $$
$o$o$o$o$$o$$$o$$o$o$$o$$o$$$o$o$o$o$o$o$o$o$o$ooo
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$o
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ " $$$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ "$$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ $$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ $$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ $$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ o$$$$"
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ooooo$$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"""""
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$
$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"
"$o$o$o$o$o$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"
"$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"
"$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"""
"""""""""""""""""""""""""""""""""""""""""""""""""""""
```

226
README.md
View file

@ -1,46 +1,79 @@
# Mangane
# Mangane FE
Mangane is an alternative frontend for Pleroma, Akkoma and Mastodon with a focus on ease of use, readability and custom branding.
![UI Mixed](ui-mixed.png)
![UI Dark](ui-dark.png)
![UI Light](ui-light.png)
This project is developped for [BDX-town](http://bdx.town/) Akkoma instance. Akkoma is a fork of Pleroma who mostly adds features, exposing them through new API endpoints. As of today, Akkoma and Pleroma API are compatible.
**Mangane FE** is a frontend for Pleroma with a focus on custom branding and ease of use.
Mangane inherit from Pleroma the native large compatibility with Mastodon API.
## :rocket: Deploy on Pleroma/Akkoma
Moreover, Mangane already has a feature detection system allowing us to adapt the experience following what platform is used as a backend.
Installing Mangane FE on an existing Pleroma server is easy.
Log in your server and follow those instructions depending on your configuration.
We are speaking about Akkoma here since we are planning to add Akkoma specific features to the project without breaking any existing compatibility.
### Download
## Manifesto
First you need to download mangane on your server.
Mangane is a fork of an existing project driven by a fundamental disagreement regarding the opinions and actions of its maintainer. This manifesto serves as a declaration of our motivations and the principles that guide the development of Mangane.
#### OTP
### Our Vision
Mangane aims to provide a more accessible interface compared to the majority of existing software interfaces. We recognize that many platforms overlook the importance of user-friendliness and fail to incorporate familiar design patterns that users are accustomed to. By leveraging well-established user interface conventions, we strive to create an inclusive environment that welcomes users from diverse backgrounds and skill levels.
```
/opt/pleroma/bin/pleroma_ctl frontend install mangane --ref dist --build-url https://github.com/BDX-town/Mangane/releases/latest/download/static.zip
```
*Note: The pleroma_ctl path may vary on your system*
### Supporting Akkoma and Promoting Sustainability
One of the primary reasons Mangane embraces Akkoma is because of its alignment with our software's objectives. Akkoma has been chosen not only for its capabilities but also because it can operate efficiently on modest hardware configurations. This choice reflects our commitment to energy efficiency and sustainability, allowing users to engage with technology while minimizing their environmental impact.
#### Mix / Source
### Transparency and Accountability
We understand the importance of demonstrating our good intentions and the integrity of our project. To that end, we invite interested parties to explore the following resources as evidence of our commitment to ethical practices:
```
mix pleroma.frontend install mangane --ref dist --build-url https://github.com/BDX-town/Mangane/releases/latest/download/static.zip
```
* [Manifesto of bdx.town (available in French)](https://bdx.town/about)
* [Rules of bdx.town (available in French)](https://bdx.town/about/rules)
* [Publicly accessible blocklist of bdx.town](https://bdx.town/api/v1/instance) (pleroma -> metadata -> federation -> mrf_simple -> reject)
#### Admin-fe with database configuration enabled
These resources provide insight into the principles upheld by the individuals involved in Mangane and showcase our dedication to creating a safe and respectful digital environment.
Just fill the form at Frontend/Available like this.
![UI Mixed](./docs/ui-mixed.png)
![UI Dark](./docs/ui-dark.png)
![UI Light](./docs/ui-light.png)
![admin-fe](./admin-fe.png)
### Activation
Then you need to activate the frontend so it will be available to your users.
#### Config.exs
Edit your configuration files to add/edit the `config :pleroma, :frontends` section like this
```
config :pleroma, :frontends,
primary: %{
"name" => "mangane",
"ref" => "dist"
}
```
##### Admin-fe with database configuration enabled
Just fill the form at Frontend/frontends/Primary like this.
![admin-fe](./admin-fe2.png)
**That's it!** :tada:
**Mangane FE is installed.**
The change will take effect immediately, just refresh your browser tab.
You may need to restart pleroma/akkoma for the change to take effect.
## :elephant: Deploy on Mastodon
See [Installing Mangane over Mastodon](https://docs.soapbox.pub/frontend/administration/mastodon/).
## How does it work?
Mangane is a [single-page application (SPA)](https://en.wikipedia.org/wiki/Single-page_application) that runs entirely in the browser with JavaScript.
Mangane FE is a [single-page application (SPA)](https://en.wikipedia.org/wiki/Single-page_application) that runs entirely in the browser with JavaScript.
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).
Here is a simplified configuration example with Nginx:
Here is a simplified example with Nginx:
```nginx
location /api {
@ -53,91 +86,11 @@ location / {
}
```
(See [`mastodon.conf`](https://github.com/BDX-town/Mangane/blob/master/installation/mastodon.conf) file for a full example.)
(See [`mastodon.conf`](https://gitlab.com/soapbox-pub/soapbox-fe/-/blob/develop/installation/mastodon.conf) for a full example.)
Mangane incorporates much of the [Mastodon API](https://docs.joinmastodon.org/methods/), [Pleroma API](https://api.pleroma.social/), and more.
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.
# :rocket: Deploy on Pleroma/Akkoma
Installing Mangane on an existing Pleroma or Akkoma instance is easy.
Log in with SSH your server and follow those instructions depending on your configuration.
## Download
First you need to download Mangane on your server.
#### For OTP install
```sh
/opt/pleroma/bin/pleroma_ctl frontend install mangane --ref dist --build-url https://github.com/BDX-town/Mangane/releases/latest/download/static.zip
```
*Note: The pleroma_ctl path may vary on your system, if you are using Akkoma it's probably in /opt/akkoma/bin/*
#### For Mix/Source install
```sh
mix pleroma.frontend install mangane --ref dist --build-url https://github.com/BDX-town/Mangane/releases/latest/download/static.zip
```
#### With Admin FE
If database configuration is enabled, you can also install Mangane from the Admin interface of Pleroma/Akkoma.
Just fill the form at Frontend/Available like this.
![admin-fe](./docs/admin-ui-1.png)
### Activation
Then you need to activate the frontend so it will be available to your users.
#### With Config.exs file
Edit your configuration files to add/edit the `config :pleroma, :frontends` section like this
```
config :pleroma, :frontends,
primary: %{
"name" => "mangane",
"ref" => "dist"
}
```
#### With Admin FE (database configuration enabled)
Just fill the form at Frontend/frontends/Primary like this.
![admin-fe](./docs/admin-ui-2.png)
**That's it!** :tada:
Mangane is now installed.
The change will take effect immediately, just refresh your browser tab, and Mangane will replace the default Pleroma FE or Akkoma FE interface.
You may need to restart Pleroma/Akkoma for the change to take effect.
If you notice some issue with UI colors, please take a look at the Troubleshooting section.
## Install in other environments
#### Yunohost server
If you use Akkoma or Pleroma packaged application for [Yunohost](https://yunohost.org), a Debian system dedicated to self hosting, you can install Mangane from the command line `pleroma_ctl`) or with Pleromas admin interface (Admin FE). More instructionh can be found in [Installing on Yunohost](./docs/administration/yunohost.md) documentation page.
#### Deploy on Mastodon
Mangane is developed and tested only for Pleroma and Akkoma, this mean that there is _no_ explicit support to be installed as a frontend for a Mastodon instance. If you want to try anyway, procede with caution. See the Soapbox (version 2) outdated documentation on [installing over Mastodon](./docs/administration/mastodon.md).
# Upgrade
To upgrade Mangane, run the install commands again, on top of actual version.
```
/opt/pleroma/bin/pleroma_ctl frontend install mangane --ref dist --build-url https://github.com/BDX-town/Mangane/releases/latest/download/static.zip
```
If you want, you can also upgrade from the admin interface (Admin FE), doing a _new_ installation.
# Running locally
To get it running, just clone the repo:
@ -196,7 +149,7 @@ All configuration is optional, except `NODE_ENV`.
#### `NODE_ENV`
The Node environment.
Mangane checks for the following options:
Mangane FE checks for the following options:
- `development` - What you should use while developing Mangane FE.
- `production` - Use when compiling to deploy to a live server.
@ -251,63 +204,44 @@ NODE_ENV=development
# Contributing
We welcome contributions to this project. To contribute, first review the [Contributing doc](docs/contributing.md)
We welcome contributions to this project. To contribute, first review the [Contributing doc](docs/contributing.md)
Additional supporting documents include:
* [Mangane History](docs/history.md)
* Redux Store Map
* [Redux Store Map](docs/history.md)
# Customization
Mangane supports customization of the user interface, to allow per instance branding and other features. Current customization features include:
Mangane supports customization of the user interface, to allow per instance branding and other features. Current customization features include:
* Instance name, site logo and favicon.
* Custom pages: e.g About, Terms of Service page, Privacy Policy page, Copyright Policy (DMCA).
* Promo panel custom links (e.g. link to blog or documentation external site).
* Mangane extensions.
* Default instance settings (e.g. default theme).
* 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
* Mangane extensions, e.g. Patron module
* Default settings, e.g. default theme
Customization details can be found in the [Customization documentation](docs/customization.md)
# Troubleshooting
## Unable to upload some files (notably svg files)
It's a [known issue](https://git.pleroma.social/pleroma/pleroma/-/issues/2768#note_97928) with the `exiftool` filter.
To solve these upload problems, go to your admin-fe, search the upload section and remove `exiftool` from the enabled filters.
## Messy colors / style configuration
Akkoma recently changed their Content Security Policy (Content-Secutiry-Policy HTTP response header) to make it more strict.
If you notice any issue with your UI style configuration, please update your HTTP server configuration to override Akkoma's CSP header so the `style-src` section is set to `'self' 'unsafe-inline';`
Here is a example configuration for nginx:
```
# add style-src for mangane
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "upgrade-insecure-requests;script-src 'self';connect-src 'self' blob: https://example.com wss://example.com;media-src 'self' https:;img-src 'self' data: blob: https:;default-src 'none';base-uri 'self';frame-ancestors 'none';style-src 'self' 'unsafe-inline';font-src 'self';manifest-src 'self';" always;
```
*Please replace https://example.com with your own domain*
Customization details can be found in the [Customization doc](docs/customization.md)
# License & Credits
Mangane is free software: you can redistribute it and/or modify
Mangane FE is based on [Soapbox](https://soapbox.pub)'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.
Mangane FE is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Mangane is distributed in the hope that it will be useful,
Mangane FE 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
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Mangane. If not, see <https://www.gnu.org/licenses/>.
Mangane make use of code from other opensource and free software under various licenses:
- Mangane is a fork of [Soapbox](https://soapbox.pub) a frontend for Rebased, Pleroma and Mastodon, licensed under AGPL v3 or later.
- `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.
- [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) licensed by Tailwindlab under the simple permissive MIT License.
along with Mangane FE. If not, see <https://www.gnu.org/licenses/>.

BIN
admin-fe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
admin-fe2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -1 +0,0 @@
<svg class="h-5 w-5 text-gray-600 dark:text-gray-500 group-hover:text-primary-500 dark:group-hover:text-primary-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" data-testid="svg-icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M5.931 6.936l1.275 4.249m5.607 5.609l4.251 1.275"></path><path d="M11.683 12.317l5.759 -5.759"></path><circle cx="5.5" cy="5.5" r="1.5"></circle><circle cx="18.5" cy="5.5" r="1.5"></circle><circle cx="18.5" cy="18.5" r="1.5"></circle><circle cx="8.5" cy="15.5" r="4.5"></circle></svg>

Before

Width:  |  Height:  |  Size: 666 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 95 95" width="100" height="100"><path d="M94.909 19.374C94.909 8.674 86.235 0 75.534 0c-10.647 0-19.28 8.591-19.365 19.217l-15.631 2.09c-1.961-6.007-7.598-10.35-14.258-10.35-8.284 0-15.002 6.716-15.002 15.002 0 6.642 4.321 12.267 10.303 14.24l-2.205 16.056c-10.66.049-19.285 8.7-19.285 19.37C.091 86.325 8.765 95 19.466 95c10.677 0 19.332-8.638 19.37-19.304l18.093-2.501c1.979 5.972 7.598 10.285 14.234 10.285 8.284 0 15.002-6.716 15.002-15.002 0-6.891-4.652-12.682-10.983-14.441l1.365-15.339c10.229-.53 18.363-8.966 18.363-19.324zM56.194 67.8l-18.116 2.505a19.39 19.39 0 0 0-13.312-13.3l2.205-16.077a14.98 14.98 0 0 0 14.27-14.222l15.655-2.094c1.894 6.757 7.351 12.009 14.225 13.612l-1.365 15.322c-7.4.688-13.224 6.753-13.562 14.254z" fill="#ffffff"/></svg>

After

Width:  |  Height:  |  Size: 812 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 95 95" width="100" height="100"><path d="M94.909 19.374C94.909 8.674 86.235 0 75.534 0c-10.647 0-19.28 8.591-19.365 19.217l-15.631 2.09c-1.961-6.007-7.598-10.35-14.258-10.35-8.284 0-15.002 6.716-15.002 15.002 0 6.642 4.321 12.267 10.303 14.24l-2.205 16.056c-10.66.049-19.285 8.7-19.285 19.37C.091 86.325 8.765 95 19.466 95c10.677 0 19.332-8.638 19.37-19.304l18.093-2.501c1.979 5.972 7.598 10.285 14.234 10.285 8.284 0 15.002-6.716 15.002-15.002 0-6.891-4.652-12.682-10.983-14.441l1.365-15.339c10.229-.53 18.363-8.966 18.363-19.324zM56.194 67.8l-18.116 2.505a19.39 19.39 0 0 0-13.312-13.3l2.205-16.077a14.98 14.98 0 0 0 14.27-14.222l15.655-2.094c1.894 6.757 7.351 12.009 14.225 13.612l-1.365 15.322c-7.4.688-13.224 6.753-13.562 14.254z" fill="#0482d8"/></svg>

After

Width:  |  Height:  |  Size: 812 B

View file

@ -1,5 +1,5 @@
{
"logo": "/instance/images/mangane-logo.svg",
"logo": "/instance/images/soapbox-logo.svg",
"brandColor": "#0482d8",
"promoPanel": {
"items": [{

View file

@ -110,7 +110,7 @@
{
"tuple": [
":path",
"/api/v1/pleroma/app_metrics"
"/api/pleroma/app_metrics"
]
}
]

View file

@ -71,7 +71,7 @@
"column.mutes": "Muted users",
"column.notifications": "Alerts",
"column.preferences": "Preferences",
"column.public": "Discover",
"column.public": "Federated timeline",
"column.security": "Security",
"column_back_button.label": "Back",
"column_header.hide_settings": "Hide settings",

View file

@ -3000,7 +3000,7 @@
{
"tuple": [
":path",
"/api/v1/pleroma/app_metrics"
"/api/pleroma/app_metrics"
]
},
{

View file

@ -1,5 +1,5 @@
{
"/api/v1/pleroma/frontend_configurations": "eyJtYXN0b19mZSI6eyJzaG93SW5zdGFuY2VTcGVjaWZpY1BhbmVsIjp0cnVlfSwicGxlcm9tYV9mZSI6eyJhbHdheXNTaG93U3ViamVjdElucHV0Ijp0cnVlLCJiYWNrZ3JvdW5kIjoiL2ltYWdlcy9jaXR5LmpwZyIsImNvbGxhcHNlTWVzc2FnZVdpdGhTdWJqZWN0IjpmYWxzZSwiZGlzYWJsZUNoYXQiOmZhbHNlLCJncmVlbnRleHQiOmZhbHNlLCJoaWRlRmlsdGVyZWRTdGF0dXNlcyI6ZmFsc2UsImhpZGVNdXRlZFBvc3RzIjpmYWxzZSwiaGlkZVBvc3RTdGF0cyI6ZmFsc2UsImhpZGVTaXRlbmFtZSI6ZmFsc2UsImhpZGVVc2VyU3RhdHMiOmZhbHNlLCJsb2dpbk1ldGhvZCI6InBhc3N3b3JkIiwibG9nbyI6Ii9zdGF0aWMvbG9nby5zdmciLCJsb2dvTWFyZ2luIjoiLjFlbSIsImxvZ29NYXNrIjp0cnVlLCJtaW5pbWFsU2NvcGVzTW9kZSI6ZmFsc2UsIm5vQXR0YWNobWVudExpbmtzIjpmYWxzZSwibnNmd0NlbnNvckltYWdlIjoiIiwicG9zdENvbnRlbnRUeXBlIjoidGV4dC9wbGFpbiIsInJlZGlyZWN0Um9vdExvZ2luIjoiL21haW4vZnJpZW5kcyIsInJlZGlyZWN0Um9vdE5vTG9naW4iOiIvbWFpbi9hbGwiLCJzY29wZUNvcHkiOnRydWUsInNob3dGZWF0dXJlc1BhbmVsIjp0cnVlLCJzaG93SW5zdGFuY2VTcGVjaWZpY1BhbmVsIjpmYWxzZSwic2lkZWJhclJpZ2h0IjpmYWxzZSwic3ViamVjdExpbmVCZWhhdmlvciI6ImVtYWlsIiwidGhlbWUiOiJwbGVyb21hLWRhcmsiLCJ3ZWJQdXNoTm90aWZpY2F0aW9ucyI6ZmFsc2V9LCJzb2FwYm94X2ZlIjp7ImJyYW5kQ29sb3IiOiIjMWNhODJiIiwiY3J5cHRvQWRkcmVzc2VzIjpbeyJhZGRyZXNzIjoiYmMxcTljeDM1YWRwbTczYXEyZnc0MHllNnRzOGhmeHF6anI1dW53ZzBuIiwibm90ZSI6IiIsInRpY2tlciI6ImJ0YyJ9LHsiYWRkcmVzcyI6IjB4QWM5YUI1RmMwNERjMWNCMTc4OUFmNzViNTIzQmQyM0M3MEIyRDcxNyIsInRpY2tlciI6ImV0aCJ9LHsiYWRkcmVzcyI6IkQ1elZaczZqclJha2FQVkdpRXJrUWlIdDlzYXl6bTZWNUQiLCJ0aWNrZXIiOiJkb2dlIn0seyJhZGRyZXNzIjoiMHg1NDFhNDVjYjIxMmI1N2Y0MTM5MzQyN2ZiMTUzMzVmYzg5YzM1ODUxIiwidGlja2VyIjoidWJxIn0seyJhZGRyZXNzIjoiNDVKRENMcmpKNGJnVlVTYmJzMnlqeTltNU1mNFZMUFc4Zkc3anc5c3E1dTY5clhaWm9wUW9nWk5leVlrTUJuWHBrYWlwNHA0UXdhYUpOaGRUb3RQYTlnNDREQkN6ZEsiLCJub3RlIjoiIiwidGlja2VyIjoieG1yIn0seyJhZGRyZXNzIjoibHRjMXFkYTY0NWpkZjRqc3p3eGN2c24zMnlrZGhlbXZseDd5bDluNWd6OSIsIm5vdGUiOiIiLCJ0aWNrZXIiOiJsdGMifSx7ImFkZHJlc3MiOiJiaXRjb2luY2FzaDpxcGNmbm05dzh1ZW1heDM4eXFoeWc1OHpuMnB0cGY2c3p2a3IwbjQ4YTciLCJub3RlIjoiIiwidGlja2VyIjoiYmNoIn0seyJhZGRyZXNzIjoiWG5CNXA0SnZMM1NvOTFBMWMxTUVSb3paRWplTVNzQUQ3SiIsIm5vdGUiOiIiLCJ0aWNrZXIiOiJkYXNoIn0seyJhZGRyZXNzIjoidDFQSFpYNVpqWTd5NjFpQzE5QTk1OFc5aGR5SDNTaUxKdUYiLCJub3RlIjoiIiwidGlja2VyIjoiemVjIn0seyJhZGRyZXNzIjoiMHhCODFCQUVFMTBkMTYzNDA0YTFjNjAwNDVhODcyYTBkYTlFMjU4NDY1Iiwibm90ZSI6IiIsInRpY2tlciI6ImV0YyJ9LHsiYWRkcmVzcyI6IkFHVExSWGFwUFlweHQzUExkaVhFczh5NGtMdzZReTNDNHQiLCJub3RlIjoiIiwidGlja2VyIjoiYnRnIn0seyJhZGRyZXNzIjoiU2JRY0ZVRGk3a0t5eGttc2t6VzN3NzR4NjhINWVVcmc3NiIsIm5vdGUiOiIiLCJ0aWNrZXIiOiJkZ2IifSx7ImFkZHJlc3MiOiJON25vbXBVVnh6NUFUcnpSVlR6dzdDYUFKb1NpVnRFY1F4Iiwibm90ZSI6IiIsInRpY2tlciI6Im5tYyJ9LHsiYWRkcmVzcyI6IjNBUWNVZ0NiRjZ5bWlSNEhHQ1U4QU54OVNxYnpMNm54OHIiLCJub3RlIjoiIiwidGlja2VyIjoidnRjIn1dLCJjcnlwdG9Eb25hdGVQYW5lbCI6eyJsaW1pdCI6MX0sImRlZmF1bHRTZXR0aW5ncyI6eyJ0aGVtZU1vZGUiOiJsaWdodCJ9LCJleHRlbnNpb25zIjp7InBhdHJvbiI6eyJlbmFibGVkIjp0cnVlfX0sImdyZWVudGV4dCI6dHJ1ZSwibG9nbyI6Imh0dHBzOi8vbWVkaWEuZ2xlYXNvbmF0b3IuY29tLzBjNzYwYjNlY2RiYzk5M2JhNDdiNzg1ZDBhZGVjZjBlYzcxZmQ5YzU5ODA4ZTI3ZDA2NjViOWY3N2EzMmQ4ZGUucG5nIiwibmF2bGlua3MiOnsiaG9tZUZvb3RlciI6W3sidGl0bGUiOiJBYm91dCIsInVybCI6Ii9hYm91dCJ9LHsidGl0bGUiOiJUZXJtcyBvZiBTZXJ2aWNlIiwidXJsIjoiL2Fib3V0L3RvcyJ9LHsidGl0bGUiOiJQcml2YWN5IFBvbGljeSIsInVybCI6Ii9hYm91dC9wcml2YWN5In0seyJ0aXRsZSI6IkRNQ0EiLCJ1cmwiOiIvYWJvdXQvZG1jYSJ9LHsidGl0bGUiOiJTb3VyY2UgQ29kZSIsInVybCI6Ii9hYm91dCNvcGVuc291cmNlIn1dfSwicHJvbW9QYW5lbCI6eyJpdGVtcyI6W3siaWNvbiI6ImNvbW1lbnQtbyIsInRleHQiOiJHbGVhc29uYXRvciB0aGVtZSBzb25nIiwidXJsIjoiaHR0cHM6Ly9tZWRpYS5nbGVhc29uYXRvci5jb20vY3VzdG9tLzI2MTkwNV9nbGVhc29uYXRvcl9zb25nLm1wMyJ9XX0sInZlcmlmaWVkQ2FuRWRpdE5hbWUiOnRydWV9fQ==",
"/api/pleroma/frontend_configurations": "eyJtYXN0b19mZSI6eyJzaG93SW5zdGFuY2VTcGVjaWZpY1BhbmVsIjp0cnVlfSwicGxlcm9tYV9mZSI6eyJhbHdheXNTaG93U3ViamVjdElucHV0Ijp0cnVlLCJiYWNrZ3JvdW5kIjoiL2ltYWdlcy9jaXR5LmpwZyIsImNvbGxhcHNlTWVzc2FnZVdpdGhTdWJqZWN0IjpmYWxzZSwiZGlzYWJsZUNoYXQiOmZhbHNlLCJncmVlbnRleHQiOmZhbHNlLCJoaWRlRmlsdGVyZWRTdGF0dXNlcyI6ZmFsc2UsImhpZGVNdXRlZFBvc3RzIjpmYWxzZSwiaGlkZVBvc3RTdGF0cyI6ZmFsc2UsImhpZGVTaXRlbmFtZSI6ZmFsc2UsImhpZGVVc2VyU3RhdHMiOmZhbHNlLCJsb2dpbk1ldGhvZCI6InBhc3N3b3JkIiwibG9nbyI6Ii9zdGF0aWMvbG9nby5zdmciLCJsb2dvTWFyZ2luIjoiLjFlbSIsImxvZ29NYXNrIjp0cnVlLCJtaW5pbWFsU2NvcGVzTW9kZSI6ZmFsc2UsIm5vQXR0YWNobWVudExpbmtzIjpmYWxzZSwibnNmd0NlbnNvckltYWdlIjoiIiwicG9zdENvbnRlbnRUeXBlIjoidGV4dC9wbGFpbiIsInJlZGlyZWN0Um9vdExvZ2luIjoiL21haW4vZnJpZW5kcyIsInJlZGlyZWN0Um9vdE5vTG9naW4iOiIvbWFpbi9hbGwiLCJzY29wZUNvcHkiOnRydWUsInNob3dGZWF0dXJlc1BhbmVsIjp0cnVlLCJzaG93SW5zdGFuY2VTcGVjaWZpY1BhbmVsIjpmYWxzZSwic2lkZWJhclJpZ2h0IjpmYWxzZSwic3ViamVjdExpbmVCZWhhdmlvciI6ImVtYWlsIiwidGhlbWUiOiJwbGVyb21hLWRhcmsiLCJ3ZWJQdXNoTm90aWZpY2F0aW9ucyI6ZmFsc2V9LCJzb2FwYm94X2ZlIjp7ImJyYW5kQ29sb3IiOiIjMWNhODJiIiwiY3J5cHRvQWRkcmVzc2VzIjpbeyJhZGRyZXNzIjoiYmMxcTljeDM1YWRwbTczYXEyZnc0MHllNnRzOGhmeHF6anI1dW53ZzBuIiwibm90ZSI6IiIsInRpY2tlciI6ImJ0YyJ9LHsiYWRkcmVzcyI6IjB4QWM5YUI1RmMwNERjMWNCMTc4OUFmNzViNTIzQmQyM0M3MEIyRDcxNyIsInRpY2tlciI6ImV0aCJ9LHsiYWRkcmVzcyI6IkQ1elZaczZqclJha2FQVkdpRXJrUWlIdDlzYXl6bTZWNUQiLCJ0aWNrZXIiOiJkb2dlIn0seyJhZGRyZXNzIjoiMHg1NDFhNDVjYjIxMmI1N2Y0MTM5MzQyN2ZiMTUzMzVmYzg5YzM1ODUxIiwidGlja2VyIjoidWJxIn0seyJhZGRyZXNzIjoiNDVKRENMcmpKNGJnVlVTYmJzMnlqeTltNU1mNFZMUFc4Zkc3anc5c3E1dTY5clhaWm9wUW9nWk5leVlrTUJuWHBrYWlwNHA0UXdhYUpOaGRUb3RQYTlnNDREQkN6ZEsiLCJub3RlIjoiIiwidGlja2VyIjoieG1yIn0seyJhZGRyZXNzIjoibHRjMXFkYTY0NWpkZjRqc3p3eGN2c24zMnlrZGhlbXZseDd5bDluNWd6OSIsIm5vdGUiOiIiLCJ0aWNrZXIiOiJsdGMifSx7ImFkZHJlc3MiOiJiaXRjb2luY2FzaDpxcGNmbm05dzh1ZW1heDM4eXFoeWc1OHpuMnB0cGY2c3p2a3IwbjQ4YTciLCJub3RlIjoiIiwidGlja2VyIjoiYmNoIn0seyJhZGRyZXNzIjoiWG5CNXA0SnZMM1NvOTFBMWMxTUVSb3paRWplTVNzQUQ3SiIsIm5vdGUiOiIiLCJ0aWNrZXIiOiJkYXNoIn0seyJhZGRyZXNzIjoidDFQSFpYNVpqWTd5NjFpQzE5QTk1OFc5aGR5SDNTaUxKdUYiLCJub3RlIjoiIiwidGlja2VyIjoiemVjIn0seyJhZGRyZXNzIjoiMHhCODFCQUVFMTBkMTYzNDA0YTFjNjAwNDVhODcyYTBkYTlFMjU4NDY1Iiwibm90ZSI6IiIsInRpY2tlciI6ImV0YyJ9LHsiYWRkcmVzcyI6IkFHVExSWGFwUFlweHQzUExkaVhFczh5NGtMdzZReTNDNHQiLCJub3RlIjoiIiwidGlja2VyIjoiYnRnIn0seyJhZGRyZXNzIjoiU2JRY0ZVRGk3a0t5eGttc2t6VzN3NzR4NjhINWVVcmc3NiIsIm5vdGUiOiIiLCJ0aWNrZXIiOiJkZ2IifSx7ImFkZHJlc3MiOiJON25vbXBVVnh6NUFUcnpSVlR6dzdDYUFKb1NpVnRFY1F4Iiwibm90ZSI6IiIsInRpY2tlciI6Im5tYyJ9LHsiYWRkcmVzcyI6IjNBUWNVZ0NiRjZ5bWlSNEhHQ1U4QU54OVNxYnpMNm54OHIiLCJub3RlIjoiIiwidGlja2VyIjoidnRjIn1dLCJjcnlwdG9Eb25hdGVQYW5lbCI6eyJsaW1pdCI6MX0sImRlZmF1bHRTZXR0aW5ncyI6eyJ0aGVtZU1vZGUiOiJsaWdodCJ9LCJleHRlbnNpb25zIjp7InBhdHJvbiI6eyJlbmFibGVkIjp0cnVlfX0sImdyZWVudGV4dCI6dHJ1ZSwibG9nbyI6Imh0dHBzOi8vbWVkaWEuZ2xlYXNvbmF0b3IuY29tLzBjNzYwYjNlY2RiYzk5M2JhNDdiNzg1ZDBhZGVjZjBlYzcxZmQ5YzU5ODA4ZTI3ZDA2NjViOWY3N2EzMmQ4ZGUucG5nIiwibmF2bGlua3MiOnsiaG9tZUZvb3RlciI6W3sidGl0bGUiOiJBYm91dCIsInVybCI6Ii9hYm91dCJ9LHsidGl0bGUiOiJUZXJtcyBvZiBTZXJ2aWNlIiwidXJsIjoiL2Fib3V0L3RvcyJ9LHsidGl0bGUiOiJQcml2YWN5IFBvbGljeSIsInVybCI6Ii9hYm91dC9wcml2YWN5In0seyJ0aXRsZSI6IkRNQ0EiLCJ1cmwiOiIvYWJvdXQvZG1jYSJ9LHsidGl0bGUiOiJTb3VyY2UgQ29kZSIsInVybCI6Ii9hYm91dCNvcGVuc291cmNlIn1dfSwicHJvbW9QYW5lbCI6eyJpdGVtcyI6W3siaWNvbiI6ImNvbW1lbnQtbyIsInRleHQiOiJHbGVhc29uYXRvciB0aGVtZSBzb25nIiwidXJsIjoiaHR0cHM6Ly9tZWRpYS5nbGVhc29uYXRvci5jb20vY3VzdG9tLzI2MTkwNV9nbGVhc29uYXRvcl9zb25nLm1wMyJ9XX0sInZlcmlmaWVkQ2FuRWRpdE5hbWUiOnRydWV9fQ==",
"/api/v1/instance": "eyJhcHByb3ZhbF9yZXF1aXJlZCI6dHJ1ZSwiYXZhdGFyX3VwbG9hZF9saW1pdCI6MjAwMDAwMCwiYmFja2dyb3VuZF9pbWFnZSI6Imh0dHBzOi8vZ2xlYXNvbmF0b3IuY29tL2ltYWdlcy9jaXR5LmpwZyIsImJhY2tncm91bmRfdXBsb2FkX2xpbWl0Ijo0MDAwMDAwLCJiYW5uZXJfdXBsb2FkX2xpbWl0Ijo0MDAwMDAwLCJjaGF0X2xpbWl0Ijo1MDAwLCJkZXNjcmlwdGlvbiI6IkJ1aWxkaW5nIHRoZSBuZXh0IGdlbmVyYXRpb24gb2YgdGhlIEZlZGl2ZXJzZS4gU3BlYWsgZnJlZWx5LiIsImRlc2NyaXB0aW9uX2xpbWl0Ijo1MDAwLCJlbWFpbCI6ImFsZXhAYWxleGdsZWFzb24ubWUiLCJsYW5ndWFnZXMiOlsiZW4iXSwibWF4X3Rvb3RfY2hhcnMiOjUwMDAsInBsZXJvbWEiOnsibWV0YWRhdGEiOnsiYWNjb3VudF9hY3RpdmF0aW9uX3JlcXVpcmVkIjpmYWxzZSwiZmVhdHVyZXMiOlsicGxlcm9tYV9hcGkiLCJtYXN0b2Rvbl9hcGkiLCJtYXN0b2Rvbl9hcGlfc3RyZWFtaW5nIiwicG9sbHMiLCJwbGVyb21hX2V4cGxpY2l0X2FkZHJlc3NpbmciLCJzaGFyZWFibGVfZW1vamlfcGFja3MiLCJtdWx0aWZldGNoIiwicGxlcm9tYTphcGkvdjEvbm90aWZpY2F0aW9uczppbmNsdWRlX3R5cGVzX2ZpbHRlciIsIm1lZGlhX3Byb3h5IiwicmVsYXkiLCJwbGVyb21hX2Vtb2ppX3JlYWN0aW9ucyIsInBsZXJvbWFfY2hhdF9tZXNzYWdlcyIsImVtYWlsX2xpc3QiXSwiZmVkZXJhdGlvbiI6eyJlbmFibGVkIjp0cnVlLCJleGNsdXNpb25zIjpmYWxzZSwibXJmX3BvbGljaWVzIjpbIlRhZ1BvbGljeSIsIlNpbXBsZVBvbGljeSJdLCJtcmZfc2ltcGxlIjp7ImFjY2VwdCI6W10sImF2YXRhcl9yZW1vdmFsIjpbInBhd29vLm5ldCIsInNpbmJsci5jb20iLCJkYWppYXdlaWJvLmNvbSJdLCJiYW5uZXJfcmVtb3ZhbCI6WyJwYXdvby5uZXQiLCJzaW5ibHIuY29tIiwiZGFqaWF3ZWliby5jb20iXSwiZmVkZXJhdGVkX3RpbWVsaW5lX3JlbW92YWwiOltdLCJmb2xsb3dlcnNfb25seSI6W10sIm1lZGlhX25zZnciOltdLCJtZWRpYV9yZW1vdmFsIjpbInBhd29vLm5ldCIsInNpbmJsci5jb20iLCJkYWppYXdlaWJvLmNvbSJdLCJyZWplY3QiOltdLCJyZWplY3RfZGVsZXRlcyI6W10sInJlcG9ydF9yZW1vdmFsIjpbXX0sInF1YXJhbnRpbmVkX2luc3RhbmNlcyI6W119LCJmaWVsZHNfbGltaXRzIjp7Im1heF9maWVsZHMiOjE1LCJtYXhfcmVtb3RlX2ZpZWxkcyI6MjAsIm5hbWVfbGVuZ3RoIjo1MTIsInZhbHVlX2xlbmd0aCI6MjA0OH0sInBvc3RfZm9ybWF0cyI6WyJ0ZXh0L3BsYWluIiwidGV4dC9odG1sIiwidGV4dC9tYXJrZG93biIsInRleHQvYmJjb2RlIl19LCJzdGF0cyI6eyJtYXUiOjU0fSwidmFwaWRfcHVibGljX2tleSI6IkJMRWxMUVZKVm1ZX2U0RjVKb1l4STVqWGlWT1lOc0o5cC1hbWt5a2M5TmNJLWp3YTlUMVkyR0liRHFiWS1IcUM2YXlQa2ZXNEs0bzl2Z0JGS1lta3VTNCJ9LCJwb2xsX2xpbWl0cyI6eyJtYXhfZXhwaXJhdGlvbiI6MzE1MzYwMDAsIm1heF9vcHRpb25fY2hhcnMiOjIwMCwibWF4X29wdGlvbnMiOjIwLCJtaW5fZXhwaXJhdGlvbiI6MH0sInJlZ2lzdHJhdGlvbnMiOnRydWUsInNvYXBib3giOnsidmVyc2lvbiI6IjEuMS4xIn0sInN0YXRzIjp7ImRvbWFpbl9jb3VudCI6NzIwMCwic3RhdHVzX2NvdW50Ijo3ODkwNiwidXNlcl9jb3VudCI6MzU3fSwidGh1bWJuYWlsIjoiaHR0cHM6Ly9nbGVhc29uYXRvci5jb21odHRwczovL21lZGlhLmdsZWFzb25hdG9yLmNvbS9jMGQzOGJkZTZlZjBiM2JhYTQ4M2Y1NzQ3OTc2NjJlYmQ4M2VmOWUxYTExNjJlOGU0ZmNkOTMwYmI0YjNjMDY4LnBuZyIsInRpdGxlIjoiR2xlYXNvbmF0b3IiLCJ1cGxvYWRfbGltaXQiOjEwMDAwMDAwMCwidXJpIjoiaHR0cHM6Ly9nbGVhc29uYXRvci5jb20iLCJ1cmxzIjp7InN0cmVhbWluZ19hcGkiOiJ3c3M6Ly9nbGVhc29uYXRvci5jb20ifSwidmVyc2lvbiI6IjIuNy4yIChjb21wYXRpYmxlOyBQbGVyb21hIDIuMy4wLTExMS1nYjQ3OGE4N2UtZGV2ZWxvcCkifQ==",
"/instance/panel.html": "IjxkaXYgc3R5bGU9XCJtYXJnaW4tbGVmdDoxMnB4OyBtYXJnaW4tcmlnaHQ6MTJweFwiPlxuPHA+V2VsY29tZSB0byA8YSBocmVmPVwiaHR0cHM6Ly9wbGVyb21hLnNvY2lhbFwiIHRhcmdldD1cIl9ibGFua1wiPlBsZXJvbWEhPC9hPjwvcD4gICAgXG48cD48YSBocmVmPVwiL21haW4vYWxsXCI+UGxlcm9tYSBGRTwvYT4gfCA8YSBocmVmPVwiL3dlYlwiPk1hc3RvZG9uIEZFPC9hPjwvcD5cbjwvZGl2PlxuXG4i",
"/nodeinfo/2.0.json": "eyJtZXRhZGF0YSI6eyJhY2NvdW50QWN0aXZhdGlvblJlcXVpcmVkIjpmYWxzZSwiZmVhdHVyZXMiOlsicGxlcm9tYV9hcGkiLCJtYXN0b2Rvbl9hcGkiLCJtYXN0b2Rvbl9hcGlfc3RyZWFtaW5nIiwicG9sbHMiLCJwbGVyb21hX2V4cGxpY2l0X2FkZHJlc3NpbmciLCJzaGFyZWFibGVfZW1vamlfcGFja3MiLCJtdWx0aWZldGNoIiwicGxlcm9tYTphcGkvdjEvbm90aWZpY2F0aW9uczppbmNsdWRlX3R5cGVzX2ZpbHRlciIsIm1lZGlhX3Byb3h5IiwicmVsYXkiLCJwbGVyb21hX2Vtb2ppX3JlYWN0aW9ucyIsInBsZXJvbWFfY2hhdF9tZXNzYWdlcyIsImVtYWlsX2xpc3QiXSwiZmVkZXJhdGlvbiI6eyJlbmFibGVkIjp0cnVlLCJleGNsdXNpb25zIjpmYWxzZSwibXJmX3BvbGljaWVzIjpbIlRhZ1BvbGljeSIsIlNpbXBsZVBvbGljeSJdLCJtcmZfc2ltcGxlIjp7ImFjY2VwdCI6W10sImF2YXRhcl9yZW1vdmFsIjpbInBhd29vLm5ldCIsInNpbmJsci5jb20iLCJkYWppYXdlaWJvLmNvbSJdLCJiYW5uZXJfcmVtb3ZhbCI6WyJwYXdvby5uZXQiLCJzaW5ibHIuY29tIiwiZGFqaWF3ZWliby5jb20iXSwiZmVkZXJhdGVkX3RpbWVsaW5lX3JlbW92YWwiOltdLCJmb2xsb3dlcnNfb25seSI6W10sIm1lZGlhX25zZnciOltdLCJtZWRpYV9yZW1vdmFsIjpbInBhd29vLm5ldCIsInNpbmJsci5jb20iLCJkYWppYXdlaWJvLmNvbSJdLCJyZWplY3QiOltdLCJyZWplY3RfZGVsZXRlcyI6W10sInJlcG9ydF9yZW1vdmFsIjpbXX0sInF1YXJhbnRpbmVkX2luc3RhbmNlcyI6W119LCJmaWVsZHNMaW1pdHMiOnsibWF4RmllbGRzIjoxNSwibWF4UmVtb3RlRmllbGRzIjoyMCwibmFtZUxlbmd0aCI6NTEyLCJ2YWx1ZUxlbmd0aCI6MjA0OH0sImludml0ZXNFbmFibGVkIjpmYWxzZSwibWFpbGVyRW5hYmxlZCI6dHJ1ZSwibm9kZURlc2NyaXB0aW9uIjoiQnVpbGRpbmcgdGhlIG5leHQgZ2VuZXJhdGlvbiBvZiB0aGUgRmVkaXZlcnNlLiBTcGVhayBmcmVlbHkuIiwibm9kZU5hbWUiOiJHbGVhc29uYXRvciIsInBvbGxMaW1pdHMiOnsibWF4X2V4cGlyYXRpb24iOjMxNTM2MDAwLCJtYXhfb3B0aW9uX2NoYXJzIjoyMDAsIm1heF9vcHRpb25zIjoyMCwibWluX2V4cGlyYXRpb24iOjB9LCJwb3N0Rm9ybWF0cyI6WyJ0ZXh0L3BsYWluIiwidGV4dC9odG1sIiwidGV4dC9tYXJrZG93biIsInRleHQvYmJjb2RlIl0sInByaXZhdGUiOmZhbHNlLCJyZXN0cmljdGVkTmlja25hbWVzIjpbIi53ZWxsLWtub3duIiwifiIsImFib3V0IiwiYWN0aXZpdGllcyIsImFwaSIsImF1dGgiLCJjaGVja19wYXNzd29yZCIsImRldiIsImZyaWVuZC1yZXF1ZXN0cyIsImluYm94IiwiaW50ZXJuYWwiLCJtYWluIiwibWVkaWEiLCJub2RlaW5mbyIsIm5vdGljZSIsIm9hdXRoIiwib2JqZWN0cyIsIm9zdGF0dXNfc3Vic2NyaWJlIiwicGxlcm9tYSIsInByb3h5IiwicHVzaCIsInJlZ2lzdHJhdGlvbiIsInJlbGF5Iiwic2V0dGluZ3MiLCJzdGF0dXMiLCJ0YWciLCJ1c2VyLXNlYXJjaCIsInVzZXJfZXhpc3RzIiwidXNlcnMiLCJ3ZWIiLCJ2ZXJpZnlfY3JlZGVudGlhbHMiLCJ1cGRhdGVfY3JlZGVudGlhbHMiLCJyZWxhdGlvbnNoaXBzIiwic2VhcmNoIiwiY29uZmlybWF0aW9uX3Jlc2VuZCIsIm1mYSJdLCJza2lwVGhyZWFkQ29udGFpbm1lbnQiOnRydWUsInN0YWZmQWNjb3VudHMiOlsiaHR0cHM6Ly9nbGVhc29uYXRvci5jb20vdXNlcnMvYWxleCJdLCJzdWdnZXN0aW9ucyI6eyJlbmFibGVkIjpmYWxzZX0sInVwbG9hZExpbWl0cyI6eyJhdmF0YXIiOjIwMDAwMDAsImJhY2tncm91bmQiOjQwMDAwMDAsImJhbm5lciI6NDAwMDAwMCwiZ2VuZXJhbCI6MTAwMDAwMDAwfX0sIm9wZW5SZWdpc3RyYXRpb25zIjp0cnVlLCJwcm90b2NvbHMiOlsiYWN0aXZpdHlwdWIiXSwic2VydmljZXMiOnsiaW5ib3VuZCI6W10sIm91dGJvdW5kIjpbXX0sInNvZnR3YXJlIjp7Im5hbWUiOiJwbGVyb21hIiwidmVyc2lvbiI6IjIuMy4wLTExMS1nYjQ3OGE4N2UtZGV2ZWxvcCJ9LCJ1c2FnZSI6eyJsb2NhbFBvc3RzIjo3ODkwNiwidXNlcnMiOnsidG90YWwiOjM1N319LCJ2ZXJzaW9uIjoiMi4wIn0="

View file

@ -849,7 +849,7 @@ const unpinAccount = (id: string) =>
const updateNotificationSettings = (params: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: NOTIFICATION_SETTINGS_REQUEST, params });
return api(getState).put('/api/v1/pleroma/notification_settings', params).then(({ data }) => {
return api(getState).put('/api/pleroma/notification_settings', params).then(({ data }) => {
dispatch({ type: NOTIFICATION_SETTINGS_SUCCESS, params, data });
}).catch(error => {
dispatch({ type: NOTIFICATION_SETTINGS_FAIL, params, error });
@ -891,7 +891,7 @@ const fetchPinnedAccounts = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchPinnedAccountsRequest(id));
api(getState).get(`/api/v1/endorsements`).then(response => {
api(getState).get(`/api/v1/pleroma/accounts/${id}/endorsements`).then(response => {
dispatch(importFetchedAccounts(response.data));
dispatch(fetchPinnedAccountsSuccess(id, response.data, null));
}).catch(error => {

View file

@ -82,7 +82,7 @@ const fetchConfig = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_CONFIG_FETCH_REQUEST });
return api(getState)
.get('/api/v1/pleroma/admin/config')
.get('/api/pleroma/admin/config')
.then(({ data }) => {
dispatch({ type: ADMIN_CONFIG_FETCH_SUCCESS, configs: data.configs, needsReboot: data.need_reboot });
}).catch(error => {
@ -94,7 +94,7 @@ const updateConfig = (configs: Record<string, any>[]) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_CONFIG_UPDATE_REQUEST, configs });
return api(getState)
.post('/api/v1/pleroma/admin/config', { configs })
.post('/api/pleroma/admin/config', { configs })
.then(({ data }) => {
dispatch({ type: ADMIN_CONFIG_UPDATE_SUCCESS, configs: data.configs, needsReboot: data.need_reboot });
}).catch(error => {
@ -120,7 +120,7 @@ const fetchMastodonReports = (params: Record<string, any>) =>
const fetchPleromaReports = (params: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) =>
api(getState)
.get('/api/v1/pleroma/admin/reports', { params })
.get('/api/pleroma/admin/reports', { params })
.then(({ data: { reports } }) => {
reports.forEach((report: APIEntity) => {
dispatch(importFetchedAccount(report.account));
@ -169,7 +169,7 @@ const patchMastodonReports = (reports: { id: string, state: string }[]) =>
const patchPleromaReports = (reports: { id: string, state: string }[]) =>
(dispatch: AppDispatch, getState: () => RootState) =>
api(getState)
.patch('/api/v1/pleroma/admin/reports', { reports })
.patch('/api/pleroma/admin/reports', { reports })
.then(() => {
dispatch({ type: ADMIN_REPORTS_PATCH_SUCCESS, reports });
}).catch(error => {
@ -231,7 +231,7 @@ const fetchPleromaUsers = (filters: string[], page: number, query?: string | nul
if (query) params.query = query;
return api(getState)
.get('/api/v1/pleroma/admin/users', { params })
.get('/api/pleroma/admin/users', { params })
.then(({ data: { users, count, page_size: pageSize } }) => {
dispatch(fetchRelationships(users.map((user: APIEntity) => user.id)));
dispatch({ type: ADMIN_USERS_FETCH_SUCCESS, users, count, pageSize, filters, page });
@ -276,7 +276,7 @@ const deactivatePleromaUsers = (accountIds: string[]) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const nicknames = nicknamesFromIds(getState, accountIds);
return api(getState)
.patch('/api/v1/pleroma/admin/users/deactivate', { nicknames })
.patch('/api/pleroma/admin/users/deactivate', { nicknames })
.then(({ data: { users } }) => {
dispatch({ type: ADMIN_USERS_DEACTIVATE_SUCCESS, users, accountIds });
}).catch(error => {
@ -305,7 +305,7 @@ const deleteUsers = (accountIds: string[]) =>
const nicknames = nicknamesFromIds(getState, accountIds);
dispatch({ type: ADMIN_USERS_DELETE_REQUEST, accountIds });
return api(getState)
.delete('/api/v1/pleroma/admin/users', { data: { nicknames } })
.delete('/api/pleroma/admin/users', { data: { nicknames } })
.then(({ data: nicknames }) => {
dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames, accountIds });
}).catch(error => {
@ -329,7 +329,7 @@ const approvePleromaUsers = (accountIds: string[]) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const nicknames = nicknamesFromIds(getState, accountIds);
return api(getState)
.patch('/api/v1/pleroma/admin/users/approve', { nicknames })
.patch('/api/pleroma/admin/users/approve', { nicknames })
.then(({ data: { users } }) => {
dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users, accountIds });
}).catch(error => {
@ -357,7 +357,7 @@ const deleteStatus = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_STATUS_DELETE_REQUEST, id });
return api(getState)
.delete(`/api/v1/pleroma/admin/statuses/${id}`)
.delete(`/api/pleroma/admin/statuses/${id}`)
.then(() => {
dispatch({ type: ADMIN_STATUS_DELETE_SUCCESS, id });
}).catch(error => {
@ -369,7 +369,7 @@ const toggleStatusSensitivity = (id: string, sensitive: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST, id });
return api(getState)
.put(`/api/v1/pleroma/admin/statuses/${id}`, { sensitive: !sensitive })
.put(`/api/pleroma/admin/statuses/${id}`, { sensitive: !sensitive })
.then(() => {
dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS, id });
}).catch(error => {
@ -381,7 +381,7 @@ const fetchModerationLog = (params?: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_LOG_FETCH_REQUEST });
return api(getState)
.get('/api/v1/pleroma/admin/moderation_log', { params })
.get('/api/pleroma/admin/moderation_log', { params })
.then(({ data }) => {
dispatch({ type: ADMIN_LOG_FETCH_SUCCESS, items: data.items, total: data.total });
return data;
@ -484,7 +484,7 @@ const suggestUsers = (accountIds: string[]) =>
const nicknames = nicknamesFromIds(getState, accountIds);
dispatch({ type: ADMIN_USERS_SUGGEST_REQUEST, accountIds });
return api(getState)
.patch('/api/v1/pleroma/admin/users/suggest', { nicknames })
.patch('/api/pleroma/admin/users/suggest', { nicknames })
.then(({ data: { users } }) => {
dispatch({ type: ADMIN_USERS_SUGGEST_SUCCESS, users, accountIds });
}).catch(error => {
@ -497,7 +497,7 @@ const unsuggestUsers = (accountIds: string[]) =>
const nicknames = nicknamesFromIds(getState, accountIds);
dispatch({ type: ADMIN_USERS_UNSUGGEST_REQUEST, accountIds });
return api(getState)
.patch('/api/v1/pleroma/admin/users/unsuggest', { nicknames })
.patch('/api/pleroma/admin/users/unsuggest', { nicknames })
.then(({ data: { users } }) => {
dispatch({ type: ADMIN_USERS_UNSUGGEST_SUCCESS, users, accountIds });
}).catch(error => {

View file

@ -285,7 +285,7 @@ export const register = (params: Record<string, any>) =>
export const fetchCaptcha = () =>
(_dispatch: AppDispatch, getState: () => RootState) => {
return api(getState).get('/api/v1/pleroma/captcha');
return api(getState).get('/api/pleroma/captcha');
};
export const authLoggedIn = (token: Record<string, string | number>) =>

View file

@ -1,14 +1,11 @@
import { defineMessages } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import { getFilters } from 'soapbox/selectors';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features';
import api from '../api';
import { STATUS_APPLY_FILTERS } from './statuses';
import type { AppDispatch, RootState } from 'soapbox/store';
const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
@ -29,7 +26,7 @@ const messages = defineMessages({
});
const fetchFilters = () =>
async(dispatch: AppDispatch, getState: () => RootState) => {
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
@ -43,26 +40,19 @@ const fetchFilters = () =>
skipLoading: true,
});
try {
const { data } = await api(getState).get('/api/v1/filters');
await dispatch({
api(getState)
.get('/api/v1/filters')
.then(({ data }) => dispatch({
type: FILTERS_FETCH_SUCCESS,
filters: data,
skipLoading: true,
});
const filters = getFilters(getState(), null);
dispatch({
type: STATUS_APPLY_FILTERS,
filters,
});
} catch (err) {
dispatch({
}))
.catch(err => dispatch({
type: FILTERS_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
});
}
}));
};
const createFilter = (phrase: string, expires_at: string, context: Array<string>, whole_word: boolean, irreversible: boolean) =>

View file

@ -1,5 +1,3 @@
import { getFilters } from 'soapbox/selectors';
import { getSettings } from '../settings';
import type { AppDispatch, RootState } from 'soapbox/store';
@ -23,16 +21,14 @@ export function importAccounts(accounts: APIEntity[]) {
export function importStatus(status: APIEntity, idempotencyKey?: string) {
return (dispatch: AppDispatch, getState: () => RootState) => {
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
const filters = getFilters(getState(), null);
return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers, filters });
return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers });
};
}
export function importStatuses(statuses: APIEntity[]) {
return (dispatch: AppDispatch, getState: () => RootState) => {
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
const filters = getFilters(getState(), null);
return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers, filters });
return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers });
};
}

View file

@ -107,11 +107,9 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record<
filtered = regex && regex.test(searchIndex);
}
if (filtered) return;
// Desktop notifications
try {
if (showAlert) {
if (showAlert && !filtered) {
const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : '');
@ -130,7 +128,7 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record<
console.warn(e);
}
if (playSound) {
if (playSound && !filtered) {
dispatch({
type: NOTIFICATIONS_UPDATE_NOOP,
meta: { sound: 'boop' },

View file

@ -69,7 +69,7 @@ const revokeOAuthTokenById = (id: number) =>
const changePassword = (oldPassword: string, newPassword: string, confirmation: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: CHANGE_PASSWORD_REQUEST });
return api(getState).post('/api/v1/pleroma/change_password', {
return api(getState).post('/api/pleroma/change_password', {
password: oldPassword,
new_password: newPassword,
new_password_confirmation: confirmation,
@ -123,7 +123,7 @@ const resetPasswordConfirm = (password: string, token: string) =>
const changeEmail = (email: string, password: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: CHANGE_EMAIL_REQUEST, email });
return api(getState).post('/api/v1/pleroma/change_email', {
return api(getState).post('/api/pleroma/change_email', {
email,
password,
}).then(response => {
@ -144,7 +144,7 @@ const deleteAccount = (password: string) =>
const account = getLoggedInAccount(getState());
dispatch({ type: DELETE_ACCOUNT_REQUEST });
return api(getState).post('/api/v1/pleroma/delete_account', {
return api(getState).post('/api/pleroma/delete_account', {
password,
}).then(response => {
if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure
@ -160,7 +160,7 @@ const deleteAccount = (password: string) =>
const moveAccount = (targetAccount: string, password: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: MOVE_ACCOUNT_REQUEST });
return api(getState).post('/api/v1/pleroma/move_account', {
return api(getState).post('/api/pleroma/move_account', {
password,
target_account: targetAccount,
}).then(response => {

View file

@ -132,7 +132,6 @@ const defaultSettings = ImmutableMap({
}),
public: ImmutableMap({
bubble: true,
shows: ImmutableMap({
reblog: true,
reply: true,

View file

@ -97,7 +97,7 @@ const fetchSoapboxJson = (host: string | null) =>
const importSoapboxConfig = (soapboxConfig: APIEntity, host: string | null) => {
if (!soapboxConfig.brandColor) {
soapboxConfig.brandColor = '#F24173';
soapboxConfig.brandColor = '#0482d8';
}
return {
type: SOAPBOX_CONFIG_REQUEST_SUCCESS,

View file

@ -40,36 +40,13 @@ const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
const STATUS_TRANSLATE_REQUEST = 'STATUS_TRANSLATE_REQUEST';
const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
const STATUS_REVEAL = 'STATUS_REVEAL';
const STATUS_HIDE = 'STATUS_HIDE';
const STATUS_APPLY_FILTERS = 'STATUS_APPLY_FILTERS';
const statusExists = (getState: () => RootState, statusId: string) => {
return (getState().statuses.get(statusId) || null) !== null;
};
const translateStatus = (statusId: string, language: string) => {
return (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: STATUS_TRANSLATE_REQUEST, statusId });
return api(getState).request({
url: `/api/v1/statuses/${statusId}/translations/${language}`,
method: 'get',
}).then(({ data: translation }) => {
dispatch({ type: STATUS_TRANSLATE_SUCCESS, statusId, language, text: translation.text });
return translation;
}).catch(error => {
dispatch({ type: STATUS_TRANSLATE_FAIL, statusId, error });
throw error;
});
};
};
const createStatus = (params: Record<string, any>, idempotencyKey: string, statusId: string | null) => {
return (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: STATUS_CREATE_REQUEST, params, idempotencyKey, editing: !!statusId });
@ -124,7 +101,7 @@ const editStatus = (id: string) => (dispatch: AppDispatch, getState: () => RootS
api(getState).get(`/api/v1/statuses/${id}/source`).then(response => {
dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS });
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.content_type));
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, false));
dispatch(openModal('COMPOSE'));
}).catch(error => {
dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error });
@ -352,10 +329,6 @@ export {
STATUS_UNMUTE_FAIL,
STATUS_REVEAL,
STATUS_HIDE,
STATUS_TRANSLATE_REQUEST,
STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_FAIL,
STATUS_APPLY_FILTERS,
createStatus,
editStatus,
fetchStatus,
@ -372,5 +345,4 @@ export {
hideStatus,
revealStatus,
toggleStatusHidden,
translateStatus,
};

View file

@ -135,10 +135,6 @@ const connectUserStream = () =>
const connectCommunityStream = ({ onlyMedia }: Record<string, any> = {}) =>
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
// Bubble stream doesnt exists for now
// const connectBubbleStream = ({ onlyMedia }: Record<string, any> = {}) =>
// connectTimelineStream(`bubble${onlyMedia ? ':media' : ''}`, `bubble${onlyMedia ? ':media' : ''}`);
const connectPublicStream = ({ onlyMedia }: Record<string, any> = {}) =>
connectTimelineStream(`public${onlyMedia ? ':media' : ''}`, `public${onlyMedia ? ':media' : ''}`);
@ -169,5 +165,4 @@ export {
connectDirectStream,
connectListStream,
connectGroupStream,
// connectBubbleStream,
};

View file

@ -1,119 +0,0 @@
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features';
import api, { getNextLink } from '../api';
import type { AppDispatch, RootState } from 'soapbox/store';
const TAG_FETCH_REQUEST = 'TAG_FETCH_REQUEST';
const TAG_FETCH_SUCCESS = 'TAG_FETCH_SUCCESS';
const TAG_FETCH_FAIL = 'TAG_FETCH_FAIL';
const TAG_FOLLOW_REQUEST = 'TAG_FOLLOW_REQUEST';
const TAG_FOLLOW_SUCCESS = 'TAG_FOLLOW_SUCCESS';
const TAG_FOLLOW_FAIL = 'TAG_FOLLOW_FAIL';
const TAG_UNFOLLOW_REQUEST = 'TAG_UNFOLLOW_REQUEST';
const TAG_UNFOLLOW_SUCCESS = 'TAG_UNFOLLOW_SUCCESS';
const TAG_UNFOLLOW_FAIL = 'TAG_UNFOLLOW_FAIL';
const fetchTags = () => async(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (!features.followTags) return;
dispatch({ type: TAG_FETCH_REQUEST, skipLoading: true });
try {
let next = null;
let tags = [];
do {
const response = await api(getState).get(next || '/api/v1/followed_tags');
tags = [...tags, ...response.data];
next = getNextLink(response);
} while (next);
dispatch({
type: TAG_FETCH_SUCCESS,
tags,
skipLoading: true,
});
} catch (err) {
dispatch({
type: TAG_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
});
}
};
const followTag = (tagId: string) => async(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const features = getFeatures(state.instance);
if (!features.followTags) return;
dispatch({ type: TAG_FOLLOW_REQUEST });
try {
const { data } = await api(getState).post(`/api/v1/tags/${tagId}/follow`);
dispatch({
type: TAG_FOLLOW_SUCCESS,
tag: data,
});
} catch (err) {
dispatch({
type: TAG_FOLLOW_FAIL,
err,
});
}
};
const unfollowTag = (tagId: string) => async(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const features = getFeatures(state.instance);
if (!features.followTags) return;
dispatch({ type: TAG_UNFOLLOW_REQUEST });
try {
const { data } = await api(getState).post(`/api/v1/tags/${tagId}/unfollow`);
dispatch({
type: TAG_UNFOLLOW_SUCCESS,
tag: data,
});
} catch (err) {
dispatch({
type: TAG_UNFOLLOW_FAIL,
err,
});
}
};
export {
fetchTags,
TAG_FETCH_FAIL,
TAG_FETCH_REQUEST,
TAG_FETCH_SUCCESS,
followTag,
TAG_FOLLOW_FAIL,
TAG_FOLLOW_REQUEST,
TAG_FOLLOW_SUCCESS,
unfollowTag,
TAG_UNFOLLOW_FAIL,
TAG_UNFOLLOW_REQUEST,
TAG_UNFOLLOW_SUCCESS,
};

View file

@ -195,9 +195,6 @@ const expandHomeTimeline = ({ accountId, maxId }: Record<string, any> = {}, done
const expandPublicTimeline = ({ maxId, onlyMedia }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
const expandBubbleTimeline = ({ maxId, onlyMedia }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`bubble${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/bubble', { max_id: maxId, only_media: !!onlyMedia }, done);
const expandRemoteTimeline = (instance: string, { maxId, onlyMedia }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`remote${onlyMedia ? ':media' : ''}:${instance}`, '/api/v1/timelines/public', { local: false, instance: instance, max_id: maxId, only_media: !!onlyMedia }, done);
@ -245,7 +242,6 @@ const expandTimelineSuccess = (timeline: string, statuses: APIEntity[], next: st
partial,
isLoadingRecent,
skipLoading: !isLoadingMore,
isLoadingMore,
});
const expandTimelineFail = (timeline: string, error: AxiosError, isLoadingMore: boolean) => ({
@ -322,5 +318,4 @@ export {
scrollTopTimeline,
insertSuggestionsIntoTimeline,
clearFeedAccountId,
expandBubbleTimeline,
};

View file

@ -6,14 +6,13 @@ import VerificationBadge from 'soapbox/components/verification_badge';
import ActionButton from 'soapbox/features/ui/components/action-button';
import { useAppSelector, useOnScreen } from 'soapbox/hooks';
import { getAcct } from 'soapbox/utils/accounts';
import { EmojiReact as EmojiReactType } from 'soapbox/utils/emoji_reacts';
import { displayFqn } from 'soapbox/utils/state';
import RelativeTimestamp from './relative_timestamp';
import { Avatar, EmojiReact, HStack, Icon, IconButton, Stack, Text } from './ui';
import type { Account as AccountEntity } from 'soapbox/types/entities';
import { EmojiReact as EmojiReactType } from 'soapbox/utils/emoji_reacts';
interface IInstanceFavicon {
@ -164,9 +163,9 @@ const Account = ({
const LinkEl: any = withLinkToProfile ? Link : 'div';
return (
<div data-testid='account' className='shrink-0 group block w-full' ref={overflowRef}>
<div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}>
<HStack alignItems={actionAlignment} justifyContent='between'>
<HStack className='grow min-w-0' alignItems={withAccountNote ? 'top' : 'center'} space={3}>
<HStack alignItems={withAccountNote ? 'top' : 'center'} space={3}>
<ProfilePopper
condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
@ -186,7 +185,7 @@ const Account = ({
</LinkEl>
</ProfilePopper>
<div className='grow min-w-0'>
<div className='flex-grow'>
<ProfilePopper
condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>}
@ -210,7 +209,7 @@ const Account = ({
</ProfilePopper>
<Stack space={withAccountNote ? 1 : 0}>
<HStack className='grow' alignItems='center' space={1} style={style}>
<HStack alignItems='center' space={1} style={style}>
<Text theme='muted' size='sm' truncate>@{username}</Text>
{account.favicon && (

View file

@ -30,7 +30,7 @@ const AnnouncementContent: React.FC<IAnnouncementContent> = ({ announcement }) =
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
e.stopPropagation();
history.push(`/tag/${hashtag}`);
history.push(`/tags/${hashtag}`);
}
};

View file

@ -3,7 +3,7 @@ import React from 'react';
import { TransitionMotion, spring } from 'react-motion';
import { Icon } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/containers/emoji_picker_dropdown_container';
import EmojiPickerDropdown from 'soapbox/features/compose/containers/emoji_picker_dropdown_container';
import { useSettings } from 'soapbox/hooks';
import Reaction from './reaction';

View file

@ -4,10 +4,10 @@ import { useDispatch } from 'react-redux';
import { unblockDomain } from 'soapbox/actions/domain_blocks';
import Icon from './icon';
import { Button } from './ui';
import IconButton from './icon_button';
const messages = defineMessages({
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
});
@ -19,27 +19,29 @@ const Domain: React.FC<IDomain> = ({ domain }) => {
const dispatch = useDispatch();
const intl = useIntl();
// const onBlockDomain = () => {
// dispatch(openModal('CONFIRM', {
// icon: require('@tabler/icons/ban.svg'),
// heading: <FormattedMessage id='confirmations.domain_block.heading' defaultMessage='Block {domain}' values={{ domain }} />,
// message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />,
// confirm: intl.formatMessage(messages.blockDomainConfirm),
// onConfirm: () => dispatch(blockDomain(domain)),
// }));
// }
const handleDomainUnblock = () => {
dispatch(unblockDomain(domain));
};
return (
<div className='domain'>
<div className='domain__wrapper flex justify-between items-center'>
<div className='flex gap-1 items-center'>
<Icon src={require('@tabler/icons/world.svg')} />
<strong className='text-gray-700 dark:text-white'>{domain}</strong>
</div>
<div className='domain__wrapper'>
<span className='domain__domain-name'>
<strong>{domain}</strong>
</span>
<div className='domain__buttons'>
<Button theme='ghost' onClick={handleDomainUnblock}>
<div className='flex gap-1 items-center'>
<Icon src={require('@tabler/icons/lock-open.svg')} />
<span>
{ intl.formatMessage(messages.unblockDomain) }
</span>
</div>
</Button>
<IconButton active src={require('@tabler/icons/lock-open.svg')} title={intl.formatMessage(messages.unblockDomain, { domain })} onClick={handleDomainUnblock} />
</div>
</div>
</div>

View file

@ -1,161 +0,0 @@
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import classNames from 'classnames';
import { List as ImmutableList } from 'immutable';
import React, { MouseEventHandler } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { IconButton } from 'soapbox/components/ui';
import { useTheme } from 'soapbox/hooks';
import { isMobile } from 'soapbox/is_mobile';
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
people: { id: 'emoji_button.people', defaultMessage: 'People' },
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
skins: { id: 'emoji_button.skins', defaultMessage: 'Skins' },
});
interface IWrapper {
target: any,
show: boolean,
onClose: MouseEventHandler,
children: React.ReactNode,
}
const Wrapper: React.FC<IWrapper> = ({ target, show, onClose, children }) => {
if (!show) return null;
return (
<div className='emoji-picker fixed top-0 left-0 w-screen h-screen bg-gray-800 z-[100]'>
<div className='bg-white dark:bg-slate-800 flex flex-col overflow-hidden sm:rounded-lg absolute top-1/2 left-1/2 -translate-x-[50%] -translate-y-[50%] '>
<div className='p-1'>
<IconButton
className='ml-auto text-gray-500'
src={require('@tabler/icons/x.svg')}
onClick={onClose}
/>
</div>
{ children }
</div>
</div>
);
};
interface IEmojiPicker {
custom_emojis?: ImmutableList<string>,
button?: React.ReactNode,
onPickEmoji: Function,
}
const EmojiPickerUI : React.FC<IEmojiPicker> = ({
custom_emojis = ImmutableList(),
button,
onPickEmoji,
}) => {
const root = React.useRef<HTMLDivElement>(null);
const [active, setActive] = React.useState(false);
const intl = useIntl();
const theme = useTheme();
const handleClose = React.useCallback((e = null) => {
if (e) {
e.stopPropagation();
}
setActive(false);
}, []);
const handleToggle = React.useCallback((e) => {
e.stopPropagation();
if (e.key === 'Escape') {
setActive(false);
return;
}
if ((!e.key || e.key === 'Enter')) {
setActive(!active);
}
}, [active]);
const buildCustomEmojis = React.useCallback((custom_emojis: ImmutableList<any>) => {
const emojis = custom_emojis.map((emoji) => (
{
id: emoji.get('shortcode'),
name: emoji.get('shortcode'),
keywords: [emoji.get('shortcode')],
skins: [{ src: emoji.get('static_url') }],
}
)).toJS();
return [{
id: 'custom',
name: intl.formatMessage(messages.custom),
emojis,
}];
}, []);
const handlePick = React.useCallback((emoji) => {
onPickEmoji({ ...emoji, native: emoji.native || emoji.shortcodes });
handleClose();
}, [handleClose, onPickEmoji]);
return (
<>
<div className='relative' ref={root} onKeyDown={handleToggle}>
<div role='button' tabIndex={0} onClick={handleToggle}>
{
button || <IconButton
className={classNames({
'text-gray-400 hover:text-gray-600': true,
})}
alt='😀'
src={require('@tabler/icons/mood-happy.svg')}
/>
}
</div>
<Wrapper target={root} show={active} onClose={handleClose}>
<Picker
theme={theme}
dynamicWidth={isMobile(window.innerWidth)}
categories={['frequent', 'custom', 'people', 'nature', 'foods', 'activity', 'places', 'objects', 'symbols', 'flags']}
previewPosition='none'
custom={buildCustomEmojis(custom_emojis)}
data={data}
onEmojiSelect={handlePick}
i18n={
{
search: intl.formatMessage(messages.emoji_search),
notfound: intl.formatMessage(messages.emoji_not_found),
skins: intl.formatMessage(messages.skins),
categories: {
search: intl.formatMessage(messages.search_results),
recent: intl.formatMessage(messages.recent),
people: intl.formatMessage(messages.people),
nature: intl.formatMessage(messages.nature),
foods: intl.formatMessage(messages.food),
activity: intl.formatMessage(messages.activity),
places: intl.formatMessage(messages.travel),
objects: intl.formatMessage(messages.objects),
symbols: intl.formatMessage(messages.symbols),
flags: intl.formatMessage(messages.flags),
custom: intl.formatMessage(messages.custom),
},
}
}
/>
</Wrapper>
</div>
</>
);
};
export default EmojiPickerUI;

View file

@ -23,7 +23,7 @@ const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
return (
<HStack alignItems='center' justifyContent='between' data-testid='hashtag'>
<Stack>
<Permalink href={hashtag.url} to={`/tag/${hashtag.name}`} className='hover:underline'>
<Permalink href={hashtag.url} to={`/tags/${hashtag.name}`} className='hover:underline'>
<Text tag='span' size='sm' weight='semibold'>#{hashtag.name}</Text>
</Permalink>

View file

@ -598,7 +598,7 @@ class MediaGallery extends React.PureComponent {
return (
<div className={classNames('media-gallery', { 'media-gallery--compact': compact })} style={sizeData.get('style')} ref={this.handleRef}>
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible || compact })}>
{(
{sensitive && (
(visible || compact) ? (
<Button
text={intl.formatMessage(messages.toggle_visible)}
@ -612,14 +612,11 @@ class MediaGallery extends React.PureComponent {
<div className='p-4 rounded-xl shadow-xl backdrop-blur-sm bg-white/75 dark:bg-slate-800/75 text-center inline-block space-y-4 max-w-[280px]'>
<div className='space-y-1'>
<Text weight='semibold'>{warning}</Text>
{
sensitive && (
<Text size='sm'>
<FormattedMessage id='status.sensitive_warning.subtitle' defaultMessage='This content may not be suitable for all audiences.' />
</Text>
)
}
<Text size='sm'>
<FormattedMessage id='status.sensitive_warning.subtitle' defaultMessage='This content may not be suitable for all audiences.' />
</Text>
</div>
<Button type='button' theme='primary' size='sm' icon={require('@tabler/icons/eye.svg')}>
<FormattedMessage id='status.sensitive_warning.action' defaultMessage='Show content' />
</Button>

View file

@ -37,7 +37,14 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
intl.formatMessage(messages.closed) :
<RelativeTimestamp weight='medium' timestamp={poll.expires_at} futureDate />;
const votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
let votesCount = null;
if (poll.voters_count !== null && poll.voters_count !== undefined) {
votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />;
} else {
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
}
return (
<Stack space={4} data-testid='poll-footer'>
@ -51,35 +58,35 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
{poll.pleroma.get('non_anonymous') && (
<>
<Tooltip text={intl.formatMessage(messages.nonAnonymous)}>
<Text tag='span' theme='muted' weight='medium'>
<Text theme='muted' weight='medium'>
<FormattedMessage id='poll.non_anonymous' defaultMessage='Public poll' />
</Text>
</Tooltip>
<Text tag='span' theme='muted'>&middot;</Text>
<Text theme='muted'>&middot;</Text>
</>
)}
{showResults && (
<>
<button className='text-gray-600 underline' onClick={handleRefresh} data-testid='poll-refresh'>
<Text tag='span' theme='muted' weight='medium'>
<Text theme='muted' weight='medium'>
<FormattedMessage id='poll.refresh' defaultMessage='Refresh' />
</Text>
</button>
<Text tag='span' theme='muted'>&middot;</Text>
<Text theme='muted'>&middot;</Text>
</>
)}
<Text tag='span' theme='muted' weight='medium'>
<Text theme='muted' weight='medium'>
{votesCount}
</Text>
{poll.expires_at && (
<>
<Text tag='span' theme='muted'>&middot;</Text>
<Text tag='span' weight='medium' theme='muted' data-testid='poll-expiration'>{timeRemaining}</Text>
<Text theme='muted'>&middot;</Text>
<Text weight='medium' theme='muted' data-testid='poll-expiration'>{timeRemaining}</Text>
</>
)}
</HStack>

View file

@ -114,7 +114,7 @@ const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
if (!poll) return null;
const pollVotesCount = poll.votes_count;
const pollVotesCount = poll.voters_count || poll.votes_count;
const percent = pollVotesCount === 0 ? 0 : (option.votes_count / pollVotesCount) * 100;
const leading = poll.options.filterNot(other => other.title === option.title).every(other => option.votes_count >= other.votes_count);
const voted = poll.own_votes?.includes(index);

View file

@ -1,3 +1,4 @@
import classNames from 'classnames';
import throttle from 'lodash/throttle';
import React, { useState, useEffect, useCallback } from 'react';
import { useIntl, MessageDescriptor } from 'react-intl';
@ -30,38 +31,38 @@ const ScrollTopButton: React.FC<IScrollTopButton> = ({
const intl = useIntl();
const settings = useSettings();
const timer = React.useRef(null);
const [scrolled, setScrolled] = useState<boolean>(false);
const autoload = settings.get('autoloadTimelines') === true;
const getScrollTop = React.useCallback((): number => {
return (document.scrollingElement || document.documentElement).scrollTop;
}, []);
const visible = count > 0 && scrolled;
const maybeUnload = React.useCallback(() => {
// we need to add a timer since there is a delay between content render and
// scroll top calculation. Without it, new content is always loaded because
// scrollTop is 0 at first.
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
if (count > 0 && autoload && getScrollTop() <= autoloadThreshold) {
onClick();
}
timer.current = null;
}, 250);
}, [autoload, autoloadThreshold, onClick, count]);
const classes = classNames('left-1/2 -translate-x-1/2 fixed top-20 z-50', {
'hidden': !visible,
});
const getScrollTop = (): number => {
return (document.scrollingElement || document.documentElement).scrollTop;
};
const maybeUnload = () => {
if (autoload && getScrollTop() <= autoloadThreshold) {
onClick();
}
};
const handleScroll = useCallback(throttle(() => {
maybeUnload();
if (getScrollTop() > threshold) {
setScrolled(true);
} else {
setScrolled(false);
}
}, 150, { trailing: true }), [threshold]);
}, 150, { trailing: true }), [autoload, threshold, autoloadThreshold, onClick]);
const scrollUp = React.useCallback(() => {
const scrollUp = () => {
window.scrollTo({ top: 0 });
}, []);
};
const handleClick: React.MouseEventHandler = () => {
setTimeout(scrollUp, 10);
@ -78,23 +79,19 @@ const ScrollTopButton: React.FC<IScrollTopButton> = ({
useEffect(() => {
maybeUnload();
}, [maybeUnload]);
const visible = React.useMemo(() => count > 0 && scrolled, [count, scrolled]) ;
if (!visible) return null;
}, [count]);
return (
<div className='left-1/2 -translate-x-1/2 fixed top-20 z-50'>
<button
className='flex items-center bg-primary-600 hover:bg-primary-700 hover:scale-105 active:scale-100 transition-transform text-white rounded-full px-4 py-2 space-x-1.5 cursor-pointer whitespace-nowrap'
onClick={handleClick}
>
<div className={classes}>
<a className='flex items-center bg-primary-600 hover:bg-primary-700 hover:scale-105 active:scale-100 transition-transform text-white rounded-full px-4 py-2 space-x-1.5 cursor-pointer whitespace-nowrap' onClick={handleClick}>
<Icon src={require('@tabler/icons/arrow-bar-to-up.svg')} />
<Text theme='inherit' size='sm'>
{intl.formatMessage(message, { count })}
</Text>
</button>
{(count > 0) && (
<Text theme='inherit' size='sm'>
{intl.formatMessage(message, { count })}
</Text>
)}
</a>
</div>
);
};

View file

@ -18,11 +18,10 @@ const messages = defineMessages({
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
dashboard: { id: 'tabs_bar.dashboard', defaultMessage: 'Dashboard' },
all: { id: 'tabs_bar.all', defaultMessage: 'All' },
fediverse: { id: 'tabs_bar.fediverse', defaultMessage: 'Explore' },
fediverse: { id: 'tabs_bar.fediverse', defaultMessage: 'Fediverse' },
settings: { id: 'tabs_bar.settings', defaultMessage: 'Settings' },
direct: { id: 'column.direct', defaultMessage: 'Direct messages' },
directory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' },
tags: { id: 'navigation_bar.tags', defaultMessage: 'Hashtags' },
});
/** Desktop sidebar with links to different views in the app. */
@ -39,7 +38,6 @@ const SidebarNavigation = () => {
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
const features = getFeatures(instance);
const bubbleTimeline = features.bubbleTimeline && settings.getIn(['public', 'bubble']);
const makeMenu = (): Menu => {
const menu: Menu = [];
@ -54,15 +52,11 @@ const SidebarNavigation = () => {
});
}
// we only want to add this option is it's not already shown
// so only when chats are supported
if (features.chats) {
menu.push({
to: '/messages',
text: intl.formatMessage(messages.direct),
icon: require('@tabler/icons/mail.svg'),
});
}
menu.push({
to: '/messages',
text: intl.formatMessage(messages.direct),
icon: require('@tabler/icons/mail.svg'),
});
if (features.bookmarks) {
menu.push({
@ -80,15 +74,7 @@ const SidebarNavigation = () => {
});
}
if (features.followTags) {
menu.push({
to: '/followed_hashtags',
text: intl.formatMessage(messages.tags),
icon: require('@tabler/icons/hash.svg'),
});
}
if (features.profileDirectory) {
if(features.profileDirectory) {
menu.push({
to: '/directory',
text: intl.formatMessage(messages.directory),
@ -144,7 +130,7 @@ const SidebarNavigation = () => {
<SidebarNavigationLink
to='/messages'
icon={require('@tabler/icons/mail.svg')}
text={<FormattedMessage id='column.direct' defaultMessage='Direct messages' />}
text={<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />}
/>
);
}
@ -180,8 +166,8 @@ const SidebarNavigation = () => {
{
features.federating && (
<SidebarNavigationLink
icon={!bubbleTimeline ? require('icons/fediverse.svg') : require('@tabler/icons/hexagon.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Explore' />}
icon={require('icons/fediverse.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />}
to='/timeline/fediverse'
/>
)

View file

@ -25,6 +25,10 @@ const messages = defineMessages({
follows: { id: 'account.follows', defaultMessage: 'Follows' },
profile: { id: 'account.profile', defaultMessage: 'Profile' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domainBlocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
soapboxConfig: { id: 'navigation_bar.soapbox_config', defaultMessage: 'Soapbox config' },
importData: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' },
accountMigration: { id: 'navigation_bar.account_migration', defaultMessage: 'Move account' },
@ -37,8 +41,6 @@ const messages = defineMessages({
addAccount: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' },
direct: { id: 'column.direct', defaultMessage: 'Direct messages' },
directory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' },
dashboard: { id: 'tabs_bar.dashboard', defaultMessage: 'Dashboard' },
tags: { id: 'navigation_bar.tags', defaultMessage: 'Hashtags' },
});
interface ISidebarLink {
@ -92,8 +94,6 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
const settings = useAppSelector((state) => getSettings(state));
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
const bubbleTimeline = features.bubbleTimeline && settings.getIn(['public', 'bubble']);
const closeButtonRef = React.useRef(null);
const [switcher, setSwitcher] = React.useState(false);
@ -244,15 +244,6 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
/>
)}
{features.followTags && (
<SidebarLink
to='/followed_hashtags'
icon={require('@tabler/icons/hash.svg')}
text={intl.formatMessage(messages.tags)}
onClick={onClose}
/>
)}
{features.profileDirectory && (
<SidebarLink
to='/directory'
@ -284,8 +275,8 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
{features.federating && (
<SidebarLink
to='/timeline/fediverse'
icon={!bubbleTimeline ? require('icons/fediverse.svg') : require('@tabler/icons/hexagon.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Explore' />}
icon={require('icons/fediverse.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />}
onClick={onClose}
/>
)}
@ -303,6 +294,38 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
/>
}
<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}
/>
{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}
/>
)}
<SidebarLink
to='/settings/preferences'
icon={require('@tabler/icons/settings.svg')}
@ -319,15 +342,6 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
/>
)}
{account.staff && (
<SidebarLink
to='/soapbox/admin'
icon={require('@tabler/icons/dashboard.svg')}
text={intl.formatMessage(messages.dashboard)}
onClick={onClose}
/>
)}
{features.import && (
<SidebarLink
to='/settings/import'
@ -354,4 +368,4 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
);
};
export default SidebarMenu;
export default SidebarMenu;

View file

@ -1,6 +1,6 @@
import classNames from 'classnames';
import { List as ImmutableList } from 'immutable';
import React, { useMemo } from 'react';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
@ -109,8 +109,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const isStaff = account ? account.staff : false;
const isAdmin = account ? account.admin : false;
const quotePosts = useMemo(() => features.quotePosts && soapboxConfig.quotePosts, [features, soapboxConfig]);
if (!status) {
return null;
}
@ -520,7 +518,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
return menu;
};
const publicStatus = ['public', 'unlisted', 'local'].includes(status.visibility);
const publicStatus = ['public', 'unlisted'].includes(status.visibility);
const replyCount = status.replies_count;
const reblogCount = status.reblogs_count;
@ -603,7 +601,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
text={withLabels ? intl.formatMessage(messages.reply) : undefined}
/>
{(quotePosts && me) ? (
{(features.quotePosts && me) ? (
<DropdownMenuContainer
items={reblogMenu}
disabled={!publicStatus}

View file

@ -11,7 +11,7 @@ import { toggleStatusHidden } from 'soapbox/actions/statuses';
import Icon from 'soapbox/components/icon';
import AccountContainer from 'soapbox/containers/account_container';
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
import { useAppDispatch, useSettings, useLogo } from 'soapbox/hooks';
import { useAppDispatch, useSettings } from 'soapbox/hooks';
import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status';
import StatusActionBar from './status-action-bar';
@ -79,8 +79,6 @@ const Status: React.FC<IStatus> = (props) => {
const actualStatus = getActualStatus(status);
const logo = useLogo();
// Track height changes we know about to compensate scrolling.
useEffect(() => {
didShowCard.current = Boolean(!muted && !hidden && status?.card);
@ -182,18 +180,8 @@ const Status: React.FC<IStatus> = (props) => {
firstEmoji?.focus();
};
const privacyIcon = React.useMemo(() => {
switch (actualStatus?.visibility) {
default:
case 'public': return require('@tabler/icons/world.svg');
case 'unlisted': return require('@tabler/icons/eye-off.svg');
case 'local': return logo;
case 'private': return require('@tabler/icons/lock.svg');
case 'direct': return require('@tabler/icons/mail.svg');
}
}, [actualStatus?.visibility]);
if (!status) return null;
let rebloggedByText, reblogElement, reblogElementMobile;
if (hidden) {
return (
@ -204,6 +192,76 @@ const Status: React.FC<IStatus> = (props) => {
);
}
if (status.filtered || actualStatus.filtered) {
const minHandlers = muted ? undefined : {
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
};
return (
<HotKeys handlers={minHandlers}>
<div className={classNames('status__wrapper', 'status__wrapper--filtered', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
</div>
</HotKeys>
);
}
if (status.reblog && typeof status.reblog === 'object') {
const displayNameHtml = { __html: String(status.getIn(['account', 'display_name_html'])) };
reblogElement = (
<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'
>
<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />
<HStack alignItems='center'>
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: <bdi className='max-w-[100px] truncate pr-1'>
<strong className='text-gray-800 dark:text-gray-200' dangerouslySetInnerHTML={displayNameHtml} />
</bdi>,
}}
/>
</HStack>
</NavLink>
);
reblogElementMobile = (
<div className='pb-5 -mt-2 sm:hidden truncate'>
<NavLink
to={`/@${status.getIn(['account', 'acct'])}`}
onClick={(event) => event.stopPropagation()}
className='flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline'
>
<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />
<span>
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: <bdi>
<strong className='text-gray-800 dark:text-gray-200' dangerouslySetInnerHTML={displayNameHtml} />
</bdi>,
}}
/>
</span>
</NavLink>
</div>
);
rebloggedByText = intl.formatMessage(
messages.reblogged_by,
{ name: String(status.getIn(['account', 'acct'])) },
);
}
let quote;
if (actualStatus.quote) {
@ -233,8 +291,6 @@ const Status: React.FC<IStatus> = (props) => {
react: handleHotkeyReact,
};
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
return (
@ -243,10 +299,7 @@ const Status: React.FC<IStatus> = (props) => {
className={classNames('status cursor-pointer', { focusable })}
tabIndex={focusable && !muted ? 0 : undefined}
data-featured={featured ? 'true' : null}
aria-label={textForScreenReader(intl, actualStatus, intl.formatMessage(
messages.reblogged_by,
{ name: String(status.getIn(['account', 'acct'])) },
))}
aria-label={textForScreenReader(intl, actualStatus, rebloggedByText)}
ref={node}
onClick={() => history.push(statusUrl)}
role='link'
@ -271,42 +324,16 @@ const Status: React.FC<IStatus> = (props) => {
})}
data-id={status.id}
>
{reblogElementMobile}
<div className={classNames('flex items-center', { 'mb-3': status.reblog && typeof status.reblog === 'object' })}>
<div className='grow min-w-0'>
{
status.reblog && typeof status.reblog === 'object' && (
<NavLink
to={`/@${status.getIn(['account', 'acct'])}`}
onClick={(event) => event.stopPropagation()}
className='flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline'
>
<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />
<HStack alignItems='center'>
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: <bdi className='max-w-[100px] truncate pr-1'>
<strong className='text-gray-800 dark:text-gray-200' dangerouslySetInnerHTML={{ __html: String(status.getIn(['account', 'display_name_html'])) }} />
</bdi>,
}}
/>
</HStack>
</NavLink>
)
}
</div>
<Icon aria-hidden src={privacyIcon} className='h-5 w-5 shrink-0 text-gray-400 dark:text-gray-600' />
</div>
<div className='mb-3'>
<div className='mb-4'>
<AccountContainer
key={String(actualStatus.getIn(['account', 'id']))}
id={String(actualStatus.getIn(['account', 'id']))}
timestamp={actualStatus.created_at}
timestampUrl={statusUrl}
hideActions
action={reblogElement}
hideActions={!reblogElement}
showEdit={!!actualStatus.edited_at}
showProfileHoverCard={hoverable}
withLinkToProfile={hoverable}
@ -333,21 +360,15 @@ const Status: React.FC<IStatus> = (props) => {
collapsable
/>
{
!actualStatus.hidden && (
<>
<StatusMedia
status={actualStatus}
muted={muted}
onClick={handleClick}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
{ quote }
</>
<StatusMedia
status={actualStatus}
muted={muted}
onClick={handleClick}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
)
}
{quote}
{!hideActionBar && (
<div className='pt-4'>

View file

@ -11,7 +11,6 @@ import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich_content';
import { isRtl } from '../rtl';
import Poll from './polls/poll';
import { Button, Text } from './ui';
import type { Status, Mention } from 'soapbox/types/entities';
@ -29,11 +28,10 @@ interface IReadMoreButton {
/** Button to expand a truncated status (due to too much content) */
const ReadMoreButton: React.FC<IReadMoreButton> = ({ onClick }) => (
<Button onClick={onClick} theme='link' size='sm' classNames='-mx-3'>
<button className='status__content__read-more-button' onClick={onClick}>
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
<Icon className='inline-block h-5 w-5' src={require('@tabler/icons/chevron-right.svg')} fixedWidth />
</Button>
</button>
);
interface ISpoilerButton {
@ -44,9 +42,18 @@ interface ISpoilerButton {
/** Button to expand status text behind a content warning */
const SpoilerButton: React.FC<ISpoilerButton> = ({ onClick, hidden, tabIndex }) => (
<Button
theme='ghost'
size='sm'
<button
tabIndex={tabIndex}
className={classNames(
'inline-block rounded-md px-1.5 py-0.5 ml-[0.5em]',
'text-black dark:text-white',
'font-bold text-[11px] uppercase',
'bg-primary-100 dark:bg-primary-900',
'hover:bg-primary-300 dark:hover:bg-primary-600',
'focus:bg-primary-200 dark:focus:bg-primary-600',
'hover:no-underline',
'duration-100',
)}
onClick={onClick}
>
{hidden ? (
@ -54,68 +61,9 @@ const SpoilerButton: React.FC<ISpoilerButton> = ({ onClick, hidden, tabIndex })
) : (
<FormattedMessage id='status.show_less' defaultMessage='Show less' />
)}
</Button>
</button>
);
interface ISpoiler {
hidden: boolean,
status: Status,
onClick: (event: React.MouseEvent<Element, MouseEvent>) => void,
}
const Spoiler: React.FC<ISpoiler> = ({ hidden, onClick, status }) => {
return (
<div className='flex items-center justify-between bg-gray-100 dark:bg-slate-700 p-2 rounded mt-1'>
{
status.spoiler_text.length > 0 ? (
<span>
<Text tag='span' weight='medium'>
<FormattedMessage
id='status.cw'
defaultMessage='Warning:'
/>
</Text>
&nbsp;
<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} lang={status.language || undefined} />
</span>
) : (
<span>
<Text tag='span'>
<FormattedMessage
id='status.filtered'
defaultMessage='Filtered'
/>
</Text>
<br />
<Text size='xs' theme='muted' tag='span'>
<FormattedMessage
id='status.filtered-hint'
defaultMessage='Status was hidden by filter settings'
/>
</Text>
</span>
)
}
<div className='flex gap-3 items-center'>
{
status.media_attachments?.count() > 0 && (
<div aria-hidden className='flex gap-1 items-center'>
<Icon className='inline-block' src={require('@tabler/icons/paperclip.svg')} />
<Text tag='span' size='xs' theme='muted'>{ status.media_attachments.count() }</Text>
</div>
)
}
<SpoilerButton
tabIndex={0}
onClick={onClick}
hidden={hidden}
/>
</div>
</div>
);
};
interface IStatusContent {
status: Status,
expanded?: boolean,
@ -151,7 +99,7 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
e.stopPropagation();
history.push(`/tag/${hashtag}`);
history.push(`/tags/${hashtag}`);
}
};
@ -266,6 +214,7 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
const isHidden = onExpandedToggle ? !expanded : hidden;
const content = { __html: parsedHtml };
const spoilerContent = { __html: status.spoilerHtml };
const directionStyle: React.CSSProperties = { direction: 'ltr' };
const className = classNames('status__content', {
'status__content--with-action': onClick,
@ -278,50 +227,80 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
directionStyle.direction = 'rtl';
}
return (
<>
<div className={`${className} flex flex-col gap-2`} ref={node} tabIndex={0} style={directionStyle} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
{
// post has a spoiler or was filtered
(status.spoiler_text.length > 0 || status.filtered) && (
<Spoiler
status={status}
hidden={isHidden}
onClick={handleSpoilerClick}
/>
)
}
{
// actual content
!isHidden && (
<>
<div
tabIndex={!isHidden ? 0 : undefined}
className={classNames('min-h-0 overflow-hidden text-ellipsis status__content__text status__content__text--visible')}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
/>
{
// post folded because too long
collapsed && onClick && (
<div>
<ReadMoreButton onClick={onClick} key='read-more' />
</div>
)
}
{
// post has a poll
!collapsed && status.poll && typeof status.poll === 'string' && (
<Poll id={status.poll} key='poll' status={status.url} />
)
}
</>
)
}
if (status.spoiler_text.length > 0) {
return (
<div className={className} ref={node} tabIndex={0} style={directionStyle} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
<p style={{ marginBottom: isHidden && status.mentions.isEmpty() ? 0 : undefined }}>
<span dangerouslySetInnerHTML={spoilerContent} lang={status.language || undefined} />
<SpoilerButton
tabIndex={0}
onClick={handleSpoilerClick}
hidden={isHidden}
/>
</p>
<div
tabIndex={!isHidden ? 0 : undefined}
className={classNames('status__content__text', {
'status__content__text--visible': !isHidden,
})}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
/>
{!isHidden && status.poll && typeof status.poll === 'string' && (
<Poll id={status.poll} status={status.url} />
)}
</div>
</>
);
);
} else if (onClick) {
const output = [
<div
ref={node}
tabIndex={0}
key='content'
className={className}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
/>,
];
if (collapsed) {
output.push(<ReadMoreButton onClick={onClick} key='read-more' />);
}
const hasPoll = status.poll && typeof status.poll === 'string';
if (hasPoll) {
output.push(<Poll id={status.poll} key='poll' status={status.url} />);
}
return <div className={classNames({ 'rounded-md p-4': hasPoll })}>{output}</div>;
} else {
const output = [
<div
ref={node}
tabIndex={0}
key='content'
className={classNames('status__content', {
'status__content--big': onlyEmoji,
})}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
/>,
];
if (status.poll && typeof status.poll === 'string') {
output.push(<Poll id={status.poll} key='poll' status={status.url} />);
}
return <>{output}</>;
}
};
export default React.memo(StatusContent);

View file

@ -2,11 +2,10 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import ThumbNavigationLink from 'soapbox/components/thumb_navigation-link';
import { useAppSelector, useLogo, useOwnAccount, useSettings } from 'soapbox/hooks';
import { useAppSelector, useLogo, useOwnAccount } from 'soapbox/hooks';
import { getFeatures } from 'soapbox/utils/features';
const ThumbNavigation: React.FC = (): JSX.Element => {
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));
@ -14,8 +13,6 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
const instance = useAppSelector((state) => state.instance);
const logo = useLogo();
const bubbleTimeline = features.bubbleTimeline && settings.getIn(['public', 'bubble']);
/** Conditionally render the supported messages link */
const renderMessagesLink = (): React.ReactNode => {
if (features.chats) {
@ -74,8 +71,8 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
{
features.federating && (
<ThumbNavigationLink
src={!bubbleTimeline ? require('icons/fediverse.svg') : require('@tabler/icons/hexagon.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Explore' />}
src={require('icons/fediverse.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />}
to='/timeline/fediverse'
exact
/>

View file

@ -12,6 +12,7 @@ interface IButton {
block?: boolean,
/** Elements inside the <button> */
children?: React.ReactNode,
/** @deprecated unused */
classNames?: string,
/** Prevent the button from being clicked. */
disabled?: boolean,
@ -21,6 +22,7 @@ interface IButton {
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void,
/** A predefined button size. */
size?: ButtonSizes,
/** @deprecated unused */
style?: React.CSSProperties,
/** Text inside the button. Takes precedence over `children`. */
text?: React.ReactNode,
@ -35,7 +37,6 @@ interface IButton {
/** Customizable button element with various themes. */
const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Element => {
const {
classNames,
block = false,
children,
disabled = false,
@ -46,7 +47,6 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
theme = 'accent',
to,
type = 'button',
style,
} = props;
const themeClass = useButtonStyles({
@ -72,13 +72,12 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
const renderButton = () => (
<button
className={`${themeClass} ${classNames}`}
className={themeClass}
disabled={disabled}
onClick={handleClick}
ref={ref}
type={type}
data-testid='button'
style={style}
>
{renderIcon()}
{text || children}

View file

@ -85,7 +85,7 @@ interface ICardTitle {
/** A card's title. */
const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
<Text className='grow' size='xl' weight='bold' tag='h1' data-testid='card-title' truncate>{title}</Text>
<Text size='xl' weight='bold' tag='h1' data-testid='card-title' truncate>{title}</Text>
);
/** A card's body. */

View file

@ -10,7 +10,7 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** URL to the svg icon. */
src: string,
/** Text to display next ot the button. */
text?: React.ReactNode,
text?: string,
/** Don't render a background behind the icon. */
transparent?: boolean,
/** Predefined styles to display for the button. */

View file

@ -83,7 +83,7 @@ const Modal: React.FC<IModal> = ({
}, [skipFocus, buttonRef]);
return (
<div data-testid='modal' className={classNames('block w-full p-6 mx-auto text-left align-middle transition-all bg-white dark:bg-slate-800 text-black dark:text-white shadow-xl rounded-2xl pointer-events-auto', widths[width])}>
<div data-testid='modal' className={classNames('block w-full p-6 mx-auto text-left align-middle transition-all transform bg-white dark:bg-slate-800 text-black dark:text-white shadow-xl rounded-2xl pointer-events-auto', widths[width])}>
<div className='sm:flex sm:items-start w-full justify-between'>
<div className='w-full'>
{title && (

View file

@ -1,37 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { useEmoji } from '../actions/emojis';
import EmojiPicker from '../components/emoji_picker';
const getCustomEmojis = createSelector([
state => state.get('custom_emojis'),
], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
const aShort = a.get('shortcode').toLowerCase();
const bShort = b.get('shortcode').toLowerCase();
if (aShort < bShort) {
return -1;
} else if (aShort > bShort) {
return 1;
} else {
return 0;
}
}));
const mapStateToProps = state => ({
custom_emojis: getCustomEmojis(state),
});
const mapDispatchToProps = (dispatch, props) => ({
onPickEmoji: emoji => {
dispatch(useEmoji(emoji)); // eslint-disable-line react-hooks/rules-of-hooks
if (props.onPickEmoji) {
props.onPickEmoji(emoji);
}
},
});
export default connect(mapStateToProps, mapDispatchToProps)(EmojiPicker);

View file

@ -35,7 +35,6 @@ import {
import type { Menu as MenuType } from 'soapbox/components/dropdown_menu';
const messages = defineMessages({
open_profile: { id: 'account.open_profile', defaultMessage: 'Open Original Profile' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
@ -369,16 +368,6 @@ const Header: React.FC<IHeader> = ({ account }) => {
menu.push(null);
}
if (isRemote(account)) {
const originalUrl = account.url;
menu.push({
text: intl.formatMessage(messages.open_profile),
icon: require('@tabler/icons/external-link.svg'),
href: originalUrl,
newTab: true,
});
}
if (account.id === ownAccount?.id) {
menu.push({
text: intl.formatMessage(messages.edit_profile),
@ -803,13 +792,8 @@ const Header: React.FC<IHeader> = ({ account }) => {
{menuItem.icon && (
<SvgIcon src={menuItem.icon} className='mr-3 h-5 w-5 text-gray-400 flex-none group-hover:text-gray-500' />
)}
{menuItem.href ?
<a
href={menuItem.href}
className='truncate'
target={menuItem.newTab ? '_blank' : '_self'}
>{menuItem.text}</a>
: <div className='truncate'>{menuItem.text}</div>}
<div className='truncate'>{menuItem.text}</div>
</div>
</Comp>
);

View file

@ -1,9 +1,9 @@
import classNames from 'classnames';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { fetchBackups, createBackup } from 'soapbox/actions/backups';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Button, FormActions, Text, Spinner } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import Column from '../ui/components/better_column';
@ -24,14 +24,22 @@ const Backups = () => {
const [isLoading, setIsLoading] = useState(true);
const handleCreateBackup: React.MouseEventHandler = e => {
const handleCreateBackup: React.MouseEventHandler<HTMLAnchorElement> = e => {
dispatch(createBackup());
e.preventDefault();
};
const makeColumnMenu = () => {
return [{
text: intl.formatMessage(messages.create),
action: handleCreateBackup,
icon: require('@tabler/icons/plus.svg'),
}];
};
useEffect(() => {
dispatch(fetchBackups()).then(() => {
setIsLoading(false);
setIsLoading(true);
}).catch(() => {});
}, []);
@ -39,47 +47,30 @@ const Backups = () => {
const emptyMessageAction = (
<a href='#' onClick={handleCreateBackup}>
<Text tag='span' theme='primary' size='sm' className='hover:underline'>
{intl.formatMessage(messages.emptyMessageAction)}
</Text>
{intl.formatMessage(messages.emptyMessageAction)}
</a>
);
return (
<Column label={intl.formatMessage(messages.heading)}>
<Column icon='cloud-download' label={intl.formatMessage(messages.heading)} menu={makeColumnMenu()}>
<ScrollableList
isLoading={isLoading}
showLoading={showLoading}
scrollKey='backups'
emptyMessage={intl.formatMessage(messages.emptyMessage, { action: emptyMessageAction })}
>
{backups.map((backup) => {
const insertedAt = new Date(backup.inserted_at).toLocaleDateString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
return (
<div
className='p-2 mb-3 rounded bg-gray-100 dark:bg-slate-900 flex justify-between items-center'
key={backup.id}
>
<div>
{backup.processed
? <a href={backup.url} target='_blank'>{insertedAt}</a>
: <Text theme='subtle'>{insertedAt}&nbsp;-&nbsp;{intl.formatMessage(messages.pending)}</Text>
}
</div>
{
!backup.processed && <Spinner withText={false} size={15} />
}
</div>
);
})}
{backups.map((backup) => (
<div
className={classNames('backup', { 'backup--pending': !backup.processed })}
key={backup.id}
>
{backup.processed
? <a href={backup.url} target='_blank'>{backup.inserted_at}</a>
: <div>{intl.formatMessage(messages.pending)}: {backup.inserted_at}</div>
}
</div>
))}
</ScrollableList>
<div className='mt-4'>
<FormActions>
<Button theme='primary' disabled={isLoading} onClick={handleCreateBackup}>
{intl.formatMessage(messages.create)}
</Button>
</FormActions>
</div>
</Column>
);
};

View file

@ -23,12 +23,19 @@ const Blocks: React.FC = () => {
const accountIds = useAppSelector((state) => state.user_lists.blocks.items);
const hasMore = useAppSelector((state) => !!state.user_lists.blocks.next);
const loading = useAppSelector((state) => state.user_lists.blocks.isLoading);
React.useEffect(() => {
dispatch(fetchBlocks());
}, []);
if (!accountIds) {
return (
<Column>
<Spinner />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.blocks' defaultMessage="You haven't blocked any users yet." />;
return (
@ -38,16 +45,12 @@ const Blocks: React.FC = () => {
onLoadMore={() => handleLoadMore(dispatch)}
hasMore={hasMore}
emptyMessage={emptyMessage}
itemClassName='flex flex-col gap-3 pb-4'
itemClassName='pb-4'
>
{accountIds.map((id) =>
<AccountContainer key={id} id={id} actionType='blocking' />,
)}
{
loading && <Spinner />
}
</ScrollableList>
</Column>
);
};

View file

@ -7,9 +7,8 @@ import {
markChatRead,
} from 'soapbox/actions/chats';
import { uploadMedia } from 'soapbox/actions/media';
import { IconButton } from 'soapbox/components/ui';
import IconButton from 'soapbox/components/icon_button';
import UploadProgress from 'soapbox/components/upload-progress';
import EmojiPickerDropdown from 'soapbox/containers/emoji_picker_dropdown_container';
import UploadButton from 'soapbox/features/compose/components/upload_button';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { truncateFilename } from 'soapbox/utils/media';
@ -141,14 +140,6 @@ const ChatBox: React.FC<IChatBox> = ({ chatId, onSetInputRef, autosize }) => {
});
};
const handleEmojiPick = React.useCallback((data) => {
if (data.custom) {
setContent(content + ' ' + data.native + ' ');
} else {
setContent(content + data.native);
}
}, [content]);
const renderAttachment = () => {
if (!attachment) return null;
@ -167,47 +158,40 @@ const ChatBox: React.FC<IChatBox> = ({ chatId, onSetInputRef, autosize }) => {
);
};
const renderActionButton = () => {
return canSubmit() ? (
<IconButton
src={require('@tabler/icons/send.svg')}
title={intl.formatMessage(messages.send)}
onClick={sendMessage}
/>
) : (
<UploadButton onSelectFile={handleFiles} resetFileKey={resetFileKey} />
);
};
if (!chatMessageIds) return null;
return (
<div className='chat-box' onMouseOver={handleHover}>
<ChatMessageList chatMessageIds={chatMessageIds} chatId={chatId} />
<div className='chat-box__actions'>
<div>
{renderAttachment()}
{isUploading && (
<UploadProgress progress={uploadProgress * 100} />
)}
</div>
<div className='flex items-center gap-2'>
<textarea
className='border'
rows={1}
placeholder={intl.formatMessage(messages.placeholder)}
onKeyDown={handleKeyDown}
onChange={handleContentChange}
onPaste={handlePaste}
value={content}
ref={setInputRef}
/>
<div className='chat-box__send flex items-center gap-1'>
<EmojiPickerDropdown
onPickEmoji={handleEmojiPick}
/>
{
canSubmit() ? (
<IconButton
className='text-gray-400 hover:text-gray-600'
src={require('@tabler/icons/send.svg')}
title={intl.formatMessage(messages.send)}
onClick={sendMessage}
/>
) : (
<UploadButton onSelectFile={handleFiles} resetFileKey={resetFileKey} />
)
}
</div>
<ChatMessageList chatMessageIds={chatMessageIds} chatId={chatId} autosize />
{renderAttachment()}
{isUploading && (
<UploadProgress progress={uploadProgress * 100} />
)}
<div className='chat-box__actions simple_form'>
<div className='chat-box__send'>
{renderActionButton()}
</div>
<textarea
rows={1}
placeholder={intl.formatMessage(messages.placeholder)}
onKeyDown={handleKeyDown}
onChange={handleContentChange}
onPaste={handlePaste}
value={content}
ref={setInputRef}
/>
</div>
</div>
);

View file

@ -66,6 +66,8 @@ interface IChatMessageList {
chatId: string,
/** Message IDs to render. */
chatMessageIds: ImmutableOrderedSet<string>,
/** Whether to make the chatbox fill the height of the screen. */
autosize?: boolean,
}
/** Scrollable list of chat messages. */
@ -306,7 +308,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
}, [chatMessageIds.first()]);
return (
<div className='chat-messages' ref={node}>
<div className='chat-messages' style={{ height: autosize ? 'calc(100vh - 16rem)' : undefined }} ref={node}>
{chatMessages.reduce((acc, curr, idx) => {
const lastMessage = chatMessages.get(idx - 1);

View file

@ -12,14 +12,13 @@ import AutosuggestInput from 'soapbox/components/autosuggest_input';
import AutosuggestTextarea from 'soapbox/components/autosuggest_textarea';
import Icon from 'soapbox/components/icon';
import { Button, Stack } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import { isMobile } from 'soapbox/is_mobile';
import EmojiPickerDropdown from '../../../containers/emoji_picker_dropdown_container';
import PollForm from '../components/polls/poll-form';
import ReplyMentions from '../components/reply_mentions';
import UploadForm from '../components/upload_form';
import Warning from '../components/warning';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import MarkdownButtonContainer from '../containers/markdown_button_container';
import PollButtonContainer from '../containers/poll_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
@ -32,8 +31,6 @@ import UploadButtonContainer from '../containers/upload_button_container';
import WarningContainer from '../containers/warning_container';
import { countableText } from '../util/counter';
import TextCharacterCounter from './text_character_counter';
import VisualCharacterCounter from './visual_character_counter';
@ -48,10 +45,8 @@ const messages = defineMessages({
message: { id: 'compose_form.message', defaultMessage: 'Message' },
schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' },
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
marked: { id: 'compose_form.spoiler.marked', defaultMessage: 'Text is hidden behind warning' },
});
export default @withRouter
class ComposeForm extends ImmutablePureComponent {
@ -90,12 +85,10 @@ class ComposeForm extends ImmutablePureComponent {
clickableAreaRef: PropTypes.object,
scheduledAt: PropTypes.instanceOf(Date),
features: PropTypes.object.isRequired,
spoilerForced: PropTypes.bool,
};
static defaultProps = {
showSearch: false,
spoilerForced: false,
};
handleChange = (e) => {
@ -263,7 +256,7 @@ class ComposeForm extends ImmutablePureComponent {
}
render() {
const { intl, onPaste, showSearch, anyMedia, shouldCondense, autoFocus, isModalOpen, maxTootChars, scheduledStatusCount, features, spoilerForced } = this.props;
const { intl, onPaste, showSearch, anyMedia, shouldCondense, autoFocus, isModalOpen, maxTootChars, scheduledStatusCount, features } = this.props;
const condensed = shouldCondense && !this.state.composeFocused && this.isEmpty() && !this.props.isUploading;
const disabled = this.props.isSubmitting;
const text = [this.props.spoilerText, countableText(this.props.text)].join('');
@ -334,7 +327,7 @@ class ComposeForm extends ImmutablePureComponent {
value={this.props.spoilerText}
onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown}
disabled={this.props.spoilerForced || !this.props.spoiler}
disabled={!this.props.spoiler}
ref={this.setSpoilerText}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
@ -387,13 +380,7 @@ class ComposeForm extends ImmutablePureComponent {
{features.polls && <PollButtonContainer />}
{features.privacyScopes && <PrivacyDropdownContainer />}
{features.scheduledStatuses && <ScheduleButtonContainer />}
{features.spoilers && (
spoilerForced ? <SvgIcon
className={classNames('cursor-not-allowed text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300')}
src={require('@tabler/icons/alert-triangle.svg')}
title={intl.formatMessage(messages.marked)}
/> : <SpoilerButtonContainer />
)}
{features.spoilers && <SpoilerButtonContainer />}
{features.richText && <MarkdownButtonContainer />}
</div>

View file

@ -0,0 +1,397 @@
import classNames from 'classnames';
import { supportsPassiveEvents } from 'detect-passive-events';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl } from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay';
import { IconButton } from 'soapbox/components/ui';
import { buildCustomEmojis } from '../../emoji/emoji';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
people: { id: 'emoji_button.people', defaultMessage: 'People' },
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
});
let EmojiPicker, Emoji; // load asynchronously
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const categoriesSort = [
'recent',
'custom',
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
];
class ModifierPickerMenu extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
onSelect: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
handleClick = e => {
this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
}
componentDidUpdate(prevProps) {
if (this.props.active) {
this.attachListeners();
} else {
this.removeListeners();
}
}
componentWillUnmount() {
this.removeListeners();
}
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
attachListeners() {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
removeListeners() {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
render() {
const { active } = this.props;
return (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
<button onClick={this.handleClick} data-index={1}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={2}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={3}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={4}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={5}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={6}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
</div>
);
}
}
class ModifierPicker extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
modifier: PropTypes.number,
onChange: PropTypes.func,
onClose: PropTypes.func,
onOpen: PropTypes.func,
};
handleClick = () => {
if (this.props.active) {
this.props.onClose();
} else {
this.props.onOpen();
}
}
handleSelect = modifier => {
this.props.onChange(modifier);
this.props.onClose();
}
render() {
const { active, modifier } = this.props;
return (
<div className='emoji-picker-dropdown__modifiers'>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
</div>
);
}
}
@injectIntl
class EmojiPickerMenu extends React.PureComponent {
static propTypes = {
custom_emojis: ImmutablePropTypes.list,
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
loading: PropTypes.bool,
onClose: PropTypes.func.isRequired,
onPick: PropTypes.func.isRequired,
style: PropTypes.object,
placement: PropTypes.string,
arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string,
intl: PropTypes.object.isRequired,
skinTone: PropTypes.number.isRequired,
onSkinTone: PropTypes.func.isRequired,
};
static defaultProps = {
style: {},
loading: true,
frequentlyUsedEmojis: [],
};
state = {
modifierOpen: false,
placement: null,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
getI18n = () => {
const { intl } = this.props;
return {
search: intl.formatMessage(messages.emoji_search),
notfound: intl.formatMessage(messages.emoji_not_found),
categories: {
search: intl.formatMessage(messages.search_results),
recent: intl.formatMessage(messages.recent),
people: intl.formatMessage(messages.people),
nature: intl.formatMessage(messages.nature),
foods: intl.formatMessage(messages.food),
activity: intl.formatMessage(messages.activity),
places: intl.formatMessage(messages.travel),
objects: intl.formatMessage(messages.objects),
symbols: intl.formatMessage(messages.symbols),
flags: intl.formatMessage(messages.flags),
custom: intl.formatMessage(messages.custom),
},
};
}
handleClick = emoji => {
if (!emoji.native) {
emoji.native = emoji.colons;
}
this.props.onClose();
this.props.onPick(emoji);
}
handleModifierOpen = () => {
this.setState({ modifierOpen: true });
}
handleModifierClose = () => {
this.setState({ modifierOpen: false });
}
handleModifierChange = modifier => {
this.props.onSkinTone(modifier);
}
render() {
const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
if (loading) {
return <div style={{ width: 299 }} />;
}
const title = intl.formatMessage(messages.emoji);
const { modifierOpen } = this.state;
return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<EmojiPicker
perLine={8}
emojiSize={22}
sheetSize={32}
custom={buildCustomEmojis(custom_emojis)}
color=''
emoji=''
set='twitter'
title={title}
i18n={this.getI18n()}
onClick={this.handleClick}
include={categoriesSort}
recent={frequentlyUsedEmojis}
skin={skinTone}
showPreview={false}
backgroundImageFn={backgroundImageFn}
autoFocus
emojiTooltip
/>
<ModifierPicker
active={modifierOpen}
modifier={skinTone}
onOpen={this.handleModifierOpen}
onClose={this.handleModifierClose}
onChange={this.handleModifierChange}
/>
</div>
);
}
}
export default @injectIntl
class EmojiPickerDropdown extends React.PureComponent {
static propTypes = {
custom_emojis: ImmutablePropTypes.list,
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
intl: PropTypes.object.isRequired,
onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
button: PropTypes.node,
};
state = {
active: false,
loading: false,
};
setRef = (c) => {
this.dropdown = c;
}
onShowDropdown = (e) => {
e.stopPropagation();
this.setState({ active: true });
if (!EmojiPicker) {
this.setState({ loading: true });
EmojiPickerAsync().then(EmojiMart => {
EmojiPicker = EmojiMart.Picker;
Emoji = EmojiMart.Emoji;
this.setState({ loading: false });
}).catch(() => {
this.setState({ loading: false });
});
}
const { top } = e.target.getBoundingClientRect();
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
}
onHideDropdown = () => {
this.setState({ active: false });
}
onToggle = (e) => {
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
if (this.state.active) {
this.onHideDropdown();
} else {
this.onShowDropdown(e);
}
}
}
handleKeyDown = e => {
if (e.key === 'Escape') {
this.onHideDropdown();
}
}
setTargetRef = c => {
this.target = c;
}
findTarget = () => {
return this.target;
}
render() {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state;
return (
<div className='relative' onKeyDown={this.handleKeyDown}>
<div
ref={this.setTargetRef}
title={title}
aria-label={title}
aria-expanded={active}
role='button'
onClick={this.onToggle}
onKeyDown={this.onToggle}
tabIndex={0}
>
{button || <IconButton
className={classNames({
'text-gray-400 hover:text-gray-600': true,
'pulse-loading': active && loading,
})}
alt='😀'
src={require('@tabler/icons/mood-happy.svg')}
/>}
</div>
<Overlay show={active} placement={placement} target={this.findTarget}>
<EmojiPickerMenu
custom_emojis={this.props.custom_emojis}
loading={loading}
onClose={this.onHideDropdown}
onPick={onPickEmoji}
onSkinTone={onSkinTone}
skinTone={skinTone}
frequentlyUsedEmojis={frequentlyUsedEmojis}
/>
</Overlay>
</div>
);
}
}

View file

@ -6,8 +6,8 @@ import { spring } from 'react-motion';
// @ts-ignore
import Overlay from 'react-overlays/lib/Overlay';
import { IconButton, Icon } from 'soapbox/components/ui';
import { useFeatures, useLogo } from 'soapbox/hooks';
import Icon from 'soapbox/components/icon';
import { IconButton } from 'soapbox/components/ui';
import Motion from '../../ui/util/optional_motion';
@ -16,8 +16,6 @@ const messages = defineMessages({
public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not post to public timelines' },
local_short: { id: 'privacy.local.short', defaultMessage: 'Local-only' },
local_long: { id: 'privacy.local.long', defaultMessage: 'Status is only visible to people on this instance' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
@ -122,7 +120,7 @@ const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, pla
{items.map(item => (
<div role='option' tabIndex={0} key={item.value} data-index={item.value} onKeyDown={handleKeyDown} onClick={handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? focusedItem : null}>
<div className='privacy-dropdown__option__icon'>
<Icon size={16} src={item.icon} />
<Icon src={item.icon} />
</div>
<div className='privacy-dropdown__option__content'>
@ -158,16 +156,13 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
const intl = useIntl();
const node = useRef<HTMLDivElement>(null);
const activeElement = useRef<HTMLElement | null>(null);
const logo = useLogo();
const features = useFeatures();
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState('bottom');
const options = [
{ icon: require('@tabler/icons/world.svg'), value: 'public', text: intl.formatMessage(messages.public_short), meta: intl.formatMessage(messages.public_long) },
{ icon: require('@tabler/icons/eye-off.svg'), value: 'unlisted', text: intl.formatMessage(messages.unlisted_short), meta: intl.formatMessage(messages.unlisted_long) },
...(features.localOnlyPrivacy ? [{ icon: logo, value: 'local', text: intl.formatMessage(messages.local_short), meta: intl.formatMessage(messages.local_long) }] : []),
{ icon: require('@tabler/icons/lock-open.svg'), value: 'unlisted', text: intl.formatMessage(messages.unlisted_short), meta: intl.formatMessage(messages.unlisted_long) },
{ icon: require('@tabler/icons/lock.svg'), value: 'private', text: intl.formatMessage(messages.private_short), meta: intl.formatMessage(messages.private_long) },
{ icon: require('@tabler/icons/mail.svg'), value: 'direct', text: intl.formatMessage(messages.direct_short), meta: intl.formatMessage(messages.direct_long) },
];

View file

@ -49,16 +49,16 @@ const SearchResults = () => {
const renderFilterBar = () => {
const items = [
{
text: intl.formatMessage(messages.statuses),
action: () => selectFilter('statuses'),
name: 'statuses',
},
{
text: intl.formatMessage(messages.accounts),
action: () => selectFilter('accounts'),
name: 'accounts',
},
{
text: intl.formatMessage(messages.statuses),
action: () => selectFilter('statuses'),
name: 'statuses',
},
{
text: intl.formatMessage(messages.hashtags),
action: () => selectFilter('hashtags'),

View file

@ -13,7 +13,6 @@ import {
} from 'soapbox/actions/compose';
import { getFeatures } from 'soapbox/utils/features';
import ComposeForm from '../components/compose_form';
const mapStateToProps = state => {
@ -24,7 +23,6 @@ const mapStateToProps = state => {
suggestions: state.getIn(['compose', 'suggestions']),
spoiler: state.getIn(['compose', 'spoiler']),
spoilerText: state.getIn(['compose', 'spoiler_text']),
spoilerForced: state.getIn(['compose', 'spoiler_forced']),
privacy: state.getIn(['compose', 'privacy']),
focusDate: state.getIn(['compose', 'focusDate']),
caretPosition: state.getIn(['compose', 'caretPosition']),

View file

@ -0,0 +1,84 @@
import { Map as ImmutableMap } from 'immutable';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { useEmoji } from '../../../actions/emojis';
import { getSettings, changeSetting } from '../../../actions/settings';
import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
const perLine = 8;
const lines = 2;
const DEFAULTS = [
'+1',
'grinning',
'kissing_heart',
'heart_eyes',
'laughing',
'stuck_out_tongue_winking_eye',
'sweat_smile',
'joy',
'yum',
'disappointed',
'thinking_face',
'weary',
'sob',
'sunglasses',
'heart',
'ok_hand',
];
const getFrequentlyUsedEmojis = createSelector([
state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
], emojiCounters => {
let emojis = emojiCounters
.keySeq()
.sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
.reverse()
.slice(0, perLine * lines)
.toArray();
if (emojis.length < DEFAULTS.length) {
const uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
}
return emojis;
});
const getCustomEmojis = createSelector([
state => state.get('custom_emojis'),
], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
const aShort = a.get('shortcode').toLowerCase();
const bShort = b.get('shortcode').toLowerCase();
if (aShort < bShort) {
return -1;
} else if (aShort > bShort) {
return 1;
} else {
return 0;
}
}));
const mapStateToProps = state => ({
custom_emojis: getCustomEmojis(state),
skinTone: getSettings(state).get('skinTone'),
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
});
const mapDispatchToProps = (dispatch, props) => ({
onSkinTone: skinTone => {
dispatch(changeSetting(['skinTone'], skinTone));
},
onPickEmoji: emoji => {
dispatch(useEmoji(emoji)); // eslint-disable-line react-hooks/rules-of-hooks
if (props.onPickEmoji) {
props.onPickEmoji(emoji);
}
},
});
export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);

View file

@ -26,13 +26,19 @@ const DomainBlocks: React.FC = () => {
const domains = useAppSelector((state) => state.domain_lists.blocks.items);
const hasMore = useAppSelector((state) => !!state.domain_lists.blocks.next);
const loading = useAppSelector((state) => state.domain_lists.blocks.isLoading);
React.useEffect(() => {
dispatch(fetchDomainBlocks());
}, []);
if (!domains) {
return (
<Column>
<Spinner />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.domain_blocks' defaultMessage='There are no hidden domains yet.' />;
return (
@ -42,16 +48,10 @@ const DomainBlocks: React.FC = () => {
onLoadMore={() => handleLoadMore(dispatch)}
hasMore={hasMore}
emptyMessage={emptyMessage}
itemClassName='flex flex-col gap-3'
>
{domains.map((domain) => (
<div className='rounded p-1 bg-gray-100 dark:bg-slate-900'>
<Domain key={domain} domain={domain} />
</div>
))}
{
loading && <Spinner />
}
{domains.map((domain) =>
<Domain key={domain} domain={domain} />,
)}
</ScrollableList>
</Column>
);

View file

@ -5,12 +5,20 @@
// It's designed to be emitted in an array format to take up less space
// over the wire.
const { emojis, aliases, categories } = require('@emoji-mart/data');
const { emojiIndex } = require('emoji-mart');
let data = require('emoji-mart/data/all.json');
const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data');
const emojiMap = require('./emoji_map.json');
const { unicodeToFilename } = require('./unicode_to_filename');
const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
if (data.compressed) {
data = emojiMartUncompress(data);
}
const emojiMartData = data;
const excluded = ['®', '©', '™'];
const skinTones = ['🏻', '🏼', '🏽', '🏾', '🏿'];
const shortcodeMap = {};
@ -18,9 +26,15 @@ const shortcodeMap = {};
const shortCodesToEmojiData = {};
const emojisWithoutShortCodes = [];
Object.keys(emojis).forEach(key => {
const emoji = emojis[key];
shortcodeMap[emoji.skins[0].native] = emoji.id;
Object.keys(emojiIndex.emojis).forEach(key => {
let emoji = emojiIndex.emojis[key];
// Emojis with skin tone modifiers are stored like this
if (Object.prototype.hasOwnProperty.call(emoji, '1')) {
emoji = emoji['1'];
}
shortcodeMap[emoji.native] = emoji.id;
});
const stripModifiers = unicode => {
@ -64,15 +78,27 @@ Object.keys(emojiMap).forEach(key => {
}
});
Object.keys(emojis).forEach(key => {
const emoji = emojis[key];
const { native, unified } = emoji.skins[0];
Object.keys(emojiIndex.emojis).forEach(key => {
let emoji = emojiIndex.emojis[key];
// Emojis with skin tone modifiers are stored like this
if (Object.prototype.hasOwnProperty.call(emoji, '1')) {
emoji = emoji['1'];
}
const { native } = emoji;
const { short_names, search, unified } = emojiMartData.emojis[key];
if (short_names[0] !== key) {
throw new Error('The compresser expects the first short_code to be the ' +
'key. It may need to be rewritten if the emoji change such that this ' +
'is no longer the case.');
}
const searchData = [
native,
emoji.id,
[...emoji.keywords, ...emoji.name.toLowerCase().split(' ')].join(','),
short_names.slice(1), // first short name can be inferred from the key
search,
];
if (unicodeToUnifiedName(native) !== unified) {
@ -91,8 +117,8 @@ Object.keys(emojis).forEach(key => {
// inconsistent behavior in dev mode
module.exports = JSON.parse(JSON.stringify([
shortCodesToEmojiData,
undefined, // legacy value, but order is still important
categories,
aliases,
emojiMartData.skins,
emojiMartData.categories,
emojiMartData.aliases,
emojisWithoutShortCodes,
]));

View file

@ -0,0 +1,7 @@
import Emoji from 'emoji-mart/dist-es/components/emoji/emoji';
import Picker from 'emoji-mart/dist-es/components/picker/picker';
export {
Picker,
Emoji,
};

View file

@ -4,13 +4,13 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters';
import snackbar from 'soapbox/actions/snackbar';
import Icon from 'soapbox/components/icon';
import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui';
import {
FieldsGroup,
Checkbox,
} from 'soapbox/features/forms';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { Filter } from 'soapbox/types/entities';
const messages = defineMessages({
heading: { id: 'column.filters', defaultMessage: 'Muted words' },
@ -20,9 +20,8 @@ const messages = defineMessages({
expires_hint: { id: 'column.filters.expires_hint', defaultMessage: 'Expiration dates are not currently supported' },
home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' },
public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' },
notifications: { id: 'column.filters.notifications', defaultMessage: 'Also apply for notifications' },
notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' },
conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' },
drop_notifications: { id: 'column.filters.drop_notifications', defaultMessage: 'Will also hide status in notifications' },
drop_header: { id: 'column.filters.drop_header', defaultMessage: 'Drop instead of hide' },
drop_hint: { id: 'column.filters.drop_hint', defaultMessage: 'Filtered posts will disappear irreversibly, even if filter is later removed' },
whole_word_header: { id: 'column.filters.whole_word_header', defaultMessage: 'Whole word' },
@ -34,6 +33,15 @@ const messages = defineMessages({
delete: { id: 'column.filters.delete', defaultMessage: 'Delete' },
});
// const expirations = {
// null: 'Never',
// // 3600: '30 minutes',
// // 21600: '1 hour',
// // 43200: '12 hours',
// // 86400 : '1 day',
// // 604800: '1 week',
// };
const Filters = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
@ -42,20 +50,43 @@ const Filters = () => {
const [phrase, setPhrase] = useState('');
const [expiresAt] = useState('');
const [homeTimeline, setHomeTimeline] = useState(true);
const [publicTimeline, setPublicTimeline] = useState(false);
const [notifications, setNotifications] = useState(false);
const [conversations, setConversations] = useState(false);
const [irreversible, setIrreversible] = useState(false);
const [wholeWord, setWholeWord] = useState(true);
// const handleSelectChange = e => {
// this.setState({ [e.target.name]: e.target.value });
// };
const handleAddNew: React.FormEventHandler = e => {
e.preventDefault();
dispatch(createFilter(phrase, expiresAt, [], wholeWord, irreversible)).then(() => {
const context: Array<string> = [];
if (homeTimeline) {
context.push('home');
}
if (publicTimeline) {
context.push('public');
}
if (notifications) {
context.push('notifications');
}
if (conversations) {
context.push('thread');
}
dispatch(createFilter(phrase, expiresAt, context, wholeWord, irreversible)).then(() => {
return dispatch(fetchFilters());
}).catch(error => {
dispatch(snackbar.error(intl.formatMessage(messages.create_error)));
});
};
const handleFilterDelete = (filter: Filter) => {
dispatch(deleteFilter(filter.id)).then(() => {
const handleFilterDelete: React.MouseEventHandler<HTMLDivElement> = e => {
dispatch(deleteFilter(e.currentTarget.dataset.value!)).then(() => {
return dispatch(fetchFilters());
}).catch(() => {
dispatch(snackbar.error(intl.formatMessage(messages.delete_error)));
@ -82,23 +113,65 @@ const Filters = () => {
onChange={({ target }) => setPhrase(target.value)}
/>
</FormGroup>
{/* <FormGroup labelText={intl.formatMessage(messages.expires)} hintText={intl.formatMessage(messages.expires_hint)}>
<SelectDropdown
items={expirations}
defaultValue={expirations.never}
onChange={this.handleSelectChange}
/>
</FormGroup> */}
<FieldsGroup>
<div className='flex flex-col gap-2'>
<Text tag='label'>
<FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' />
</Text>
<Text theme='muted' size='xs'>
<FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' />
</Text>
<div className='two-col'>
<Checkbox
label={intl.formatMessage(messages.drop_header)}
hint={intl.formatMessage(messages.drop_hint)}
name='irreversible'
checked={irreversible}
onChange={({ target }) => setIrreversible(target.checked)}
label={intl.formatMessage(messages.home_timeline)}
name='home_timeline'
checked={homeTimeline}
onChange={({ target }) => setHomeTimeline(target.checked)}
/>
<Checkbox
label={intl.formatMessage(messages.whole_word_header)}
hint={intl.formatMessage(messages.whole_word_hint)}
name='whole_word'
checked={wholeWord}
onChange={({ target }) => setWholeWord(target.checked)}
label={intl.formatMessage(messages.public_timeline)}
name='public_timeline'
checked={publicTimeline}
onChange={({ target }) => setPublicTimeline(target.checked)}
/>
<Checkbox
label={intl.formatMessage(messages.notifications)}
name='notifications'
checked={notifications}
onChange={({ target }) => setNotifications(target.checked)}
/>
<Checkbox
label={intl.formatMessage(messages.conversations)}
name='conversations'
checked={conversations}
onChange={({ target }) => setConversations(target.checked)}
/>
</div>
</FieldsGroup>
<FieldsGroup>
<Checkbox
label={intl.formatMessage(messages.drop_header)}
hint={intl.formatMessage(messages.drop_hint)}
name='irreversible'
checked={irreversible}
onChange={({ target }) => setIrreversible(target.checked)}
/>
<Checkbox
label={intl.formatMessage(messages.whole_word_header)}
hint={intl.formatMessage(messages.whole_word_hint)}
name='whole_word'
checked={wholeWord}
onChange={({ target }) => setWholeWord(target.checked)}
/>
</FieldsGroup>
<FormActions>
@ -110,19 +183,27 @@ const Filters = () => {
<CardTitle title={intl.formatMessage(messages.subheading_filters)} />
</CardHeader>
<div>
{
filters.size === 0 && <div>{ emptyMessage }</div>
}
<ScrollableList
scrollKey='filters'
emptyMessage={emptyMessage}
>
{filters.map((filter, i) => (
<div key={i} className='filter__container rounded bg-gray-100 dark:bg-slate-900 p-2 my-3'>
<div className=''>
<div className='mb-1'>
<span className='pr-1 text-gray-600 dark:text-gray-400'><FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' /></span>
<div key={i} className='filter__container'>
<div className='filter__details'>
<div className='filter__phrase'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' /></span>
<span className='filter__list-value'>{filter.phrase}</span>
</div>
<div className=''>
<span className='pr-1 text-gray-600 dark:text-gray-400'><FormattedMessage id='filters.filters_list_details_label' defaultMessage='Filter settings:' /></span>
<div className='filter__contexts'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' /></span>
<span className='filter__list-value'>
{filter.context.map((context, i) => (
<span key={i} className='context'>{context}</span>
))}
</span>
</div>
<div className='filter__details'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_details_label' defaultMessage='Filter settings:' /></span>
<span className='filter__list-value'>
{filter.irreversible ?
<span><FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /></span> :
@ -134,18 +215,13 @@ const Filters = () => {
</span>
</div>
</div>
<div>
<Button theme='ghost' onClick={() => handleFilterDelete(filter)} aria-label={intl.formatMessage(messages.delete)}>
<div className='flex items-end gap-1'>
<FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' />
<Icon src={require('@tabler/icons/x.svg')} />
</div>
</Button>
<div className='filter__delete' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={filter.id} aria-label={intl.formatMessage(messages.delete)}>
<Icon className='filter__delete-icon' src={require('@tabler/icons/x.svg')} />
<span className='filter__delete-label'><FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' /></span>
</div>
</div>
))}
</div>
</ScrollableList>
</Column>
);
};

View file

@ -26,18 +26,24 @@ const FollowRequests: React.FC = () => {
const accountIds = useAppSelector((state) => state.user_lists.follow_requests.items);
const hasMore = useAppSelector((state) => !!state.user_lists.follow_requests.next);
const loading = useAppSelector((state) => state.user_lists.mutes.isLoading);
React.useEffect(() => {
dispatch(fetchFollowRequests());
}, []);
if (!accountIds) {
return (
<Column>
<Spinner />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.follow_requests' defaultMessage="You don't have any follow requests yet. When you receive one, it will show up here." />;
return (
<Column icon='user-plus' label={intl.formatMessage(messages.heading)}>
<ScrollableList
className='flex flex-col gap-3'
scrollKey='follow_requests'
onLoadMore={() => handleLoadMore(dispatch)}
hasMore={hasMore}
@ -46,9 +52,6 @@ const FollowRequests: React.FC = () => {
{accountIds.map(id =>
<AccountAuthorize key={id} id={id} />,
)}
{
loading && <Spinner />
}
</ScrollableList>
</Column>
);

View file

@ -1,102 +0,0 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchTags, followTag, unfollowTag } from 'soapbox/actions/tags';
import Icon from 'soapbox/components/icon';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Button, Column, IconButton, Spinner, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'column.tags', defaultMessage: 'Followed hashtags' },
});
interface IFollowButton {
id: string,
}
const FollowButton: React.FC<IFollowButton> = ({ id }) => {
const { isFollow } = useAppSelector(state => ({ isFollow: state.tags.list.find((t) => t.name === id) }));
const dispatch = useAppDispatch();
const onClick = React.useCallback(() => {
const action = isFollow ? unfollowTag : followTag;
dispatch(action(id));
}, [isFollow, id]);
const text = React.useMemo(() => {
return isFollow ? (
<FormattedMessage id='hashtag_timeline.unfollow' defaultMessage='Unfollow' />
) : (
<FormattedMessage id='hashtag_timeline.follow' defaultMessage='Follow' />
);
}, [isFollow]);
return (
<IconButton className='mx-3' style={{ background: 'transparent' }} onClick={onClick} src={isFollow ? require('@tabler/icons/minus.svg') : require('@tabler/icons/plus.svg')} text={text} />
);
};
const FollowedHashtags = () => {
const intl = useIntl();
const [tags, setTags] = React.useState(null);
const dispatch = useAppDispatch();
const { tags: serverTags, loading } = useAppSelector((state) => ({ tags: state.tags.list, loading: state.tags.loading }));
// we want to keep our own list to allow user to refollow instantly if unfollow was a mistake
React.useEffect(() => {
if (loading || tags) return;
setTags(serverTags);
}, [serverTags, tags, loading]);
React.useEffect(() => {
dispatch(fetchTags());
}, [dispatch]);
return (
<Column label={intl.formatMessage(messages.heading)}>
{
!tags ? (
<Spinner />
) : (
<ScrollableList
className='flex flex-col gap-2'
scrollKey='followed_hashtags'
emptyMessage={<FormattedMessage id='column.tags.empty' defaultMessage="You don't follow any hashtag yet." />}
>
{
tags?.map((tag) => (
<div className='p-1 bg-gray-100 dark:bg-slate-900 rounded flex flex-wrap justify-between items-center'>
<div className='flex items-center grow'>
<Icon src={require('@tabler/icons/hash.svg')} />
<Text tag='span' weight='semibold'>
{ tag.name }
</Text>
</div>
<div className='flex items-center gap-3 grow shrink justify-end'>
<FollowButton id={tag.name} />
<span className='dark:text-slate-800 text-gray-300' >|</span>
<Button theme='link' to={`/tag/${tag.name}`}>
<div className='flex items-center'>
<FormattedMessage id='column.tags.see' defaultMessage='See' />
&nbsp;
<Icon src={require('@tabler/icons/arrow-right.svg')} />
</div>
</Button>
</div>
</div>
))
}
</ScrollableList>
)
}
</Column>
);
};
export default FollowedHashtags;

View file

@ -0,0 +1,130 @@
import isEqual from 'lodash/isEqual';
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { connectHashtagStream } from '../../actions/streaming';
import { expandHashtagTimeline, clearTimeline } from '../../actions/timelines';
import ColumnHeader from '../../components/column_header';
import { Column } from '../../components/ui';
import Timeline from '../ui/components/timeline';
const mapStateToProps = (state, props) => ({
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
});
export default @connect(mapStateToProps)
class HashtagTimeline extends React.PureComponent {
disconnects = [];
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
hasUnread: PropTypes.bool,
};
title = () => {
const title = [`#${this.props.params.id}`];
// TODO: wtf is all this?
// It exists in Mastodon's codebase, but undocumented
if (this.additionalFor('any')) {
title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />);
}
if (this.additionalFor('all')) {
title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />);
}
if (this.additionalFor('none')) {
title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />);
}
return title;
}
// TODO: wtf is this?
// It exists in Mastodon's codebase, but undocumented
additionalFor = (mode) => {
const { tags } = this.props.params;
if (tags && (tags[mode] || []).length > 0) {
return tags[mode].map(tag => tag.value).join('/');
} else {
return '';
}
}
_subscribe(dispatch, id, tags = {}) {
const any = (tags.any || []).map(tag => tag.value);
const all = (tags.all || []).map(tag => tag.value);
const none = (tags.none || []).map(tag => tag.value);
[id, ...any].map(tag => {
this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => {
const tags = status.tags.map(tag => tag.name);
return all.filter(tag => tags.includes(tag)).length === all.length &&
none.filter(tag => tags.includes(tag)).length === 0;
})));
});
}
_unsubscribe() {
this.disconnects.map(disconnect => disconnect());
this.disconnects = [];
}
componentDidMount() {
const { dispatch } = this.props;
const { id, tags } = this.props.params;
this._subscribe(dispatch, id, tags);
dispatch(expandHashtagTimeline(id, { tags }));
}
componentDidUpdate(prevProps) {
const { dispatch } = this.props;
const { id, tags } = this.props.params;
const { id: prevId, tags: prevTags } = prevProps.params;
if (id !== prevId || !isEqual(tags, prevTags)) {
this._unsubscribe();
this._subscribe(dispatch, id, tags);
dispatch(clearTimeline(`hashtag:${id}`));
dispatch(expandHashtagTimeline(id, { tags }));
}
}
componentWillUnmount() {
this._unsubscribe();
}
handleLoadMore = maxId => {
const { id, tags } = this.props.params;
this.props.dispatch(expandHashtagTimeline(id, { maxId, tags }));
}
render() {
const { hasUnread } = this.props;
const { id } = this.props.params;
return (
<Column label={`#${id}`} transparent withHeader={false}>
<div className='px-4 pt-4 sm:p-0'>
<ColumnHeader active={hasUnread} title={this.title()} />
</div>
<Timeline
scrollKey='hashtag_timeline'
timelineId={`hashtag:${id}`}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
divideType='space'
/>
</Column>
);
}
}

View file

@ -1,161 +0,0 @@
import isEqual from 'lodash/isEqual';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router-dom';
import { unfollowTag, followTag } from 'soapbox/actions/tags';
import Icon from 'soapbox/components/icon';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { connectHashtagStream } from '../../actions/streaming';
import { expandHashtagTimeline, clearTimeline } from '../../actions/timelines';
import ColumnHeader from '../../components/column_header';
import { Button, Column, Spinner } from '../../components/ui';
import Timeline from '../ui/components/timeline';
interface IFollowButton {
id: string,
}
const FollowButton: React.FC<IFollowButton> = ({ id }) => {
const { isFollow, loading } = useAppSelector(state => ({ loading: state.tags.loading, isFollow: state.tags.list.find((t) => t.name.toLowerCase() === id.toLowerCase()) }));
const dispatch = useAppDispatch();
const onClick = React.useCallback(() => {
const action = isFollow ? unfollowTag : followTag;
dispatch(action(id));
}, [isFollow, id]);
const text = React.useMemo(() => {
if (loading) return <FormattedMessage id='hashtag_timeline.loading' defaultMessage='Loading...' />;
return isFollow ? (
<FormattedMessage id='hashtag_timeline.unfollow' defaultMessage='Unfollow tag' />
) : (
<FormattedMessage id='hashtag_timeline.follow' defaultMessage='Follow this tag' />
);
}, [loading, isFollow]);
return (
<Button disabled={loading} theme={isFollow ? 'secondary' : 'primary'} size='sm' onClick={onClick}>
{
loading ? <Spinner withText={false} size={16} /> : <Icon src={isFollow ? require('@tabler/icons/minus.svg') : require('@tabler/icons/plus.svg')} className='mr-1' />
}
&nbsp;
{text}
</Button>
);
};
const HashtagTimeline: React.FC = () => {
const dispatch = useAppDispatch();
const disconnects = React.useRef([]);
const { id, tags } = useParams<{ id: string, tags: any }>();
const prevParams = React.useRef({ id, tags });
const isLoggedIn = useAppSelector((state) => state.me);
const hasUnread = useAppSelector((state) => (state.getIn(['timelines', `hashtag:${id}`, 'unread']) as number) > 0);
// TODO: wtf is this?
// It exists in Mastodon's codebase, but undocumented
const additionalFor = React.useCallback((mode) => {
if (tags && (tags[mode] || []).length > 0) {
return tags[mode].map(tag => tag.value).join('/');
} else {
return '';
}
}, [tags]);
const title = React.useMemo(() => {
const t: React.ReactNode[] = [`#${id}`];
// TODO: wtf is all this?
// It exists in Mastodon's codebase, but undocumented
if (additionalFor('any')) {
t.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: additionalFor('any') }} defaultMessage='or {additional}' />);
}
if (additionalFor('all')) {
t.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: additionalFor('all') }} defaultMessage='and {additional}' />);
}
if (additionalFor('none')) {
t.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: additionalFor('none') }} defaultMessage='without {additional}' />);
}
return t;
}, [id, additionalFor]);
const subscribe = React.useCallback((dispatch, _id, tags = {}) => {
const any = (tags.any || []).map(tag => tag.value);
const all = (tags.all || []).map(tag => tag.value);
const none = (tags.none || []).map(tag => tag.value);
[_id, ...any].map(tag => {
disconnects.current.push(dispatch(connectHashtagStream(_id, tag, status => {
const tags = status.tags.map(tag => tag.name);
return all.filter(tag => tags.includes(tag)).length === all.length &&
none.filter(tag => tags.includes(tag)).length === 0;
})));
});
}, []);
const unsubscribe = React.useCallback(() => {
disconnects.current.map((d) => d());
disconnects.current = [];
}, []);
React.useEffect(() => {
const { id: prevId, tags: prevTags } = prevParams.current;
if (id !== prevId || !isEqual(tags, prevTags)) {
dispatch(clearTimeline(`hashtag:${id}`));
}
subscribe(dispatch, id, tags);
dispatch(expandHashtagTimeline(id, { tags }));
return () => {
unsubscribe();
};
}, [dispatch, tags, id, subscribe, unsubscribe]);
const handleLoadMore = React.useCallback((maxId) => {
dispatch(expandHashtagTimeline(id, { maxId, tags }));
}, [dispatch, id, tags]);
return (
<Column label={`#${id}`} transparent withHeader={false}>
<div className='px-4 pt-4 sm:p-0'>
<ColumnHeader
active={hasUnread}
title={
<div className='flex justify-between items-center'>
{ title }
{
isLoggedIn && (
<FollowButton id={id} />
)
}
</div>
}
/>
</div>
<Timeline
scrollKey='hashtag_timeline'
timelineId={`hashtag:${id}`}
onLoadMore={handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
divideType='space'
/>
</Column>
);
};
export default HashtagTimeline;

View file

@ -1,15 +1,16 @@
import { List as ImmutableList } from 'immutable';
import Immutable from 'immutable';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { fetchAccount } from 'soapbox/actions/accounts';
import { prepareRequest } from 'soapbox/actions/consumer-auth';
import Account from 'soapbox/components/account';
import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui';
import Account from 'soapbox/components/account';
import VerificationBadge from 'soapbox/components/verification_badge';
import RegistrationForm from 'soapbox/features/auth_login/components/registration_form';
import { useAppDispatch, useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
import { capitalize } from 'soapbox/utils/strings';
import { List as ImmutableList } from 'immutable';
const LandingPage = () => {
const dispatch = useAppDispatch();
@ -22,14 +23,14 @@ const LandingPage = () => {
const pepeOpen = useAppSelector(state => state.verification.instance.get('registrations') === true);
React.useEffect(() => {
const staff = (instance.pleroma.getIn(['metadata', 'staff_accounts']) as ImmutableList<string>).map((s) => s.split('/').pop());
const staff = (instance.pleroma.getIn(["metadata", "staff_accounts"]) as ImmutableList<string>).map((s) => s.split('/').pop());
staff.forEach((s) => dispatch(fetchAccount(s)));
}, [instance, dispatch]);
const staffAccounts = React.useMemo(() => {
// eslint-disable-next-line eqeqeq
if (accounts == null) return [];
if(accounts == null) return [];
const a = Object.values(accounts?.toJSON()).filter((a) => a.admin || a.moderator);
console.log(a);
return a;
}, [accounts]);
@ -61,33 +62,6 @@ const LandingPage = () => {
);
};
/** Custom Registrations */
const renderCustom = () => {
const { customRegProvider } = soapboxConfig;
const { customRegUrl } = soapboxConfig;
const onClickUrl = () => {
window.open(customRegUrl);
};
return (
<Stack space={3}>
<Stack>
<Text size='2xl' weight='bold' align='center'>
<FormattedMessage id='registrations.redirect' defaultMessage='No account yet?' />
</Text>
</Stack>
<Button onClick={onClickUrl} theme='primary' block>
<FormattedMessage
id='registration.custom_provider_tooltip'
defaultMessage='Sign up with {provider}'
values={{ provider: capitalize(customRegProvider) }}
/>
</Button>
</Stack>
);
};
/** Mastodon API registrations are open */
const renderOpen = () => {
return <RegistrationForm />;
@ -146,8 +120,6 @@ const LandingPage = () => {
return renderPepe();
} else if (features.accountCreation && instance.registrations) {
return renderOpen();
} else if (soapboxConfig.customRegProvider) {
return renderCustom();
} else {
return renderClosed();
}
@ -170,16 +142,16 @@ const LandingPage = () => {
</Text>
<div>
<div className='flex justify-between'>
<h2 className='text-xl text-gray-800 dark:text-gray-300'>
<h2 className="text-xl text-gray-800">
<FormattedMessage id='landingPage.admins' defaultMessage='Moderators' />
</h2>
</div>
<a href={`mailto:${instance.email}`}>
{instance.email}
</a>
<Stack space={3} className='mt-4'>
{instance.email}
</a>
<Stack space={3} className="mt-4">
{
staffAccounts.map((s) => <Account key={s.id} hideActions account={s} />)
staffAccounts.map((s) => <Account key={s.id} hideActions={true} account={s} />)
}
</Stack>
</div>

View file

@ -23,13 +23,19 @@ const Mutes: React.FC = () => {
const accountIds = useAppSelector((state) => state.user_lists.mutes.items);
const hasMore = useAppSelector((state) => !!state.user_lists.mutes.next);
const loading = useAppSelector((state) => state.user_lists.mutes.isLoading);
React.useEffect(() => {
dispatch(fetchMutes());
}, []);
if (!accountIds) {
return (
<Column>
<Spinner />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />;
return (
@ -39,16 +45,12 @@ const Mutes: React.FC = () => {
onLoadMore={() => handleLoadMore(dispatch)}
hasMore={hasMore}
emptyMessage={emptyMessage}
itemClassName='flex flex-col gap-3 pb-4'
itemClassName='pb-4'
>
{accountIds.map((id) =>
<AccountContainer key={id} id={id} actionType='muting' />,
)}
{
loading && <Spinner />
}
</ScrollableList>
</Column>
);
};

View file

@ -40,7 +40,7 @@ const Feeds = ({ onNext } : { onNext: () => void }) => {
</div>
<div className='flex-grow-1'>
<h3 className='text-xl font-bold mb-2 text-primary-500'>
<FormattedMessage id='onboarding.feeds.title3' defaultMessage='Explore' />
<FormattedMessage id='onboarding.feeds.title3' defaultMessage='Discover' />
</h3>
<p dangerouslySetInnerHTML={{ __html: intl.formatMessage(messages.col3) }} />
<p className='mt-4 italic'>

View file

@ -31,7 +31,7 @@ const Privacy = ({ onNext } : { onNext : () => void}) => {
</div>
<div className='flex-grow-1 w-1/2'>
<h4 className='items-center text-xl font-bold'>
<Icon className='inline-block text-primary-500 align-middle mr-1 w-6 h-6' src={require('@tabler/icons/eye-off.svg')} />
<Icon className='inline-block text-primary-500 align-middle mr-1 w-6 h-6' src={require('@tabler/icons/lock-open.svg')} />
<span className='align-middle'>
<FormattedMessage id='onboarding.privacy.unlisted-title' defaultMessage='Unlisted' />
</span>

View file

@ -81,13 +81,10 @@ const messages = defineMessages({
display_media_hide_all: { id: 'preferences.fields.display_media.hide_all', defaultMessage: 'Always hide media' },
display_media_show_all: { id: 'preferences.fields.display_media.show_all', defaultMessage: 'Always show media' },
privacy_public: { id: 'preferences.options.privacy_public', defaultMessage: 'Public' },
privacy_local: { id: 'preferences.options.privacy_local', defaultMessage: 'Local-only' },
privacy_unlisted: { id: 'preferences.options.privacy_unlisted', defaultMessage: 'Unlisted' },
privacy_followers_only: { id: 'preferences.options.privacy_followers_only', defaultMessage: 'Followers-only' },
content_type_plaintext: { id: 'preferences.options.content_type_plaintext', defaultMessage: 'Plain text' },
content_type_markdown: { id: 'preferences.options.content_type_markdown', defaultMessage: 'Markdown' },
bubble_timeline_label: { id: 'preferences.options.bubble_timeline_label', defaultMessage: 'Curated timeline' },
bubble_timeline_hint: { id: 'preferences.options.bubble_timeline_hint', defaultMessage: 'Replace the fediverse timeline with the Akkoma bubble timeline showing public statuses from a list of handpicked instances.' },
});
const Preferences = () => {
@ -112,7 +109,6 @@ const Preferences = () => {
const defaultPrivacyOptions = React.useMemo(() => ({
public: intl.formatMessage(messages.privacy_public),
...(features.localOnlyPrivacy ? { local: intl.formatMessage(messages.privacy_local) } : {}),
unlisted: intl.formatMessage(messages.privacy_unlisted),
private: intl.formatMessage(messages.privacy_followers_only),
}), []);
@ -125,17 +121,6 @@ const Preferences = () => {
return (
<Form>
<List>
{
features.bubbleTimeline && (
<ListItem
label={intl.formatMessage(messages.bubble_timeline_label)}
hint={intl.formatMessage(messages.bubble_timeline_hint)}
>
<SettingToggle settings={settings} settingPath={['public', 'bubble']} onChange={onToggleChange} />
</ListItem>
)
}
<ListItem
label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reposts' />}
hint={<FormattedMessage id='preferences.hints.feed' defaultMessage='In your home feed' />}

View file

@ -1,53 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router-dom';
import { fetchAccountByUsername } from 'soapbox/actions/accounts';
import MissingIndicator from 'soapbox/components/missing_indicator';
import { Card } from 'soapbox/components/ui';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import {
ProfileFieldsPanel,
} from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { findAccountByUsername } from 'soapbox/selectors';
const ProfileFields = () => {
const { username } = useParams<{ username: string }>();
const dispatch = useAppDispatch();
const account = useAppSelector(state => {
const account = findAccountByUsername(state, username);
if (!account) {
dispatch(fetchAccountByUsername(username));
}
return account;
});
const isAccount = useAppSelector(state => !!state.getIn(['accounts', account?.id]));
if (!isAccount) {
return (
<MissingIndicator />
);
}
return (
account.fields.isEmpty() ? (
<div className='mt-2'>
<Card variant='rounded' size='lg'>
<FormattedMessage id='account.no_fields' defaultMessage='This section is empty for now.' />
</Card>
</div>
) : (
<BundleContainer fetchComponent={ProfileFieldsPanel}>
{Component => <Component account={account} />}
</BundleContainer>
)
);
};
export default ProfileFields;

View file

@ -4,16 +4,20 @@ import { Link } from 'react-router-dom';
import { changeSetting } from 'soapbox/actions/settings';
import { connectPublicStream } from 'soapbox/actions/streaming';
import { expandPublicTimeline, expandBubbleTimeline } from 'soapbox/actions/timelines';
import { expandPublicTimeline } from 'soapbox/actions/timelines';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import { Button, Column, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useSettings, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { Column } from 'soapbox/components/ui';
import Accordion from 'soapbox/features/ui/components/accordion';
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
import PinnedHostsPicker from '../remote_timeline/components/pinned_hosts_picker';
import Timeline from '../ui/components/timeline';
import ColumnSettings from './containers/column_settings_container';
const messages = defineMessages({
title: { id: 'column.public', defaultMessage: 'Explore' },
title: { id: 'column.public', defaultMessage: 'Fediverse timeline' },
dismiss: { id: 'fediverse_tab.explanation_box.dismiss', defaultMessage: 'Don\'t show again' },
});
const CommunityTimeline = () => {
@ -21,60 +25,53 @@ const CommunityTimeline = () => {
const dispatch = useAppDispatch();
const settings = useSettings();
const account = useOwnAccount();
const onlyMedia = settings.getIn(['public', 'other', 'onlyMedia']);
const timelineId = 'public';
const siteTitle = useAppSelector((state) => state.instance.title);
const explanationBoxExpanded = settings.get('explanationBox');
const showExplanationBox = settings.get('showExplanationBox');
const features = useFeatures();
const bubbleTimeline = account && features.bubbleTimeline && settings.getIn(['public', 'bubble']);
const explanationBoxMenu = () => {
return [{ text: intl.formatMessage(messages.dismiss), action: dismissExplanationBox }];
};
const timelineId = React.useMemo(() => !bubbleTimeline ? 'public' : 'bubble', [bubbleTimeline]);
const dismissExplanationBox = React.useCallback(() => {
const dismissExplanationBox = () => {
dispatch(changeSetting(['showExplanationBox'], false));
}, [dispatch]);
};
const handleLoadMore = React.useCallback((maxId: string) => {
if(!bubbleTimeline) {
dispatch(expandPublicTimeline({ maxId, onlyMedia }));
} else {
dispatch(expandBubbleTimeline({ maxId, onlyMedia }));
}
}, [bubbleTimeline, dispatch, onlyMedia]);
const toggleExplanationBox = (setting: boolean) => {
dispatch(changeSetting(['explanationBox'], setting));
};
const handleRefresh = React.useCallback(() => {
if(!bubbleTimeline) {
return dispatch(expandPublicTimeline({ onlyMedia } as any));
} else {
return dispatch(expandBubbleTimeline({ onlyMedia } as any));
}
}, [bubbleTimeline, dispatch, onlyMedia]);
const handleLoadMore = (maxId: string) => {
dispatch(expandPublicTimeline({ maxId, onlyMedia }));
};
const handleRefresh = () => {
return dispatch(expandPublicTimeline({ onlyMedia } as any));
};
useEffect(() => {
if(!bubbleTimeline) {
dispatch(expandPublicTimeline({ onlyMedia } as any));
const disconnect = dispatch(connectPublicStream({ onlyMedia }));
dispatch(expandPublicTimeline({ onlyMedia } as any));
const disconnect = dispatch(connectPublicStream({ onlyMedia }));
return () => {
disconnect();
};
} else {
dispatch(expandBubbleTimeline({ onlyMedia } as any));
// bubble timeline doesnt have streaming for now
}
}, [onlyMedia, bubbleTimeline]);
return () => {
disconnect();
};
}, [onlyMedia]);
return (
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
<PinnedHostsPicker />
{showExplanationBox && <div className='mb-4'>
<Text size="lg" weight="bold" className="mb-2">
<FormattedMessage id='fediverse_tab.explanation_box.title' defaultMessage='What is the Fediverse?' />
</Text>
<Accordion
headline={<FormattedMessage id='fediverse_tab.explanation_box.title' defaultMessage='What is the Fediverse?' />}
menu={explanationBoxMenu()}
expanded={explanationBoxExpanded}
onToggle={toggleExplanationBox}
>
<FormattedMessage
id='fediverse_tab.explanation_box.explanation'
defaultMessage='{site_title} is part of the Fediverse, a social network made up of thousands of independent social media sites (aka "servers"). The posts you see here are from 3rd-party servers. You have the freedom to engage with them, or to block any server you don&apos;t like. Pay attention to the full username after the second @ symbol to know which server a post is from. To see only {site_title} posts, visit {local}.'
@ -91,18 +88,7 @@ const CommunityTimeline = () => {
),
}}
/>
{
bubbleTimeline && (
<p className='mt-2'>
<FormattedMessage id='fediverse_tab.explanation_box.bubble' defaultMessage='This timeline shows you all the statuses published on a selection of other instances curated by your moderators.' />
</p>
)
}
<div className="text-right">
<Button theme="link" onClick={dismissExplanationBox}>
<FormattedMessage id='fediverse_tab.explanation_box.dismiss' defaultMessage="Don\'t show again" />
</Button>
</div>
</Accordion>
</div>}
<PullToRefresh onRefresh={handleRefresh}>
<Timeline

View file

@ -1,18 +1,20 @@
import React, { useEffect, useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { connectRemoteStream } from 'soapbox/actions/streaming';
import { expandRemoteTimeline } from 'soapbox/actions/timelines';
import IconButton from 'soapbox/components/icon_button';
import { HStack, Text } from 'soapbox/components/ui';
import Column from 'soapbox/features/ui/components/column';
import { useAppDispatch, useSettings } from 'soapbox/hooks';
import { isMobile } from 'soapbox/is_mobile';
import Timeline from '../ui/components/timeline';
import PinnedHostsPicker from './components/pinned_hosts_picker';
const messages = defineMessages({
title: { id: 'remote_timeline.filter_message', defaultMessage: 'You are viewing the timeline of {instance}.' },
title: { id: 'column.remote', defaultMessage: 'Federated timeline' },
});
interface IRemoteTimeline {
@ -24,6 +26,7 @@ interface IRemoteTimeline {
/** View statuses from a remote instance. */
const RemoteTimeline: React.FC<IRemoteTimeline> = ({ params }) => {
const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
const instance = params?.instance as string;
@ -34,12 +37,18 @@ const RemoteTimeline: React.FC<IRemoteTimeline> = ({ params }) => {
const timelineId = 'remote';
const onlyMedia = !!settings.getIn(['remote', 'other', 'onlyMedia']);
const pinned: boolean = (settings.getIn(['remote_timeline', 'pinnedHosts']) as any).includes(instance);
const disconnect = () => {
if (stream.current) {
stream.current();
}
};
const handleCloseClick: React.MouseEventHandler = () => {
history.push('/timeline/fediverse');
};
const handleLoadMore = (maxId: string) => {
dispatch(expandRemoteTimeline(instance, { maxId, onlyMedia }));
};
@ -47,36 +56,41 @@ const RemoteTimeline: React.FC<IRemoteTimeline> = ({ params }) => {
useEffect(() => {
disconnect();
dispatch(expandRemoteTimeline(instance, { onlyMedia, maxId: undefined }));
stream.current = dispatch(connectRemoteStream(instance, { onlyMedia }));
if (!isMobile(window.innerWidth)) {
stream.current = dispatch(connectRemoteStream(instance, { onlyMedia }));
return () => {
disconnect();
stream.current = null;
};
}
return () => {
disconnect();
stream.current = null;
};
}, [onlyMedia]);
return (
<div className='pt-3'>
<Column label={instance} heading={intl.formatMessage(messages.title, { instance })} transparent withHeader={false}>
{instance && <PinnedHostsPicker host={instance} />}
<Timeline
scrollKey={`${timelineId}_${instance}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}:${instance}`}
onLoadMore={handleLoadMore}
emptyMessage={
<FormattedMessage
id='empty_column.remote'
defaultMessage='There is nothing here! Manually follow users from {instance} to fill it up.'
values={{ instance }}
/>
}
divideType='space'
/>
</Column>
</div>
<Column label={intl.formatMessage(messages.title)} heading={instance} transparent withHeader={false}>
{instance && <PinnedHostsPicker host={instance} />}
{!pinned && <HStack className='mb-4 px-2' space={2}>
<IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/x.svg')} onClick={handleCloseClick} />
<Text>
<FormattedMessage
id='remote_timeline.filter_message'
defaultMessage='You are viewing the timeline of {instance}.'
values={{ instance }}
/>
</Text>
</HStack>}
<Timeline
scrollKey={`${timelineId}_${instance}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}:${instance}`}
onLoadMore={handleLoadMore}
emptyMessage={
<FormattedMessage
id='empty_column.remote'
defaultMessage='There is nothing here! Manually follow users from {instance} to fill it up.'
values={{ instance }}
/>
}
divideType='space'
/>
</Column>
);
};

View file

@ -7,7 +7,6 @@ import { fetchMfa } from 'soapbox/actions/mfa';
import List, { ListItem } from 'soapbox/components/list';
import { Card, CardBody, CardHeader, CardTitle, Column } from 'soapbox/components/ui';
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { ConfigDB } from 'soapbox/utils/config_db';
import { getFeatures } from 'soapbox/utils/features';
import Preferences from '../preferences';
@ -28,14 +27,6 @@ const messages = defineMessages({
other: { id: 'settings.other', defaultMessage: 'Other options' },
mfaEnabled: { id: 'mfa.enabled', defaultMessage: 'Enabled' },
mfaDisabled: { id: 'mfa.disabled', defaultMessage: 'Disabled' },
content: { id: 'settings.content', defaultMessage: 'Content' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domainBlocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
backups: { id: 'column.backups', defaultMessage: 'Backups' },
importData: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' },
exportData: { id: 'column.export_data', defaultMessage: 'Export data' },
});
/** User settings page. */
@ -45,7 +36,6 @@ const Settings = () => {
const intl = useIntl();
const mfa = useAppSelector((state) => state.security.get('mfa'));
const configuration = useAppSelector((state) => state.admin.get('configs'));
const features = useAppSelector((state) => getFeatures(state.instance));
const account = useOwnAccount();
@ -57,17 +47,8 @@ const Settings = () => {
const navigateToDeleteAccount = () => history.push('/settings/account');
const navigateToMoveAccount = () => history.push('/settings/migration');
const navigateToAliases = () => history.push('/settings/aliases');
const navigateToBackups = () => history.push('/settings/backups');
const navigateToImportData = () => history.push('/settings/import');
const navigateToExportData = () => history.push('/settings/export');
const navigateToBlocks = () => history.push('/blocks');
const navigateToMutes = () => history.push('/mutes');
const navigateToDomainBlocks = () => history.push('/domain_blocks');
const navigateToFilters = () => history.push('/filters');
const isMfaEnabled = mfa.getIn(['settings', 'totp']);
const isLdapEnabled = React.useMemo(() => ConfigDB.find(configuration, ':pleroma', ':ldap')?.get('value').find((e) => e.get('tuple').get(0) === ':enabled')?.getIn(['tuple', 1]), [configuration]);
useEffect(() => {
dispatch(fetchMfa());
@ -92,23 +73,6 @@ const Settings = () => {
</List>
</CardBody>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.content)} />
</CardHeader>
<CardBody>
<List>
<ListItem label={intl.formatMessage(messages.blocks)} onClick={navigateToBlocks} />
<ListItem label={intl.formatMessage(messages.mutes)} onClick={navigateToMutes} />
{
features.federating && <ListItem label={intl.formatMessage(messages.domainBlocks)} onClick={navigateToDomainBlocks} />
}
{
features.filters && <ListItem label={intl.formatMessage(messages.filters)} onClick={navigateToFilters} />
}
</List>
</CardBody>
{(features.security || features.sessions) && (
<>
<CardHeader>
@ -120,11 +84,7 @@ const Settings = () => {
{features.security && (
<>
<ListItem label={intl.formatMessage(messages.changeEmail)} onClick={navigateToChangeEmail} />
{
!isLdapEnabled && (
<ListItem label={intl.formatMessage(messages.changePassword)} onClick={navigateToChangePassword} />
)
}
<ListItem label={intl.formatMessage(messages.changePassword)} onClick={navigateToChangePassword} />
<ListItem label={intl.formatMessage(messages.configureMfa)} onClick={navigateToMfa}>
{isMfaEnabled ?
intl.formatMessage(messages.mfaEnabled) :
@ -156,27 +116,14 @@ const Settings = () => {
<CardBody>
<List>
{features.importData && (
<ListItem label={intl.formatMessage(messages.importData)} onClick={navigateToImportData} />
{features.security && (
<ListItem label={intl.formatMessage(messages.deleteAccount)} onClick={navigateToDeleteAccount} />
)}
{features.exportData && (
<ListItem label={intl.formatMessage(messages.exportData)} onClick={navigateToExportData} />
)}
{features.backups && (
<ListItem label={intl.formatMessage(messages.backups)} onClick={navigateToBackups} />
)}
{features.federating && (features.accountMoving ? (
<ListItem label={intl.formatMessage(messages.accountMigration)} onClick={navigateToMoveAccount} />
) : features.accountAliases && (
<ListItem label={intl.formatMessage(messages.accountAliases)} onClick={navigateToAliases} />
))}
{features.security && (
<ListItem label={intl.formatMessage(messages.deleteAccount)} onClick={navigateToDeleteAccount} />
)}
</List>
</CardBody>
</>

View file

@ -1,18 +1,16 @@
import React from 'react';
import EmojiPicker from 'soapbox/components/emoji_picker';
import IconPickerDropdown from './icon_picker_dropdown';
interface IIconPicker {
value: string,
onChange: React.ChangeEventHandler,
}
const IconPicker: React.FC<IIconPicker> = ({ value, onChange }) => {
return (
<div className='mt-1 relative rounded-md shadow-sm dark:bg-slate-800 border border-solid border-gray-300 dark:border-gray-600 rounded-md'>
<EmojiPicker button={<div className='grayscale h-[38px] w-[38px] text-lg flex items-center justify-center cursor-pointer'>{ value }</div>} onPickEmoji={onChange} />
<IconPickerDropdown value={value} onPickEmoji={onChange} />
</div>
);
};

View file

@ -0,0 +1,245 @@
import classNames from 'classnames';
import { supportsPassiveEvents } from 'detect-passive-events';
import Picker from 'emoji-mart/dist-es/components/picker/picker';
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay';
import Icon from 'soapbox/components/icon';
const messages = defineMessages({
emoji: { id: 'icon_button.label', defaultMessage: 'Select icon' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
emoji_not_found: { id: 'icon_button.not_found', defaultMessage: 'No icons!! (╯°□°)╯︵ ┻━┻' },
custom: { id: 'icon_button.icons', defaultMessage: 'Icons' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
});
const backgroundImageFn = () => '';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const categoriesSort = ['custom'];
@injectIntl
class IconPickerMenu extends React.PureComponent {
static propTypes = {
custom_emojis: PropTypes.object,
loading: PropTypes.bool,
onClose: PropTypes.func.isRequired,
onPick: PropTypes.func.isRequired,
style: PropTypes.object,
placement: PropTypes.string,
arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string,
intl: PropTypes.object.isRequired,
};
static defaultProps = {
style: {},
loading: true,
};
state = {
modifierOpen: false,
placement: null,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
componentDidMount() {
setTimeout(() => {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}, 200);
}
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
if (!c) return;
// Nice and dirty hack to display the icons
c.querySelectorAll('button.emoji-mart-emoji > img').forEach(elem => {
const newIcon = document.createElement('span');
newIcon.innerHTML = `<i class="fa fa-${elem.parentNode.getAttribute('title')} fa-hack"></i>`;
elem.parentNode.replaceChild(newIcon, elem);
});
}
getI18n = () => {
const { intl } = this.props;
return {
search: intl.formatMessage(messages.emoji_search),
notfound: intl.formatMessage(messages.emoji_not_found),
categories: {
search: intl.formatMessage(messages.search_results),
custom: intl.formatMessage(messages.custom),
},
};
}
handleClick = emoji => {
emoji.native = emoji.colons;
this.props.onClose();
this.props.onPick(emoji);
}
buildIcons = (customEmojis, autoplay = false) => {
const emojis = [];
Object.values(customEmojis).forEach(category => {
category.forEach(function(icon) {
const name = icon.replace('fa fa-', '');
if (icon !== 'email' && icon !== 'memo') {
emojis.push({
id: name,
name,
short_names: [name],
emoticons: [],
keywords: [name],
imageUrl: '',
});
}
});
});
return emojis;
};
render() {
const { loading, style, intl, custom_emojis } = this.props;
if (loading) {
return <div style={{ width: 299 }} />;
}
const data = { compressed: true, categories: [], aliases: [], emojis: [] };
const title = intl.formatMessage(messages.emoji);
const { modifierOpen } = this.state;
return (
<div className={classNames('font-icon-picker emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<Picker
perLine={8}
emojiSize={22}
include={categoriesSort}
sheetSize={32}
custom={this.buildIcons(custom_emojis)}
color=''
emoji=''
set=''
title={title}
i18n={this.getI18n()}
onClick={this.handleClick}
showPreview={false}
backgroundImageFn={backgroundImageFn}
emojiTooltip
noShowAnchors
data={data}
/>
</div>
);
}
}
export default @injectIntl
class IconPickerDropdown extends React.PureComponent {
static propTypes = {
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
intl: PropTypes.object.isRequired,
onPickEmoji: PropTypes.func.isRequired,
value: PropTypes.string,
};
state = {
active: false,
loading: false,
};
setRef = (c) => {
this.dropdown = c;
}
onShowDropdown = ({ target }) => {
const { top } = target.getBoundingClientRect();
this.setState({active: true, placement: top * 2 < innerHeight ? 'bottom' : 'top' });
}
onHideDropdown = () => {
this.setState({ active: false });
}
onToggle = (e) => {
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
if (this.state.active) {
this.onHideDropdown();
} else {
this.onShowDropdown(e);
}
}
}
handleKeyDown = e => {
if (e.key === 'Escape') {
this.onHideDropdown();
}
}
setTargetRef = c => {
this.target = c;
}
findTarget = () => {
return this.target;
}
render() {
const { intl, onPickEmoji, value } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state;
const forkAwesomeIcons = require('../forkawesome.json');
return (
<div onKeyDown={this.handleKeyDown}>
<div
ref={this.setTargetRef}
className='h-[38px] w-[38px] text-lg flex items-center justify-center cursor-pointer'
title={title}
aria-label={title}
aria-expanded={active}
role='button'
onClick={this.onToggle}
onKeyDown={this.onToggle}
tabIndex={0}
>
<Icon id={value} />
</div>
<Overlay show={active} placement={placement} target={this.findTarget}>
<IconPickerMenu
custom_emojis={forkAwesomeIcons}
loading={loading}
onClose={this.onHideDropdown}
onPick={onPickEmoji}
/>
</Overlay>
</div>
);
}
}

View file

@ -18,7 +18,7 @@ const PromoPanelInput: StreamfieldComponent<PromoPanelItem> = ({ value, onChange
const intl = useIntl();
const handleIconChange = (icon: any) => {
onChange(value.set('icon', icon.native));
onChange(value.set('icon', icon.id));
};
const handleChange = (key: 'text' | 'url'): React.ChangeEventHandler<HTMLInputElement> => {

View file

@ -44,7 +44,6 @@ const messages = defineMessages({
verifiedCanEditNameLabel: { id: 'soapbox_config.verified_can_edit_name_label', defaultMessage: 'Allow verified users to edit their own display name.' },
displayFqnLabel: { id: 'soapbox_config.display_fqn_label', defaultMessage: 'Display domain (eg @user@domain) for local accounts.' },
greentextLabel: { id: 'soapbox_config.greentext_label', defaultMessage: 'Enable greentext support' },
quoteRT: { id: 'soapbox_config.quote_rt', defaultMessage: 'Enable Quote RT' },
promoPanelIconsLink: { id: 'soapbox_config.hints.promo_panel_icons.link', defaultMessage: 'Soapbox Icons List' },
authenticatedProfileLabel: { id: 'soapbox_config.authenticated_profile_label', defaultMessage: 'Profiles require authentication' },
authenticatedProfileHint: { id: 'soapbox_config.authenticated_profile_hint', defaultMessage: 'Users must be logged-in to view replies and media on user profiles.' },
@ -52,7 +51,7 @@ const messages = defineMessages({
singleUserModeHint: { id: 'soapbox_config.single_user_mode_hint', defaultMessage: 'Front page will redirect to a given user profile.' },
singleUserModeProfileLabel: { id: 'soapbox_config.single_user_mode_profile_label', defaultMessage: 'Main user handle' },
singleUserModeProfileHint: { id: 'soapbox_config.single_user_mode_profile_hint', defaultMessage: '@handle' },
homeDescription: { id: 'soapbox_config.home_description', defaultMessage: 'Instance\'s description shown in Home page. Supports HTML. Use [users] to insert the number of current users on the instance.' },
homeDescription: { id: 'soapbox_config.home_description', defaultMessage: 'Instance\'s description shown in Home page. Supports HTML. Use [users] to insert the number of current users on the instance.' }
});
type ValueGetter<T = Element> = (e: React.ChangeEvent<T>) => any;
@ -73,7 +72,6 @@ const SoapboxConfig: React.FC = () => {
const initialData = useAppSelector(state => state.soapbox);
const [isLoading, setLoading] = useState(false);
const [data, setData] = useState(initialData);
const [jsonEditorExpanded, setJsonEditorExpanded] = useState(false);
@ -263,13 +261,6 @@ const SoapboxConfig: React.FC = () => {
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.quoteRT)}>
<Toggle
checked={soapbox.quotePosts === true}
onChange={handleChange(['quotePosts'], (e) => e.target.checked)}
/>
</ListItem>
<ListItem
label={intl.formatMessage(messages.authenticatedProfileLabel)}
hint={intl.formatMessage(messages.authenticatedProfileHint)}

View file

@ -1,21 +1,15 @@
import React, { useRef } from 'react';
import { FormattedDate, FormattedMessage, useIntl } from 'react-intl';
import { getSettings } from 'soapbox/actions/settings';
import Icon from 'soapbox/components/icon';
import StatusMedia from 'soapbox/components/status-media';
import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
import StatusContent from 'soapbox/components/status_content';
import { HStack, Stack, Text, Button } from 'soapbox/components/ui';
import { HStack, Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
import { useAppSelector, useOwnAccount, useLogo } from 'soapbox/hooks';
import { getFeatures } from 'soapbox/utils/features';
import { getActualStatus } from 'soapbox/utils/status';
import StatusInteractionBar from './status-interaction-bar';
import type { List as ImmutableList } from 'immutable';
@ -26,7 +20,6 @@ interface IDetailedStatus {
onOpenMedia: (media: ImmutableList<AttachmentEntity>, index: number) => void,
onOpenVideo: (media: ImmutableList<AttachmentEntity>, start: number) => void,
onToggleHidden: (status: StatusEntity) => void,
onTranslate: (status: StatusEntity, language: string) => void,
showMedia: boolean,
onOpenCompareHistoryModal: (status: StatusEntity) => void,
onToggleMediaVisibility: () => void,
@ -37,23 +30,10 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
onToggleHidden,
onOpenCompareHistoryModal,
onToggleMediaVisibility,
onTranslate,
showMedia,
}) => {
const intl = useIntl();
const node = useRef<HTMLDivElement>(null);
const ownAccount = useOwnAccount();
const locale = useAppSelector((state) => getSettings(state).get('locale')) as string;
const localeTranslated = React.useMemo(() => (new Intl.DisplayNames([locale], { type: 'language' })).of(locale), [locale]);
const features = useAppSelector((state) => getFeatures(state.instance));
const logo = useLogo();
const actualStatus = getActualStatus(status);
const { account } = actualStatus;
const canTranslate = React.useMemo(() =>
ownAccount && features.translations && actualStatus.language !== locale
, [ownAccount, features, actualStatus]);
const handleExpandedToggle = () => {
onToggleHidden(status);
@ -63,25 +43,12 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
onOpenCompareHistoryModal(status);
};
const handleTranslateStatus = React.useCallback(() => {
onTranslate(status, locale);
}, [status, locale]);
const privacyIcon = React.useMemo(() => {
switch (actualStatus?.visibility) {
default:
case 'public': return require('@tabler/icons/world.svg');
case 'unlisted': return require('@tabler/icons/eye-off.svg');
case 'local': return logo;
case 'private': return require('@tabler/icons/lock.svg');
case 'direct': return require('@tabler/icons/mail.svg');
}
}, [actualStatus?.visibility]);
const actualStatus = getActualStatus(status);
if (!actualStatus) return null;
const { account } = actualStatus;
if (!account || typeof account !== 'object') return null;
let statusTypeIcon = null;
let quote;
@ -97,33 +64,16 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
}
}
if (actualStatus.visibility === 'direct') {
statusTypeIcon = <Icon className='text-gray-700 dark:text-gray-600' src={require('@tabler/icons/mail.svg')} />;
} else if (actualStatus.visibility === 'private') {
statusTypeIcon = <Icon className='text-gray-700 dark:text-gray-600' src={require('@tabler/icons/lock.svg')} />;
}
return (
<div className='border-box'>
<div ref={node} className='detailed-actualStatus' tabIndex={-1}>
<div className='mb-4 flex items-center justify-between gap-1'>
{
canTranslate ? (
!actualStatus.translations.get(locale) ? (
<Button theme='link' size='sm' onClick={handleTranslateStatus}>
<Icon className='mr-1' src={require('@tabler/icons/language.svg')} />
<FormattedMessage id='actualStatuses.translate' defaultMessage='Translate' />
</Button>
) : (
<Text theme='subtle' className='flex items-center' size='xs'>
<Icon className='mr-1' src={require('@tabler/icons/check.svg')} />
<FormattedMessage id='actualStatuses.translated' defaultMessage='Translate' />
</Text>
)
) : (
<Icon className='text-gray-300 dark:text-slate-500' src={require('@tabler/icons/note.svg')} />
)
}
<Icon aria-hidden src={privacyIcon} className='h-5 w-5 shrink-0 text-gray-400 dark:text-gray-600' />
</div>
<div className='mb-3'>
<div className='mb-4'>
<AccountContainer
key={account.id}
id={account.id}
@ -141,41 +91,20 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
onExpandedToggle={handleExpandedToggle}
/>
{
actualStatus.translations.get(locale) && !actualStatus.hidden && (
<>
<hr className='my-3' />
<Text className='mb-1' size='md' weight='medium'>
<FormattedMessage
id='actualStatuses.translate_done'
defaultMessage='Status translated to {locale}'
values={{ locale: localeTranslated }}
/>
</Text>
<div
className='status__content'
lang={locale}
dangerouslySetInnerHTML={{ __html: actualStatus.translations.get(locale) }}
/>
</>
)
}
<StatusMedia
status={actualStatus}
showMedia={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
{!actualStatus.hidden && (
<>
<StatusMedia
status={actualStatus}
showMedia={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
{ quote }
</>
)}
{quote}
<HStack justifyContent='between' alignItems='center' className='py-2'>
<StatusInteractionBar status={actualStatus} />
<Stack space={1} className='items-end mb-3'>
<Stack space={1} alignItems='center'>
{statusTypeIcon}
<span>
<a href={actualStatus.url} target='_blank' rel='noopener' className='hover:underline'>
<Text tag='span' theme='muted' size='sm'>
@ -201,8 +130,6 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
</span>
</Stack>
</HStack>
</div>
</div>
);

View file

@ -7,10 +7,6 @@ import { useAppSelector } from 'soapbox/hooks';
/** Prompts logged-out users to log in when viewing a thread. */
const ThreadLoginCta: React.FC = () => {
const siteTitle = useAppSelector(state => state.instance.title);
const registrationOpen = useAppSelector((state) => state.instance.registrations);
if (!registrationOpen) return null;
return (
<Card className='px-6 py-12 space-y-6 text-center' variant='rounded'>

View file

@ -24,13 +24,11 @@ import {
revealStatus,
fetchStatusWithContext,
fetchNext,
translateStatus,
} from 'soapbox/actions/statuses';
import MissingIndicator from 'soapbox/components/missing_indicator';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import ScrollableList from 'soapbox/components/scrollable_list';
import StatusActionBar from 'soapbox/components/status-action-bar';
import Sticky from 'soapbox/components/sticky';
import SubNavigation from 'soapbox/components/sub_navigation';
import Tombstone from 'soapbox/components/tombstone';
import { Column, Stack } from 'soapbox/components/ui';
@ -51,7 +49,7 @@ import type {
Attachment as AttachmentEntity,
Status as StatusEntity,
} from 'soapbox/types/entities';
import Sticky from 'soapbox/components/sticky';
const messages = defineMessages({
title: { id: 'status.title', defaultMessage: '@{username}\'s Post' },
@ -309,10 +307,6 @@ const Thread: React.FC<IThread> = (props) => {
handleToggleMediaVisibility();
};
const handleTranslate = React.useCallback((status: StatusEntity, language: string) => {
dispatch(translateStatus(status.id, language));
}, []);
const handleMoveUp = (id: string) => {
if (id === status?.id) {
_selectChild(ancestorsIds.size - 1);
@ -489,7 +483,6 @@ const Thread: React.FC<IThread> = (props) => {
showMedia={showMedia}
onToggleMediaVisibility={handleToggleMediaVisibility}
onOpenCompareHistoryModal={handleOpenCompareHistoryModal}
onTranslate={handleTranslate}
/>
<hr className='mb-2 dark:border-slate-600' />
@ -525,10 +518,10 @@ const Thread: React.FC<IThread> = (props) => {
<Column label={intl.formatMessage(titleMessage, { username })} transparent withHeader={false}>
<Sticky stickyClassName='sm:hidden w-full shadow-lg before:-z-10 before:bg-gradient-sm before:w-full before:h-full before:absolute before:top-0 before:left-0 bg-white dark:bg-slate-900'>
<div className='px-4 pt-4 sm:p-0'>
<SubNavigation message={intl.formatMessage(titleMessage, { username })} />
<SubNavigation message={intl.formatMessage(titleMessage, { username })} />
</div>
</Sticky>
<PullToRefresh onRefresh={handleRefresh}>
<Stack space={2}>
<div ref={node} className='thread'>

View file

@ -13,7 +13,7 @@ import {
rejectFollowRequest,
} from 'soapbox/actions/accounts';
import { openModal } from 'soapbox/actions/modals';
import { Button, HStack, Text } from 'soapbox/components/ui';
import { Button, HStack } from 'soapbox/components/ui';
import { useAppSelector, useFeatures } from 'soapbox/hooks';
import type { Account as AccountEntity } from 'soapbox/types/entities';
@ -25,8 +25,8 @@ const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
remote_follow: { id: 'account.remote_follow', defaultMessage: 'Remote follow' },
requested: { id: 'account.requested', defaultMessage: 'Click to cancel' },
awaiting_approval: { id: 'account.awaiting_approval', defaultMessage: 'Awaiting approval' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
requested_small: { id: 'account.requested_small', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
@ -94,6 +94,7 @@ const ActionButton: React.FC<IActionButton> = ({ account, actionType, small }) =
ap_id: account.url,
}));
};
/** Handles actionType='muting' */
const mutingAction = () => {
const isMuted = account.relationship?.muting;
@ -205,17 +206,12 @@ const ActionButton: React.FC<IActionButton> = ({ account, actionType, small }) =
} else if (account.relationship?.requested) {
// Awaiting acceptance
return (
<div className='flex flex-col gap-1'>
<Text size='xs' theme='muted'>
{ intl.formatMessage(messages.awaiting_approval) }
</Text>
<Button
size='sm'
theme='secondary'
text={intl.formatMessage(messages.requested)}
onClick={handleFollow}
/>
</div>
<Button
size='sm'
theme='secondary'
text={small ? intl.formatMessage(messages.requested_small) : intl.formatMessage(messages.requested)}
onClick={handleFollow}
/>
);
} else if (!account.relationship?.blocking && !account.relationship?.muting) {
// Follow & Unfollow

View file

@ -40,12 +40,23 @@ const LinkFooter: React.FC = (): JSX.Element => {
<div className='space-y-2'>
<div className='flex flex-wrap items-center divide-x-dot text-gray-400'>
{account && <>
<FooterLink to='/blocks'><FormattedMessage id='navigation_bar.blocks' defaultMessage='Blocks' /></FooterLink>
<FooterLink to='/mutes'><FormattedMessage id='navigation_bar.mutes' defaultMessage='Mutes' /></FooterLink>
{features.filters && (
<FooterLink to='/filters'><FormattedMessage id='navigation_bar.filters' defaultMessage='Filters' /></FooterLink>
)}
{features.federating && (
<FooterLink to='/domain_blocks'><FormattedMessage id='navigation_bar.domain_blocks' defaultMessage='Domain blocks' /></FooterLink>
)}
{account.admin && (
<FooterLink to='/soapbox/config'><FormattedMessage id='navigation_bar.soapbox_config' defaultMessage='Mangane config' /></FooterLink>
)}
{account.locked && (
<FooterLink to='/follow_requests'><FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' /></FooterLink>
)}
{features.import && (
<FooterLink to='/settings/import'><FormattedMessage id='navigation_bar.import_data' defaultMessage='Import data' /></FooterLink>
)}
<FooterLink to='/logout' onClick={onClickLogOut}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></FooterLink>
</>}
</div>

View file

@ -7,10 +7,9 @@ import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
const SignUpPanel = () => {
const { singleUserMode } = useSoapboxConfig();
const siteTitle = useAppSelector((state) => state.instance.title);
const registrationOpen = useAppSelector((state) => state.instance.registrations);
const me = useAppSelector((state) => state.me);
if (me || singleUserMode || !registrationOpen) return null;
if (me || singleUserMode) return null;
return (
<Stack space={2}>

View file

@ -116,7 +116,9 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
{menuItem.toggle}
</div>
);
} else if (menuItem.text) {
} else if (!menuItem.text) {
return <MenuDivider key={idx} />;
} else {
const Comp: any = menuItem.action ? MenuItem : MenuLink;
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link };

View file

@ -1,8 +1,8 @@
import classNames from 'classnames';
import React from 'react';
import { defineMessages, useIntl, FormatDateOptions } from 'react-intl';
import { defineMessages, useIntl, FormattedMessage, FormatDateOptions } from 'react-intl';
import { Stack, HStack, Icon, Text } from 'soapbox/components/ui';
import { Widget, Stack, HStack, Icon, Text } from 'soapbox/components/ui';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { CryptoAddress } from 'soapbox/features/ui/util/async-components';
@ -79,11 +79,13 @@ interface IProfileFieldsPanel {
/** Custom profile fields for sidebar. */
const ProfileFieldsPanel: React.FC<IProfileFieldsPanel> = ({ account }) => {
return (
<Stack space={4}>
{account.fields.map((field, i) => (
<ProfileField field={field} key={i} />
))}
</Stack>
<Widget title={<FormattedMessage id='profile_fields_panel.title' defaultMessage='Profile fields' />}>
<Stack space={4}>
{account.fields.map((field, i) => (
<ProfileField field={field} key={i} />
))}
</Stack>
</Widget>
);
};

View file

@ -1,26 +1,26 @@
import React from 'react';
import Icon from 'soapbox/components/icon';
import { Widget, Stack, Text } from 'soapbox/components/ui';
import { useSettings, useSoapboxConfig } from 'soapbox/hooks';
import { useAppSelector, useSettings, useSoapboxConfig } from 'soapbox/hooks';
const PromoPanel: React.FC = () => {
const { promoPanel } = useSoapboxConfig();
const settings = useSettings();
const siteTitle = useAppSelector(state => state.instance.title);
const promoItems = promoPanel.get('items');
const locale = settings.get('locale');
if (!promoItems || promoItems.isEmpty()) return null;
return (
<Widget title=''>
<Widget title={siteTitle}>
<Stack space={2}>
{promoItems.map((item, i) => (
<Text key={i}>
<a className='flex items-center' href={item.url} target='_blank'>
<div className='text-lg mr-2 grayscale'>
{ item.icon }
</div>
<Icon id={item.icon} className='flex-none text-lg mr-2' fixedWidth />
{item.textLocales.get(locale) || item.text}
</a>
</Text>

View file

@ -1,7 +1,6 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import debounce from 'lodash/debounce';
import React, { useCallback } from 'react';
import { createPortal } from 'react-dom';
import { defineMessages } from 'react-intl';
import { dequeueTimeline, scrollTopTimeline } from 'soapbox/actions/timelines';
@ -54,14 +53,13 @@ const Timeline: React.FC<ITimeline> = ({
return (
<>
{
createPortal(<ScrollTopButton
key='timeline-queue-button-header'
onClick={handleDequeueTimeline}
count={totalQueuedItemsCount}
message={messages.queue}
/>, document.body)
}
<ScrollTopButton
key='timeline-queue-button-header'
onClick={handleDequeueTimeline}
count={totalQueuedItemsCount}
message={messages.queue}
/>
<StatusList
timelineId={timelineId}
onScrollToTop={handleScrollToTop}

View file

@ -21,7 +21,6 @@ import { register as registerPushNotifications } from 'soapbox/actions/push_noti
import { fetchScheduledStatuses } from 'soapbox/actions/scheduled_statuses';
import { connectUserStream } from 'soapbox/actions/streaming';
import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions';
import { fetchTags } from 'soapbox/actions/tags';
import { expandHomeTimeline } from 'soapbox/actions/timelines';
import Icon from 'soapbox/components/icon';
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
@ -34,9 +33,9 @@ import DefaultPage from 'soapbox/pages/default_page';
// import GroupPage from 'soapbox/pages/group_page';
import HomePage from 'soapbox/pages/home_page';
import ProfilePage from 'soapbox/pages/profile_page';
import RemoteInstancePage from 'soapbox/pages/remote_instance_page';
import StatusPage from 'soapbox/pages/status_page';
import { getAccessToken, getVapidKey } from 'soapbox/utils/auth';
import { ConfigDB } from 'soapbox/utils/config_db';
import { isStandalone } from 'soapbox/utils/state';
// import GroupSidebarPanel from '../groups/sidebar_panel';
@ -83,9 +82,9 @@ import {
EmailConfirmation,
DeleteAccount,
SoapboxConfig,
ExportData,
// ExportData,
ImportData,
Backups,
// Backups,
MfaForm,
ChatIndex,
ChatRoom,
@ -114,12 +113,9 @@ import {
TestTimeline,
LogoutPage,
AuthTokenList,
ProfileFields,
FollowedHashtags,
} from './util/async-components';
import { WrappedRoute } from './util/react_router_helpers';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles.
import 'soapbox/components/status';
@ -165,8 +161,6 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
const features = useFeatures();
const { search } = useLocation();
const configuration = useAppSelector((state) => state.admin.get('configs'));
const isLdapEnabled = React.useMemo(() => ConfigDB.find(configuration, ':pleroma', ':ldap')?.get('value').find((e) => e.get('tuple').get(0) === ':enabled')?.getIn(['tuple', 1]), [configuration]);
const { authenticatedProfile, cryptoAddresses } = useSoapboxConfig();
const hasCrypto = cryptoAddresses.size > 0;
@ -188,7 +182,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
*/}
{features.federating && <WrappedRoute path='/timeline/local' exact page={HomePage} component={CommunityTimeline} content={children} publicRoute />}
{features.federating && <WrappedRoute path='/timeline/fediverse' exact page={HomePage} component={PublicTimeline} content={children} publicRoute />}
{features.federating && <WrappedRoute path='/timeline/:instance' exact page={HomePage} component={RemoteTimeline} content={children} />}
{features.federating && <WrappedRoute path='/timeline/:instance' exact page={RemoteInstancePage} component={RemoteTimeline} content={children} />}
{features.conversations && <WrappedRoute path='/conversations' page={DefaultPage} component={Conversations} content={children} />}
{features.directTimeline && <WrappedRoute path='/messages' page={DefaultPage} component={DirectTimeline} content={children} />}
@ -196,6 +190,18 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
<WrappedRoute path='/messages' page={DefaultPage} component={Conversations} content={children} />
)}
{/* Gab groups */}
{/*
<WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} componentParams={{ activeTab: 'featured' }} />
<WrappedRoute path='/groups/create' page={GroupsPage} component={Groups} content={children} componentParams={{ showCreateForm: true, activeTab: 'featured' }} />
<WrappedRoute path='/groups/browse/member' page={GroupsPage} component={Groups} content={children} componentParams={{ activeTab: 'member' }} />
<WrappedRoute path='/groups/browse/admin' page={GroupsPage} component={Groups} content={children} componentParams={{ activeTab: 'admin' }} />
<WrappedRoute path='/groups/:id/members' page={GroupPage} component={GroupMembers} content={children} />
<WrappedRoute path='/groups/:id/removed_accounts' page={GroupPage} component={GroupRemovedAccounts} content={children} />
<WrappedRoute path='/groups/:id/edit' page={GroupPage} component={GroupEdit} content={children} />
<WrappedRoute path='/groups/:id' page={GroupPage} component={GroupTimeline} content={children} />
*/}
{/* Mastodon web routes */}
<Redirect from='/web/:path1/:path2/:path3' to='/:path1/:path2/:path3' />
<Redirect from='/web/:path1/:path2' to='/:path1/:path2' />
@ -209,7 +215,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
<Redirect from='/main/all' to='/timeline/fediverse' />
<Redirect from='/main/public' to='/timeline/local' />
<Redirect from='/main/friends' to='/' />
<Redirect from='/tags/:id' to='/tag/:id' />
<Redirect from='/tag/:id' to='/tags/:id' />
<Redirect from='/user-settings' to='/settings/profile' />
<WrappedRoute path='/notice/:statusId' publicRoute exact page={DefaultPage} component={Status} content={children} />
<Redirect from='/users/:username/statuses/:statusId' to='/@:username/posts/:statusId' />
@ -245,7 +251,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
<Redirect from='/auth/password/new' to='/reset-password' />
<Redirect from='/auth/password/edit' to={`/edit-password${search}`} />
<WrappedRoute path='/tag/:id' publicRoute page={DefaultPage} component={HashtagTimeline} content={children} />
<WrappedRoute path='/tags/:id' publicRoute page={DefaultPage} component={HashtagTimeline} content={children} />
{features.lists && <WrappedRoute path='/lists' page={DefaultPage} component={Lists} content={children} />}
{features.lists && <WrappedRoute path='/list/:id' page={HomePage} component={ListTimeline} content={children} />}
@ -261,19 +267,17 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
{features.chats && <WrappedRoute path='/chats/:chatId' page={DefaultPage} component={ChatRoom} content={children} />}
<WrappedRoute path='/follow_requests' page={DefaultPage} component={FollowRequests} content={children} />
<WrappedRoute path='/followed_hashtags' page={DefaultPage} component={FollowedHashtags} content={children} />
<WrappedRoute path='/blocks' page={DefaultPage} component={Blocks} content={children} />
{features.federating && <WrappedRoute path='/domain_blocks' page={DefaultPage} component={DomainBlocks} content={children} />}
<WrappedRoute path='/mutes' page={DefaultPage} component={Mutes} content={children} />
{features.filters && <WrappedRoute path='/filters' page={DefaultPage} component={Filters} content={children} />}
<WrappedRoute path='/@:username' publicRoute={!authenticatedProfile} exact component={AccountTimeline} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username' publicRoute exact component={AccountTimeline} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/with_replies' publicRoute={!authenticatedProfile} component={AccountTimeline} page={ProfilePage} content={children} componentParams={{ withReplies: true }} />
<WrappedRoute path='/@:username/followers' publicRoute={!authenticatedProfile} component={Followers} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/following' publicRoute={!authenticatedProfile} component={Following} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/media' publicRoute={!authenticatedProfile} component={AccountGallery} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/tagged/:tag' exact component={AccountTimeline} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/favorites' component={FavouritedStatuses} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/about' component={ProfileFields} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/pins' component={PinnedStatuses} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/posts/:statusId' publicRoute exact page={StatusPage} component={Status} content={children} />
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
@ -283,22 +287,19 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
{features.scheduledStatuses && <WrappedRoute path='/scheduled_statuses' page={DefaultPage} component={ScheduledStatuses} content={children} />}
<WrappedRoute path='/settings/profile' page={DefaultPage} component={EditProfile} content={children} />
{features.exportData && <WrappedRoute path='/settings/export' page={DefaultPage} component={ExportData} content={children} />}
{/* FIXME: this could DDoS our API? :\ */}
{/* <WrappedRoute path='/settings/export' page={DefaultPage} component={ExportData} content={children} /> */}
{features.importData && <WrappedRoute path='/settings/import' page={DefaultPage} component={ImportData} content={children} />}
{features.accountAliases && <WrappedRoute path='/settings/aliases' page={DefaultPage} component={Aliases} content={children} />}
{features.accountMoving && <WrappedRoute path='/settings/migration' page={DefaultPage} component={Migration} content={children} />}
{features.backups && <WrappedRoute path='/settings/backups' page={DefaultPage} component={Backups} content={children} />}
<WrappedRoute path='/settings/email' page={DefaultPage} component={EditEmail} content={children} />
{
!isLdapEnabled && (
<WrappedRoute path='/settings/password' page={DefaultPage} component={EditPassword} content={children} />
)
}
<WrappedRoute path='/settings/password' page={DefaultPage} component={EditPassword} content={children} />
<WrappedRoute path='/settings/account' page={DefaultPage} component={DeleteAccount} content={children} />
<WrappedRoute path='/settings/media_display' page={DefaultPage} component={MediaDisplay} content={children} />
<WrappedRoute path='/settings/mfa' page={DefaultPage} component={MfaForm} exact />
<WrappedRoute path='/settings/tokens' page={DefaultPage} component={AuthTokenList} content={children} />
<WrappedRoute path='/settings' page={DefaultPage} component={Settings} content={children} />
{/* <WrappedRoute path='/backups' page={DefaultPage} component={Backups} content={children} /> */}
<WrappedRoute path='/soapbox/config' adminOnly page={DefaultPage} component={SoapboxConfig} content={children} />
<WrappedRoute path='/soapbox/admin' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
@ -468,13 +469,11 @@ const UI: React.FC = ({ children }) => {
setTimeout(() => dispatch(fetchFilters()), 500);
setTimeout(() => dispatch(fetchTags()), 700);
if (account.locked) {
setTimeout(() => dispatch(fetchFollowRequests()), 900);
setTimeout(() => dispatch(fetchFollowRequests()), 700);
}
setTimeout(() => dispatch(fetchScheduledStatuses()), 1100);
setTimeout(() => dispatch(fetchScheduledStatuses()), 900);
};
useEffect(() => {

View file

@ -1,3 +1,7 @@
export function EmojiPicker() {
return import(/* webpackChunkName: "emoji_picker" */'../../emoji/emoji_picker');
}
export function Notifications() {
return import(/* webpackChunkName: "features/notifications" */'../../notifications');
}
@ -362,10 +366,6 @@ export function ProfileMediaPanel() {
return import(/* webpackChunkName: "features/account_gallery" */'../components/profile_media_panel');
}
export function ProfileFields() {
return import(/* webpackChunkName: "features/favourited_statuses" */'../../profile_fields');
}
export function ProfileFieldsPanel() {
return import(/* webpackChunkName: "features/account_timeline" */'../components/profile_fields_panel');
}
@ -525,7 +525,3 @@ export function FamiliarFollowersModal() {
export function AnnouncementsPanel() {
return import(/* webpackChunkName: "features/announcements" */'../../../components/announcements/announcements-panel');
}
export function FollowedHashtags() {
return import('../../../features/followed_tags');
}

View file

@ -11,17 +11,19 @@ const useLogo = (): string => {
const { logo, logoDarkMode } = useSoapboxConfig();
const darkMode = useTheme() === 'dark';
/** Mangane logo. */
const manganeLogo = require('images/mangane-logo.svg');
/** Soapbox logo. */
const soapboxLogo = darkMode
? require('images/soapbox-logo-white.svg')
: require('images/soapbox-logo.svg');
// Use the right logo if provided, then use fallbacks.
const getSrc = () => {
// In demo mode, use the mangane logo.
if (settings.get('demo')) return manganeLogo;
// In demo mode, use the Soapbox logo.
if (settings.get('demo')) return soapboxLogo;
return (darkMode && logoDarkMode)
? logoDarkMode
: logo || logoDarkMode || manganeLogo;
: logo || logoDarkMode || soapboxLogo;
};
return getSrc();

View file

@ -2,9 +2,7 @@
"about.also_available": "Available in:",
"accordion.collapse": "Collapse",
"accordion.expand": "Expand",
"account.about": "About",
"account.add_or_remove_from_list": "أضفه أو أزله من القائمة",
"account.awaiting_approval": "Awaiting approval",
"account.badges.bot": "روبوت",
"account.birthday": "Born {date}",
"account.birthday_today": "Birthday is today!",
@ -12,7 +10,6 @@
"account.block_domain": "إخفاء كل شيئ قادم من اسم النطاق {domain}",
"account.blocked": "محظور",
"account.chat": "Chat with @{name}",
"account.days": "Days",
"account.deactivated": "Deactivated",
"account.direct": "رسالة خاصة إلى @{name}",
"account.domain_blocked": "Domain hidden",
@ -40,8 +37,6 @@
"account.mute": "أكتم @{name}",
"account.muted": "Muted",
"account.never_active": "Never",
"account.no_fields": "This section is empty for now.",
"account.open_profile": "Open Original Profile",
"account.posts": "تبويقات",
"account.posts_with_replies": "التبويقات و الردود",
"account.profile": "Profile",
@ -50,13 +45,13 @@
"account.remove_from_followers": "Remove this follower",
"account.report": "ابلِغ عن @{name}",
"account.requested": "في انتظار الموافقة. اضْغَطْ/ي لإلغاء طلب المتابعة",
"account.requested_small": "Awaiting approval",
"account.search": "Search from @{name}",
"account.share": "شارك ملف تعريف @{name}",
"account.show_reblogs": "اعرض ترقيات @{name}",
"account.subscribe": "Subscribe to notifications from @{name}",
"account.subscribe.failure": "An error occurred trying to subscribed to this account.",
"account.subscribe.success": "You have subscribed to this account.",
"account.today": "Today",
"account.unblock": "إلغاء الحظر عن @{name}",
"account.unblock_domain": "فك الخْفى عن {domain}",
"account.unendorse": "أزل ترويجه مِن الملف التعريفي",
@ -67,7 +62,6 @@
"account.unsubscribe.failure": "An error occurred trying to unsubscribed to this account.",
"account.unsubscribe.success": "You have unsubscribed from this account.",
"account.verified": "Verified Account",
"account.yesterday": "Yesterday",
"account_gallery.none": "No media to show.",
"account_note.hint": "You can keep notes about this user for yourself (this will not be shared with them):",
"account_note.placeholder": "No comment provided",
@ -76,9 +70,6 @@
"account_search.placeholder": "Search for an account",
"actualStatus.edited": "Edited {date}",
"actualStatuses.quote_tombstone": "Post is unavailable.",
"actualStatuses.translate": "Translate",
"actualStatuses.translate_done": "Status translated to {locale}",
"actualStatuses.translated": "Translate",
"admin.awaiting_approval.approved_message": "{acct} was approved!",
"admin.awaiting_approval.empty_message": "There is nobody waiting for approval. When a new user signs up, you can review them here.",
"admin.awaiting_approval.rejected_message": "{acct} was rejected.",
@ -238,7 +229,6 @@
"column.filters.delete_error": "Error deleting filter",
"column.filters.drop_header": "Drop instead of hide",
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
"column.filters.drop_notifications": "Will also hide status in notifications",
"column.filters.expires": "Expire after",
"column.filters.expires_hint": "Expiration dates are not currently supported",
"column.filters.home_timeline": "Home timeline",
@ -271,13 +261,11 @@
"column.public": "الخيط العام الموحد",
"column.reactions": "Reactions",
"column.reblogs": "Reposts",
"column.remote": "Federated timeline",
"column.scheduled_statuses": "Scheduled Posts",
"column.search": "Search",
"column.settings_store": "Settings store",
"column.soapbox_config": "Soapbox config",
"column.tags": "Followed hashtags",
"column.tags.empty": "You don't follow any hashtag yet.",
"column.tags.see": "See",
"column.test": "Test timeline",
"column_back_button.label": "العودة",
"column_forbidden.body": "You do not have permission to access this page.",
@ -285,6 +273,8 @@
"column_header.show_settings": "عرض الإعدادات",
"common.cancel": "Cancel",
"common.error": "Something isn't right. Try reloading the page.",
"community.column_settings.media_only": "الوسائط فقط",
"community.column_settings.title": "Local timeline settings",
"compare_history_modal.header": "Edit history",
"compose.character_counter.title": "Used {chars} out of {maxChars} characters",
"compose.edit_success": "Your post was edited",
@ -474,7 +464,6 @@
"emoji_button.recent": "الشائعة الاستخدام",
"emoji_button.search": "ابحث...",
"emoji_button.search_results": "نتائج البحث",
"emoji_button.skins": "Skins",
"emoji_button.symbols": "رموز",
"emoji_button.travel": "الأماكن والسفر",
"empty_column.account_blocked": "You are blocked by @{accountUsername}.",
@ -511,6 +500,45 @@
"empty_column.search.hashtags": "There are no hashtags results for \"{term}\"",
"empty_column.search.statuses": "There are no posts results for \"{term}\"",
"empty_column.test": "The test timeline is empty.",
"enlistment.next": "Next",
"enlistment.pass": "Ignore",
"onboarding.welcome.body": "Mangane is your gateway to a network of independent servers that communicate together to form a larger social network: the fediverse. Each server is called an “instance”. Your instance is simply this site: ",
"onboarding.welcome.explanation": "It is this identifier that you can share on the fediverse",
"onboarding.welcome.title": "Welcome on Mangane",
"onboarding.welcome.username": "You full username",
"onboarding.how-it-works.explanation": "Don't worry though, when writing a post, autosuggestion will help you find the right mention! Moreover, if you reply to a post, the mention will automatically be written in the right way.",
"onboarding.how-it-works.left": "Here you are on {title}. If you exchange with people from the same instance as you, you can simply mention them with {username}{br}{br}ex: {contact}, if you want to talk to the admin of {title}",
"onboarding.how-it-works.right": "If you exchange with a person from another instance, you must mention them with their <span class='font-bold'>@pseudo@instance</span><br/><br/> ex: <a href=' https://oslo.town/@matt'>@matt@oslo.town</a>, if you want to talk to the Oslo.town admin",
"onboarding.how-it-works.title": "How it works ?",
"onboarding.how-it-works.username": "@username",
"onboarding.feeds.col1": "Here you are on familiar ground: only your publications and those of the people you follow will be displayed on this thread.",
"onboarding.feeds.col2": "Here is a bit like your neighborhood: you will only find publications from members of this server, whether you follow them or not.",
"onboarding.feeds.col3": "Think outside the box and go explore the rest of the world: this thread displays posts from all known instances.",
"onboarding.feeds.explanation1": "At first it will be a little empty but don't worry we can help you fill it!",
"onboarding.feeds.explanation2": "We usually call it \"local\" thread",
"onboarding.feeds.explanation3": "We usually call it \"global\" or \"federated\" thread",
"onboarding.feeds.title1": "Home",
"onboarding.feeds.title3": "Discover",
"onboarding.privacy.description": "This site offers precise control over who can see your posts and therefore interact with you.",
"onboarding.privacy.direct-description": "The post is only visible to people mentioned via @username@instance",
"onboarding.privacy.direct-title": "Direct",
"onboarding.privacy.followers-description": "The post is not displayed on any public feeds and is only visible to people who follow you",
"onboarding.privacy.followers-title": "Followers only",
"onboarding.privacy.public-description": "The post is displayed on all feeds, including other instances.",
"onboarding.privacy.public-title": "Public",
"onboarding.privacy.title": "Privacy",
"onboarding.privacy.unlisted-description": "The post is public but only appears in your subscribers' feeds and on your profile",
"onboarding.privacy.unlisted-title": "Unlisted",
"enlistment.step4.complete": "complete your profile",
"enlistment.step4.conduct": "code of conduct",
"enlistment.step4.disclaimer": "You're almost ready to dive into the deep end.",
"enlistment.step4.informations": "If you have any questions about this new playground, do not hesitate to ask questions to the people you meet online, the community is quite caring and welcoming. You will also find more comprehensive online resources {wiki}.",
"enlistment.step4.point-1": "Think of {complete} (profile photo, banner and bio). You can even add hashtags to let people know your interests.",
"enlistment.step4.point-2": "It is customary to {publish} to make you discover. Do not forget the hashtags #introduction or #presentation.",
"enlistment.step4.point-3": "Let's go, it's up to you to discover and tame the places, find people to follow, share your ideas while respecting the {conduct} and the values of {title}.",
"enlistment.step4.publish": "publish a presentation",
"enlistment.step4.title": "What's next ?",
"enlistment.step4.wiki": "on the official wiki",
"export_data.actions.export": "Export",
"export_data.actions.export_blocks": "Export blocks",
"export_data.actions.export_follows": "Export follows",
@ -533,13 +561,15 @@
"federation_restrictions.explanation_box.message": "Normally servers on the Fediverse can communicate freely. {siteTitle} has imposed restrictions on the following servers.",
"federation_restrictions.explanation_box.title": "Instance-specific policies",
"federation_restrictions.not_disclosed_message": "{siteTitle} does not disclose federation restrictions through the API.",
"fediverse_tab.explanation_box.bubble": "This timeline shows you all the statuses published on a selection of other instances curated by your moderators.",
"fediverse_tab.explanation_box.dismiss": "Don't show again",
"fediverse_tab.explanation_box.explanation": "{site_title} is part of the Fediverse, a social network made up of thousands of independent social media sites (aka \"servers\"). The posts you see here are from 3rd-party servers. You have the freedom to engage with them, or to block any server you don't like. Pay attention to the full username after the second @ symbol to know which server a post is from. To see only {site_title} posts, visit {local}.",
"fediverse_tab.explanation_box.title": "What is the Fediverse?",
"feed_suggestions.heading": "Suggested profiles",
"feed_suggestions.view_all": "View all",
"filters.added": "Filter added.",
"filters.context_header": "Filter contexts",
"filters.context_hint": "One or multiple contexts where the filter should apply",
"filters.filters_list_context_label": "Filter contexts:",
"filters.filters_list_delete": "Delete",
"filters.filters_list_details_label": "Filter settings:",
"filters.filters_list_drop": "Drop",
@ -577,9 +607,6 @@
"hashtag.column_header.tag_mode.all": "و {additional}",
"hashtag.column_header.tag_mode.any": "أو {additional}",
"hashtag.column_header.tag_mode.none": "بدون {additional}",
"hashtag_timeline.follow": "Follow this tag",
"hashtag_timeline.loading": "Loading...",
"hashtag_timeline.unfollow": "Unfollow tag",
"header.home.label": "Home",
"header.login.forgot_password": "Forgot password?",
"header.login.label": "Log in",
@ -588,6 +615,9 @@
"header.register.label": "Register",
"home.column_settings.show_reblogs": "عرض الترقيات",
"home.column_settings.show_replies": "اعرض الردود",
"icon_button.icons": "Icons",
"icon_button.label": "Select icon",
"icon_button.not_found": "No icons!! (╯°□°)╯︵ ┻━┻",
"import_data.actions.import": "Import",
"import_data.actions.import_blocks": "Import blocks",
"import_data.actions.import_follows": "Import follows",
@ -634,7 +664,6 @@
"keyboard_shortcuts.toot": "لتحرير تبويق جديد",
"keyboard_shortcuts.unfocus": "لإلغاء التركيز على حقل النص أو نافذة البحث",
"keyboard_shortcuts.up": "للانتقال إلى أعلى القائمة",
"landingPage.admins": "Moderators",
"landing_page_modal.download": "Download",
"landing_page_modal.helpCenter": "Help Center",
"lightbox.close": "إغلاق",
@ -723,6 +752,7 @@
"navigation_bar.compose_quote": "Quote post",
"navigation_bar.compose_reply": "Reply to post",
"navigation_bar.domain_blocks": "النطاقات المخفية",
"navigation_bar.favourites": "المفضلة",
"navigation_bar.filters": "الكلمات المكتومة",
"navigation_bar.follow_requests": "طلبات المتابعة",
"navigation_bar.import_data": "Import data",
@ -733,7 +763,6 @@
"navigation_bar.preferences": "التفضيلات",
"navigation_bar.profile_directory": "Profile directory",
"navigation_bar.soapbox_config": "Soapbox config",
"navigation_bar.tags": "Hashtags",
"notification.favourite": "أُعجِب {name} بمنشورك",
"notification.follow": "{name} يتابعك",
"notification.follow_request": "{name} has requested to follow you",
@ -767,44 +796,19 @@
"onboarding.avatar.title": "Choose a profile picture",
"onboarding.display_name.subtitle": "You can always edit this later.",
"onboarding.display_name.title": "Choose a display name",
"onboarding.feeds.col1": "Here you are on familiar ground: only your publications and those of the people you follow will be displayed on this thread.",
"onboarding.feeds.col2": "Here is a bit like your neighborhood: you will only find publications from members of this server, whether you follow them or not.",
"onboarding.feeds.col3": "Think outside the box and go explore the rest of the world: this thread displays posts from all known instances.",
"onboarding.feeds.explanation1": "At first it will be a little empty but don't worry we can help you fill it!",
"onboarding.feeds.explanation2": "We usually call it \"local\" thread",
"onboarding.feeds.explanation3": "We usually call it \"global\" or \"federated\" thread",
"onboarding.feeds.title1": "Home",
"onboarding.feeds.title3": "Discover",
"onboarding.done": "Done",
"onboarding.finished.message": "We are very excited to welcome you to our community! Tap the button below to get started.",
"onboarding.finished.title": "Onboarding complete",
"onboarding.header.subtitle": "This will be shown at the top of your profile.",
"onboarding.header.title": "Pick a cover image",
"onboarding.how-it-works.explanation": "Don't worry though, when writing a post, autosuggestion will help you find the right mention! Moreover, if you reply to a post, the mention will automatically be written in the right way.",
"onboarding.how-it-works.left": "Here you are on {title}. If you exchange with people from the same instance as you, you can simply mention them with {username}{br}{br}ex: {contact}, if you want to talk to the admin of {title}",
"onboarding.how-it-works.right": "If you exchange with a person from another instance, you must mention them with their <span class='font-bold'>@pseudo@instance</span><br/><br/> ex: <a href=' https://oslo.town/@matt'>@matt@oslo.town</a>, if you want to talk to the Oslo.town admin",
"onboarding.how-it-works.title": "How it works ?",
"onboarding.how-it-works.username": "@username",
"onboarding.next": "Next",
"onboarding.note.subtitle": "You can always edit this later.",
"onboarding.note.title": "Write a short bio",
"onboarding.privacy.description": "This site offers precise control over who can see your posts and therefore interact with you.",
"onboarding.privacy.direct-description": "The post is only visible to people mentioned via @username@instance",
"onboarding.privacy.direct-title": "Direct",
"onboarding.privacy.followers-description": "The post is not displayed on any public feeds and is only visible to people who follow you",
"onboarding.privacy.followers-title": "Followers only",
"onboarding.privacy.public-description": "The post is displayed on all feeds, including other instances.",
"onboarding.privacy.public-title": "Public",
"onboarding.privacy.title": "Privacy",
"onboarding.privacy.unlisted-description": "The post is public but only appears in your subscribers' feeds and on your profile",
"onboarding.privacy.unlisted-title": "Unlisted",
"onboarding.saving": "Saving…",
"onboarding.skip": "Skip for now",
"onboarding.suggestions.subtitle": "Here are a few of the most popular accounts you might like.",
"onboarding.suggestions.title": "Suggested accounts",
"onboarding.view_feed": "View Feed",
"onboarding.welcome.body1": "This website is your gateway to a network of independent servers that communicate together to form a larger social network: the fediverse.",
"onboarding.welcome.body2": "Each server is called an “instance”. Your instance is simply this site: ",
"onboarding.welcome.explanation": "It is this identifier that you can share on the fediverse",
"onboarding.welcome.title": "Welcome on Mangane",
"onboarding.welcome.username": "You full username",
"password_reset.confirmation": "Check your email for confirmation.",
"password_reset.fields.username_placeholder": "Email or username",
"password_reset.header": "Reset Password",
@ -818,6 +822,7 @@
"poll.non_anonymous": "Public poll",
"poll.non_anonymous.label": "Other instances may display the options you voted for",
"poll.refresh": "تحديث",
"poll.total_people": "{count, plural, one {# person} other {# people}}",
"poll.total_votes": "{count, plural, one {# صوت} other {# أصوات}}",
"poll.vote": "صَوّت",
"poll.voted": "You voted for this answer",
@ -835,6 +840,7 @@
"preferences.fields.display_media.hide_all": "Always hide media",
"preferences.fields.display_media.show_all": "Always show media",
"preferences.fields.dyslexic_font_label": "Dyslexic mode",
"preferences.fields.enlisted": "Ignore onboarding",
"preferences.fields.expand_spoilers_label": "Always expand posts marked with content warnings",
"preferences.fields.language_label": "Language",
"preferences.fields.media_display_label": "Media display",
@ -848,19 +854,14 @@
"preferences.hints.demetricator": "Decrease social media anxiety by hiding all numbers from the site.",
"preferences.hints.feed": "In your home feed",
"preferences.notifications.advanced": "Show all notification categories",
"preferences.options.bubble_timeline_hint": "Replace the fediverse timeline with the Akkoma bubble timeline showing public statuses from a list of handpicked instances.",
"preferences.options.bubble_timeline_label": "Curated timeline",
"preferences.options.content_type_markdown": "Markdown",
"preferences.options.content_type_plaintext": "Plain text",
"preferences.options.privacy_followers_only": "Followers-only",
"preferences.options.privacy_local": "Local-only",
"preferences.options.privacy_public": "Public",
"preferences.options.privacy_unlisted": "Unlisted",
"privacy.change": "اضبط خصوصية المنشور",
"privacy.direct.long": "أنشر إلى المستخدمين المشار إليهم فقط",
"privacy.direct.short": "مباشر",
"privacy.local.long": "Status is only visible to people on this instance",
"privacy.local.short": "Local-only",
"privacy.private.long": "أنشر لمتابعيك فقط",
"privacy.private.short": "لمتابعيك فقط",
"privacy.public.long": "أنشر على الخيوط العامة",
@ -872,6 +873,7 @@
"profile_dropdown.switch_account": "Switch accounts",
"profile_dropdown.theme": "Theme",
"profile_fields_panel.title": "Profile fields",
"public.column_settings.title": "Fediverse timeline settings",
"reactions.all": "All",
"regeneration_indicator.label": "جارٍ التحميل…",
"regeneration_indicator.sublabel": "جارٍ تجهيز تغذية صفحتك الرئيسية!",
@ -884,7 +886,6 @@
"registration.closed_message": "{instance} is not accepting new members",
"registration.closed_title": "Registrations Closed",
"registration.confirmation_modal.close": "Close",
"registration.custom_provider_tooltip": "Sign up with {provider}",
"registration.fields.confirm_placeholder": "Password (again)",
"registration.fields.email_placeholder": "E-Mail address",
"registration.fields.password_placeholder": "Password",
@ -905,7 +906,6 @@
"registrations.create_account": "Create an account",
"registrations.error": "Failed to register your account.",
"registrations.get_started": "Let's get started!",
"registrations.redirect": "No account yet?",
"registrations.success": "Welcome to {siteTitle}!",
"registrations.tagline": "Social Media Without Discrimination",
"registrations.unprocessable_entity": "This username has already been taken.",
@ -998,7 +998,6 @@
"settings.change_email": "Change Email",
"settings.change_password": "Change Password",
"settings.configure_mfa": "Configure MFA",
"settings.content": "Content",
"settings.delete_account": "Delete Account",
"settings.edit_profile": "Edit Profile",
"settings.other": "Other options",
@ -1016,32 +1015,35 @@
"soapbox_config.authenticated_profile_hint": "Users must be logged-in to view replies and media on user profiles.",
"soapbox_config.authenticated_profile_label": "Profiles require authentication",
"soapbox_config.copyright_footer.meta_fields.label_placeholder": "Copyright footer",
"soapbox_config.crypto_address.meta_fields.address_placeholder": "Address",
"soapbox_config.crypto_address.meta_fields.note_placeholder": "Note (optional)",
"soapbox_config.crypto_address.meta_fields.ticker_placeholder": "Ticker",
"soapbox_config.crypto_donate_panel_limit.meta_fields.limit_placeholder": "Number of items to display in the crypto homepage widget",
"soapbox_config.custom_css.meta_fields.url_placeholder": "URL",
"soapbox_config.display_fqn_label": "Display domain (eg @user@domain) for local accounts.",
"soapbox_config.fields.accent_color_label": "Accent color",
"soapbox_config.fields.brand_color_label": "Brand color",
"soapbox_config.fields.crypto_addresses_label": "Cryptocurrency addresses",
"soapbox_config.fields.home_footer_fields_label": "Home footer items",
"soapbox_config.fields.logo_label": "Logo",
"soapbox_config.fields.promo_panel_fields_label": "Promo panel items",
"soapbox_config.fields.theme_label": "Default theme",
"soapbox_config.greentext_label": "Enable greentext support",
"soapbox_config.headings.advanced": "Advanced",
"soapbox_config.headings.home": "Home",
"soapbox_config.headings.cryptocurrency": "Cryptocurrency",
"soapbox_config.headings.navigation": "Navigation",
"soapbox_config.headings.options": "Options",
"soapbox_config.headings.theme": "Theme",
"soapbox_config.hints.crypto_addresses": "Add cryptocurrency addresses so users of your site can donate to you. Order matters, and you must use lowercase ticker values.",
"soapbox_config.hints.home_footer_fields": "You can have custom defined links displayed on the footer of your static pages",
"soapbox_config.hints.logo": "SVG. At most 2 MB. Will be displayed to 50px height, maintaining aspect ratio",
"soapbox_config.hints.promo_panel_fields": "You can have custom defined links displayed on the right panel of the timelines page.",
"soapbox_config.hints.promo_panel_icons.link": "Soapbox Icons List",
"soapbox_config.home_description": "Instance's description shown in Home page. Supports HTML. Use [users] to insert the number of current users on the instance.",
"soapbox_config.home_footer.meta_fields.label_placeholder": "Label",
"soapbox_config.home_footer.meta_fields.url_placeholder": "URL",
"soapbox_config.promo_panel.meta_fields.icon_placeholder": "Icon",
"soapbox_config.promo_panel.meta_fields.label_placeholder": "Label",
"soapbox_config.promo_panel.meta_fields.url_placeholder": "URL",
"soapbox_config.quote_rt": "Enable Quote RT",
"soapbox_config.raw_json_hint": "Edit the settings data directly. Changes made directly to the JSON file will override the form fields above. Click Save to apply your changes.",
"soapbox_config.raw_json_label": "Advanced: Edit raw JSON data",
"soapbox_config.save": "Save",
@ -1054,15 +1056,16 @@
"sponsored.info.message": "{siteTitle} displays ads to help fund our service.",
"sponsored.info.title": "Why am I seeing this ad?",
"sponsored.subtitle": "Sponsored post",
"status.actions.more": "More",
"status.admin_account": "افتح الواجهة الإدارية لـ @{name}",
"status.admin_status": "افتح هذا المنشور على واجهة الإشراف",
"status.block": "احجب @{name}",
"status.bookmark": "Bookmark",
"status.bookmarked": "Bookmark added.",
"status.cancel_reblog_private": "إلغاء الترقية",
"status.cannot_reblog": "تعذرت ترقية هذا المنشور",
"status.chat": "Chat with @{name}",
"status.copy": "نسخ رابط المنشور",
"status.cw": "Warning:",
"status.delete": "احذف",
"status.detailed_status": "تفاصيل المحادثة",
"status.direct": "رسالة خاصة إلى @{name}",
@ -1070,11 +1073,11 @@
"status.embed": "إدماج",
"status.favourite": "أضف إلى المفضلة",
"status.filtered": "مُصفّى",
"status.filtered-hint": "Status was hidden by filter settings",
"status.load_more": "حمّل المزيد",
"status.media_hidden": "الصورة مستترة",
"status.mention": "أذكُر @{name}",
"status.more": "المزيد",
"status.mute": "أكتم @{name}",
"status.mute_conversation": "كتم المحادثة",
"status.open": "وسع هذه المشاركة",
"status.pin": "دبّسه على الصفحة التعريفية",
@ -1087,6 +1090,7 @@
"status.reactions.like": "Like",
"status.reactions.open_mouth": "Wow",
"status.reactions.weary": "Weary",
"status.reactions_expand": "Select emoji",
"status.read_more": "اقرأ المزيد",
"status.reblog": "رَقِّي",
"status.reblog_private": "القيام بالترقية إلى الجمهور الأصلي",

View file

@ -2,9 +2,7 @@
"about.also_available": "Available in:",
"accordion.collapse": "Collapse",
"accordion.expand": "Expand",
"account.about": "About",
"account.add_or_remove_from_list": "Add or Remove from lists",
"account.awaiting_approval": "Awaiting approval",
"account.badges.bot": "Robó",
"account.birthday": "Born {date}",
"account.birthday_today": "Birthday is today!",
@ -12,7 +10,6 @@
"account.block_domain": "Anubrir tolo de {domain}",
"account.blocked": "Blocked",
"account.chat": "Chat with @{name}",
"account.days": "Days",
"account.deactivated": "Deactivated",
"account.direct": "Unviar un mensaxe direutu a @{name}",
"account.domain_blocked": "Domain hidden",
@ -40,8 +37,6 @@
"account.mute": "Silenciar a @{name}",
"account.muted": "Muted",
"account.never_active": "Never",
"account.no_fields": "This section is empty for now.",
"account.open_profile": "Open Original Profile",
"account.posts": "Posts",
"account.posts_with_replies": "Posts y rempuestes",
"account.profile": "Profile",
@ -50,13 +45,13 @@
"account.remove_from_followers": "Remove this follower",
"account.report": "Report @{name}",
"account.requested": "Awaiting approval. Click to cancel follow request",
"account.requested_small": "Awaiting approval",
"account.search": "Search from @{name}",
"account.share": "Share @{name}'s profile",
"account.show_reblogs": "Show reposts from @{name}",
"account.subscribe": "Subscribe to notifications from @{name}",
"account.subscribe.failure": "An error occurred trying to subscribed to this account.",
"account.subscribe.success": "You have subscribed to this account.",
"account.today": "Today",
"account.unblock": "Desbloquiar a @{name}",
"account.unblock_domain": "Amosar {domain}",
"account.unendorse": "Don't feature on profile",
@ -67,7 +62,6 @@
"account.unsubscribe.failure": "An error occurred trying to unsubscribed to this account.",
"account.unsubscribe.success": "You have unsubscribed from this account.",
"account.verified": "Verified Account",
"account.yesterday": "Yesterday",
"account_gallery.none": "No media to show.",
"account_note.hint": "You can keep notes about this user for yourself (this will not be shared with them):",
"account_note.placeholder": "No comment provided",
@ -76,9 +70,6 @@
"account_search.placeholder": "Search for an account",
"actualStatus.edited": "Edited {date}",
"actualStatuses.quote_tombstone": "Post is unavailable.",
"actualStatuses.translate": "Translate",
"actualStatuses.translate_done": "Status translated to {locale}",
"actualStatuses.translated": "Translate",
"admin.awaiting_approval.approved_message": "{acct} was approved!",
"admin.awaiting_approval.empty_message": "There is nobody waiting for approval. When a new user signs up, you can review them here.",
"admin.awaiting_approval.rejected_message": "{acct} was rejected.",
@ -238,7 +229,6 @@
"column.filters.delete_error": "Error deleting filter",
"column.filters.drop_header": "Drop instead of hide",
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
"column.filters.drop_notifications": "Will also hide status in notifications",
"column.filters.expires": "Expire after",
"column.filters.expires_hint": "Expiration dates are not currently supported",
"column.filters.home_timeline": "Home timeline",
@ -271,13 +261,11 @@
"column.public": "Llinia temporal federada",
"column.reactions": "Reactions",
"column.reblogs": "Reposts",
"column.remote": "Federated timeline",
"column.scheduled_statuses": "Scheduled Posts",
"column.search": "Search",
"column.settings_store": "Settings store",
"column.soapbox_config": "Soapbox config",
"column.tags": "Followed hashtags",
"column.tags.empty": "You don't follow any hashtag yet.",
"column.tags.see": "See",
"column.test": "Test timeline",
"column_back_button.label": "Atrás",
"column_forbidden.body": "You do not have permission to access this page.",
@ -285,6 +273,8 @@
"column_header.show_settings": "Show settings",
"common.cancel": "Cancel",
"common.error": "Something isn't right. Try reloading the page.",
"community.column_settings.media_only": "Media only",
"community.column_settings.title": "Local timeline settings",
"compare_history_modal.header": "Edit history",
"compose.character_counter.title": "Used {chars} out of {maxChars} characters",
"compose.edit_success": "Your post was edited",
@ -474,7 +464,6 @@
"emoji_button.recent": "Úsase davezu",
"emoji_button.search": "Guetar...",
"emoji_button.search_results": "Search results",
"emoji_button.skins": "Skins",
"emoji_button.symbols": "Símbolos",
"emoji_button.travel": "Viaxes y llugares",
"empty_column.account_blocked": "You are blocked by @{accountUsername}.",
@ -511,6 +500,45 @@
"empty_column.search.hashtags": "There are no hashtags results for \"{term}\"",
"empty_column.search.statuses": "There are no posts results for \"{term}\"",
"empty_column.test": "The test timeline is empty.",
"enlistment.next": "Next",
"enlistment.pass": "Ignore",
"onboarding.welcome.body": "Mangane is your gateway to a network of independent servers that communicate together to form a larger social network: the fediverse. Each server is called an “instance”. Your instance is simply this site: ",
"onboarding.welcome.explanation": "It is this identifier that you can share on the fediverse",
"onboarding.welcome.title": "Welcome on Mangane",
"onboarding.welcome.username": "You full username",
"onboarding.how-it-works.explanation": "Don't worry though, when writing a post, autosuggestion will help you find the right mention! Moreover, if you reply to a post, the mention will automatically be written in the right way.",
"onboarding.how-it-works.left": "Here you are on {title}. If you exchange with people from the same instance as you, you can simply mention them with {username}{br}{br}ex: {contact}, if you want to talk to the admin of {title}",
"onboarding.how-it-works.right": "If you exchange with a person from another instance, you must mention them with their <span class='font-bold'>@pseudo@instance</span><br/><br/> ex: <a href=' https://oslo.town/@matt'>@matt@oslo.town</a>, if you want to talk to the Oslo.town admin",
"onboarding.how-it-works.title": "How it works ?",
"onboarding.how-it-works.username": "@username",
"onboarding.feeds.col1": "Here you are on familiar ground: only your publications and those of the people you follow will be displayed on this thread.",
"onboarding.feeds.col2": "Here is a bit like your neighborhood: you will only find publications from members of this server, whether you follow them or not.",
"onboarding.feeds.col3": "Think outside the box and go explore the rest of the world: this thread displays posts from all known instances.",
"onboarding.feeds.explanation1": "At first it will be a little empty but don't worry we can help you fill it!",
"onboarding.feeds.explanation2": "We usually call it \"local\" thread",
"onboarding.feeds.explanation3": "We usually call it \"global\" or \"federated\" thread",
"onboarding.feeds.title1": "Home",
"onboarding.feeds.title3": "Discover",
"onboarding.privacy.description": "This site offers precise control over who can see your posts and therefore interact with you.",
"onboarding.privacy.direct-description": "The post is only visible to people mentioned via @username@instance",
"onboarding.privacy.direct-title": "Direct",
"onboarding.privacy.followers-description": "The post is not displayed on any public feeds and is only visible to people who follow you",
"onboarding.privacy.followers-title": "Followers only",
"onboarding.privacy.public-description": "The post is displayed on all feeds, including other instances.",
"onboarding.privacy.public-title": "Public",
"onboarding.privacy.title": "Privacy",
"onboarding.privacy.unlisted-description": "The post is public but only appears in your subscribers' feeds and on your profile",
"onboarding.privacy.unlisted-title": "Unlisted",
"enlistment.step4.complete": "complete your profile",
"enlistment.step4.conduct": "code of conduct",
"enlistment.step4.disclaimer": "You're almost ready to dive into the deep end.",
"enlistment.step4.informations": "If you have any questions about this new playground, do not hesitate to ask questions to the people you meet online, the community is quite caring and welcoming. You will also find more comprehensive online resources {wiki}.",
"enlistment.step4.point-1": "Think of {complete} (profile photo, banner and bio). You can even add hashtags to let people know your interests.",
"enlistment.step4.point-2": "It is customary to {publish} to make you discover. Do not forget the hashtags #introduction or #presentation.",
"enlistment.step4.point-3": "Let's go, it's up to you to discover and tame the places, find people to follow, share your ideas while respecting the {conduct} and the values of {title}.",
"enlistment.step4.publish": "publish a presentation",
"enlistment.step4.title": "What's next ?",
"enlistment.step4.wiki": "on the official wiki",
"export_data.actions.export": "Export",
"export_data.actions.export_blocks": "Export blocks",
"export_data.actions.export_follows": "Export follows",
@ -533,13 +561,15 @@
"federation_restrictions.explanation_box.message": "Normally servers on the Fediverse can communicate freely. {siteTitle} has imposed restrictions on the following servers.",
"federation_restrictions.explanation_box.title": "Instance-specific policies",
"federation_restrictions.not_disclosed_message": "{siteTitle} does not disclose federation restrictions through the API.",
"fediverse_tab.explanation_box.bubble": "This timeline shows you all the statuses published on a selection of other instances curated by your moderators.",
"fediverse_tab.explanation_box.dismiss": "Don't show again",
"fediverse_tab.explanation_box.explanation": "{site_title} is part of the Fediverse, a social network made up of thousands of independent social media sites (aka \"servers\"). The posts you see here are from 3rd-party servers. You have the freedom to engage with them, or to block any server you don't like. Pay attention to the full username after the second @ symbol to know which server a post is from. To see only {site_title} posts, visit {local}.",
"fediverse_tab.explanation_box.title": "What is the Fediverse?",
"feed_suggestions.heading": "Suggested profiles",
"feed_suggestions.view_all": "View all",
"filters.added": "Filter added.",
"filters.context_header": "Filter contexts",
"filters.context_hint": "One or multiple contexts where the filter should apply",
"filters.filters_list_context_label": "Filter contexts:",
"filters.filters_list_delete": "Delete",
"filters.filters_list_details_label": "Filter settings:",
"filters.filters_list_drop": "Drop",
@ -577,9 +607,6 @@
"hashtag.column_header.tag_mode.all": "and {additional}",
"hashtag.column_header.tag_mode.any": "or {additional}",
"hashtag.column_header.tag_mode.none": "without {additional}",
"hashtag_timeline.follow": "Follow this tag",
"hashtag_timeline.loading": "Loading...",
"hashtag_timeline.unfollow": "Unfollow tag",
"header.home.label": "Home",
"header.login.forgot_password": "Forgot password?",
"header.login.label": "Log in",
@ -588,6 +615,9 @@
"header.register.label": "Register",
"home.column_settings.show_reblogs": "Amosar toots compartíos",
"home.column_settings.show_replies": "Amosar rempuestes",
"icon_button.icons": "Icons",
"icon_button.label": "Select icon",
"icon_button.not_found": "No icons!! (╯°□°)╯︵ ┻━┻",
"import_data.actions.import": "Import",
"import_data.actions.import_blocks": "Import blocks",
"import_data.actions.import_follows": "Import follows",
@ -634,7 +664,6 @@
"keyboard_shortcuts.toot": "p'apenzar un toot nuevu",
"keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
"keyboard_shortcuts.up": "pa xubir na llista",
"landingPage.admins": "Moderators",
"landing_page_modal.download": "Download",
"landing_page_modal.helpCenter": "Help Center",
"lightbox.close": "Close",
@ -723,6 +752,7 @@
"navigation_bar.compose_quote": "Quote post",
"navigation_bar.compose_reply": "Reply to post",
"navigation_bar.domain_blocks": "Dominios anubríos",
"navigation_bar.favourites": "Favoritos",
"navigation_bar.filters": "Pallabres silenciaes",
"navigation_bar.follow_requests": "Solicitúes de siguimientu",
"navigation_bar.import_data": "Import data",
@ -733,7 +763,6 @@
"navigation_bar.preferences": "Preferencies",
"navigation_bar.profile_directory": "Profile directory",
"navigation_bar.soapbox_config": "Soapbox config",
"navigation_bar.tags": "Hashtags",
"notification.favourite": "{name} favorited your post",
"notification.follow": "{name} siguióte",
"notification.follow_request": "{name} has requested to follow you",
@ -767,44 +796,19 @@
"onboarding.avatar.title": "Choose a profile picture",
"onboarding.display_name.subtitle": "You can always edit this later.",
"onboarding.display_name.title": "Choose a display name",
"onboarding.feeds.col1": "Here you are on familiar ground: only your publications and those of the people you follow will be displayed on this thread.",
"onboarding.feeds.col2": "Here is a bit like your neighborhood: you will only find publications from members of this server, whether you follow them or not.",
"onboarding.feeds.col3": "Think outside the box and go explore the rest of the world: this thread displays posts from all known instances.",
"onboarding.feeds.explanation1": "At first it will be a little empty but don't worry we can help you fill it!",
"onboarding.feeds.explanation2": "We usually call it \"local\" thread",
"onboarding.feeds.explanation3": "We usually call it \"global\" or \"federated\" thread",
"onboarding.feeds.title1": "Home",
"onboarding.feeds.title3": "Discover",
"onboarding.done": "Done",
"onboarding.finished.message": "We are very excited to welcome you to our community! Tap the button below to get started.",
"onboarding.finished.title": "Onboarding complete",
"onboarding.header.subtitle": "This will be shown at the top of your profile.",
"onboarding.header.title": "Pick a cover image",
"onboarding.how-it-works.explanation": "Don't worry though, when writing a post, autosuggestion will help you find the right mention! Moreover, if you reply to a post, the mention will automatically be written in the right way.",
"onboarding.how-it-works.left": "Here you are on {title}. If you exchange with people from the same instance as you, you can simply mention them with {username}{br}{br}ex: {contact}, if you want to talk to the admin of {title}",
"onboarding.how-it-works.right": "If you exchange with a person from another instance, you must mention them with their <span class='font-bold'>@pseudo@instance</span><br/><br/> ex: <a href=' https://oslo.town/@matt'>@matt@oslo.town</a>, if you want to talk to the Oslo.town admin",
"onboarding.how-it-works.title": "How it works ?",
"onboarding.how-it-works.username": "@username",
"onboarding.next": "Next",
"onboarding.note.subtitle": "You can always edit this later.",
"onboarding.note.title": "Write a short bio",
"onboarding.privacy.description": "This site offers precise control over who can see your posts and therefore interact with you.",
"onboarding.privacy.direct-description": "The post is only visible to people mentioned via @username@instance",
"onboarding.privacy.direct-title": "Direct",
"onboarding.privacy.followers-description": "The post is not displayed on any public feeds and is only visible to people who follow you",
"onboarding.privacy.followers-title": "Followers only",
"onboarding.privacy.public-description": "The post is displayed on all feeds, including other instances.",
"onboarding.privacy.public-title": "Public",
"onboarding.privacy.title": "Privacy",
"onboarding.privacy.unlisted-description": "The post is public but only appears in your subscribers' feeds and on your profile",
"onboarding.privacy.unlisted-title": "Unlisted",
"onboarding.saving": "Saving…",
"onboarding.skip": "Skip for now",
"onboarding.suggestions.subtitle": "Here are a few of the most popular accounts you might like.",
"onboarding.suggestions.title": "Suggested accounts",
"onboarding.view_feed": "View Feed",
"onboarding.welcome.body1": "This website is your gateway to a network of independent servers that communicate together to form a larger social network: the fediverse.",
"onboarding.welcome.body2": "Each server is called an “instance”. Your instance is simply this site: ",
"onboarding.welcome.explanation": "It is this identifier that you can share on the fediverse",
"onboarding.welcome.title": "Welcome on Mangane",
"onboarding.welcome.username": "You full username",
"password_reset.confirmation": "Check your email for confirmation.",
"password_reset.fields.username_placeholder": "Email or username",
"password_reset.header": "Reset Password",
@ -818,6 +822,7 @@
"poll.non_anonymous": "Public poll",
"poll.non_anonymous.label": "Other instances may display the options you voted for",
"poll.refresh": "Refresh",
"poll.total_people": "{count, plural, one {# person} other {# people}}",
"poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
"poll.vote": "Vote",
"poll.voted": "You voted for this answer",
@ -835,6 +840,7 @@
"preferences.fields.display_media.hide_all": "Always hide media",
"preferences.fields.display_media.show_all": "Always show media",
"preferences.fields.dyslexic_font_label": "Dyslexic mode",
"preferences.fields.enlisted": "Ignore onboarding",
"preferences.fields.expand_spoilers_label": "Always expand posts marked with content warnings",
"preferences.fields.language_label": "Language",
"preferences.fields.media_display_label": "Media display",
@ -848,19 +854,14 @@
"preferences.hints.demetricator": "Decrease social media anxiety by hiding all numbers from the site.",
"preferences.hints.feed": "In your home feed",
"preferences.notifications.advanced": "Show all notification categories",
"preferences.options.bubble_timeline_hint": "Replace the fediverse timeline with the Akkoma bubble timeline showing public statuses from a list of handpicked instances.",
"preferences.options.bubble_timeline_label": "Curated timeline",
"preferences.options.content_type_markdown": "Markdown",
"preferences.options.content_type_plaintext": "Plain text",
"preferences.options.privacy_followers_only": "Followers-only",
"preferences.options.privacy_local": "Local-only",
"preferences.options.privacy_public": "Public",
"preferences.options.privacy_unlisted": "Unlisted",
"privacy.change": "Adjust post privacy",
"privacy.direct.long": "Post to mentioned users only",
"privacy.direct.short": "Direct",
"privacy.local.long": "Status is only visible to people on this instance",
"privacy.local.short": "Local-only",
"privacy.private.long": "Post to followers only",
"privacy.private.short": "Namái siguidores",
"privacy.public.long": "Post to public timelines",
@ -872,6 +873,7 @@
"profile_dropdown.switch_account": "Switch accounts",
"profile_dropdown.theme": "Theme",
"profile_fields_panel.title": "Profile fields",
"public.column_settings.title": "Fediverse timeline settings",
"reactions.all": "All",
"regeneration_indicator.label": "Cargando…",
"regeneration_indicator.sublabel": "Your home feed is being prepared!",
@ -884,7 +886,6 @@
"registration.closed_message": "{instance} is not accepting new members",
"registration.closed_title": "Registrations Closed",
"registration.confirmation_modal.close": "Close",
"registration.custom_provider_tooltip": "Sign up with {provider}",
"registration.fields.confirm_placeholder": "Password (again)",
"registration.fields.email_placeholder": "E-Mail address",
"registration.fields.password_placeholder": "Password",
@ -905,7 +906,6 @@
"registrations.create_account": "Create an account",
"registrations.error": "Failed to register your account.",
"registrations.get_started": "Let's get started!",
"registrations.redirect": "No account yet?",
"registrations.success": "Welcome to {siteTitle}!",
"registrations.tagline": "Social Media Without Discrimination",
"registrations.unprocessable_entity": "This username has already been taken.",
@ -998,7 +998,6 @@
"settings.change_email": "Change Email",
"settings.change_password": "Change Password",
"settings.configure_mfa": "Configure MFA",
"settings.content": "Content",
"settings.delete_account": "Delete Account",
"settings.edit_profile": "Edit Profile",
"settings.other": "Other options",
@ -1016,32 +1015,35 @@
"soapbox_config.authenticated_profile_hint": "Users must be logged-in to view replies and media on user profiles.",
"soapbox_config.authenticated_profile_label": "Profiles require authentication",
"soapbox_config.copyright_footer.meta_fields.label_placeholder": "Copyright footer",
"soapbox_config.crypto_address.meta_fields.address_placeholder": "Address",
"soapbox_config.crypto_address.meta_fields.note_placeholder": "Note (optional)",
"soapbox_config.crypto_address.meta_fields.ticker_placeholder": "Ticker",
"soapbox_config.crypto_donate_panel_limit.meta_fields.limit_placeholder": "Number of items to display in the crypto homepage widget",
"soapbox_config.custom_css.meta_fields.url_placeholder": "URL",
"soapbox_config.display_fqn_label": "Display domain (eg @user@domain) for local accounts.",
"soapbox_config.fields.accent_color_label": "Accent color",
"soapbox_config.fields.brand_color_label": "Brand color",
"soapbox_config.fields.crypto_addresses_label": "Cryptocurrency addresses",
"soapbox_config.fields.home_footer_fields_label": "Home footer items",
"soapbox_config.fields.logo_label": "Logo",
"soapbox_config.fields.promo_panel_fields_label": "Promo panel items",
"soapbox_config.fields.theme_label": "Default theme",
"soapbox_config.greentext_label": "Enable greentext support",
"soapbox_config.headings.advanced": "Advanced",
"soapbox_config.headings.home": "Home",
"soapbox_config.headings.cryptocurrency": "Cryptocurrency",
"soapbox_config.headings.navigation": "Navigation",
"soapbox_config.headings.options": "Options",
"soapbox_config.headings.theme": "Theme",
"soapbox_config.hints.crypto_addresses": "Add cryptocurrency addresses so users of your site can donate to you. Order matters, and you must use lowercase ticker values.",
"soapbox_config.hints.home_footer_fields": "You can have custom defined links displayed on the footer of your static pages",
"soapbox_config.hints.logo": "SVG. At most 2 MB. Will be displayed to 50px height, maintaining aspect ratio",
"soapbox_config.hints.promo_panel_fields": "You can have custom defined links displayed on the right panel of the timelines page.",
"soapbox_config.hints.promo_panel_icons.link": "Soapbox Icons List",
"soapbox_config.home_description": "Instance's description shown in Home page. Supports HTML. Use [users] to insert the number of current users on the instance.",
"soapbox_config.home_footer.meta_fields.label_placeholder": "Label",
"soapbox_config.home_footer.meta_fields.url_placeholder": "URL",
"soapbox_config.promo_panel.meta_fields.icon_placeholder": "Icon",
"soapbox_config.promo_panel.meta_fields.label_placeholder": "Label",
"soapbox_config.promo_panel.meta_fields.url_placeholder": "URL",
"soapbox_config.quote_rt": "Enable Quote RT",
"soapbox_config.raw_json_hint": "Edit the settings data directly. Changes made directly to the JSON file will override the form fields above. Click Save to apply your changes.",
"soapbox_config.raw_json_label": "Advanced: Edit raw JSON data",
"soapbox_config.save": "Save",
@ -1054,15 +1056,16 @@
"sponsored.info.message": "{siteTitle} displays ads to help fund our service.",
"sponsored.info.title": "Why am I seeing this ad?",
"sponsored.subtitle": "Sponsored post",
"status.actions.more": "More",
"status.admin_account": "Open moderation interface for @{name}",
"status.admin_status": "Open this post in the moderation interface",
"status.block": "Bloquiar a @{name}",
"status.bookmark": "Bookmark",
"status.bookmarked": "Bookmark added.",
"status.cancel_reblog_private": "Dexar de compartir",
"status.cannot_reblog": "Esti artículu nun pue compartise",
"status.chat": "Chat with @{name}",
"status.copy": "Copy link to post",
"status.cw": "Warning:",
"status.delete": "Delete",
"status.detailed_status": "Detailed conversation view",
"status.direct": "Unviar un mensaxe direutu a @{name}",
@ -1070,11 +1073,11 @@
"status.embed": "Empotrar",
"status.favourite": "Favorite",
"status.filtered": "Filtered",
"status.filtered-hint": "Status was hidden by filter settings",
"status.load_more": "Cargar más",
"status.media_hidden": "Mediu anubríu",
"status.mention": "Mentar a @{name}",
"status.more": "Más",
"status.mute": "Silenciar a @{name}",
"status.mute_conversation": "Silenciar la conversación",
"status.open": "Espander esti estáu",
"status.pin": "Fixar nel perfil",
@ -1087,6 +1090,7 @@
"status.reactions.like": "Like",
"status.reactions.open_mouth": "Wow",
"status.reactions.weary": "Weary",
"status.reactions_expand": "Select emoji",
"status.read_more": "Read more",
"status.reblog": "Compartir",
"status.reblog_private": "Compartir cola audiencia orixinal",

View file

@ -2,9 +2,7 @@
"about.also_available": "Available in:",
"accordion.collapse": "Collapse",
"accordion.expand": "Expand",
"account.about": "About",
"account.add_or_remove_from_list": "Добави или премахни от списъците",
"account.awaiting_approval": "Awaiting approval",
"account.badges.bot": "бот",
"account.birthday": "Born {date}",
"account.birthday_today": "Birthday is today!",
@ -12,7 +10,6 @@
"account.block_domain": "скрий всичко от {domain}",
"account.blocked": "Блокирани",
"account.chat": "Chat with @{name}",
"account.days": "Days",
"account.deactivated": "Deactivated",
"account.direct": "Direct Message @{name}",
"account.domain_blocked": "Domain hidden",
@ -40,8 +37,6 @@
"account.mute": "Mute @{name}",
"account.muted": "Muted",
"account.never_active": "Never",
"account.no_fields": "This section is empty for now.",
"account.open_profile": "Open Original Profile",
"account.posts": "Публикации",
"account.posts_with_replies": "Posts with replies",
"account.profile": "Profile",
@ -50,13 +45,13 @@
"account.remove_from_followers": "Remove this follower",
"account.report": "Report @{name}",
"account.requested": "В очакване на одобрение",
"account.requested_small": "Awaiting approval",
"account.search": "Search from @{name}",
"account.share": "Share @{name}'s profile",
"account.show_reblogs": "Show reposts from @{name}",
"account.subscribe": "Subscribe to notifications from @{name}",
"account.subscribe.failure": "An error occurred trying to subscribed to this account.",
"account.subscribe.success": "You have subscribed to this account.",
"account.today": "Today",
"account.unblock": "Не блокирай",
"account.unblock_domain": "Unhide {domain}",
"account.unendorse": "Don't feature on profile",
@ -67,7 +62,6 @@
"account.unsubscribe.failure": "An error occurred trying to unsubscribed to this account.",
"account.unsubscribe.success": "You have unsubscribed from this account.",
"account.verified": "Verified Account",
"account.yesterday": "Yesterday",
"account_gallery.none": "No media to show.",
"account_note.hint": "You can keep notes about this user for yourself (this will not be shared with them):",
"account_note.placeholder": "No comment provided",
@ -76,9 +70,6 @@
"account_search.placeholder": "Search for an account",
"actualStatus.edited": "Edited {date}",
"actualStatuses.quote_tombstone": "Post is unavailable.",
"actualStatuses.translate": "Translate",
"actualStatuses.translate_done": "Status translated to {locale}",
"actualStatuses.translated": "Translate",
"admin.awaiting_approval.approved_message": "{acct} was approved!",
"admin.awaiting_approval.empty_message": "There is nobody waiting for approval. When a new user signs up, you can review them here.",
"admin.awaiting_approval.rejected_message": "{acct} was rejected.",
@ -238,7 +229,6 @@
"column.filters.delete_error": "Error deleting filter",
"column.filters.drop_header": "Drop instead of hide",
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
"column.filters.drop_notifications": "Will also hide status in notifications",
"column.filters.expires": "Expire after",
"column.filters.expires_hint": "Expiration dates are not currently supported",
"column.filters.home_timeline": "Home timeline",
@ -271,13 +261,11 @@
"column.public": "Публичен канал",
"column.reactions": "Reactions",
"column.reblogs": "Reposts",
"column.remote": "Federated timeline",
"column.scheduled_statuses": "Scheduled Posts",
"column.search": "Search",
"column.settings_store": "Settings store",
"column.soapbox_config": "Soapbox config",
"column.tags": "Followed hashtags",
"column.tags.empty": "You don't follow any hashtag yet.",
"column.tags.see": "See",
"column.test": "Test timeline",
"column_back_button.label": "Назад",
"column_forbidden.body": "You do not have permission to access this page.",
@ -285,6 +273,8 @@
"column_header.show_settings": "Show settings",
"common.cancel": "Cancel",
"common.error": "Something isn't right. Try reloading the page.",
"community.column_settings.media_only": "Media only",
"community.column_settings.title": "Local timeline settings",
"compare_history_modal.header": "Edit history",
"compose.character_counter.title": "Used {chars} out of {maxChars} characters",
"compose.edit_success": "Your post was edited",
@ -474,7 +464,6 @@
"emoji_button.recent": "Frequently used",
"emoji_button.search": "Search...",
"emoji_button.search_results": "Search results",
"emoji_button.skins": "Skins",
"emoji_button.symbols": "Symbols",
"emoji_button.travel": "Travel & Places",
"empty_column.account_blocked": "You are blocked by @{accountUsername}.",
@ -511,6 +500,45 @@
"empty_column.search.hashtags": "There are no hashtags results for \"{term}\"",
"empty_column.search.statuses": "There are no posts results for \"{term}\"",
"empty_column.test": "The test timeline is empty.",
"enlistment.next": "Next",
"enlistment.pass": "Ignore",
"onboarding.welcome.body": "Mangane is your gateway to a network of independent servers that communicate together to form a larger social network: the fediverse. Each server is called an “instance”. Your instance is simply this site: ",
"onboarding.welcome.explanation": "It is this identifier that you can share on the fediverse",
"onboarding.welcome.title": "Welcome on Mangane",
"onboarding.welcome.username": "You full username",
"onboarding.how-it-works.explanation": "Don't worry though, when writing a post, autosuggestion will help you find the right mention! Moreover, if you reply to a post, the mention will automatically be written in the right way.",
"onboarding.how-it-works.left": "Here you are on {title}. If you exchange with people from the same instance as you, you can simply mention them with {username}{br}{br}ex: {contact}, if you want to talk to the admin of {title}",
"onboarding.how-it-works.right": "If you exchange with a person from another instance, you must mention them with their <span class='font-bold'>@pseudo@instance</span><br/><br/> ex: <a href=' https://oslo.town/@matt'>@matt@oslo.town</a>, if you want to talk to the Oslo.town admin",
"onboarding.how-it-works.title": "How it works ?",
"onboarding.how-it-works.username": "@username",
"onboarding.feeds.col1": "Here you are on familiar ground: only your publications and those of the people you follow will be displayed on this thread.",
"onboarding.feeds.col2": "Here is a bit like your neighborhood: you will only find publications from members of this server, whether you follow them or not.",
"onboarding.feeds.col3": "Think outside the box and go explore the rest of the world: this thread displays posts from all known instances.",
"onboarding.feeds.explanation1": "At first it will be a little empty but don't worry we can help you fill it!",
"onboarding.feeds.explanation2": "We usually call it \"local\" thread",
"onboarding.feeds.explanation3": "We usually call it \"global\" or \"federated\" thread",
"onboarding.feeds.title1": "Home",
"onboarding.feeds.title3": "Discover",
"onboarding.privacy.description": "This site offers precise control over who can see your posts and therefore interact with you.",
"onboarding.privacy.direct-description": "The post is only visible to people mentioned via @username@instance",
"onboarding.privacy.direct-title": "Direct",
"onboarding.privacy.followers-description": "The post is not displayed on any public feeds and is only visible to people who follow you",
"onboarding.privacy.followers-title": "Followers only",
"onboarding.privacy.public-description": "The post is displayed on all feeds, including other instances.",
"onboarding.privacy.public-title": "Public",
"onboarding.privacy.title": "Privacy",
"onboarding.privacy.unlisted-description": "The post is public but only appears in your subscribers' feeds and on your profile",
"onboarding.privacy.unlisted-title": "Unlisted",
"enlistment.step4.complete": "complete your profile",
"enlistment.step4.conduct": "code of conduct",
"enlistment.step4.disclaimer": "You're almost ready to dive into the deep end.",
"enlistment.step4.informations": "If you have any questions about this new playground, do not hesitate to ask questions to the people you meet online, the community is quite caring and welcoming. You will also find more comprehensive online resources {wiki}.",
"enlistment.step4.point-1": "Think of {complete} (profile photo, banner and bio). You can even add hashtags to let people know your interests.",
"enlistment.step4.point-2": "It is customary to {publish} to make you discover. Do not forget the hashtags #introduction or #presentation.",
"enlistment.step4.point-3": "Let's go, it's up to you to discover and tame the places, find people to follow, share your ideas while respecting the {conduct} and the values of {title}.",
"enlistment.step4.publish": "publish a presentation",
"enlistment.step4.title": "What's next ?",
"enlistment.step4.wiki": "on the official wiki",
"export_data.actions.export": "Export",
"export_data.actions.export_blocks": "Export blocks",
"export_data.actions.export_follows": "Export follows",
@ -533,13 +561,15 @@
"federation_restrictions.explanation_box.message": "Normally servers on the Fediverse can communicate freely. {siteTitle} has imposed restrictions on the following servers.",
"federation_restrictions.explanation_box.title": "Instance-specific policies",
"federation_restrictions.not_disclosed_message": "{siteTitle} does not disclose federation restrictions through the API.",
"fediverse_tab.explanation_box.bubble": "This timeline shows you all the statuses published on a selection of other instances curated by your moderators.",
"fediverse_tab.explanation_box.dismiss": "Don't show again",
"fediverse_tab.explanation_box.explanation": "{site_title} is part of the Fediverse, a social network made up of thousands of independent social media sites (aka \"servers\"). The posts you see here are from 3rd-party servers. You have the freedom to engage with them, or to block any server you don't like. Pay attention to the full username after the second @ symbol to know which server a post is from. To see only {site_title} posts, visit {local}.",
"fediverse_tab.explanation_box.title": "What is the Fediverse?",
"feed_suggestions.heading": "Suggested profiles",
"feed_suggestions.view_all": "View all",
"filters.added": "Filter added.",
"filters.context_header": "Filter contexts",
"filters.context_hint": "One or multiple contexts where the filter should apply",
"filters.filters_list_context_label": "Filter contexts:",
"filters.filters_list_delete": "Delete",
"filters.filters_list_details_label": "Filter settings:",
"filters.filters_list_drop": "Drop",
@ -577,9 +607,6 @@
"hashtag.column_header.tag_mode.all": "and {additional}",
"hashtag.column_header.tag_mode.any": "or {additional}",
"hashtag.column_header.tag_mode.none": "without {additional}",
"hashtag_timeline.follow": "Follow this tag",
"hashtag_timeline.loading": "Loading...",
"hashtag_timeline.unfollow": "Unfollow tag",
"header.home.label": "Home",
"header.login.forgot_password": "Forgot password?",
"header.login.label": "Log in",
@ -588,6 +615,9 @@
"header.register.label": "Register",
"home.column_settings.show_reblogs": "Show reposts",
"home.column_settings.show_replies": "Show replies",
"icon_button.icons": "Icons",
"icon_button.label": "Select icon",
"icon_button.not_found": "No icons!! (╯°□°)╯︵ ┻━┻",
"import_data.actions.import": "Import",
"import_data.actions.import_blocks": "Import blocks",
"import_data.actions.import_follows": "Import follows",
@ -634,7 +664,6 @@
"keyboard_shortcuts.toot": "to start a new post",
"keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
"keyboard_shortcuts.up": "to move up in the list",
"landingPage.admins": "Moderators",
"landing_page_modal.download": "Download",
"landing_page_modal.helpCenter": "Help Center",
"lightbox.close": "Затвори",
@ -723,6 +752,7 @@
"navigation_bar.compose_quote": "Quote post",
"navigation_bar.compose_reply": "Reply to post",
"navigation_bar.domain_blocks": "Hidden domains",
"navigation_bar.favourites": "Favorites",
"navigation_bar.filters": "Muted words",
"navigation_bar.follow_requests": "Follow requests",
"navigation_bar.import_data": "Import data",
@ -733,7 +763,6 @@
"navigation_bar.preferences": "Предпочитания",
"navigation_bar.profile_directory": "Profile directory",
"navigation_bar.soapbox_config": "Soapbox config",
"navigation_bar.tags": "Hashtags",
"notification.favourite": "{name} хареса твоята публикация",
"notification.follow": "{name} те последва",
"notification.follow_request": "{name} has requested to follow you",
@ -767,44 +796,19 @@
"onboarding.avatar.title": "Choose a profile picture",
"onboarding.display_name.subtitle": "You can always edit this later.",
"onboarding.display_name.title": "Choose a display name",
"onboarding.feeds.col1": "Here you are on familiar ground: only your publications and those of the people you follow will be displayed on this thread.",
"onboarding.feeds.col2": "Here is a bit like your neighborhood: you will only find publications from members of this server, whether you follow them or not.",
"onboarding.feeds.col3": "Think outside the box and go explore the rest of the world: this thread displays posts from all known instances.",
"onboarding.feeds.explanation1": "At first it will be a little empty but don't worry we can help you fill it!",
"onboarding.feeds.explanation2": "We usually call it \"local\" thread",
"onboarding.feeds.explanation3": "We usually call it \"global\" or \"federated\" thread",
"onboarding.feeds.title1": "Home",
"onboarding.feeds.title3": "Discover",
"onboarding.done": "Done",
"onboarding.finished.message": "We are very excited to welcome you to our community! Tap the button below to get started.",
"onboarding.finished.title": "Onboarding complete",
"onboarding.header.subtitle": "This will be shown at the top of your profile.",
"onboarding.header.title": "Pick a cover image",
"onboarding.how-it-works.explanation": "Don't worry though, when writing a post, autosuggestion will help you find the right mention! Moreover, if you reply to a post, the mention will automatically be written in the right way.",
"onboarding.how-it-works.left": "Here you are on {title}. If you exchange with people from the same instance as you, you can simply mention them with {username}{br}{br}ex: {contact}, if you want to talk to the admin of {title}",
"onboarding.how-it-works.right": "If you exchange with a person from another instance, you must mention them with their <span class='font-bold'>@pseudo@instance</span><br/><br/> ex: <a href=' https://oslo.town/@matt'>@matt@oslo.town</a>, if you want to talk to the Oslo.town admin",
"onboarding.how-it-works.title": "How it works ?",
"onboarding.how-it-works.username": "@username",
"onboarding.next": "Next",
"onboarding.note.subtitle": "You can always edit this later.",
"onboarding.note.title": "Write a short bio",
"onboarding.privacy.description": "This site offers precise control over who can see your posts and therefore interact with you.",
"onboarding.privacy.direct-description": "The post is only visible to people mentioned via @username@instance",
"onboarding.privacy.direct-title": "Direct",
"onboarding.privacy.followers-description": "The post is not displayed on any public feeds and is only visible to people who follow you",
"onboarding.privacy.followers-title": "Followers only",
"onboarding.privacy.public-description": "The post is displayed on all feeds, including other instances.",
"onboarding.privacy.public-title": "Public",
"onboarding.privacy.title": "Privacy",
"onboarding.privacy.unlisted-description": "The post is public but only appears in your subscribers' feeds and on your profile",
"onboarding.privacy.unlisted-title": "Unlisted",
"onboarding.saving": "Saving…",
"onboarding.skip": "Skip for now",
"onboarding.suggestions.subtitle": "Here are a few of the most popular accounts you might like.",
"onboarding.suggestions.title": "Suggested accounts",
"onboarding.view_feed": "View Feed",
"onboarding.welcome.body1": "This website is your gateway to a network of independent servers that communicate together to form a larger social network: the fediverse.",
"onboarding.welcome.body2": "Each server is called an “instance”. Your instance is simply this site: ",
"onboarding.welcome.explanation": "It is this identifier that you can share on the fediverse",
"onboarding.welcome.title": "Welcome on Mangane",
"onboarding.welcome.username": "You full username",
"password_reset.confirmation": "Check your email for confirmation.",
"password_reset.fields.username_placeholder": "Email or username",
"password_reset.header": "Reset Password",
@ -818,6 +822,7 @@
"poll.non_anonymous": "Public poll",
"poll.non_anonymous.label": "Other instances may display the options you voted for",
"poll.refresh": "Refresh",
"poll.total_people": "{count, plural, one {# person} other {# people}}",
"poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
"poll.vote": "Vote",
"poll.voted": "You voted for this answer",
@ -835,6 +840,7 @@
"preferences.fields.display_media.hide_all": "Always hide media",
"preferences.fields.display_media.show_all": "Always show media",
"preferences.fields.dyslexic_font_label": "Dyslexic mode",
"preferences.fields.enlisted": "Ignore onboarding",
"preferences.fields.expand_spoilers_label": "Always expand posts marked with content warnings",
"preferences.fields.language_label": "Language",
"preferences.fields.media_display_label": "Media display",
@ -848,19 +854,14 @@
"preferences.hints.demetricator": "Decrease social media anxiety by hiding all numbers from the site.",
"preferences.hints.feed": "In your home feed",
"preferences.notifications.advanced": "Show all notification categories",
"preferences.options.bubble_timeline_hint": "Replace the fediverse timeline with the Akkoma bubble timeline showing public statuses from a list of handpicked instances.",
"preferences.options.bubble_timeline_label": "Curated timeline",
"preferences.options.content_type_markdown": "Markdown",
"preferences.options.content_type_plaintext": "Plain text",
"preferences.options.privacy_followers_only": "Followers-only",
"preferences.options.privacy_local": "Local-only",
"preferences.options.privacy_public": "Public",
"preferences.options.privacy_unlisted": "Unlisted",
"privacy.change": "Adjust post privacy",
"privacy.direct.long": "Post to mentioned users only",
"privacy.direct.short": "Direct",
"privacy.local.long": "Status is only visible to people on this instance",
"privacy.local.short": "Local-only",
"privacy.private.long": "Post to followers only",
"privacy.private.short": "Followers-only",
"privacy.public.long": "Post to public timelines",
@ -872,6 +873,7 @@
"profile_dropdown.switch_account": "Switch accounts",
"profile_dropdown.theme": "Theme",
"profile_fields_panel.title": "Profile fields",
"public.column_settings.title": "Fediverse timeline settings",
"reactions.all": "All",
"regeneration_indicator.label": "Loading…",
"regeneration_indicator.sublabel": "Your home feed is being prepared!",
@ -884,7 +886,6 @@
"registration.closed_message": "{instance} is not accepting new members",
"registration.closed_title": "Registrations Closed",
"registration.confirmation_modal.close": "Close",
"registration.custom_provider_tooltip": "Sign up with {provider}",
"registration.fields.confirm_placeholder": "Password (again)",
"registration.fields.email_placeholder": "E-Mail address",
"registration.fields.password_placeholder": "Password",
@ -905,7 +906,6 @@
"registrations.create_account": "Create an account",
"registrations.error": "Failed to register your account.",
"registrations.get_started": "Let's get started!",
"registrations.redirect": "No account yet?",
"registrations.success": "Welcome to {siteTitle}!",
"registrations.tagline": "Social Media Without Discrimination",
"registrations.unprocessable_entity": "This username has already been taken.",
@ -998,7 +998,6 @@
"settings.change_email": "Change Email",
"settings.change_password": "Change Password",
"settings.configure_mfa": "Configure MFA",
"settings.content": "Content",
"settings.delete_account": "Delete Account",
"settings.edit_profile": "Edit Profile",
"settings.other": "Other options",
@ -1016,32 +1015,35 @@
"soapbox_config.authenticated_profile_hint": "Users must be logged-in to view replies and media on user profiles.",
"soapbox_config.authenticated_profile_label": "Profiles require authentication",
"soapbox_config.copyright_footer.meta_fields.label_placeholder": "Copyright footer",
"soapbox_config.crypto_address.meta_fields.address_placeholder": "Address",
"soapbox_config.crypto_address.meta_fields.note_placeholder": "Note (optional)",
"soapbox_config.crypto_address.meta_fields.ticker_placeholder": "Ticker",
"soapbox_config.crypto_donate_panel_limit.meta_fields.limit_placeholder": "Number of items to display in the crypto homepage widget",
"soapbox_config.custom_css.meta_fields.url_placeholder": "URL",
"soapbox_config.display_fqn_label": "Display domain (eg @user@domain) for local accounts.",
"soapbox_config.fields.accent_color_label": "Accent color",
"soapbox_config.fields.brand_color_label": "Brand color",
"soapbox_config.fields.crypto_addresses_label": "Cryptocurrency addresses",
"soapbox_config.fields.home_footer_fields_label": "Home footer items",
"soapbox_config.fields.logo_label": "Logo",
"soapbox_config.fields.promo_panel_fields_label": "Promo panel items",
"soapbox_config.fields.theme_label": "Default theme",
"soapbox_config.greentext_label": "Enable greentext support",
"soapbox_config.headings.advanced": "Advanced",
"soapbox_config.headings.home": "Home",
"soapbox_config.headings.cryptocurrency": "Cryptocurrency",
"soapbox_config.headings.navigation": "Navigation",
"soapbox_config.headings.options": "Options",
"soapbox_config.headings.theme": "Theme",
"soapbox_config.hints.crypto_addresses": "Add cryptocurrency addresses so users of your site can donate to you. Order matters, and you must use lowercase ticker values.",
"soapbox_config.hints.home_footer_fields": "You can have custom defined links displayed on the footer of your static pages",
"soapbox_config.hints.logo": "SVG. At most 2 MB. Will be displayed to 50px height, maintaining aspect ratio",
"soapbox_config.hints.promo_panel_fields": "You can have custom defined links displayed on the right panel of the timelines page.",
"soapbox_config.hints.promo_panel_icons.link": "Soapbox Icons List",
"soapbox_config.home_description": "Instance's description shown in Home page. Supports HTML. Use [users] to insert the number of current users on the instance.",
"soapbox_config.home_footer.meta_fields.label_placeholder": "Label",
"soapbox_config.home_footer.meta_fields.url_placeholder": "URL",
"soapbox_config.promo_panel.meta_fields.icon_placeholder": "Icon",
"soapbox_config.promo_panel.meta_fields.label_placeholder": "Label",
"soapbox_config.promo_panel.meta_fields.url_placeholder": "URL",
"soapbox_config.quote_rt": "Enable Quote RT",
"soapbox_config.raw_json_hint": "Edit the settings data directly. Changes made directly to the JSON file will override the form fields above. Click Save to apply your changes.",
"soapbox_config.raw_json_label": "Advanced: Edit raw JSON data",
"soapbox_config.save": "Save",
@ -1054,15 +1056,16 @@
"sponsored.info.message": "{siteTitle} displays ads to help fund our service.",
"sponsored.info.title": "Why am I seeing this ad?",
"sponsored.subtitle": "Sponsored post",
"status.actions.more": "More",
"status.admin_account": "Open moderation interface for @{name}",
"status.admin_status": "Open this post in the moderation interface",
"status.block": "Block @{name}",
"status.bookmark": "Bookmark",
"status.bookmarked": "Bookmark added.",
"status.cancel_reblog_private": "Un-repost",
"status.cannot_reblog": "This post cannot be reposted",
"status.chat": "Chat with @{name}",
"status.copy": "Copy link to post",
"status.cw": "Warning:",
"status.delete": "Изтриване",
"status.detailed_status": "Detailed conversation view",
"status.direct": "Direct message @{name}",
@ -1070,11 +1073,11 @@
"status.embed": "Embed",
"status.favourite": "Предпочитани",
"status.filtered": "Filtered",
"status.filtered-hint": "Status was hidden by filter settings",
"status.load_more": "Load more",
"status.media_hidden": "Media hidden",
"status.mention": "Споменаване",
"status.more": "More",
"status.mute": "Mute @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Expand this post",
"status.pin": "Pin on profile",
@ -1087,6 +1090,7 @@
"status.reactions.like": "Like",
"status.reactions.open_mouth": "Wow",
"status.reactions.weary": "Weary",
"status.reactions_expand": "Select emoji",
"status.read_more": "Read more",
"status.reblog": "Споделяне",
"status.reblog_private": "Repost to original audience",

View file

@ -2,9 +2,7 @@
"about.also_available": "Available in:",
"accordion.collapse": "Collapse",
"accordion.expand": "Expand",
"account.about": "About",
"account.add_or_remove_from_list": "তালিকাতে আরো যুক্ত বা মুছে ফেলুন",
"account.awaiting_approval": "Awaiting approval",
"account.badges.bot": "রোবট",
"account.birthday": "Born {date}",
"account.birthday_today": "Birthday is today!",
@ -12,7 +10,6 @@
"account.block_domain": "{domain} থেকে সব সরিয়ে ফেলুন",
"account.blocked": "বন্ধ করা হয়েছে",
"account.chat": "Chat with @{name}",
"account.days": "Days",
"account.deactivated": "Deactivated",
"account.direct": "@{name} এর কাছে সরকারি লেখা পাঠাতে",
"account.domain_blocked": "Domain hidden",
@ -40,8 +37,6 @@
"account.mute": "@{name} সব কার্যক্রম আপনার সময়রেখা থেকে সরিয়ে ফেলতে",
"account.muted": "Muted",
"account.never_active": "Never",
"account.no_fields": "This section is empty for now.",
"account.open_profile": "Open Original Profile",
"account.posts": "টুট",
"account.posts_with_replies": "টুট এবং মতামত",
"account.profile": "Profile",
@ -50,13 +45,13 @@
"account.remove_from_followers": "Remove this follower",
"account.report": "@{name} কে রিপোর্ট করতে",
"account.requested": "অনুমতির অপেক্ষায় আছে। অনুসরণ করার অনুরোধ বাতিল করতে এখানে ক্লিক করুন",
"account.requested_small": "Awaiting approval",
"account.search": "Search from @{name}",
"account.share": "@{name}র পাতা অন্যদের দেখান",
"account.show_reblogs": "@{name}র সমর্থনগুলো দেখুন",
"account.subscribe": "Subscribe to notifications from @{name}",
"account.subscribe.failure": "An error occurred trying to subscribed to this account.",
"account.subscribe.success": "You have subscribed to this account.",
"account.today": "Today",
"account.unblock": "@{name}র কার্যকলাপ আবার দেখুন",
"account.unblock_domain": "{domain}থেকে আবার দেখুন",
"account.unendorse": "আপনার নিজের পাতায় এটা না দেখাতে",
@ -67,7 +62,6 @@
"account.unsubscribe.failure": "An error occurred trying to unsubscribed to this account.",
"account.unsubscribe.success": "You have unsubscribed from this account.",
"account.verified": "Verified Account",
"account.yesterday": "Yesterday",
"account_gallery.none": "No media to show.",
"account_note.hint": "You can keep notes about this user for yourself (this will not be shared with them):",
"account_note.placeholder": "No comment provided",
@ -76,9 +70,6 @@
"account_search.placeholder": "Search for an account",
"actualStatus.edited": "Edited {date}",
"actualStatuses.quote_tombstone": "Post is unavailable.",
"actualStatuses.translate": "Translate",
"actualStatuses.translate_done": "Status translated to {locale}",
"actualStatuses.translated": "Translate",
"admin.awaiting_approval.approved_message": "{acct} was approved!",
"admin.awaiting_approval.empty_message": "There is nobody waiting for approval. When a new user signs up, you can review them here.",
"admin.awaiting_approval.rejected_message": "{acct} was rejected.",
@ -238,7 +229,6 @@
"column.filters.delete_error": "Error deleting filter",
"column.filters.drop_header": "Drop instead of hide",
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
"column.filters.drop_notifications": "Will also hide status in notifications",
"column.filters.expires": "Expire after",
"column.filters.expires_hint": "Expiration dates are not currently supported",
"column.filters.home_timeline": "Home timeline",
@ -271,13 +261,11 @@
"column.public": "যুক্ত সময়রেখা",
"column.reactions": "Reactions",
"column.reblogs": "Reposts",
"column.remote": "Federated timeline",
"column.scheduled_statuses": "Scheduled Posts",
"column.search": "Search",
"column.settings_store": "Settings store",
"column.soapbox_config": "Soapbox config",
"column.tags": "Followed hashtags",
"column.tags.empty": "You don't follow any hashtag yet.",
"column.tags.see": "See",
"column.test": "Test timeline",
"column_back_button.label": "পেছনে",
"column_forbidden.body": "You do not have permission to access this page.",
@ -285,6 +273,8 @@
"column_header.show_settings": "সেটিং দেখান",
"common.cancel": "Cancel",
"common.error": "Something isn't right. Try reloading the page.",
"community.column_settings.media_only": "শুধুমাত্র ছবি বা ভিডিও",
"community.column_settings.title": "Local timeline settings",
"compare_history_modal.header": "Edit history",
"compose.character_counter.title": "Used {chars} out of {maxChars} characters",
"compose.edit_success": "Your post was edited",
@ -474,7 +464,6 @@
"emoji_button.recent": "ঘন ব্যাবহৃত",
"emoji_button.search": "খুজুন...",
"emoji_button.search_results": "খোঁজার ফলাফল",
"emoji_button.skins": "Skins",
"emoji_button.symbols": "প্রতীক",
"emoji_button.travel": "ভ্রমণ এবং স্থান",
"empty_column.account_blocked": "You are blocked by @{accountUsername}.",
@ -511,6 +500,45 @@
"empty_column.search.hashtags": "There are no hashtags results for \"{term}\"",
"empty_column.search.statuses": "There are no posts results for \"{term}\"",
"empty_column.test": "The test timeline is empty.",
"enlistment.next": "Next",
"enlistment.pass": "Ignore",
"onboarding.welcome.body": "Mangane is your gateway to a network of independent servers that communicate together to form a larger social network: the fediverse. Each server is called an “instance”. Your instance is simply this site: ",
"onboarding.welcome.explanation": "It is this identifier that you can share on the fediverse",
"onboarding.welcome.title": "Welcome on Mangane",
"onboarding.welcome.username": "You full username",
"onboarding.how-it-works.explanation": "Don't worry though, when writing a post, autosuggestion will help you find the right mention! Moreover, if you reply to a post, the mention will automatically be written in the right way.",
"onboarding.how-it-works.left": "Here you are on {title}. If you exchange with people from the same instance as you, you can simply mention them with {username}{br}{br}ex: {contact}, if you want to talk to the admin of {title}",
"onboarding.how-it-works.right": "If you exchange with a person from another instance, you must mention them with their <span class='font-bold'>@pseudo@instance</span><br/><br/> ex: <a href=' https://oslo.town/@matt'>@matt@oslo.town</a>, if you want to talk to the Oslo.town admin",
"onboarding.how-it-works.title": "How it works ?",
"onboarding.how-it-works.username": "@username",
"onboarding.feeds.col1": "Here you are on familiar ground: only your publications and those of the people you follow will be displayed on this thread.",
"onboarding.feeds.col2": "Here is a bit like your neighborhood: you will only find publications from members of this server, whether you follow them or not.",
"onboarding.feeds.col3": "Think outside the box and go explore the rest of the world: this thread displays posts from all known instances.",
"onboarding.feeds.explanation1": "At first it will be a little empty but don't worry we can help you fill it!",
"onboarding.feeds.explanation2": "We usually call it \"local\" thread",
"onboarding.feeds.explanation3": "We usually call it \"global\" or \"federated\" thread",
"onboarding.feeds.title1": "Home",
"onboarding.feeds.title3": "Discover",
"onboarding.privacy.description": "This site offers precise control over who can see your posts and therefore interact with you.",
"onboarding.privacy.direct-description": "The post is only visible to people mentioned via @username@instance",
"onboarding.privacy.direct-title": "Direct",
"onboarding.privacy.followers-description": "The post is not displayed on any public feeds and is only visible to people who follow you",
"onboarding.privacy.followers-title": "Followers only",
"onboarding.privacy.public-description": "The post is displayed on all feeds, including other instances.",
"onboarding.privacy.public-title": "Public",
"onboarding.privacy.title": "Privacy",
"onboarding.privacy.unlisted-description": "The post is public but only appears in your subscribers' feeds and on your profile",
"onboarding.privacy.unlisted-title": "Unlisted",
"enlistment.step4.complete": "complete your profile",
"enlistment.step4.conduct": "code of conduct",
"enlistment.step4.disclaimer": "You're almost ready to dive into the deep end.",
"enlistment.step4.informations": "If you have any questions about this new playground, do not hesitate to ask questions to the people you meet online, the community is quite caring and welcoming. You will also find more comprehensive online resources {wiki}.",
"enlistment.step4.point-1": "Think of {complete} (profile photo, banner and bio). You can even add hashtags to let people know your interests.",
"enlistment.step4.point-2": "It is customary to {publish} to make you discover. Do not forget the hashtags #introduction or #presentation.",
"enlistment.step4.point-3": "Let's go, it's up to you to discover and tame the places, find people to follow, share your ideas while respecting the {conduct} and the values of {title}.",
"enlistment.step4.publish": "publish a presentation",
"enlistment.step4.title": "What's next ?",
"enlistment.step4.wiki": "on the official wiki",
"export_data.actions.export": "Export",
"export_data.actions.export_blocks": "Export blocks",
"export_data.actions.export_follows": "Export follows",
@ -533,13 +561,15 @@
"federation_restrictions.explanation_box.message": "Normally servers on the Fediverse can communicate freely. {siteTitle} has imposed restrictions on the following servers.",
"federation_restrictions.explanation_box.title": "Instance-specific policies",
"federation_restrictions.not_disclosed_message": "{siteTitle} does not disclose federation restrictions through the API.",
"fediverse_tab.explanation_box.bubble": "This timeline shows you all the statuses published on a selection of other instances curated by your moderators.",
"fediverse_tab.explanation_box.dismiss": "Don't show again",
"fediverse_tab.explanation_box.explanation": "{site_title} is part of the Fediverse, a social network made up of thousands of independent social media sites (aka \"servers\"). The posts you see here are from 3rd-party servers. You have the freedom to engage with them, or to block any server you don't like. Pay attention to the full username after the second @ symbol to know which server a post is from. To see only {site_title} posts, visit {local}.",
"fediverse_tab.explanation_box.title": "What is the Fediverse?",
"feed_suggestions.heading": "Suggested profiles",
"feed_suggestions.view_all": "View all",
"filters.added": "Filter added.",
"filters.context_header": "Filter contexts",
"filters.context_hint": "One or multiple contexts where the filter should apply",
"filters.filters_list_context_label": "Filter contexts:",
"filters.filters_list_delete": "Delete",
"filters.filters_list_details_label": "Filter settings:",
"filters.filters_list_drop": "Drop",
@ -577,9 +607,6 @@
"hashtag.column_header.tag_mode.all": "এবং {additional}",
"hashtag.column_header.tag_mode.any": "অথবা {additional}",
"hashtag.column_header.tag_mode.none": "বাদ দিয়ে {additional}",
"hashtag_timeline.follow": "Follow this tag",
"hashtag_timeline.loading": "Loading...",
"hashtag_timeline.unfollow": "Unfollow tag",
"header.home.label": "Home",
"header.login.forgot_password": "Forgot password?",
"header.login.label": "Log in",
@ -588,6 +615,9 @@
"header.register.label": "Register",
"home.column_settings.show_reblogs": "সমর্থনগুলো দেখান",
"home.column_settings.show_replies": "মতামত দেখান",
"icon_button.icons": "Icons",
"icon_button.label": "Select icon",
"icon_button.not_found": "No icons!! (╯°□°)╯︵ ┻━┻",
"import_data.actions.import": "Import",
"import_data.actions.import_blocks": "Import blocks",
"import_data.actions.import_follows": "Import follows",
@ -634,7 +664,6 @@
"keyboard_shortcuts.toot": "নতুন একটা টুট লেখা শুরু করতে",
"keyboard_shortcuts.unfocus": "লেখা বা খোঁজার জায়গায় ফোকাস না করতে",
"keyboard_shortcuts.up": "তালিকার উপরের দিকে যেতে",
"landingPage.admins": "Moderators",
"landing_page_modal.download": "Download",
"landing_page_modal.helpCenter": "Help Center",
"lightbox.close": "বন্ধ",
@ -723,6 +752,7 @@
"navigation_bar.compose_quote": "Quote post",
"navigation_bar.compose_reply": "Reply to post",
"navigation_bar.domain_blocks": "বন্ধ করা ওয়েবসাইট",
"navigation_bar.favourites": "পছন্দের",
"navigation_bar.filters": "বন্ধ করা শব্দ",
"navigation_bar.follow_requests": "অনুসরণের অনুরোধগুলি",
"navigation_bar.import_data": "Import data",
@ -733,7 +763,6 @@
"navigation_bar.preferences": "পছন্দসমূহ",
"navigation_bar.profile_directory": "Profile directory",
"navigation_bar.soapbox_config": "Soapbox config",
"navigation_bar.tags": "Hashtags",
"notification.favourite": "{name} আপনার কার্যক্রম পছন্দ করেছেন",
"notification.follow": "{name} আপনাকে অনুসরণ করেছেন",
"notification.follow_request": "{name} has requested to follow you",
@ -767,44 +796,19 @@
"onboarding.avatar.title": "Choose a profile picture",
"onboarding.display_name.subtitle": "You can always edit this later.",
"onboarding.display_name.title": "Choose a display name",
"onboarding.feeds.col1": "Here you are on familiar ground: only your publications and those of the people you follow will be displayed on this thread.",
"onboarding.feeds.col2": "Here is a bit like your neighborhood: you will only find publications from members of this server, whether you follow them or not.",
"onboarding.feeds.col3": "Think outside the box and go explore the rest of the world: this thread displays posts from all known instances.",
"onboarding.feeds.explanation1": "At first it will be a little empty but don't worry we can help you fill it!",
"onboarding.feeds.explanation2": "We usually call it \"local\" thread",
"onboarding.feeds.explanation3": "We usually call it \"global\" or \"federated\" thread",
"onboarding.feeds.title1": "Home",
"onboarding.feeds.title3": "Discover",
"onboarding.done": "Done",
"onboarding.finished.message": "We are very excited to welcome you to our community! Tap the button below to get started.",
"onboarding.finished.title": "Onboarding complete",
"onboarding.header.subtitle": "This will be shown at the top of your profile.",
"onboarding.header.title": "Pick a cover image",
"onboarding.how-it-works.explanation": "Don't worry though, when writing a post, autosuggestion will help you find the right mention! Moreover, if you reply to a post, the mention will automatically be written in the right way.",
"onboarding.how-it-works.left": "Here you are on {title}. If you exchange with people from the same instance as you, you can simply mention them with {username}{br}{br}ex: {contact}, if you want to talk to the admin of {title}",
"onboarding.how-it-works.right": "If you exchange with a person from another instance, you must mention them with their <span class='font-bold'>@pseudo@instance</span><br/><br/> ex: <a href=' https://oslo.town/@matt'>@matt@oslo.town</a>, if you want to talk to the Oslo.town admin",
"onboarding.how-it-works.title": "How it works ?",
"onboarding.how-it-works.username": "@username",
"onboarding.next": "Next",
"onboarding.note.subtitle": "You can always edit this later.",
"onboarding.note.title": "Write a short bio",
"onboarding.privacy.description": "This site offers precise control over who can see your posts and therefore interact with you.",
"onboarding.privacy.direct-description": "The post is only visible to people mentioned via @username@instance",
"onboarding.privacy.direct-title": "Direct",
"onboarding.privacy.followers-description": "The post is not displayed on any public feeds and is only visible to people who follow you",
"onboarding.privacy.followers-title": "Followers only",
"onboarding.privacy.public-description": "The post is displayed on all feeds, including other instances.",
"onboarding.privacy.public-title": "Public",
"onboarding.privacy.title": "Privacy",
"onboarding.privacy.unlisted-description": "The post is public but only appears in your subscribers' feeds and on your profile",
"onboarding.privacy.unlisted-title": "Unlisted",
"onboarding.saving": "Saving…",
"onboarding.skip": "Skip for now",
"onboarding.suggestions.subtitle": "Here are a few of the most popular accounts you might like.",
"onboarding.suggestions.title": "Suggested accounts",
"onboarding.view_feed": "View Feed",
"onboarding.welcome.body1": "This website is your gateway to a network of independent servers that communicate together to form a larger social network: the fediverse.",
"onboarding.welcome.body2": "Each server is called an “instance”. Your instance is simply this site: ",
"onboarding.welcome.explanation": "It is this identifier that you can share on the fediverse",
"onboarding.welcome.title": "Welcome on Mangane",
"onboarding.welcome.username": "You full username",
"password_reset.confirmation": "Check your email for confirmation.",
"password_reset.fields.username_placeholder": "Email or username",
"password_reset.header": "Reset Password",
@ -818,6 +822,7 @@
"poll.non_anonymous": "Public poll",
"poll.non_anonymous.label": "Other instances may display the options you voted for",
"poll.refresh": "বদলেছে কিনা দেখতে",
"poll.total_people": "{count, plural, one {# person} other {# people}}",
"poll.total_votes": "{count, plural, one {# ভোট} other {# ভোট}}",
"poll.vote": "ভোট",
"poll.voted": "You voted for this answer",
@ -835,6 +840,7 @@
"preferences.fields.display_media.hide_all": "Always hide media",
"preferences.fields.display_media.show_all": "Always show media",
"preferences.fields.dyslexic_font_label": "Dyslexic mode",
"preferences.fields.enlisted": "Ignore onboarding",
"preferences.fields.expand_spoilers_label": "Always expand posts marked with content warnings",
"preferences.fields.language_label": "Language",
"preferences.fields.media_display_label": "Media display",
@ -848,19 +854,14 @@
"preferences.hints.demetricator": "Decrease social media anxiety by hiding all numbers from the site.",
"preferences.hints.feed": "In your home feed",
"preferences.notifications.advanced": "Show all notification categories",
"preferences.options.bubble_timeline_hint": "Replace the fediverse timeline with the Akkoma bubble timeline showing public statuses from a list of handpicked instances.",
"preferences.options.bubble_timeline_label": "Curated timeline",
"preferences.options.content_type_markdown": "Markdown",
"preferences.options.content_type_plaintext": "Plain text",
"preferences.options.privacy_followers_only": "Followers-only",
"preferences.options.privacy_local": "Local-only",
"preferences.options.privacy_public": "Public",
"preferences.options.privacy_unlisted": "Unlisted",
"privacy.change": "লেখার গোপনীয়তা অবস্থা ঠিক করতে",
"privacy.direct.long": "শুধুমাত্র উল্লেখিত ব্যবহারকারীদের কাছে লিখতে",
"privacy.direct.short": "সরাসরি",
"privacy.local.long": "Status is only visible to people on this instance",
"privacy.local.short": "Local-only",
"privacy.private.long": "শুধুমাত্র আপনার অনুসরণকারীদের লিখতে",
"privacy.private.short": "শুধুমাত্র অনুসরণকারীদের জন্য",
"privacy.public.long": "সর্বজনীন প্রকাশ্য সময়রেখাতে লিখতে",
@ -872,6 +873,7 @@
"profile_dropdown.switch_account": "Switch accounts",
"profile_dropdown.theme": "Theme",
"profile_fields_panel.title": "Profile fields",
"public.column_settings.title": "Fediverse timeline settings",
"reactions.all": "All",
"regeneration_indicator.label": "আসছে…",
"regeneration_indicator.sublabel": "আপনার বাড়ির-সময়রেখা প্রস্তূত করা হচ্ছে!",
@ -884,7 +886,6 @@
"registration.closed_message": "{instance} is not accepting new members",
"registration.closed_title": "Registrations Closed",
"registration.confirmation_modal.close": "Close",
"registration.custom_provider_tooltip": "Sign up with {provider}",
"registration.fields.confirm_placeholder": "Password (again)",
"registration.fields.email_placeholder": "E-Mail address",
"registration.fields.password_placeholder": "Password",
@ -905,7 +906,6 @@
"registrations.create_account": "Create an account",
"registrations.error": "Failed to register your account.",
"registrations.get_started": "Let's get started!",
"registrations.redirect": "No account yet?",
"registrations.success": "Welcome to {siteTitle}!",
"registrations.tagline": "Social Media Without Discrimination",
"registrations.unprocessable_entity": "This username has already been taken.",
@ -998,7 +998,6 @@
"settings.change_email": "Change Email",
"settings.change_password": "Change Password",
"settings.configure_mfa": "Configure MFA",
"settings.content": "Content",
"settings.delete_account": "Delete Account",
"settings.edit_profile": "Edit Profile",
"settings.other": "Other options",
@ -1016,32 +1015,35 @@
"soapbox_config.authenticated_profile_hint": "Users must be logged-in to view replies and media on user profiles.",
"soapbox_config.authenticated_profile_label": "Profiles require authentication",
"soapbox_config.copyright_footer.meta_fields.label_placeholder": "Copyright footer",
"soapbox_config.crypto_address.meta_fields.address_placeholder": "Address",
"soapbox_config.crypto_address.meta_fields.note_placeholder": "Note (optional)",
"soapbox_config.crypto_address.meta_fields.ticker_placeholder": "Ticker",
"soapbox_config.crypto_donate_panel_limit.meta_fields.limit_placeholder": "Number of items to display in the crypto homepage widget",
"soapbox_config.custom_css.meta_fields.url_placeholder": "URL",
"soapbox_config.display_fqn_label": "Display domain (eg @user@domain) for local accounts.",
"soapbox_config.fields.accent_color_label": "Accent color",
"soapbox_config.fields.brand_color_label": "Brand color",
"soapbox_config.fields.crypto_addresses_label": "Cryptocurrency addresses",
"soapbox_config.fields.home_footer_fields_label": "Home footer items",
"soapbox_config.fields.logo_label": "Logo",
"soapbox_config.fields.promo_panel_fields_label": "Promo panel items",
"soapbox_config.fields.theme_label": "Default theme",
"soapbox_config.greentext_label": "Enable greentext support",
"soapbox_config.headings.advanced": "Advanced",
"soapbox_config.headings.home": "Home",
"soapbox_config.headings.cryptocurrency": "Cryptocurrency",
"soapbox_config.headings.navigation": "Navigation",
"soapbox_config.headings.options": "Options",
"soapbox_config.headings.theme": "Theme",
"soapbox_config.hints.crypto_addresses": "Add cryptocurrency addresses so users of your site can donate to you. Order matters, and you must use lowercase ticker values.",
"soapbox_config.hints.home_footer_fields": "You can have custom defined links displayed on the footer of your static pages",
"soapbox_config.hints.logo": "SVG. At most 2 MB. Will be displayed to 50px height, maintaining aspect ratio",
"soapbox_config.hints.promo_panel_fields": "You can have custom defined links displayed on the right panel of the timelines page.",
"soapbox_config.hints.promo_panel_icons.link": "Soapbox Icons List",
"soapbox_config.home_description": "Instance's description shown in Home page. Supports HTML. Use [users] to insert the number of current users on the instance.",
"soapbox_config.home_footer.meta_fields.label_placeholder": "Label",
"soapbox_config.home_footer.meta_fields.url_placeholder": "URL",
"soapbox_config.promo_panel.meta_fields.icon_placeholder": "Icon",
"soapbox_config.promo_panel.meta_fields.label_placeholder": "Label",
"soapbox_config.promo_panel.meta_fields.url_placeholder": "URL",
"soapbox_config.quote_rt": "Enable Quote RT",
"soapbox_config.raw_json_hint": "Edit the settings data directly. Changes made directly to the JSON file will override the form fields above. Click Save to apply your changes.",
"soapbox_config.raw_json_label": "Advanced: Edit raw JSON data",
"soapbox_config.save": "Save",
@ -1054,15 +1056,16 @@
"sponsored.info.message": "{siteTitle} displays ads to help fund our service.",
"sponsored.info.title": "Why am I seeing this ad?",
"sponsored.subtitle": "Sponsored post",
"status.actions.more": "More",
"status.admin_account": "@{name} র জন্য পরিচালনার ইন্টারফেসে ঢুকুন",
"status.admin_status": "যায় লেখাটি পরিচালনার ইন্টারফেসে খুলুন",
"status.block": "@{name}কে বন্ধ করুন",
"status.bookmark": "Bookmark",
"status.bookmarked": "Bookmark added.",
"status.cancel_reblog_private": "সমর্থন বাতিল করতে",
"status.cannot_reblog": "এটিতে সমর্থন দেওয়া যাবেনা",
"status.chat": "Chat with @{name}",
"status.copy": "লেখাটির লিংক কপি করতে",
"status.cw": "Warning:",
"status.delete": "মুছে ফেলতে",
"status.detailed_status": "বিস্তারিত কথোপকথনের হিসেবে দেখতে",
"status.direct": "@{name} কে সরাসরি লেখা পাঠাতে",
@ -1070,11 +1073,11 @@
"status.embed": "এমবেড করতে",
"status.favourite": "পছন্দের করতে",
"status.filtered": "ছাঁকনিদিত",
"status.filtered-hint": "Status was hidden by filter settings",
"status.load_more": "আরো দেখুন",
"status.media_hidden": "ছবি বা ভিডিও পেছনে",
"status.mention": "@{name}কে উল্লেখ করতে",
"status.more": "আরো",
"status.mute": "@{name}র কার্যক্রম সরিয়ে ফেলতে",
"status.mute_conversation": "কথোপকথননের প্রজ্ঞাপন সরিয়ে ফেলতে",
"status.open": "এটার সম্পূর্ণটা দেখতে",
"status.pin": "নিজের পাতায় এটা পিন করতে",
@ -1087,6 +1090,7 @@
"status.reactions.like": "Like",
"status.reactions.open_mouth": "Wow",
"status.reactions.weary": "Weary",
"status.reactions_expand": "Select emoji",
"status.read_more": "আরো পড়ুন",
"status.reblog": "সমর্থন দিতে",
"status.reblog_private": "আপনার অনুসরণকারীদের কাছে এটার সমর্থন দেখাতে",

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