Merge remote-tracking branch 'soapbox/develop' into mastodon-groups

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2022-12-21 13:02:19 +01:00
commit bd247797bb
48 changed files with 1035 additions and 935 deletions

1
.gitattributes vendored Normal file
View File

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

View File

@ -25,6 +25,7 @@ deps:
cache: cache:
<<: *cache <<: *cache
policy: push policy: push
interruptible: true
danger: danger:
stage: test stage: test
@ -33,6 +34,7 @@ danger:
- export CI_MERGE_REQUEST_IID=${CI_OPEN_MERGE_REQUESTS#*!} - export CI_MERGE_REQUEST_IID=${CI_OPEN_MERGE_REQUESTS#*!}
- npx danger ci - npx danger ci
allow_failure: true allow_failure: true
interruptible: true
lint-js: lint-js:
stage: test stage: test
@ -45,6 +47,7 @@ lint-js:
- "**/*.tsx" - "**/*.tsx"
- ".eslintignore" - ".eslintignore"
- ".eslintrc.js" - ".eslintrc.js"
interruptible: true
lint-sass: lint-sass:
stage: test stage: test
@ -54,6 +57,7 @@ lint-sass:
- "**/*.scss" - "**/*.scss"
- "**/*.css" - "**/*.css"
- ".stylelintrc.json" - ".stylelintrc.json"
interruptible: true
jest: jest:
stage: test stage: test
@ -76,6 +80,7 @@ jest:
coverage_report: coverage_report:
coverage_format: cobertura coverage_format: cobertura
path: .coverage/cobertura-coverage.xml path: .coverage/cobertura-coverage.xml
interruptible: true
nginx-test: nginx-test:
stage: test stage: test
@ -85,6 +90,7 @@ nginx-test:
only: only:
changes: changes:
- "installation/mastodon.conf" - "installation/mastodon.conf"
interruptible: true
build-production: build-production:
stage: test stage: test
@ -94,6 +100,7 @@ build-production:
artifacts: artifacts:
paths: paths:
- static - static
interruptible: true
docs-deploy: docs-deploy:
stage: deploy stage: deploy
@ -107,6 +114,7 @@ docs-deploy:
- develop - develop
changes: changes:
- "docs/**/*" - "docs/**/*"
interruptible: true
# Supposed to fail when translations are outdated, instead always passes # Supposed to fail when translations are outdated, instead always passes
# #
@ -127,6 +135,7 @@ review:
script: script:
- npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub - npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub
allow_failure: true allow_failure: true
interruptible: true
pages: pages:
stage: deploy stage: deploy
@ -142,6 +151,7 @@ pages:
only: only:
refs: refs:
- develop - develop
interruptible: true
docker: docker:
stage: deploy stage: deploy
@ -158,3 +168,4 @@ docker:
only: only:
refs: refs:
- develop - develop
interruptible: true

View File

@ -4,6 +4,87 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### 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.
- 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.
### 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.
- 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.
### 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 ## [1.3.0] - 2021-07-02
### Changed ### Changed
- Layout: show right sidebar on all pages. - Layout: show right sidebar on all pages.

208
README.md
View File

@ -1,202 +1,80 @@
# Soapbox
![Soapbox Screenshot](soapbox-screenshot.png) ![Soapbox Screenshot](soapbox-screenshot.png)
**Soapbox** is a frontend for Mastodon and Pleroma with a focus on custom branding and ease of use. **Soapbox** is customizable open-source software that puts the power of social media in the hands of the people. Feature-rich and hyper-focused on providing a user experience to rival Big Tech, Soapbox is already home to some of the biggest alternative social platforms.
## Try it out # On The Fediverse
Visit https://fe.soapbox.pub/ and point it to your favorite instance. You may have heard of **Mastodon**. Soapbox builds upon what Mastodon made great to make something even better.
## :rocket: Deploy on Pleroma You can run **Mastodon+Soapbox**, **Rebased+Soapbox**, and more.
Installing Soapbox on an existing Pleroma server is extremely easy. Soapbox is the **frontend** (what users see) while Mastodon is the **backend** (data, APIs). You can mix-and-match in the Fediverse ecosystem.
Just ssh into the server and download a .zip of the latest build:
```sh > 💡 If you're starting a new server, we highly recommend **Rebased+Soapbox**. Rebased is our custom-built backend just for Soapbox, providing important new features such as **quote posting** and **chats**.
curl -L https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/develop/download?job=build-production -o soapbox.zip >
``` > See: [Installing Rebased+Soapbox](https://soapbox.pub/install/)
Then unpack it into Pleroma's `instance` directory: # Try It Out
```sh Want to give Soapbox a shot? Here are some suggested servers:
busybox unzip soapbox.zip -o -d /opt/pleroma/instance
```
**That's it!** :tada: - [gleasonator.com](https://gleasonator.com/) - operated by the lead developer of Soapbox
**Soapbox is installed.** - [social.teci.world](https://social.teci.world/) - free speech server run by a Soapbox contributor
The change will take effect immediately, just refresh your browser tab. - [spinster.xyz](https://spinster.xyz/) - one of the largest feminist communities on the internet
It's not necessary to restart the Pleroma service. - [poa.st](https://poa.st/) - the largest Soapbox server on the network
***For OTP releases,*** *unpack to /var/lib/pleroma instead.* Want to use Soapbox against **any existing Mastodon/Pleroma server?** Try:
To remove Soapbox and revert to the default pleroma-fe, simply `rm /opt/pleroma/instance/static/index.html` (you can delete other stuff in there too, but be careful not to delete your own HTML files). - [fe.soapbox.pub](https://fe.soapbox.pub) - enter your server's domain name to use Soapbox on any server!
## :elephant: Deploy on Mastodon # 🚀 Starting Your Own Server
See [Installing Soapbox over Mastodon](https://docs.soapbox.pub/frontend/administration/mastodon/). Starting your own server is one of the best ways to have freedom online! We recommend installing **Rebased+Soapbox**.
## How does it work? See here for a detailed setup guide: [Installing Rebased+Soapbox](https://soapbox.pub/install/)
Soapbox is a [single-page application (SPA)](https://en.wikipedia.org/wiki/Single-page_application) that runs entirely in the browser with JavaScript. # Adding Soapbox to an Existing Server
It has a single HTML file, `index.html`, responsible only for loading the required JavaScript and CSS. Already have a server? No problem — it is still possible to use Soapbox.
It interacts with the backend through [XMLHttpRequest (XHR)](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest).
Here is a simplified example with Nginx: - [Deploying on Pleroma](https://docs.soapbox.pub/frontend/installing/#install-soapbox)
- [Deploying on Mastodon](https://docs.soapbox.pub/frontend/administration/mastodon/)
```nginx > 💡 If using Pleroma, it's recommended to [upgrade it to Rebased](https://gitlab.com/-/snippets/2411739). This comes with better support and many new features, helping you get the most out of Soapbox.
location /api {
proxy_pass http://backend;
}
location / { # Developing Soapbox
root /opt/soapbox;
try_files $uri index.html;
}
```
(See [`mastodon.conf`](https://gitlab.com/soapbox-pub/soapbox/-/blob/develop/installation/mastodon.conf) for a full example.) tl;dr — `git clone`, `yarn`, and `yarn dev`.
Soapbox incorporates much of the [Mastodon API](https://docs.joinmastodon.org/methods/), [Pleroma API](https://api.pleroma.social/), and more. For detailed guides, see these pages:
It detects features supported by the backend to provide the right experience for the backend.
# Running locally 1. [Soapbox local development](https://docs.soapbox.pub/frontend/development/running-locally/)
2. [yarn commands](https://docs.soapbox.pub/frontend/development/yarn-commands/)
3. [How it works](https://docs.soapbox.pub/frontend/development/how-it-works/)
4. [Environment variables](https://docs.soapbox.pub/frontend/development/local-config/)
5. [Developing a backend](https://docs.soapbox.pub/frontend/development/developing-backend/)
To get it running, just clone the repo: ## Contributing
```sh
git clone https://gitlab.com/soapbox-pub/soapbox.git
cd soapbox
```
Ensure that Node.js and Yarn are installed, then install dependencies:
```sh
yarn
```
Finally, run the dev server:
```sh
yarn dev
```
**That's it!** :tada:
It will serve at `http://localhost:3036` by default.
You should see an input box - just enter the domain name of your instance to log in.
Tip: you can even enter a local instance like `http://localhost:3000`!
### Troubleshooting: `ERROR: NODE_ENV must be set`
Create a `.env` file if you haven't already.
```sh
cp .env.example .env
```
And ensure that it contains `NODE_ENV=development`.
Try again.
### Troubleshooting: it's not working!
Run `node -V` and compare your Node.js version with the version in [`.tool-versions`](https://gitlab.com/soapbox-pub/soapbox/-/blob/develop/.tool-versions).
If they don't match, try installing [asdf](https://asdf-vm.com/).
## Local Dev Configuration
The following configuration variables are supported supported in local development.
Edit `.env` to set them.
All configuration is optional, except `NODE_ENV`.
#### `NODE_ENV`
The Node environment.
Soapbox checks for the following options:
- `development` - What you should use while developing Soapbox.
- `production` - Use when compiling to deploy to a live server.
- `test` - Use when running automated tests.
#### `BACKEND_URL`
URL to the backend server.
Can be http or https, and can include a port.
For https, be sure to also set `PROXY_HTTPS_INSECURE=true`.
**Default:** `http://localhost:4000`
#### `PROXY_HTTPS_INSECURE`
Allows using an HTTPS backend if set to `true`.
This is needed if `BACKEND_URL` is set to an `https://` value.
[More info](https://stackoverflow.com/a/48624590/8811886).
**Default:** `false`
# Yarn Commands
The following commands are supported.
You must set `NODE_ENV` to use these commands.
To do so, you can add the following line to your `.env` file:
```sh
NODE_ENV=development
```
#### Local dev server
- `yarn dev` - Run the local dev server.
#### Building
- `yarn build` - Compile without a dev server, into `/static` directory.
#### Translations
- `yarn manage:translations` - Normalizes translation files. Should always be run after editing i18n strings.
#### Tests
- `yarn test:all` - Runs all tests and linters.
- `yarn test` - Runs Jest for frontend unit tests.
- `yarn lint` - Runs all linters.
- `yarn lint:js` - Runs only JavaScript linter.
- `yarn lint:sass` - Runs only SASS linter.
# Contributing
We welcome contributions to this project. We welcome contributions to this project.
To contribute, see [Contributing to Soapbox](docs/contributing.md). To contribute, see [Contributing to Soapbox](docs/contributing.md).
# Customization Translators can help by providing [translations through Weblate](https://hosted.weblate.org/projects/soapbox-pub/soapbox/).
Native speakers from all around the world are welcome!
Soapbox supports customization of the user interface, to allow per-instance branding and other features. # Project Philosophy
Some examples include:
- Instance name Soapbox was born out of the need to build independent platforms with **a unique identity and brand**.
- Site logo
- Favicon
- About page
- Terms of Service page
- Privacy Policy page
- Copyright Policy (DMCA) page
- Promo panel list items, e.g. blog site link
- Soapbox extensions, e.g. Patron module
- Default settings, e.g. default theme
More details can be found in [Customizing Soapbox](docs/customization.md). This is in contrast to Mastodon's idea, where all servers are called "Mastodon" and use the Mastodon colors and logo. Users won't see the word "Soapbox" throughout the UI, they'll see the name of **your website** and your logo. To facilitate this, Soapbox has a robust customization UI and integrated moderation tools. Large servers are a priority.
One disadvantage of this approach is that it does not help the software spread. Some of the biggest servers on the network and running Soapbox and people don't even know it!
# License & Credits # License & Credits
Soapbox is based on [Gab Social](https://code.gab.com/gab/social/gab-social)'s frontend which is in turn based on [Mastodon](https://github.com/tootsuite/mastodon/)'s frontend. © Alex Gleason & other Soapbox contributors
© Eugen Rochko & other Mastodon contributors
- `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. © Gab AI, Inc.
Soapbox is free software: you can redistribute it and/or modify Soapbox is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by

View File

@ -0,0 +1,6 @@
# Sound licenses
- `chat.mp3`
- `chat.oga`
© [notificationsounds.com](https://notificationsounds.com/notification-sounds/intuition-561), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by-sa/4.0/).

View File

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

View File

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

View File

@ -41,6 +41,7 @@ export { default as PhoneInput } from './phone-input/phone-input';
export { default as ProgressBar } from './progress-bar/progress-bar'; export { default as ProgressBar } from './progress-bar/progress-bar';
export { default as RadioButton } from './radio-button/radio-button'; export { default as RadioButton } from './radio-button/radio-button';
export { default as Select } from './select/select'; export { default as Select } from './select/select';
export { default as Slider } from './slider/slider';
export { default as Spinner } from './spinner/spinner'; export { default as Spinner } from './spinner/spinner';
export { default as Stack } from './stack/stack'; export { default as Stack } from './stack/stack';
export { default as Streamfield } from './streamfield/streamfield'; export { default as Streamfield } from './streamfield/streamfield';

View File

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

View File

@ -41,6 +41,7 @@ import {
useInstance, useInstance,
} from 'soapbox/hooks'; } from 'soapbox/hooks';
import MESSAGES from 'soapbox/locales/messages'; import MESSAGES from 'soapbox/locales/messages';
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
import { queryClient } from 'soapbox/queries/client'; import { queryClient } from 'soapbox/queries/client';
import { useCachedLocationHandler } from 'soapbox/utils/redirect'; import { useCachedLocationHandler } from 'soapbox/utils/redirect';
import { generateThemeCss } from 'soapbox/utils/theme'; import { generateThemeCss } from 'soapbox/utils/theme';
@ -267,8 +268,9 @@ const SoapboxHead: React.FC<ISoapboxHead> = ({ children }) => {
const settings = useSettings(); const settings = useSettings();
const soapboxConfig = useSoapboxConfig(); const soapboxConfig = useSoapboxConfig();
const demo = !!settings.get('demo');
const darkMode = useTheme() === 'dark'; const darkMode = useTheme() === 'dark';
const themeCss = generateThemeCss(soapboxConfig); const themeCss = generateThemeCss(demo ? normalizeSoapboxConfig({ brandColor: '#0482d8' }) : soapboxConfig);
const bodyClass = classNames('bg-white dark:bg-gray-800 text-base h-full', { const bodyClass = classNames('bg-white dark:bg-gray-800 text-base h-full', {
'no-reduce-motion': !settings.get('reduceMotion'), 'no-reduce-motion': !settings.get('reduceMotion'),

View File

@ -526,11 +526,11 @@ const Header: React.FC<IHeader> = ({ account }) => {
}; };
const renderMessageButton = () => { const renderMessageButton = () => {
if (features.chatsWithFollowers) { // Truth Social
if (!ownAccount || !account || account.id === ownAccount?.id) { if (!ownAccount || !account || account.id === ownAccount?.id) {
return null; return null;
} }
if (features.chatsWithFollowers) { // Truth Social
const canChat = account.relationship?.followed_by; const canChat = account.relationship?.followed_by;
if (!canChat) { if (!canChat) {
return null; return null;

View File

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

View File

@ -3,12 +3,7 @@ import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { updateConfig } from 'soapbox/actions/admin'; import { updateConfig } from 'soapbox/actions/admin';
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import { import { RadioGroup, RadioItem } from 'soapbox/components/radio';
SimpleForm,
FieldsGroup,
RadioGroup,
RadioItem,
} from 'soapbox/features/forms';
import { useAppDispatch, useInstance } from 'soapbox/hooks'; import { useAppDispatch, useInstance } from 'soapbox/hooks';
import type { Instance } from 'soapbox/types/entities'; import type { Instance } from 'soapbox/types/entities';
@ -54,12 +49,7 @@ const RegistrationModePicker: React.FC = () => {
}; };
return ( return (
<SimpleForm> <RadioGroup onChange={onChange}>
<FieldsGroup>
<RadioGroup
label={<FormattedMessage id='admin.dashboard.registration_mode_label' defaultMessage='Registrations' />}
onChange={onChange}
>
<RadioItem <RadioItem
label={<FormattedMessage id='admin.dashboard.registration_mode.open_label' defaultMessage='Open' />} label={<FormattedMessage id='admin.dashboard.registration_mode.open_label' defaultMessage='Open' />}
hint={<FormattedMessage id='admin.dashboard.registration_mode.open_hint' defaultMessage='Anyone can join.' />} hint={<FormattedMessage id='admin.dashboard.registration_mode.open_hint' defaultMessage='Anyone can join.' />}
@ -79,8 +69,6 @@ const RegistrationModePicker: React.FC = () => {
value='closed' value='closed'
/> />
</RadioGroup> </RadioGroup>
</FieldsGroup>
</SimpleForm>
); );
}; };

View File

@ -4,7 +4,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { approveUsers } from 'soapbox/actions/admin'; import { approveUsers } from 'soapbox/actions/admin';
import { rejectUserModal } from 'soapbox/actions/moderation'; import { rejectUserModal } from 'soapbox/actions/moderation';
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import IconButton from 'soapbox/components/icon-button'; import { Stack, HStack, Text, IconButton } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors'; import { makeGetAccount } from 'soapbox/selectors';
@ -45,16 +45,31 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
}; };
return ( return (
<div className='unapproved-account'> <HStack space={4} justifyContent='between'>
<div className='unapproved-account__bio'> <Stack space={1}>
<div className='unapproved-account__nickname'>@{account.get('acct')}</div> <Text weight='semibold'>
<blockquote className='md'>{adminAccount?.invite_request || ''}</blockquote> @{account.get('acct')}
</div> </Text>
<div className='unapproved-account__actions'> <Text tag='blockquote' size='sm'>
<IconButton src={require('@tabler/icons/check.svg')} onClick={handleApprove} /> {adminAccount?.invite_request || ''}
<IconButton src={require('@tabler/icons/x.svg')} onClick={handleReject} /> </Text>
</div> </Stack>
</div>
<HStack space={2} alignItems='center'>
<IconButton
src={require('@tabler/icons/check.svg')}
onClick={handleApprove}
theme='outlined'
iconClassName='p-1 text-gray-600 dark:text-gray-400'
/>
<IconButton
src={require('@tabler/icons/x.svg')}
onClick={handleReject}
theme='outlined'
iconClassName='p-1 text-gray-600 dark:text-gray-400'
/>
</HStack>
</HStack>
); );
}; };

View File

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

View File

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

View File

@ -1,44 +1,49 @@
import React from 'react'; import React from 'react';
import { FormattedMessage, FormattedNumber } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email-list'; import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email-list';
import { Text } from 'soapbox/components/ui'; import List, { ListItem } from 'soapbox/components/list';
import { CardTitle, Icon, IconButton, Stack } from 'soapbox/components/ui';
import { useAppDispatch, useOwnAccount, useFeatures, useInstance } from 'soapbox/hooks'; import { useAppDispatch, useOwnAccount, useFeatures, useInstance } from 'soapbox/hooks';
import sourceCode from 'soapbox/utils/code'; import sourceCode from 'soapbox/utils/code';
import { download } from 'soapbox/utils/download'; import { download } from 'soapbox/utils/download';
import { parseVersion } from 'soapbox/utils/features'; import { parseVersion } from 'soapbox/utils/features';
import { isNumber } from 'soapbox/utils/numbers';
import { DashCounter, DashCounters } from '../components/dashcounter';
import RegistrationModePicker from '../components/registration-mode-picker'; import RegistrationModePicker from '../components/registration-mode-picker';
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const history = useHistory();
const instance = useInstance(); const instance = useInstance();
const features = useFeatures(); const features = useFeatures();
const account = useOwnAccount(); const account = useOwnAccount();
const handleSubscribersClick: React.MouseEventHandler = e => { const handleSubscribersClick: React.MouseEventHandler = e => {
dispatch(getSubscribersCsv()).then((response) => { dispatch(getSubscribersCsv()).then(({ data }) => {
download(response, 'subscribers.csv'); download(data, 'subscribers.csv');
}).catch(() => {}); }).catch(() => {});
e.preventDefault(); e.preventDefault();
}; };
const handleUnsubscribersClick: React.MouseEventHandler = e => { const handleUnsubscribersClick: React.MouseEventHandler = e => {
dispatch(getUnsubscribersCsv()).then((response) => { dispatch(getUnsubscribersCsv()).then(({ data }) => {
download(response, 'unsubscribers.csv'); download(data, 'unsubscribers.csv');
}).catch(() => {}); }).catch(() => {});
e.preventDefault(); e.preventDefault();
}; };
const handleCombinedClick: React.MouseEventHandler = e => { const handleCombinedClick: React.MouseEventHandler = e => {
dispatch(getCombinedCsv()).then((response) => { dispatch(getCombinedCsv()).then(({ data }) => {
download(response, 'combined.csv'); download(data, 'combined.csv');
}).catch(() => {}); }).catch(() => {});
e.preventDefault(); e.preventDefault();
}; };
const navigateToSoapboxConfig = () => history.push('/soapbox/config');
const navigateToModerationLog = () => history.push('/soapbox/admin/log');
const v = parseVersion(instance.version); const v = parseVersion(instance.version);
const userCount = instance.stats.get('user_count'); const userCount = instance.stats.get('user_count');
@ -46,87 +51,121 @@ const Dashboard: React.FC = () => {
const domainCount = instance.stats.get('domain_count'); const domainCount = instance.stats.get('domain_count');
const mau = instance.pleroma.getIn(['stats', 'mau']) as number | undefined; const mau = instance.pleroma.getIn(['stats', 'mau']) as number | undefined;
const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : null; const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : undefined;
if (!account) return null; if (!account) return null;
return ( return (
<Stack space={6} className='mt-4'>
<DashCounters>
<DashCounter
count={mau}
label={<FormattedMessage id='admin.dashcounters.mau_label' defaultMessage='monthly active users' />}
/>
<DashCounter
to='/soapbox/admin/users'
count={userCount}
label={<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='total users' />}
/>
<DashCounter
count={retention}
label={<FormattedMessage id='admin.dashcounters.retention_label' defaultMessage='user retention' />}
percent
/>
<DashCounter
to='/timeline/local'
count={statusCount}
label={<FormattedMessage id='admin.dashcounters.status_count_label' defaultMessage='posts' />}
/>
<DashCounter
count={domainCount}
label={<FormattedMessage id='admin.dashcounters.domain_count_label' defaultMessage='peers' />}
/>
</DashCounters>
<List>
{account.admin && (
<ListItem
onClick={navigateToSoapboxConfig}
label={<FormattedMessage id='navigation_bar.soapbox_config' defaultMessage='Soapbox config' />}
/>
)}
<ListItem
onClick={navigateToModerationLog}
label={<FormattedMessage id='column.admin.moderation_log' defaultMessage='Moderation Log' />}
/>
</List>
{account.admin && (
<> <>
<div className='dashcounters mt-8'> <CardTitle
{isNumber(mau) && ( title={<FormattedMessage id='admin.dashboard.registration_mode_label' defaultMessage='Registrations' />}
<div className='dashcounter'> />
<Text align='center' size='2xl' weight='medium'>
<FormattedNumber value={mau} />
</Text>
<Text align='center'>
<FormattedMessage id='admin.dashcounters.mau_label' defaultMessage='monthly active users' />
</Text>
</div>
)}
{isNumber(userCount) && (
<Link className='dashcounter' to='/soapbox/admin/users'>
<Text align='center' size='2xl' weight='medium'>
<FormattedNumber value={userCount} />
</Text>
<Text align='center'>
<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='total users' />
</Text>
</Link>
)}
{isNumber(retention) && (
<div className='dashcounter'>
<Text align='center' size='2xl' weight='medium'>
{retention}%
</Text>
<Text align='center'>
<FormattedMessage id='admin.dashcounters.retention_label' defaultMessage='user retention' />
</Text>
</div>
)}
{isNumber(statusCount) && (
<Link className='dashcounter' to='/timeline/local'>
<Text align='center' size='2xl' weight='medium'>
<FormattedNumber value={statusCount} />
</Text>
<Text align='center'>
<FormattedMessage id='admin.dashcounters.status_count_label' defaultMessage='posts' />
</Text>
</Link>
)}
{isNumber(domainCount) && (
<div className='dashcounter'>
<Text align='center' size='2xl' weight='medium'>
<FormattedNumber value={domainCount} />
</Text>
<Text align='center'>
<FormattedMessage id='admin.dashcounters.domain_count_label' defaultMessage='peers' />
</Text>
</div>
)}
</div>
{account.admin && <RegistrationModePicker />} <RegistrationModePicker />
<div className='dashwidgets'>
<div className='dashwidget'>
<h4><FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' /></h4>
<ul>
<li>{sourceCode.displayName} <span className='pull-right'>{sourceCode.version}</span></li>
<li>{v.software + (v.build ? `+${v.build}` : '')} <span className='pull-right'>{v.version}</span></li>
</ul>
</div>
{features.emailList && account.admin && (
<div className='dashwidget'>
<h4><FormattedMessage id='admin.dashwidgets.email_list_header' defaultMessage='Email list' /></h4>
<ul>
<li><a href='#' onClick={handleSubscribersClick} target='_blank'>subscribers.csv</a></li>
<li><a href='#' onClick={handleUnsubscribersClick} target='_blank'>unsubscribers.csv</a></li>
<li><a href='#' onClick={handleCombinedClick} target='_blank'>combined.csv</a></li>
</ul>
</div>
)}
</div>
</> </>
)}
<CardTitle
title={<FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' />}
/>
<List>
<ListItem label={<FormattedMessage id='admin.software.frontend' defaultMessage='Frontend' />}>
<a
href={sourceCode.ref ? `${sourceCode.url}/tree/${sourceCode.ref}` : sourceCode.url}
className='flex space-x-1 items-center truncate'
target='_blank'
>
<span>{sourceCode.displayName} {sourceCode.version}</span>
<Icon
className='w-4 h-4'
src={require('@tabler/icons/external-link.svg')}
/>
</a>
</ListItem>
<ListItem label={<FormattedMessage id='admin.software.backend' defaultMessage='Backend' />}>
<span>{v.software + (v.build ? `+${v.build}` : '')} {v.version}</span>
</ListItem>
</List>
{(features.emailList && account.admin) && (
<>
<CardTitle
title={<FormattedMessage id='admin.dashwidgets.email_list_header' defaultMessage='Email list' />}
/>
<List>
<ListItem label='subscribers.csv'>
<IconButton
src={require('@tabler/icons/download.svg')}
onClick={handleSubscribersClick}
iconClassName='w-5 h-5'
/>
</ListItem>
<ListItem label='unsubscribers.csv'>
<IconButton
src={require('@tabler/icons/download.svg')}
onClick={handleUnsubscribersClick}
iconClassName='w-5 h-5'
/>
</ListItem>
<ListItem label='combined.csv'>
<IconButton
src={require('@tabler/icons/download.svg')}
onClick={handleCombinedClick}
iconClassName='w-5 h-5'
/>
</ListItem>
</List>
</>
)}
</Stack>
); );
}; };

View File

@ -36,7 +36,7 @@ const Welcome = () => {
}; };
return ( return (
<Stack className='py-20 px-4 sm:px-0' data-testid='chats-welcome'> <Stack className='py-20 px-4 sm:px-0 h-full overflow-y-auto' data-testid='chats-welcome'>
<div className='w-full sm:w-3/5 xl:w-2/5 mx-auto mb-2.5'> <div className='w-full sm:w-3/5 xl:w-2/5 mx-auto mb-2.5'>
<Text align='center' weight='bold' className='mb-6 text-2xl md:text-3xl leading-8'> <Text align='center' weight='bold' className='mb-6 text-2xl md:text-3xl leading-8'>
{intl.formatMessage(messages.title, { br: <br /> })} {intl.formatMessage(messages.title, { br: <br /> })}

View File

@ -103,6 +103,13 @@ const SettingsStore: React.FC = () => {
</CardHeader> </CardHeader>
<List> <List>
<ListItem
label={<FormattedMessage id='preferences.fields.demo_label' defaultMessage='Demo mode' />}
hint={<FormattedMessage id='preferences.fields.demo_hint' defaultMessage='Use the default Soapbox logo and color scheme. Useful for taking screenshots.' />}
>
<SettingToggle settings={settings} settingPath={['demo']} onChange={onToggleChange} />
</ListItem>
<ListItem label={<FormattedMessage id='preferences.notifications.advanced' defaultMessage='Show all notification categories' />}> <ListItem label={<FormattedMessage id='preferences.notifications.advanced' defaultMessage='Show all notification categories' />}>
<SettingToggle settings={settings} settingPath={['notifications', 'quickFilter', 'advanced']} onChange={onToggleChange} /> <SettingToggle settings={settings} settingPath={['notifications', 'quickFilter', 'advanced']} onChange={onToggleChange} />
</ListItem> </ListItem>

View File

@ -102,8 +102,8 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
}; };
const handleExportClick = () => { const handleExportClick = () => {
dispatch(fetchEventIcs(status.id)).then((response) => { dispatch(fetchEventIcs(status.id)).then(({ data }) => {
download(response, 'calendar.ics'); download(data, 'calendar.ics');
}).catch(() => {}); }).catch(() => {});
}; };

View File

@ -1,8 +1,8 @@
import classNames from 'clsx'; import classNames from 'clsx';
import React, { useState, useRef } from 'react'; import React, { useState } from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Text, Select } from '../../components/ui'; import { Select } from '../../components/ui';
interface IInputContainer { interface IInputContainer {
label?: React.ReactNode, label?: React.ReactNode,
@ -175,52 +175,6 @@ export const Checkbox: React.FC<ICheckbox> = (props) => (
<SimpleInput type='checkbox' {...props} /> <SimpleInput type='checkbox' {...props} />
); );
interface IRadioGroup {
label?: React.ReactNode,
onChange?: React.ChangeEventHandler,
}
export const RadioGroup: React.FC<IRadioGroup> = (props) => {
const { label, children, onChange } = props;
const childrenWithProps = React.Children.map(children, child =>
// @ts-ignore
React.cloneElement(child, { onChange }),
);
return (
<div className='input with_floating_label radio_buttons'>
<div className='label_input'>
<label>{label}</label>
<ul>{childrenWithProps}</ul>
</div>
</div>
);
};
interface IRadioItem {
label?: React.ReactNode,
hint?: React.ReactNode,
value: string,
checked: boolean,
onChange?: React.ChangeEventHandler,
}
export const RadioItem: React.FC<IRadioItem> = (props) => {
const { current: id } = useRef<string>(uuidv4());
const { label, hint, checked = false, ...rest } = props;
return (
<li className='radio'>
<label htmlFor={id}>
<input id={id} type='radio' checked={checked} {...rest} />
<Text>{label}</Text>
{hint && <span className='hint'>{hint}</span>}
</label>
</li>
);
};
interface ISelectDropdown { interface ISelectDropdown {
label?: React.ReactNode, label?: React.ReactNode,
hint?: React.ReactNode, hint?: React.ReactNode,

View File

@ -7,7 +7,7 @@ import { useOwnAccount } from 'soapbox/hooks';
import { useUpdateCredentials } from 'soapbox/queries/accounts'; import { useUpdateCredentials } from 'soapbox/queries/accounts';
const messages = defineMessages({ const messages = defineMessages({
label: { id: 'settings.messages.label', defaultMessage: 'Allow users you follow to start a new chat with you' }, label: { id: 'settings.messages.label', defaultMessage: 'Allow users to start a new chat with you' },
}); });
const MessagesSettings = () => { const MessagesSettings = () => {

View File

@ -1,5 +1,5 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
@ -106,7 +106,7 @@ const Settings = () => {
{features.chats ? ( {features.chats ? (
<> <>
<CardHeader> <CardHeader>
<CardTitle title='Direct Messages' /> <CardTitle title={<FormattedMessage id='column.chats' defaultMessage='Chats' />} />
</CardHeader> </CardHeader>
<CardBody> <CardBody>

View File

@ -9,12 +9,12 @@ import ColorPicker from './color-picker';
import type { ColorChangeHandler } from 'react-color'; import type { ColorChangeHandler } from 'react-color';
interface IColorWithPicker { interface IColorWithPicker {
buttonId: string,
value: string, value: string,
onChange: ColorChangeHandler, onChange: ColorChangeHandler,
className?: string,
} }
const ColorWithPicker: React.FC<IColorWithPicker> = ({ buttonId, value, onChange }) => { const ColorWithPicker: React.FC<IColorWithPicker> = ({ value, onChange, className }) => {
const node = useRef<HTMLDivElement>(null); const node = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
const [placement, setPlacement] = useState<string | null>(null); const [placement, setPlacement] = useState<string | null>(null);
@ -39,11 +39,10 @@ const ColorWithPicker: React.FC<IColorWithPicker> = ({ buttonId, value, onChange
}; };
return ( return (
<div> <div className={className}>
<div <div
ref={node} ref={node}
id={buttonId} className='w-full h-full'
className='w-8 h-8 rounded-md'
role='presentation' role='presentation'
style={{ background: value }} style={{ background: value }}
title={value} title={value}

View File

@ -1,8 +1,9 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { updateConfig } from 'soapbox/actions/admin'; import { updateSoapboxConfig } from 'soapbox/actions/admin';
import { uploadMedia } from 'soapbox/actions/media'; import { uploadMedia } from 'soapbox/actions/media';
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import List, { ListItem } from 'soapbox/components/list'; import List, { ListItem } from 'soapbox/components/list';
@ -25,14 +26,11 @@ import ThemeSelector from 'soapbox/features/ui/components/theme-selector';
import { useAppSelector, useAppDispatch, useFeatures } from 'soapbox/hooks'; import { useAppSelector, useAppDispatch, useFeatures } from 'soapbox/hooks';
import { normalizeSoapboxConfig } from 'soapbox/normalizers'; import { normalizeSoapboxConfig } from 'soapbox/normalizers';
import ColorWithPicker from './components/color-with-picker';
import CryptoAddressInput from './components/crypto-address-input'; import CryptoAddressInput from './components/crypto-address-input';
import FooterLinkInput from './components/footer-link-input'; import FooterLinkInput from './components/footer-link-input';
import PromoPanelInput from './components/promo-panel-input'; import PromoPanelInput from './components/promo-panel-input';
import SitePreview from './components/site-preview'; import SitePreview from './components/site-preview';
import type { ColorChangeHandler, ColorResult } from 'react-color';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.soapbox_config', defaultMessage: 'Soapbox config' }, heading: { id: 'column.soapbox_config', defaultMessage: 'Soapbox config' },
saved: { id: 'soapbox_config.saved', defaultMessage: 'Soapbox config saved!' }, saved: { id: 'soapbox_config.saved', defaultMessage: 'Soapbox config saved!' },
@ -59,7 +57,6 @@ const messages = defineMessages({
}); });
type ValueGetter<T = Element> = (e: React.ChangeEvent<T>) => any; type ValueGetter<T = Element> = (e: React.ChangeEvent<T>) => any;
type ColorValueGetter = (color: ColorResult, event: React.ChangeEvent<HTMLInputElement>) => any;
type Template = ImmutableMap<string, any>; type Template = ImmutableMap<string, any>;
type ConfigPath = Array<string | number>; type ConfigPath = Array<string | number>;
type ThemeChangeHandler = (theme: string) => void; type ThemeChangeHandler = (theme: string) => void;
@ -72,6 +69,7 @@ const templates: Record<string, Template> = {
const SoapboxConfig: React.FC = () => { const SoapboxConfig: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const features = useFeatures(); const features = useFeatures();
@ -84,6 +82,8 @@ const SoapboxConfig: React.FC = () => {
const [rawJSON, setRawJSON] = useState<string>(JSON.stringify(initialData, null, 2)); const [rawJSON, setRawJSON] = useState<string>(JSON.stringify(initialData, null, 2));
const [jsonValid, setJsonValid] = useState(true); const [jsonValid, setJsonValid] = useState(true);
const navigateToThemeEditor = () => history.push('/soapbox/admin/theme');
const soapbox = useMemo(() => { const soapbox = useMemo(() => {
return normalizeSoapboxConfig(data); return normalizeSoapboxConfig(data);
}, [data]); }, [data]);
@ -99,18 +99,8 @@ const SoapboxConfig: React.FC = () => {
setJsonValid(true); setJsonValid(true);
}; };
const getParams = () => {
return [{
group: ':pleroma',
key: ':frontend_configurations',
value: [{
tuple: [':soapbox_fe', data.toJS()],
}],
}];
};
const handleSubmit: React.FormEventHandler = (e) => { const handleSubmit: React.FormEventHandler = (e) => {
dispatch(updateConfig(getParams())).then(() => { dispatch(updateSoapboxConfig(data.toJS())).then(() => {
setLoading(false); setLoading(false);
dispatch(snackbar.success(intl.formatMessage(messages.saved))); dispatch(snackbar.success(intl.formatMessage(messages.saved)));
}).catch(() => { }).catch(() => {
@ -132,12 +122,6 @@ const SoapboxConfig: React.FC = () => {
}; };
}; };
const handleColorChange = (path: ConfigPath, getValue: ColorValueGetter): ColorChangeHandler => {
return (color, event) => {
setConfig(path, getValue(color, event));
};
};
const handleFileChange = (path: ConfigPath): React.ChangeEventHandler<HTMLInputElement> => { const handleFileChange = (path: ConfigPath): React.ChangeEventHandler<HTMLInputElement> => {
return e => { return e => {
const data = new FormData(); const data = new FormData();
@ -224,21 +208,10 @@ const SoapboxConfig: React.FC = () => {
/> />
</ListItem> </ListItem>
<ListItem label={<FormattedMessage id='soapbox_config.fields.brand_color_label' defaultMessage='Brand color' />}> <ListItem
<ColorWithPicker label={<FormattedMessage id='soapbox_config.fields.edit_theme_label' defaultMessage='Edit theme' />}
buttonId='brandColor' onClick={navigateToThemeEditor}
value={soapbox.brandColor}
onChange={handleColorChange(['brandColor'], (color) => color.hex)}
/> />
</ListItem>
<ListItem label={<FormattedMessage id='soapbox_config.fields.accent_color_label' defaultMessage='Accent color' />}>
<ColorWithPicker
buttonId='accentColor'
value={soapbox.accentColor}
onChange={handleColorChange(['accentColor'], (color) => color.hex)}
/>
</ListItem>
</List> </List>
<CardHeader> <CardHeader>

View File

@ -0,0 +1,28 @@
import React from 'react';
import ColorWithPicker from 'soapbox/features/soapbox-config/components/color-with-picker';
import type { ColorChangeHandler } from 'react-color';
interface IColor {
color: string,
onChange: (color: string) => void,
}
/** Color input. */
const Color: React.FC<IColor> = ({ color, onChange }) => {
const handleChange: ColorChangeHandler = (result) => {
onChange(result.hex);
};
return (
<ColorWithPicker
className='w-full h-full'
value={color}
onChange={handleChange}
/>
);
};
export default Color;

View File

@ -0,0 +1,67 @@
import React, { useEffect, useState } from 'react';
import { HStack, Stack, Slider } from 'soapbox/components/ui';
import { usePrevious } from 'soapbox/hooks';
import { compareId } from 'soapbox/utils/comparators';
import { hueShift } from 'soapbox/utils/theme';
import Color from './color';
interface ColorGroup {
[tint: string]: string,
}
interface IPalette {
palette: ColorGroup,
onChange: (palette: ColorGroup) => void,
resetKey?: string,
}
/** Editable color palette. */
const Palette: React.FC<IPalette> = ({ palette, onChange, resetKey }) => {
const tints = Object.keys(palette).sort(compareId);
const [hue, setHue] = useState(0);
const lastHue = usePrevious(hue);
const handleChange = (tint: string) => {
return (color: string) => {
onChange({
...palette,
[tint]: color,
});
};
};
useEffect(() => {
const delta = hue - (lastHue || 0);
const adjusted = Object.entries(palette).reduce<ColorGroup>((result, [tint, hex]) => {
result[tint] = hueShift(hex, delta * 360);
return result;
}, {});
onChange(adjusted);
}, [hue]);
useEffect(() => {
setHue(0);
}, [resetKey]);
return (
<Stack className='w-full'>
<HStack className='h-8 rounded-md overflow-hidden'>
{tints.map(tint => (
<Color color={palette[tint]} onChange={handleChange(tint)} />
))}
</HStack>
<Slider value={hue} onChange={setHue} />
</Stack>
);
};
export {
Palette as default,
ColorGroup,
};

View File

@ -0,0 +1,273 @@
import React, { useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { v4 as uuidv4 } from 'uuid';
import { updateSoapboxConfig } from 'soapbox/actions/admin';
import { getHost } from 'soapbox/actions/instance';
import snackbar from 'soapbox/actions/snackbar';
import { fetchSoapboxConfig } from 'soapbox/actions/soapbox';
import List, { ListItem } from 'soapbox/components/list';
import { Button, Column, Form, FormActions } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import ColorWithPicker from 'soapbox/features/soapbox-config/components/color-with-picker';
import { useAppDispatch, useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
import { download } from 'soapbox/utils/download';
import Palette, { ColorGroup } from './components/palette';
import type { ColorChangeHandler } from 'react-color';
const messages = defineMessages({
title: { id: 'admin.theme.title', defaultMessage: 'Theme' },
saved: { id: 'theme_editor.saved', defaultMessage: 'Theme updated!' },
restore: { id: 'theme_editor.restore', defaultMessage: 'Restore default theme' },
export: { id: 'theme_editor.export', defaultMessage: 'Export theme' },
import: { id: 'theme_editor.import', defaultMessage: 'Import theme' },
importSuccess: { id: 'theme_editor.import_success', defaultMessage: 'Theme was successfully imported!' },
});
interface IThemeEditor {
}
/** UI for editing Tailwind theme colors. */
const ThemeEditor: React.FC<IThemeEditor> = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const soapbox = useSoapboxConfig();
const host = useAppSelector(state => getHost(state));
const rawConfig = useAppSelector(state => state.soapbox);
const [colors, setColors] = useState(soapbox.colors.toJS() as any);
const [submitting, setSubmitting] = useState(false);
const [resetKey, setResetKey] = useState(uuidv4());
const fileInput = useRef<HTMLInputElement>(null);
const updateColors = (key: string) => {
return (newColors: ColorGroup) => {
setColors({
...colors,
[key]: {
...colors[key],
...newColors,
},
});
};
};
const updateColor = (key: string) => {
return (hex: string) => {
setColors({
...colors,
[key]: hex,
});
};
};
const setTheme = (theme: any) => {
setResetKey(uuidv4());
setTimeout(() => setColors(theme));
};
const resetTheme = () => {
setTheme(soapbox.colors.toJS() as any);
};
const updateTheme = async () => {
const params = rawConfig.set('colors', colors).toJS();
await dispatch(updateSoapboxConfig(params));
};
const restoreDefaultTheme = () => {
const colors = normalizeSoapboxConfig({ brandColor: '#0482d8' }).colors.toJS();
setTheme(colors);
};
const exportTheme = () => {
const data = JSON.stringify(colors, null, 2);
download(data, 'theme.json');
};
const importTheme = () => {
fileInput.current?.click();
};
const handleSelectFile: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
const file = e.target.files?.item(0);
if (file) {
const text = await file.text();
const json = JSON.parse(text);
const colors = normalizeSoapboxConfig({ colors: json }).colors.toJS();
setTheme(colors);
dispatch(snackbar.success(intl.formatMessage(messages.importSuccess)));
}
};
const handleSubmit = async() => {
setSubmitting(true);
try {
await dispatch(fetchSoapboxConfig(host));
await updateTheme();
dispatch(snackbar.success(intl.formatMessage(messages.saved)));
setSubmitting(false);
} catch (e) {
setSubmitting(false);
}
};
return (
<Column label={intl.formatMessage(messages.title)}>
<Form onSubmit={handleSubmit}>
<List>
<PaletteListItem
label='Primary'
palette={colors.primary}
onChange={updateColors('primary')}
resetKey={resetKey}
/>
<PaletteListItem
label='Secondary'
palette={colors.secondary}
onChange={updateColors('secondary')}
resetKey={resetKey}
/>
<PaletteListItem
label='Accent'
palette={colors.accent}
onChange={updateColors('accent')}
resetKey={resetKey}
/>
<PaletteListItem
label='Gray'
palette={colors.gray}
onChange={updateColors('gray')}
resetKey={resetKey}
/>
<PaletteListItem
label='Success'
palette={colors.success}
onChange={updateColors('success')}
resetKey={resetKey}
/>
<PaletteListItem
label='Danger'
palette={colors.danger}
onChange={updateColors('danger')}
resetKey={resetKey}
/>
</List>
<List>
<ColorListItem
label='Greentext'
value={colors.greentext}
onChange={updateColor('greentext')}
/>
<ColorListItem
label='Accent Blue'
value={colors['accent-blue']}
onChange={updateColor('accent-blue')}
/>
<ColorListItem
label='Gradient Start'
value={colors['gradient-start']}
onChange={updateColor('gradient-start')}
/>
<ColorListItem
label='Gradient End'
value={colors['gradient-end']}
onChange={updateColor('gradient-end')}
/>
</List>
<FormActions>
<DropdownMenuContainer
items={[{
text: intl.formatMessage(messages.restore),
action: restoreDefaultTheme,
icon: require('@tabler/icons/refresh.svg'),
},{
text: intl.formatMessage(messages.import),
action: importTheme,
icon: require('@tabler/icons/upload.svg'),
}, {
text: intl.formatMessage(messages.export),
action: exportTheme,
icon: require('@tabler/icons/download.svg'),
}]}
/>
<Button theme='secondary' onClick={resetTheme}>
<FormattedMessage id='theme_editor.Reset' defaultMessage='Reset' />
</Button>
<Button type='submit' theme='primary' disabled={submitting}>
<FormattedMessage id='theme_editor.save' defaultMessage='Save theme' />
</Button>
</FormActions>
</Form>
<input
type='file'
ref={fileInput}
multiple
accept='application/json'
className='hidden'
onChange={handleSelectFile}
/>
</Column>
);
};
interface IPaletteListItem {
label: React.ReactNode,
palette: ColorGroup,
onChange: (palette: ColorGroup) => void,
resetKey?: string,
}
/** Palette editor inside a ListItem. */
const PaletteListItem: React.FC<IPaletteListItem> = ({ label, palette, onChange, resetKey }) => {
return (
<ListItem label={<div className='w-20'>{label}</div>}>
<Palette palette={palette} onChange={onChange} resetKey={resetKey} />
</ListItem>
);
};
interface IColorListItem {
label: React.ReactNode,
value: string,
onChange: (hex: string) => void,
}
/** Single-color picker. */
const ColorListItem: React.FC<IColorListItem> = ({ label, value, onChange }) => {
const handleChange: ColorChangeHandler = (color, _e) => {
onChange(color.hex);
};
return (
<ListItem label={label}>
<ColorWithPicker
value={value}
onChange={handleChange}
className='w-10 h-8 rounded-md overflow-hidden'
/>
</ListItem>
);
};
export default ThemeEditor;

View File

@ -45,7 +45,7 @@ class Bundle extends React.PureComponent<BundleProps, BundleState> {
this.load(this.props); this.load(this.props);
} }
componentWillReceiveProps(nextProps: BundleProps) { UNSAFE_componentWillReceiveProps(nextProps: BundleProps) {
if (nextProps.fetchComponent !== this.props.fetchComponent) { if (nextProps.fetchComponent !== this.props.fetchComponent) {
this.load(nextProps); this.load(nextProps);
} }

View File

@ -108,6 +108,7 @@ import {
TestTimeline, TestTimeline,
LogoutPage, LogoutPage,
AuthTokenList, AuthTokenList,
ThemeEditor,
Quotes, Quotes,
ServiceWorkerInfo, ServiceWorkerInfo,
EventInformation, EventInformation,
@ -312,6 +313,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
<WrappedRoute path='/soapbox/admin/reports' staffOnly page={AdminPage} component={Dashboard} content={children} exact /> <WrappedRoute path='/soapbox/admin/reports' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
<WrappedRoute path='/soapbox/admin/log' staffOnly page={AdminPage} component={ModerationLog} content={children} exact /> <WrappedRoute path='/soapbox/admin/log' staffOnly page={AdminPage} component={ModerationLog} content={children} exact />
<WrappedRoute path='/soapbox/admin/users' staffOnly page={AdminPage} component={UserIndex} content={children} exact /> <WrappedRoute path='/soapbox/admin/users' staffOnly page={AdminPage} component={UserIndex} content={children} exact />
<WrappedRoute path='/soapbox/admin/theme' staffOnly page={AdminPage} component={ThemeEditor} content={children} exact />
<WrappedRoute path='/info' page={EmptyPage} component={ServerInfo} content={children} /> <WrappedRoute path='/info' page={EmptyPage} component={ServerInfo} content={children} />
<WrappedRoute path='/developers/apps/create' developerOnly page={DefaultPage} component={CreateApp} content={children} /> <WrappedRoute path='/developers/apps/create' developerOnly page={DefaultPage} component={CreateApp} content={children} />

View File

@ -310,6 +310,10 @@ export function ModerationLog() {
return import(/* webpackChunkName: "features/admin/moderation_log" */'../../admin/moderation-log'); return import(/* webpackChunkName: "features/admin/moderation_log" */'../../admin/moderation-log');
} }
export function ThemeEditor() {
return import(/* webpackChunkName: "features/theme-editor" */'../../theme-editor');
}
export function UserPanel() { export function UserPanel() {
return import(/* webpackChunkName: "features/ui" */'../components/user-panel'); return import(/* webpackChunkName: "features/ui" */'../components/user-panel');
} }

View File

@ -1,221 +0,0 @@
# Custom Locale Data
This folder is used to store custom locale data. These custom locale data are
not yet provided by [Unicode Common Locale Data Repository](http://cldr.unicode.org/development/new-cldr-developers)
and hence not provided in [react-intl/locale-data/*](https://github.com/yahoo/react-intl).
The locale data should support [Locale Data APIs](https://github.com/yahoo/react-intl/wiki/API#locale-data-apis)
of the react-intl library.
It is recommended to start your custom locale data from this sample English
locale data ([*](#plural-rules)):
```javascript
/*eslint eqeqeq: "off"*/
/*eslint no-nested-ternary: "off"*/
export default [
{
locale: "en",
pluralRuleFunction: function(e, a) {
var n = String(e).split("."),
l = !n[1],
o = Number(n[0]) == e,
t = o && n[0].slice(-1),
r = o && n[0].slice(-2);
return a ? 1 == t && 11 != r ? "one" : 2 == t && 12 != r ? "two" : 3 == t && 13 != r ? "few" : "other" : 1 == e && l ? "one" : "other"
},
fields: {
year: {
displayName: "year",
relative: {
0: "this year",
1: "next year",
"-1": "last year"
},
relativeTime: {
future: {
one: "in {0} year",
other: "in {0} years"
},
past: {
one: "{0} year ago",
other: "{0} years ago"
}
}
},
month: {
displayName: "month",
relative: {
0: "this month",
1: "next month",
"-1": "last month"
},
relativeTime: {
future: {
one: "in {0} month",
other: "in {0} months"
},
past: {
one: "{0} month ago",
other: "{0} months ago"
}
}
},
day: {
displayName: "day",
relative: {
0: "today",
1: "tomorrow",
"-1": "yesterday"
},
relativeTime: {
future: {
one: "in {0} day",
other: "in {0} days"
},
past: {
one: "{0} day ago",
other: "{0} days ago"
}
}
},
hour: {
displayName: "hour",
relativeTime: {
future: {
one: "in {0} hour",
other: "in {0} hours"
},
past: {
one: "{0} hour ago",
other: "{0} hours ago"
}
}
},
minute: {
displayName: "minute",
relativeTime: {
future: {
one: "in {0} minute",
other: "in {0} minutes"
},
past: {
one: "{0} minute ago",
other: "{0} minutes ago"
}
}
},
second: {
displayName: "second",
relative: {
0: "now"
},
relativeTime: {
future: {
one: "in {0} second",
other: "in {0} seconds"
},
past: {
one: "{0} second ago",
other: "{0} seconds ago"
}
}
}
}
}
]
```
## Notes
### Plural Rules
The function `pluralRuleFunction()` should return the key to proper string of
a plural form(s). The purpose of the function is to provide key of translate
strings of correct plural form according. The different forms are described in
[CLDR's Plural Rules][cldr-plural-rules],
[cldr-plural-rules]: http://cldr.unicode.org/index/cldr-spec/plural-rules
#### Quick Overview on CLDR Rules
Let's take English as an example.
When you describe a number, you can be either describe it as:
* Cardinals: 1st, 2nd, 3rd ... 11th, 12th ... 21st, 22nd, 23nd ....
* Ordinals: 1, 2, 3 ...
In any of these cases, the nouns will reflect the number with singular or plural
form. For example:
* in 0 days
* in 1 day
* in 2 days
The `pluralRuleFunction` receives 2 parameters:
* `e`: a string representation of the number. Such as, "`1`", "`2`", "`2.1`".
* `a`: `true` if this is "cardinal" type of description. `false` for ordinal and other case.
#### How you should write `pluralRuleFunction`
The first rule to write pluralRuleFunction is never translate the output string
into your language. [Plural Rules][cldr-plural-rules] specified you should use
these as the return values:
* "`zero`"
* "`one`" (singular)
* "`two`" (dual)
* "`few`" (paucal)
* "`many`" (also used for fractions if they have a separate class)
* "`other`" (required—general plural form—also used if the language only has a single form)
Again, we'll use English as the example here.
Let's read the `return` statement in the pluralRuleFunction above:
```javascript
return a ? 1 == t && 11 != r ? "one" : 2 == t && 12 != r ? "two" : 3 == t && 13 != r ? "few" : "other" : 1 == e && l ? "one" : "other"
```
This nested ternary is hard to read. It basically means:
```javascript
// e: the number variable to examine
// a: "true" if cardinals
// l: "true" if the variable e has nothin after decimal mark (e.g. "1.0" would be false)
// o: "true" if the variable e is an integer
// t: the "ones" of the number. e.g. "3" for number "9123"
// r: the "ones" and "tens" of the number. e.g. "23" for number "9123"
if (a == true) {
if (t == 1 && r != 11) {
return "one"; // i.e. 1st, 21st, 101st, 121st ...
} else if (t == 2 && r != 12) {
return "two"; // i.e. 2nd, 22nd, 102nd, 122nd ...
} else if (t == 3 && r != 13) {
return "few"; // i.e. 3rd, 23rd, 103rd, 123rd ...
} else {
return "other"; // i.e. 4th, 11th, 12th, 24th ...
}
} else {
if (e == 1 && l) {
return "one"; // i.e. 1 day
} else {
return "other"; // i.e. 0 days, 2 days, 3 days
}
}
```
If your language, like French, do not have complicated cardinal rules, you may
use the French's version of it:
```javascript
function (e, a) {
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
}
```
If your language, like Chinese, do not have any pluralization rule at all you
may use the Chinese's version of it:
```javascript
function (e, a) {
return "other";
}
```

View File

@ -1,108 +0,0 @@
/*eslint eqeqeq: "off"*/
/*eslint no-nested-ternary: "off"*/
/*eslint quotes: "off"*/
export default [{
locale: "co",
pluralRuleFunction: function(e, a) {
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
},
fields: {
year: {
displayName: "annu",
relative: {
0: "quist'annu",
1: "l'annu chì vene",
"-1": "l'annu passatu",
},
relativeTime: {
future: {
one: "in {0} annu",
other: "in {0} anni",
},
past: {
one: "{0} annu fà",
other: "{0} anni fà",
},
},
},
month: {
displayName: "mese",
relative: {
0: "Questu mese",
1: "u mese chì vene",
"-1": "u mese passatu",
},
relativeTime: {
future: {
one: "in {0} mese",
other: "in {0} mesi",
},
past: {
one: "{0} mese fà",
other: "{0} mesi fà",
},
},
},
day: {
displayName: "ghjornu",
relative: {
0: "oghje",
1: "dumane",
"-1": "eri",
},
relativeTime: {
future: {
one: "in {0} ghjornu",
other: "in {0} ghjornu",
},
past: {
one: "{0} ghjornu fà",
other: "{0} ghjorni fà",
},
},
},
hour: {
displayName: "ora",
relativeTime: {
future: {
one: "in {0} ora",
other: "in {0} ore",
},
past: {
one: "{0} ora fà",
other: "{0} ore fà",
},
},
},
minute: {
displayName: "minuta",
relativeTime: {
future: {
one: "in {0} minuta",
other: "in {0} minute",
},
past: {
one: "{0} minuta fà",
other: "{0} minute fà",
},
},
},
second: {
displayName: "siconda",
relative: {
0: "avà",
},
relativeTime: {
future: {
one: "in {0} siconda",
other: "in {0} siconde",
},
past: {
one: "{0} siconda fà",
other: "{0} siconde fà",
},
},
},
},
}];

View File

@ -1,108 +0,0 @@
/*eslint eqeqeq: "off"*/
/*eslint no-nested-ternary: "off"*/
/*eslint quotes: "off"*/
export default [{
locale: "oc",
pluralRuleFunction: function(e, a) {
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
},
fields: {
year: {
displayName: "an",
relative: {
0: "ongan",
1: "l'an que ven",
"-1": "l'an passat",
},
relativeTime: {
future: {
one: "daquí {0} an",
other: "daquí {0} ans",
},
past: {
one: "fa {0} an",
other: "fa {0} ans",
},
},
},
month: {
displayName: "mes",
relative: {
0: "aqueste mes",
1: "lo mes que ven",
"-1": "lo mes passat",
},
relativeTime: {
future: {
one: "daquí {0} mes",
other: "daquí {0} meses",
},
past: {
one: "fa {0} mes",
other: "fa {0} meses",
},
},
},
day: {
displayName: "jorn",
relative: {
0: "uèi",
1: "deman",
"-1": "ièr",
},
relativeTime: {
future: {
one: "daquí {0} jorn",
other: "daquí {0} jorns",
},
past: {
one: "fa {0} jorn",
other: "fa {0} jorns",
},
},
},
hour: {
displayName: "ora",
relativeTime: {
future: {
one: "daquí {0} ora",
other: "daquí {0} oras",
},
past: {
one: "fa {0} ora",
other: "fa {0} oras",
},
},
},
minute: {
displayName: "minuta",
relativeTime: {
future: {
one: "daquí {0} minuta",
other: "daquí {0} minutas",
},
past: {
one: "fa {0} minuta",
other: "fa {0} minutas",
},
},
},
second: {
displayName: "segonda",
relative: {
0: "ara",
},
relativeTime: {
future: {
one: "daquí {0} segonda",
other: "daquí {0} segondas",
},
past: {
one: "fa {0} segonda",
other: "fa {0} segondas",
},
},
},
},
}];

View File

@ -43,7 +43,6 @@ const DEFAULT_COLORS = ImmutableMap<string, any>({
800: '#991b1b', 800: '#991b1b',
900: '#7f1d1d', 900: '#7f1d1d',
}), }),
'sea-blue': '#2feecc',
'greentext': '#789922', 'greentext': '#789922',
}); });

View File

@ -9,7 +9,7 @@ import { ADMIN_LOG_FETCH_SUCCESS } from 'soapbox/actions/admin';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
import type { APIEntity } from 'soapbox/types/entities'; import type { APIEntity } from 'soapbox/types/entities';
const LogEntryRecord = ImmutableRecord({ export const LogEntryRecord = ImmutableRecord({
data: ImmutableMap<string, any>(), data: ImmutableMap<string, any>(),
id: 0, id: 0,
message: '', message: '',

View File

@ -26,10 +26,12 @@ import {
StatusRecord, StatusRecord,
TagRecord, TagRecord,
} from 'soapbox/normalizers'; } from 'soapbox/normalizers';
import { LogEntryRecord } from 'soapbox/reducers/admin-log';
import type { Record as ImmutableRecord } from 'immutable'; import type { Record as ImmutableRecord } from 'immutable';
type AdminAccount = ReturnType<typeof AdminAccountRecord>; type AdminAccount = ReturnType<typeof AdminAccountRecord>;
type AdminLog = ReturnType<typeof LogEntryRecord>;
type AdminReport = ReturnType<typeof AdminReportRecord>; type AdminReport = ReturnType<typeof AdminReportRecord>;
type Announcement = ReturnType<typeof AnnouncementRecord>; type Announcement = ReturnType<typeof AnnouncementRecord>;
type AnnouncementReaction = ReturnType<typeof AnnouncementReactionRecord>; type AnnouncementReaction = ReturnType<typeof AnnouncementReactionRecord>;
@ -72,6 +74,7 @@ type EmbeddedEntity<T extends object> = null | string | ReturnType<ImmutableReco
export { export {
AdminAccount, AdminAccount,
AdminLog,
AdminReport, AdminReport,
Account, Account,
Announcement, Announcement,

View File

@ -3,6 +3,8 @@ const { execSync } = require('child_process');
const pkg = require('../../../package.json'); const pkg = require('../../../package.json');
const { CI_COMMIT_TAG, CI_COMMIT_REF_NAME, CI_COMMIT_SHA } = process.env;
const shortRepoName = url => new URL(url).pathname.substring(1); const shortRepoName = url => new URL(url).pathname.substring(1);
const trimHash = hash => hash.substring(0, 7); const trimHash = hash => hash.substring(0, 7);
@ -10,14 +12,12 @@ const tryGit = cmd => {
try { try {
return String(execSync(cmd)); return String(execSync(cmd));
} catch (e) { } catch (e) {
return null; return undefined;
} }
}; };
const version = pkg => { const version = pkg => {
// Try to discern from GitLab CI first // Try to discern from GitLab CI first
const { CI_COMMIT_TAG, CI_COMMIT_REF_NAME, CI_COMMIT_SHA } = process.env;
if (CI_COMMIT_TAG === `v${pkg.version}` || CI_COMMIT_REF_NAME === 'stable') { if (CI_COMMIT_TAG === `v${pkg.version}` || CI_COMMIT_REF_NAME === 'stable') {
return pkg.version; return pkg.version;
} }
@ -43,4 +43,5 @@ module.exports = {
repository: shortRepoName(pkg.repository.url), repository: shortRepoName(pkg.repository.url),
version: version(pkg), version: version(pkg),
homepage: pkg.homepage, homepage: pkg.homepage,
ref: CI_COMMIT_TAG || CI_COMMIT_SHA || tryGit('git rev-parse HEAD'),
}; };

View File

@ -1,9 +1,7 @@
import type { AxiosResponse } from 'axios';
/** Download the file from the response instead of opening it in a tab. */ /** Download the file from the response instead of opening it in a tab. */
// https://stackoverflow.com/a/53230807 // https://stackoverflow.com/a/53230807
export const download = (response: AxiosResponse, filename: string) => { export const download = (data: string, filename: string): void => {
const url = URL.createObjectURL(new Blob([response.data])); const url = URL.createObjectURL(new Blob([data]));
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.setAttribute('download', filename); link.setAttribute('download', filename);

View File

@ -116,3 +116,18 @@ export const colorsToCss = (colors: TailwindColorPalette): string => {
export const generateThemeCss = (soapboxConfig: SoapboxConfig): string => { export const generateThemeCss = (soapboxConfig: SoapboxConfig): string => {
return colorsToCss(soapboxConfig.colors.toJS() as TailwindColorPalette); return colorsToCss(soapboxConfig.colors.toJS() as TailwindColorPalette);
}; };
const hexToHsl = (hex: string): Hsl | null => {
const rgb = hexToRgb(hex);
return rgb ? rgbToHsl(rgb) : null;
};
export const hueShift = (hex: string, delta: number): string => {
const { h, s, l } = hexToHsl(hex)!;
return hslToHex({
h: (h + delta) % 360,
s,
l,
});
};

View File

@ -48,7 +48,6 @@
@import 'components/audio-player'; @import 'components/audio-player';
@import 'components/filters'; @import 'components/filters';
@import 'components/snackbar'; @import 'components/snackbar';
@import 'components/admin';
@import 'components/backups'; @import 'components/backups';
@import 'components/crypto-donate'; @import 'components/crypto-donate';
@import 'components/aliases'; @import 'components/aliases';

View File

@ -1,67 +0,0 @@
.dashcounters {
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mb-4;
}
.dashcounter {
@apply bg-gray-200 dark:bg-gray-800 p-4 rounded flex flex-col items-center space-y-2 hover:-translate-y-1 transition-transform cursor-pointer;
}
.dashwidgets {
display: flex;
flex-wrap: wrap;
margin: 0 -5px;
padding: 0 20px 20px 20px;
}
.dashwidget {
flex: 1;
margin-bottom: 20px;
padding: 0 5px;
h4 {
text-transform: uppercase;
font-size: 13px;
font-weight: 700;
color: hsla(var(--primary-text-color_hsl), 0.6);
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 1px solid var(--accent-color--med);
}
a {
color: var(--brand-color);
}
}
.unapproved-account {
padding: 15px 20px;
font-size: 14px;
display: flex;
&__nickname {
font-weight: bold;
}
&__actions {
margin-left: auto;
display: flex;
flex-wrap: nowrap;
column-gap: 10px;
padding-left: 20px;
.svg-icon {
height: 24px;
width: 24px;
}
}
}
.logentry {
padding: 15px;
&__timestamp {
color: var(--primary-text-color--faint);
font-size: 13px;
text-align: right;
}
}

View File

@ -70,7 +70,6 @@ body,
--dark-blue: #1d1953; --dark-blue: #1d1953;
--electric-blue: #5448ee; --electric-blue: #5448ee;
--electric-blue-contrast: #e8e7fd; --electric-blue-contrast: #e8e7fd;
--sea-blue: #2feecc;
// Sizes // Sizes
--border-radius-base: 4px; --border-radius-base: 4px;

View File

@ -1,5 +1,8 @@
import { danger, warn, message } from 'danger'; import { danger, warn, message } from 'danger';
// App changes
const app = danger.git.fileMatch('app/soapbox/**');
// Docs changes // Docs changes
const docs = danger.git.fileMatch('docs/**/*.md'); const docs = danger.git.fileMatch('docs/**/*.md');
@ -7,6 +10,13 @@ if (docs.edited) {
message('Thanks - We :heart: our [documentarians](http://www.writethedocs.org/)!'); message('Thanks - We :heart: our [documentarians](http://www.writethedocs.org/)!');
} }
// Enforce CHANGELOG.md additions
const changelog = danger.git.fileMatch('CHANGELOG.md');
if (app.edited && !changelog.edited) {
warn('You have not updated `CHANGELOG.md`. If this change directly impacts admins or users, please update the changelog. Otherwise you can ignore this message. See: https://keepachangelog.com');
}
// UI components // UI components
const uiCode = danger.git.fileMatch('app/soapbox/components/ui/**'); const uiCode = danger.git.fileMatch('app/soapbox/components/ui/**');
const uiTests = danger.git.fileMatch('app/soapbox/components/ui/**/__tests__/**'); const uiTests = danger.git.fileMatch('app/soapbox/components/ui/**/__tests__/**');

View File

@ -21,7 +21,6 @@ module.exports = {
'app/soapbox/**/*.ts', 'app/soapbox/**/*.ts',
'app/soapbox/**/*.tsx', 'app/soapbox/**/*.tsx',
'!app/soapbox/features/emoji/emoji-compressed.js', '!app/soapbox/features/emoji/emoji-compressed.js',
'!app/soapbox/locales/locale-data/*.js',
'!app/soapbox/service-worker/entry.ts', '!app/soapbox/service-worker/entry.ts',
'!app/soapbox/jest/test-setup.ts', '!app/soapbox/jest/test-setup.ts',
'!app/soapbox/jest/test-helpers.ts', '!app/soapbox/jest/test-helpers.ts',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 512 KiB

After

Width:  |  Height:  |  Size: 469 KiB

View File

@ -58,7 +58,6 @@ module.exports = {
'accent-blue': true, 'accent-blue': true,
'gradient-start': true, 'gradient-start': true,
'gradient-end': true, 'gradient-end': true,
'sea-blue': true,
'greentext': true, 'greentext': true,
}), }),
animation: { animation: {

View File

@ -42,12 +42,10 @@ describe('parseColorMatrix()', () => {
accent: [300, 500], accent: [300, 500],
'gradient-start': true, 'gradient-start': true,
'gradient-end': true, 'gradient-end': true,
'sea-blue': true,
}; };
const result = parseColorMatrix(colorMatrix); const result = parseColorMatrix(colorMatrix);
expect(result['sea-blue']({})).toEqual('rgb(var(--color-sea-blue))');
expect(result['gradient-start']({ opacityValue: .7 })).toEqual('rgb(var(--color-gradient-start) / 0.7)'); expect(result['gradient-start']({ opacityValue: .7 })).toEqual('rgb(var(--color-gradient-start) / 0.7)');
}); });
}); });