Merge remote-tracking branch 'soapbox/develop' into update-emoji-mart

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-update-emo-25jnc5/deployments/2714
marcin mikołajczak 2023-02-23 17:42:31 +01:00
commit 528acb8ac5
1301 zmienionych plików z 72361 dodań i 72476 usunięć

34
.dockerignore 100644
Wyświetl plik

@ -0,0 +1,34 @@
.git
/node_modules/
/tmp/
/build/
/coverage/
/.coverage/
/.eslintcache
/.env
/deploy.sh
/.vs/
yarn-error.log*
/junit.xml
/static/
/static-test/
/public/
/dist/
.idea
.DS_Store
# Custom build files
/custom/**/*
!/custom/*
/custom/*.*
!/custom/.gitkeep
!/custom/**/.gitkeep
# surge.sh
/CNAME
/AUTH
/CORS
/ROUTER

Wyświetl plik

@ -5,4 +5,4 @@
/tmp/**
/coverage/**
/custom/**
!.eslintrc.js
!.eslintrc.cjs

Wyświetl plik

@ -4,6 +4,8 @@ module.exports = {
extends: [
'eslint:recommended',
'plugin:import/typescript',
'plugin:compat/recommended',
'plugin:tailwindcss/recommended',
],
env: {
@ -17,7 +19,7 @@ module.exports = {
ATTACHMENT_HOST: false,
},
parser: 'babel-eslint',
parser: '@babel/eslint-parser',
plugins: [
'react',
@ -42,7 +44,7 @@ module.exports = {
react: {
version: 'detect',
},
'import/extensions': ['.js', '.jsx', '.ts', '.tsx'],
'import/extensions': ['.js', '.jsx', '.cjs', '.mjs', '.ts', '.tsx'],
'import/ignore': [
'node_modules',
'\\.(css|scss|json)$',
@ -52,6 +54,17 @@ module.exports = {
paths: ['app'],
},
},
polyfills: [
'es:all', // core-js
'IntersectionObserver', // npm:intersection-observer
'Promise', // core-js
'ResizeObserver', // npm:resize-observer-polyfill
'URL', // core-js
'URLSearchParams', // core-js
],
tailwindcss: {
config: 'tailwind.config.cjs',
},
},
rules: {
@ -69,7 +82,6 @@ module.exports = {
'space-infix-ops': 'error',
'space-in-parens': ['error', 'never'],
'keyword-spacing': 'error',
'consistent-return': 'error',
'dot-notation': 'error',
eqeqeq: 'error',
indent: ['error', 2, {
@ -227,18 +239,7 @@ module.exports = {
},
],
'import/newline-after-import': 'error',
'import/no-extraneous-dependencies': [
'error',
// {
// devDependencies: [
// 'webpack/**',
// 'app/soapbox/test_setup.js',
// 'app/soapbox/test_helpers.js',
// 'app/**/__tests__/**',
// 'app/**/__mocks__/**',
// ],
// },
],
'import/no-extraneous-dependencies': 'error',
'import/no-unresolved': 'error',
'import/no-webpack-loader-syntax': 'error',
'import/order': [
@ -259,16 +260,37 @@ module.exports = {
},
],
'@typescript-eslint/no-duplicate-imports': 'error',
'@typescript-eslint/member-delimiter-style': [
'error',
{
multiline: {
delimiter: 'none',
},
singleline: {
delimiter: 'comma',
},
},
],
'promise/catch-or-return': 'error',
'react-hooks/rules-of-hooks': 'error',
'tailwindcss/classnames-order': [
'error',
{
classRegex: '^(base|container|icon|item|list|outer|wrapper)?[c|C]lass(Name)?$',
config: 'tailwind.config.cjs',
},
],
'tailwindcss/migration-from-tailwind-2': 'error',
},
overrides: [
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'no-undef': 'off', // https://stackoverflow.com/a/69155899
'space-before-function-paren': 'off',
},
parser: '@typescript-eslint/parser',
},

1
.gitattributes vendored 100644
Wyświetl plik

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

Wyświetl plik

@ -3,6 +3,9 @@ image: node:18
variables:
NODE_ENV: test
default:
interruptible: true
cache: &cache
key:
files:
@ -15,6 +18,7 @@ stages:
- deps
- test
- deploy
- release
deps:
stage: deps
@ -32,6 +36,9 @@ danger:
# https://github.com/danger/danger-js/issues/1029#issuecomment-998915436
- export CI_MERGE_REQUEST_IID=${CI_OPEN_MERGE_REQUESTS#*!}
- npx danger ci
except:
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
allow_failure: true
lint-js:
@ -41,10 +48,12 @@ lint-js:
changes:
- "**/*.js"
- "**/*.jsx"
- "**/*.cjs"
- "**/*.mjs"
- "**/*.ts"
- "**/*.tsx"
- ".eslintignore"
- ".eslintrc.js"
- ".eslintrc.cjs"
lint-sass:
stage: test
@ -65,7 +74,7 @@ jest:
- "app/soapbox/**/*"
- "webpack/**/*"
- "custom/**/*"
- "jest.config.js"
- "jest.config.cjs"
- "package.json"
- "yarn.lock"
- ".gitlab-ci.yml"
@ -80,7 +89,8 @@ jest:
nginx-test:
stage: test
image: nginx:latest
before_script: cp installation/mastodon.conf /etc/nginx/conf.d/default.conf
before_script:
- cp installation/mastodon.conf /etc/nginx/conf.d/default.conf
script: nginx -t
only:
changes:
@ -88,7 +98,12 @@ nginx-test:
build-production:
stage: test
script: yarn build
script:
- yarn build
- yarn manage:translations en
# Fail if files got changed.
# https://stackoverflow.com/a/9066385
- git diff --quiet
variables:
NODE_ENV: production
artifacts:
@ -103,22 +118,11 @@ docs-deploy:
script:
- curl -X POST -F"token=$CI_JOB_TOKEN" -F'ref=master' https://gitlab.com/api/v4/projects/15685485/trigger/pipeline
only:
refs:
- develop
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
changes:
- "docs/**/*"
# Supposed to fail when translations are outdated, instead always passes
#
# i18n:
# stage: build
# script: yarn manage:translations
# variables:
# NODE_ENV: development
# before_script:
# - yarn
# - yarn build
review:
stage: deploy
environment:
@ -140,5 +144,33 @@ pages:
paths:
- public
only:
refs:
- develop
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
docker:
stage: deploy
image: docker:23.0.0
services:
- docker:23.0.0-dind
tags:
- dind
# https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df
script:
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
rules:
- if: $CI_COMMIT_TAG
interruptible: false
release:
stage: release
rules:
- if: $CI_COMMIT_TAG
script:
- npx ts-node ./scripts/do-release.ts
interruptible: false
include:
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
- template: Security/License-Scanning.gitlab-ci.yml

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,5 +1,8 @@
{
"*.js": "eslint --cache",
"*.cjs": "eslint --cache",
"*.mjs": "eslint --cache",
"*.ts": "eslint --cache",
"*.tsx": "eslint --cache",
"app/styles/**/*.scss": "stylelint"
}

43
.storybook/main.ts 100644
Wyświetl plik

@ -0,0 +1,43 @@
import sharedConfig from '../webpack/shared';
import type { StorybookConfig } from '@storybook/core-common';
const config: StorybookConfig = {
stories: [
'../stories/**/*.stories.mdx',
'../stories/**/*.stories.@(js|jsx|ts|tsx)'
],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'storybook-react-intl',
{
name: '@storybook/addon-postcss',
options: {
postcssLoaderOptions: {
implementation: require('postcss'),
},
},
},
],
framework: '@storybook/react',
core: {
builder: '@storybook/builder-webpack5',
},
webpackFinal: async (config) => {
config.resolve!.alias = {
...sharedConfig.resolve!.alias,
...config.resolve!.alias,
};
config.resolve!.modules = [
...sharedConfig.resolve!.modules!,
...config.resolve!.modules!,
];
return config;
},
};
export default config;

Wyświetl plik

@ -0,0 +1,22 @@
import '../app/styles/tailwind.css';
import '../stories/theme.css';
import { addDecorator, Story } from '@storybook/react';
import { IntlProvider } from 'react-intl';
import React from 'react';
const withProvider = (Story: Story) => (
<IntlProvider locale='en'><Story /></IntlProvider>
);
addDecorator(withProvider);
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};

Wyświetl plik

@ -1,16 +1,22 @@
{
"extends": ["stylelint-config-standard"],
"ignoreFiles": ["app/styles/reset.scss"],
"plugins": ["stylelint-scss"],
"extends": ["stylelint-config-standard-scss"],
"rules": {
"alpha-value-notation": null,
"at-rule-no-unknown": null,
"at-rule-empty-line-before": ["always", { "ignore": ["after-comment", "first-nested", "inside-block", "blockless-after-same-name-blockless", "blockless-after-blockless"] }],
"color-function-notation": null,
"custom-property-pattern": null,
"declaration-block-no-redundant-longhand-properties": null,
"declaration-colon-newline-after": null,
"declaration-empty-line-before": "never",
"font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free", "OpenDyslexic", "soapbox"] }],
"font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free"] }],
"max-line-length": null,
"no-descending-specificity": null,
"no-duplicate-selectors": null,
"scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["/tailwind/", "layer"]}],
"no-invalid-position-at-import-rule": null
"no-invalid-position-at-import-rule": null,
"scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["tailwind", "apply", "layer", "config"]}],
"scss/operator-no-unspaced": null,
"selector-class-pattern": null,
"string-quotes": "single"
}
}

Wyświetl plik

@ -1 +1 @@
nodejs 18.2.0
nodejs 18.14.0

9
.vscode/extensions.json vendored 100644
Wyświetl plik

@ -0,0 +1,9 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"stylelint.vscode-stylelint",
"wix.vscode-import-cost",
"redhat.vscode-yaml"
]
}

21
.vscode/settings.json vendored 100644
Wyświetl plik

@ -0,0 +1,21 @@
{
"css.validate": false,
"editor.insertSpaces": true,
"editor.tabSize": 2,
"files.associations": {
"*.conf.template": "properties"
},
"files.eol": "\n",
"files.insertFinalNewline": false,
"json.schemas": [
{
"fileMatch": [".lintstagedrc.json"],
"url": "https://json.schemastore.org/lintstagedrc.schema.json"
},
{
"fileMatch": ["renovate.json"],
"url": "https://docs.renovatebot.com/renovate-schema.json"
}
],
"scss.validate": false
}

58
.vscode/soapbox.code-snippets vendored 100644
Wyświetl plik

@ -0,0 +1,58 @@
{
// Place your Soapbox workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
// Placeholders with the same ids are connected.
// Example:
// "Print to console": {
// "scope": "javascript,typescript",
// "prefix": "log",
// "body": [
// "console.log('$1');",
// "$2"
// ],
// "description": "Log output to console"
// }
"React component": {
"scope": "typescriptreact",
"prefix": ["component", "react component"],
"body": [
"import React from 'react';",
"",
"interface I${1:Component} {",
"}",
"",
"/** ${1:Component} component. */",
"const ${1:Component}: React.FC<I${1:Component}> = () => {",
" return (",
" <></>",
" );",
"};",
"",
"export default ${1:Component};"
],
"description": "React component"
},
"React component test": {
"scope": "typescriptreact",
"prefix": ["test", "component test", "react component test"],
"body": [
"import React from 'react';",
"",
"import { render, screen } from 'soapbox/jest/test-helpers';",
"",
"import ${1:Component} from '${2:..}';",
"",
"describe('<${1:Component} />', () => {",
" it('renders', () => {",
" render(<${1:Component} />);",
"",
" expect(screen.getByTestId('${3:test}')).toBeInTheDocument();",
" });",
"});"
],
"description": "React component test"
}
}

Wyświetl plik

@ -4,6 +4,175 @@ 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
### Changed
### Fixed
- Posts: fixed emojis being cut off in reactions modal.
- Posts: fix audio player progress bar visibility.
## [3.2.0] - 2023-02-15
### Added
- Admin: redirect the homepage to any URL.
- Compatibility: added compatibility with Friendica.
- Posts: bot badge on statuses from bot accounts.
- Compatibility: improved browser support for older browsers.
- Events: allow to repost events in event menu.
- Profile: Add RSS link to user profiles.
- Reactions: adds support for reacting to chat messages.
- Groups: initial support for groups.
- Profile: add RSS link to user profiles.
- Chats: reset chat message field height after sending a message.
- Admin: allow to manage announcements.
### Changed
- Chats: improved display of media attachments.
- ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away.
- Posts: increased font size of focused status in threads.
- Posts: let "mute conversation" be clicked from any feed, not just noficiations.
- Posts: display all emoji reactions.
- Reactions: improved UI of reactions on statuses.
- Profile: make verified badge more prominent, overlapping with avatar.
### Fixed
- Admin: fixed hover card in reports modal shows reporter not reportee
- Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load.
- Chats: don't display "copy" button for messages without text.
- Posts: don't have to click the play button twice for embedded videos.
- index.html: remove `referrer` meta tag so it doesn't conflict with backend's `Referrer-Policy` header.
- Modals: fix media modal automatically switching to video.
- Navigation: profile dropdown erratic behavior.
- Posts: fix posts filtering.
### Removed
- Admin: single user mode. Now the homepage can be redirected to any URL.
## [3.1.0] - 2023-01-13
### 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.
- Composer: add more descriptive discard confirmation message.
### 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.
- Editing: don't display edited posts as pending posts.
- 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.
- Composer: fix alignment of icon in submit button.
- Login: add a border around QR codes.
- Composer: don't display action button in reply indicator.
## [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.
@ -211,7 +380,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial beta release.
[Unreleased]: https://gitlab.com/soapbox-pub/soapbox-fe/-/compare/v1.0.0...develop
[Unreleased patch]: https://gitlab.com/soapbox-pub/soapbox-fe/-/compare/v1.0.0...stable/1.0.x
[1.0.0]: https://gitlab.com/soapbox-pub/soapbox-fe/-/compare/v0.9.0...v1.0.0
[0.9.0]: https://gitlab.com/soapbox-pub/soapbox-fe/-/tags/v0.9.0
[Unreleased]: https://gitlab.com/soapbox-pub/soapbox/-/compare/v1.0.0...develop
[Unreleased patch]: https://gitlab.com/soapbox-pub/soapbox/-/compare/v1.0.0...stable/1.0.x
[1.0.0]: https://gitlab.com/soapbox-pub/soapbox/-/compare/v0.9.0...v1.0.0
[0.9.0]: https://gitlab.com/soapbox-pub/soapbox/-/tags/v0.9.0

17
Dockerfile 100644
Wyświetl plik

@ -0,0 +1,17 @@
FROM node:18 as build
WORKDIR /app
COPY package.json .
COPY yarn.lock .
RUN yarn
COPY . .
ARG NODE_ENV=production
RUN yarn build
FROM nginx:stable-alpine
EXPOSE 5000
ENV PORT=5000
ENV FALLBACK_PORT=4444
ENV BACKEND_URL=http://localhost:4444
ENV CSP=
COPY installation/docker.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /app/static /usr/share/nginx/html

18
Dockerfile.dev 100644
Wyświetl plik

@ -0,0 +1,18 @@
FROM node:18
RUN apt-get update &&\
apt-get install -y inotify-tools &&\
# clean up apt
rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=development
COPY package.json .
COPY yarn.lock .
RUN yarn
COPY . .
ENV DEVSERVER_URL=http://0.0.0.0:3036
CMD yarn dev

217
README.md
Wyświetl plik

@ -1,208 +1,91 @@
# Soapbox FE
![Soapbox Screenshot](soapbox-screenshot.png)
![Soapbox FE Screenshot](soapbox-screenshot.png)
**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.
**Soapbox FE** is a frontend for Mastodon and Pleroma with a focus on custom branding and ease of use.
It's part of the [Soapbox](https://soapbox.pub) project.
# On The Fediverse
## Try it out
You may have heard of **Mastodon**. Soapbox builds upon what Mastodon made great to make something even better.
Visit https://fe.soapbox.pub/ and point it to your favorite instance.
You can run **Mastodon+Soapbox**, **Rebased+Soapbox**, and more.
## :rocket: Deploy on Pleroma
Soapbox is the **frontend** (what users see) while Mastodon is the **backend** (data, APIs). You can mix-and-match in the Fediverse ecosystem.
Installing Soapbox FE on an existing Pleroma server is extremely easy.
Just ssh into the server and download a .zip of the latest build:
> 💡 If you're starting a new server, we highly recommend **Rebased+Soapbox**. Rebased is our custom-built backend just for Soapbox, providing important new features such as **quote posting** and **chats**.
>
> See: [Installing Rebased+Soapbox](https://soapbox.pub/install/)
```sh
curl -L https://gitlab.com/soapbox-pub/soapbox-fe/-/jobs/artifacts/v2.0.0/download?job=build-production -o soapbox-fe.zip
```
# Try It Out
Then unpack it into Pleroma's `instance` directory:
Want to give Soapbox a shot? Here are some suggested servers:
```sh
busybox unzip soapbox-fe.zip -o -d /opt/pleroma/instance
```
- [gleasonator.com](https://gleasonator.com/) - operated by the lead developer of Soapbox
- [social.teci.world](https://social.teci.world/) - free speech server run by a Soapbox contributor
- [spinster.xyz](https://spinster.xyz/) - one of the largest feminist communities on the internet
- [poa.st](https://poa.st/) - the largest Soapbox server on the network
**That's it!** :tada:
**Soapbox FE is installed.**
The change will take effect immediately, just refresh your browser tab.
It's not necessary to restart the Pleroma service.
Want to use Soapbox against **any existing Mastodon/Pleroma server?** Try:
To remove Soapbox FE 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!
## How does it work?
# 🚀 Starting Your Own Server
Soapbox FE is a [single-page application (SPA)](https://en.wikipedia.org/wiki/Single-page_application) that runs entirely in the browser with JavaScript.
Starting your own server is one of the best ways to have freedom online! We recommend installing **Rebased+Soapbox**.
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).
See here for a detailed setup guide: [Installing Rebased+Soapbox](https://soapbox.pub/install/)
It incorporates much of the [Mastodon API](https://docs.joinmastodon.org/methods/) used by Pleroma and Mastodon, but requires many [Pleroma-specific features](https://docs.pleroma.social/backend/development/API/differences_in_mastoapi_responses/) in order to function.
# Adding Soapbox to an Existing Server
# Running locally
Already have a server? No problem — it is still possible to use Soapbox.
To get it running, just clone the repo:
- [Deploying on Pleroma](https://docs.soapbox.pub/frontend/installing/#install-soapbox)
- [Deploying on Mastodon](https://docs.soapbox.pub/frontend/administration/mastodon/)
```sh
git clone https://gitlab.com/soapbox-pub/soapbox-fe.git
cd soapbox-fe
```
> 💡 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.
Ensure that Node.js and Yarn are installed, then install dependencies:
# Developing Soapbox
```sh
yarn
```
tl;dr — `git clone`, `yarn`, and `yarn dev`.
Finally, run the dev server:
For detailed guides, see these pages:
```sh
yarn dev
```
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/)
**That's it!** :tada:
## Contributing
It will serve at `http://localhost:3036` by default.
We welcome contributions to this project.
To contribute, see [Contributing to Soapbox](docs/contributing.md).
It will proxy requests to the backend for you.
For Pleroma running on `localhost:4000` (the default) no other changes are required, just start a local Pleroma server and it should begin working.
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!
### Troubleshooting: `ERROR: NODE_ENV must be set`
# Project Philosophy
Create a `.env` file if you haven't already.
Soapbox was born out of the need to build independent platforms with **a unique identity and brand**.
```sh
cp .env.example .env
```
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.
And ensure that it contains `NODE_ENV=development`.
Try again.
## Developing against a live backend
You can also run Soapbox FE locally with a live production server as the backend.
> **Note:** Whether or not this works depends on your production server. It does not seem to work with Cloudflare or VanwaNet.
To do so, just copy the env file:
```sh
cp .env.example .env
```
And edit `.env`, setting the configuration like this:
```sh
BACKEND_URL="https://pleroma.example.com"
PROXY_HTTPS_INSECURE=true
```
You will need to restart the local development server for the changes to take effect.
## 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 FE checks for the following options:
- `development` - What you should use while developing Soapbox FE.
- `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. To contribute, first review the [Contributing doc](docs/contributing.md)
Additional supporting documents include:
* [Soapbox History](docs/history.md)
* [Redux Store Map](docs/history.md)
# Customization
Soapbox supports customization of the user interface, to allow per instance branding and other features. Current customization features include:
* Instance name
* Site logo
* Favicon
* About page
* Terms of Service page
* Privacy Policy page
* Copyright Policy (DMCA) page
* Promo panel list items, e.g. blog site link
* Soapbox extensions, e.g. Patron module
* Default settings, e.g. default theme
Customization details can be found in the [Customization doc](docs/customization.md)
One disadvantage of this approach is that it does not help the software spread. Some of the biggest servers on the network and running Soapbox and people don't even know it!
# License & Credits
Soapbox FE 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
© Trump Media & Technology Group
© Gab AI, Inc.
- `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.
Soapbox FE 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
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Soapbox FE is distributed in the hope that it will be useful,
Soapbox is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Soapbox FE. If not, see <https://www.gnu.org/licenses/>.
along with Soapbox. If not, see <https://www.gnu.org/licenses/>.

7
app.json 100644
Wyświetl plik

@ -0,0 +1,7 @@
{
"name": "Soapbox",
"description": "Software for the next generation of social media.",
"keywords": ["fediverse"],
"website": "https://soapbox.pub",
"stack": "container"
}

Wyświetl plik

@ -1,14 +0,0 @@
import loadPolyfills from './soapbox/load_polyfills';
// @ts-ignore
require.context('./images/', true);
// Load stylesheet
require('react-datepicker/dist/react-datepicker.css');
require('./styles/application.scss');
loadPolyfills().then(() => {
require('./soapbox/main').default();
}).catch(e => {
console.error(e);
});

Wyświetl plik

@ -1,6 +1,5 @@
# Custom icons
- fediverse.svg - Modified from Wikipedia, CC0
- verified.svg - Created by Alex Gleason. CC0
Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 302 B

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 302 B

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 4.8 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 4.8 KiB

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 4.0 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 4.0 KiB

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 2.2 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 2.2 KiB

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 81 B

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 81 B

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 812 B

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 812 B

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 812 B

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 812 B

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.4 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.4 KiB

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 99 B

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 99 B

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 1.3 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.3 KiB

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 1.0 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.0 KiB

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 811 B

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 811 B

Wyświetl plik

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

Wyświetl plik

@ -1,94 +0,0 @@
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
with Reserved Font Name OpenDyslexic.
Copyright (c) 12/2012 - 2019
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Wyświetl plik

@ -1,12 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Generated by IcoMoon</metadata>
<defs>
<font id="icomoon" horiz-adv-x="1024">
<font-face units-per-em="1024" ascent="960" descent="-64" />
<missing-glyph horiz-adv-x="1024" />
<glyph unicode="&#x20;" horiz-adv-x="512" d="" />
<glyph unicode="&#xe900;" glyph-name="spinster" d="M512 857.6c-226.216 0-409.6-183.384-409.6-409.6v0c0-226.216 183.384-409.6 409.6-409.6v0c226.216 0 409.6 183.384 409.6 409.6v0c0 226.216-183.384 409.6-409.6 409.6v0zM525.84 706.224c32.633 0 64.793-3.777 96.48-11.344 31.687-7.095 59.576-17.748 83.696-31.936l-43.264-104.272c-47.294 25.539-93.176 38.304-137.632 38.304-27.903 0-48.239-4.255-61.008-12.768-12.769-8.040-19.152-18.677-19.152-31.92s7.57-23.171 22.704-29.792c15.134-6.621 39.493-13.482 73.072-20.576 37.835-8.040 69.039-16.797 93.632-26.256 25.066-8.986 46.588-23.648 64.56-43.984 18.445-19.863 27.664-47.059 27.664-81.584 0-29.795-8.279-56.744-24.832-80.864s-41.374-43.515-74.48-58.176c-33.106-14.188-73.314-21.28-120.608-21.28-40.2 0-79.205 4.964-117.040 14.896s-68.577 23.175-92.224 39.728l46.112 103.568c22.228-14.661 48.006-26.486 77.328-35.472s58.168-13.472 86.544-13.472c53.915 0 80.864 13.475 80.864 40.432 0 14.188-7.801 24.595-23.408 31.216-15.134 7.094-39.724 14.432-73.776 22-37.362 8.040-68.582 16.551-93.648 25.536-25.066 9.459-46.572 24.352-64.544 44.688s-26.96 47.763-26.96 82.288c0 30.268 8.279 57.464 24.832 81.584 16.553 24.592 41.143 43.988 73.776 58.176 33.106 14.188 73.545 21.28 121.312 21.28z" />
<glyph unicode="&#xe901;" glyph-name="fediverse" d="M553.99 908.789c-46.369-0.785-83.969-37.261-86.545-83.096l-0.010-0.231c-0.083-1.432-0.13-3.108-0.13-4.794 0-46.987 36.77-85.385 83.105-87.99l0.231-0.010c1.432-0.083 3.107-0.13 4.794-0.13 46.988 0 85.387 36.772 87.99 83.108l0.010 0.231c0.083 1.431 0.13 3.106 0.13 4.791 0 46.988-36.771 85.387-83.108 87.99l-0.231 0.010c-1.441 0.084-3.127 0.132-4.823 0.132-0.497 0-0.993-0.004-1.487-0.012l0.075 0.001zM459.882 805.031l-251.29-127.347c13.547-13.809 23-31.679 26.366-51.617l0.080-0.57 251.287 127.353c-13.545 13.808-22.997 31.675-26.363 51.611l-0.080 0.57zM641.318 775.903c-9.415-17.78-23.636-31.938-40.939-41.021l-0.532-0.254 198.787-199.554c9.415 17.78 23.634 31.938 40.936 41.021l0.532 0.254zM487.306 751.83l-147.023-287.024 43.408-43.576 155.667 303.891c-20.483 3.55-38.302 13.087-52.060 26.716l0.007-0.007zM599.388 734.397c-12.846-6.718-28.060-10.66-44.195-10.66-1.77 0-3.529 0.047-5.276 0.141l0.244-0.010c-3.232 0.199-6.15 0.516-9.026 0.959l0.542-0.069 22.259-142.535 60.737-9.746zM138.038 697.983c-46.37-0.783-83.972-37.26-86.548-83.095l-0.010-0.231c-0.083-1.432-0.13-3.107-0.13-4.794 0-46.988 36.772-85.387 83.108-87.99l0.231-0.010c1.432-0.083 3.107-0.13 4.794-0.13 46.988 0 85.387 36.772 87.99 83.108l0.010 0.231c0.083 1.432 0.13 3.107 0.13 4.794 0 46.988-36.772 85.387-83.108 87.99l-0.231 0.010c-1.43 0.083-3.103 0.13-4.787 0.13-0.51 0-1.018-0.004-1.526-0.013l0.076 0.001zM235.216 624.428c0.752-4.537 1.182-9.766 1.182-15.095 0-1.667-0.042-3.325-0.125-4.972l0.009 0.231c-0.796-13.969-4.43-26.918-10.33-38.52l0.254 0.551 142.645-22.911 28.036 54.751zM479.695 585.167l-28.039-54.757 337.040-54.13c-0.697 4.368-1.096 9.405-1.096 14.535 0 1.678 0.043 3.346 0.127 5.002l-0.009-0.232c0.815 14.158 4.546 27.272 10.597 38.992l-0.254-0.542zM883.076 578.43c-46.37-0.783-83.972-37.26-86.548-83.095l-0.010-0.231c-0.083-1.432-0.13-3.107-0.13-4.794 0-46.988 36.772-85.387 83.108-87.99l0.231-0.010c1.432-0.083 3.107-0.13 4.794-0.13 46.988 0 85.387 36.772 87.99 83.108l0.010 0.231c0.083 1.432 0.13 3.107 0.13 4.794 0 46.988-36.772 85.387-83.108 87.99l-0.231 0.010c-1.438 0.084-3.119 0.131-4.812 0.131-0.501 0-1-0.004-1.499-0.012l0.075 0.001zM225.366 565.098c-9.414-17.777-23.632-31.933-40.931-41.016l-0.532-0.254 227.623-228.511 54.877 27.811zM182.639 523.19c-12.642-6.466-27.577-10.256-43.397-10.256-1.77 0-3.529 0.047-5.276 0.141l0.244-0.010c-3.521 0.199-6.741 0.548-9.909 1.050l0.551-0.072 43.485-278.147c12.642 6.466 27.577 10.256 43.397 10.256 1.77 0 3.529-0.047 5.276-0.141l-0.244 0.010c3.519-0.2 6.737-0.548 9.903-1.050l-0.55 0.072zM576.873 499.359l52.629-336.996c12.457 6.245 27.143 9.902 42.682 9.902 1.773 0 3.535-0.048 5.285-0.142l-0.244 0.010c3.8-0.219 7.286-0.616 10.711-1.192l-0.569 0.079-49.754 318.595zM788.965 474.681l-128.865-65.308 9.501-60.776 145.806 73.896c-13.546 13.809-22.998 31.679-26.363 51.617l-0.080 0.57zM816.386 421.477l-128.362-250.594c20.486-3.55 38.307-13.087 52.065-26.719l-0.007 0.007 128.359 250.591c-20.485 3.551-38.305 13.090-52.062 26.722l0.007-0.007zM302.044 390.153l-74.471-145.382c20.481-3.55 38.298-13.086 52.054-26.714l-0.007 0.007 65.83 128.515zM585.292 371.462l-304.691-154.416c13.549-13.81 23.003-31.682 26.368-51.622l0.080-0.57 287.744 145.83zM525.607 263.696l-54.877-27.811 115.337-115.788c9.415 17.78 23.636 31.938 40.939 41.021l0.532 0.254zM210.049 237.339c-46.369-0.785-83.969-37.261-86.545-83.096l-0.010-0.231c-0.083-1.432-0.13-3.107-0.13-4.794 0-46.988 36.772-85.387 83.108-87.99l0.231-0.010c1.432-0.083 3.107-0.13 4.794-0.13 46.988 0 85.387 36.772 87.99 83.108l0.010 0.231c0.083 1.432 0.13 3.107 0.13 4.794 0 46.988-36.772 85.387-83.108 87.99l-0.231 0.010c-1.438 0.084-3.12 0.132-4.813 0.132-0.501 0-1.002-0.004-1.501-0.013l0.075 0.001zM307.279 163.476c0.72-4.438 1.131-9.554 1.131-14.766 0-1.675-0.042-3.34-0.126-4.993l0.009 0.232c-0.806-14.078-4.495-27.122-10.481-38.793l0.254 0.546 278.1-44.626c-0.721 4.442-1.133 9.563-1.133 14.779 0 1.671 0.042 3.332 0.126 4.983l-0.009-0.232c0.807 14.078 4.497 27.12 10.484 38.79l-0.254-0.546zM670.509 163.451c-46.37-0.783-83.972-37.26-86.548-83.095l-0.010-0.231c-0.083-1.432-0.13-3.107-0.13-4.794 0-46.988 36.772-85.387 83.108-87.99l0.231-0.010c1.432-0.083 3.107-0.13 4.794-0.13 46.988 0 85.387 36.772 87.99 83.108l0.010 0.231c0.083 1.432 0.13 3.107 0.13 4.794 0 46.988-36.772 85.387-83.108 87.99l-0.231 0.010c-1.438 0.084-3.119 0.131-4.812 0.131-0.501 0-1-0.004-1.499-0.012l0.075 0.001z" />
</font></defs></svg>

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 6.0 KiB

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Wyświetl plik

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1792" height="1792" viewBox="0 0 1792 1792" fill="currentColor">
<path d="M 343.16176,591.34767 A 171.09965,171.09965 0 0 1 163.0094,752.88799 171.09965,171.09965 0 0 1 1.4690156,572.73567 171.09965,171.09965 0 0 1 181.62138,411.19523 171.09965,171.09965 0 0 1 343.16176,591.34767 Z M 482.96755,1485.6494 A 171.09965,171.09965 0 0 1 302.81519,1647.1895 171.09965,171.09965 0 0 1 141.2748,1467.0372 171.09965,171.09965 0 0 1 321.42717,1305.4967 171.09965,171.09965 0 0 1 482.96755,1485.6494 Z m 893.94285,143.4473 a 171.09965,171.09965 0 0 1 -180.1523,161.5405 171.09965,171.09965 0 0 1 -161.5404,-180.1524 171.09965,171.09965 0 0 1 180.1524,-161.5405 171.09965,171.09965 0 0 1 161.5403,180.1524 z M 1789.5918,823.45087 A 171.09965,171.09965 0 0 1 1609.4395,984.99118 171.09965,171.09965 0 0 1 1447.899,804.83886 171.09965,171.09965 0 0 1 1628.0513,643.29854 171.09965,171.09965 0 0 1 1789.5918,823.45087 Z M 1150.7,182.08661 A 171.09965,171.09965 0 0 1 970.54747,343.627 171.09965,171.09965 0 0 1 809.00715,163.47462 171.09965,171.09965 0 0 1 989.15948,1.9342312 171.09965,171.09965 0 0 1 1150.7,182.08661 Z m -792.52346,371.6819 a 188.20963,188.20963 0 0 1 2.07029,38.5086 188.20963,188.20963 0 0 1 -19.56107,73.71432 l 276.93395,44.47923 54.4306,-106.2947 z m 474.63645,76.2221 -54.43595,106.30538 654.33596,105.0888 a 188.20963,188.20963 0 0 1 -1.8996,-37.47876 188.20963,188.20963 0 0 1 20.0788,-74.64799 z M 1065.1875,340.27205 a 188.20963,188.20963 0 0 1 -95.56964,20.44149 188.20963,188.20963 0 0 1 -16.47175,-1.72883 l 43.21482,276.72059 117.91607,18.92069 z m -43.7109,456.30786 102.1755,654.25059 a 188.20963,188.20963 0 0 1 92.651,-18.9688 188.20963,188.20963 0 0 1 19.6891,2.1609 L 1139.398,815.49535 Z M 794.34712,203.1417 306.48853,450.37661 a 188.20963,188.20963 0 0 1 51.34118,101.31626 L 845.683,304.44741 A 188.20963,188.20963 0 0 1 794.34712,203.1417 Z m 352.24368,56.54894 a 188.20963,188.20963 0 0 1 -80.5121,80.13321 l 385.9286,387.41724 a 188.20963,188.20963 0 0 1 80.5069,-80.13321 z m 339.8804,688.09027 -249.2037,486.50849 a 188.20963,188.20963 0 0 1 101.0656,51.8588 L 1587.5315,999.64494 A 188.20963,188.20963 0 0 1 1486.4712,947.78091 Z M 498.08153,1448.6696 a 188.20963,188.20963 0 0 1 1.9689,37.9111 188.20963,188.20963 0 0 1 -19.85455,74.2528 l 539.90952,86.6376 a 188.20963,188.20963 0 0 1 -1.9742,-37.9162 188.20963,188.20963 0 0 1 19.8598,-74.2477 z M 256.10246,750.31321 a 188.20963,188.20963 0 0 1 -94.02233,19.65712 188.20963,188.20963 0 0 1 -18.16843,-1.89976 l 84.42322,540.00023 a 188.20963,188.20963 0 0 1 94.02233,-19.6571 188.20963,188.20963 0 0 1 18.15773,1.9001 z M 847.58784,306.427 562.15394,863.6618 646.42776,948.26106 948.64274,358.28043 A 188.20963,188.20963 0 0 1 847.58784,306.427 Z m -359.67106,702.1662 -144.57913,282.2484 a 188.20963,188.20963 0 0 1 101.04426,51.8481 l 127.80337,-249.5025 z m 945.31912,-164.10293 -250.1803,126.78949 18.446,117.99084 283.07,-143.4639 A 188.20963,188.20963 0 0 1 1433.2359,844.49027 Z M 1037.8202,1044.882 446.28679,1344.6691 a 188.20963,188.20963 0 0 1 51.34651,101.3273 l 558.6328,-283.1181 z M 339.05298,668.95274 a 188.20963,188.20963 0 0 1 -80.49604,80.12252 l 441.91192,443.63544 106.54017,-53.9931 z m 582.89469,585.14656 -106.54012,53.993 223.91735,224.7922 a 188.20963,188.20963 0 0 1 80.5121,-80.1332 z" fill-opacity=".996"/>
</svg>

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.3 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 221 KiB

File diff suppressed because one or more lines are too long

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 69 KiB

Wyświetl plik

@ -1,69 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="63.161953mm"
height="181.12712mm"
viewBox="0 0 63.161953 181.12712"
version="1.1"
id="svg1199"
inkscape:version="0.92.4 (unknown)"
sodipodi:docname="spider.svg">
<defs
id="defs1193" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35355339"
inkscape:cx="188.63933"
inkscape:cy="154.00309"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1366"
inkscape:window-height="705"
inkscape:window-x="0"
inkscape:window-y="30"
inkscape:window-maximized="1"
inkscape:snap-global="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0" />
<metadata
id="metadata1196">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-54.223528,-39.965002)">
<path
style="stroke-width:0.99999994"
d="m 329.96094,151.04883 -7.95132,372.20898 c -30.02705,2.9243 -45.57271,12.92382 -64.25977,32.67188 -25.16762,33.38088 -18.43249,69.4298 -0.4707,100.66992 12.24879,17.03193 32.3984,27.97627 53.07033,34.15036 0,0 -5.52814,0.0857 -11.58984,9.46094 -18.91001,-5.43999 -38.07073,-9.95039 -57.14063,-14.82032 -10.49976,0.9523 -28.58163,18.8274 -36.67969,24.9004 0.27746,13.19067 1.67361,27.14135 3.33008,39.15039 1.1699,-1.57002 0.83916,-3.5804 1.03906,-5.40039 0.9,-10.79003 0.60034,-21.66016 1.99024,-32.41016 9.28,-6.03999 17.7906,-13.20072 26.7207,-19.7207 18.99884,1.97067 39.37112,9.36858 55.91016,13.35156 -0.49,2.41999 -1.38047,5.27974 -4.23047,5.67969 -18.4,4.60002 -36.81969,9.10007 -55.17969,13.83007 -4.86555,6.81697 -23.47884,41.76065 -22.16992,48 3.32807,9.25919 3.76668,29.35751 8.58984,35.70899 -0.65616,-11.27353 -1.26587,-23.12102 -2.88086,-33.41016 4.366,-15.53732 14.77165,-31.85507 21.21094,-44.72851 16.36837,-5.03014 33.6873,-8.93673 49.58008,-11.32032 -0.0299,3.31998 -0.081,6.66013 -0.20117,9.99024 -10.89612,8.5036 -30.45632,23.65603 -40.40821,30.44922 -2.57681,15.80044 -3.38605,33.75066 -4.2207,48.55078 2.50279,8.85582 13.19431,23.74406 18.17156,23.90823 -2.93816,-7.30216 -8.51629,-14.68425 -10.88086,-21.31836 -0.17087,-16.87764 2.99403,-32.98356 3.70114,-48.41015 11.61344,-9.80937 25.4679,-15.10577 35.89062,-24.25 2.26541,6.18864 7.32913,9.97253 10.32813,15.05859 -2.15,3.10001 -5.51922,5.79 -5.94922,9.75 2.88,4.37998 6.60955,8.25101 10.68945,11.54102 -0.85,-3.43 -2.26023,-6.68056 -3.24023,-10.06055 l 6.20117,-7.18945 c 10.18753,5.69922 19.39911,4.81707 28.78906,0.75976 2.12,2.45 4.30149,5.11952 5.27149,8.26953 -0.85,3.26 -2.7418,6.14966 -3.5918,9.42969 4.21,-3.40003 8.09071,-7.32883 11.4707,-11.54883 -0.72,-4.08 -4.4693,-6.80104 -5.27929,-10.79101 3.66,-4.43003 7.97023,-8.42941 10.24023,-13.85938 5.68622,5.4072 34.43902,22.24881 34.94922,26.88086 0.36518,16.19209 3.11897,31.74502 2,46.75 -4.46916,8.68536 -7.12999,16.57554 -14.39063,22.67969 9.90723,0.50906 17.4253,-14.74937 21.52152,-22.69328 -0.18697,-17.91233 -0.74645,-33.39521 -1.16992,-49.66992 -13.47001,-10.57002 -27.16094,-20.89017 -40.46094,-31.66016 0.59,-3.81003 0.49976,-7.6583 0.50977,-11.48828 15.73,4.66001 31.80992,8.14868 47.66992,12.38867 7.58475,10.99663 15.5151,31.43552 20.24023,42.75977 0.43698,13.66208 -3.68079,27.5449 -4.08008,40.14062 1.49998,-1.33999 1.6498,-3.42013 2.17969,-5.24023 1.88197,-11.16719 9.61842,-29.63645 8.13086,-37.92969 -6.21997,-14.23003 -11.95978,-28.75009 -18.42969,-42.83008 -19.30273,-6.68031 -40.27482,-12.85569 -58.39062,-17.73047 -0.65,-1.72002 -1.1801,-3.47951 -1.5,-5.26953 17.78,-3.66999 35.60009,-7.40034 53.33008,-11.32031 5.35892,-0.14205 29.14876,22.09172 28.98047,23.98047 1.30016,6.78634 -2.08415,29.71011 1.61914,33.13086 2.05988,-11.02999 3.41097,-22.17002 5.12109,-33.25 -0.32862,-6.33401 -29.16337,-28.29439 -33.91016,-30.79102 -20.42635,4.13166 -40.67884,9.74123 -59.80078,12.63086 -5.16629,-4.96887 -11.64306,-7.41991 -17.4707,-10.33984 26.33,-1.87998 52.09,-16.02008 66.25,-38.58008 9.5235,-13.96814 12.87637,-29.769 13.1992,-45.79102 0.33714,-20.46694 -8.12112,-40.39069 -21.6211,-55.4707 -18.78284,-17.43524 -31.48782,-23.12017 -55.43945,-26.73828 l 6.93151,-372.80078 z"
id="path1768"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
transform="scale(0.26458333)" />
</g>
</svg>

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 5.2 KiB

File diff suppressed because one or more lines are too long

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 77 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 12 KiB

Wyświetl plik

@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="svg46269" viewBox="0 0 340.00001 394.2857" height="111.27618mm" width="95.955559mm">
<defs id="defs46271">
<linearGradient id="linearGradient46839">
<stop id="stop46841" offset="0" style="stop-color:#904700;stop-opacity:1;"/>
<stop id="stop46843" offset="1" style="stop-color:#904700;stop-opacity:0;"/>
</linearGradient>
<linearGradient id="linearGradient46831">
<stop id="stop46833" offset="0" style="stop-color:#904700;stop-opacity:1;"/>
<stop id="stop46835" offset="1" style="stop-color:#904700;stop-opacity:0;"/>
</linearGradient>
<linearGradient id="linearGradient46823">
<stop id="stop46825" offset="0" style="stop-color:#904700;stop-opacity:1;"/>
<stop id="stop46827" offset="1" style="stop-color:#904700;stop-opacity:0;"/>
</linearGradient>
<radialGradient gradientTransform="matrix(4.9019612,0,0,4.9019612,-600.72836,-1264.1473)" gradientUnits="userSpaceOnUse" r="72.85714" fy="330.93362" fx="152.85715" cy="330.93362" cx="152.85715" id="radialGradient46829" xlink:href="#linearGradient46823"/>
<radialGradient gradientTransform="matrix(3.3636365,0,0,3.3636365,-602.85717,-938.05096)" gradientUnits="userSpaceOnUse" r="62.857143" fy="429.50507" fx="251.42857" cy="429.50507" cx="251.42857" id="radialGradient46837" xlink:href="#linearGradient46831"/>
<radialGradient gradientTransform="matrix(1.7317072,0,0,1.7317072,-145.78397,-287.44272)" gradientUnits="userSpaceOnUse" r="58.57143" fy="470.93369" fx="132.85715" cy="470.93369" cx="132.85715" id="radialGradient46845" xlink:href="#linearGradient46839"/>
</defs>
<metadata id="metadata46274">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g transform="translate(-8.5714264,-218.07648)" id="layer1">
<circle r="140" cy="358.07648" cx="148.57143" id="path46817" style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:url(#radialGradient46829);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:20, 5;stroke-dashoffset:0;stroke-opacity:1;marker:none;enable-background:accumulate"/>
<circle r="105.71429" cy="506.64789" cx="242.85715" id="path46819" style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:url(#radialGradient46837);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:20, 5;stroke-dashoffset:0;stroke-opacity:1;marker:none;enable-background:accumulate"/>
<circle r="58.57143" cy="528.07654" cx="84.285713" id="path46821" style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:url(#radialGradient46845);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:20, 5;stroke-dashoffset:0;stroke-opacity:1;marker:none;enable-background:accumulate"/>
</g>
</svg>

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.4 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 1.4 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 10 KiB

Wyświetl plik

@ -5,10 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="referrer" content="same-origin" />
<link href="/manifest.json" rel="manifest">
<!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png">
<%= snippets %>
</head>
<body class="theme-mode-light no-reduce-motion">

Wyświetl plik

@ -23,6 +23,5 @@
</ol>
<h1 id="opensource">Open Source Software</h1>
<p>Soapbox is free and open source (FOSS) software that runs atop a Pleroma server</p>
<p>The Soapbox repository can be found at <a href="https://gitlab.com/soapbox-pub/soapbox-fe">Soapbox-fe</a></p>
<p>The Pleroma server repository can be found at <a href="https://git.pleroma.social/pleroma/pleroma">Pleroma-be</a></p>
<p>Soapbox is free and open source (FOSS) software.</p>
<p>The Soapbox repository can be found at <a href="https://gitlab.com/soapbox-pub/soapbox">Soapbox</a></p>

Wyświetl plik

@ -0,0 +1,105 @@
{
"approval_required": false,
"avatar_upload_limit": 2000000,
"background_image": "https://fe.disroot.org/images/city.jpg",
"background_upload_limit": 4000000,
"banner_upload_limit": 4000000,
"description": "FEDIsroot - Federated social network powered by Pleroma (open beta)",
"description_limit": 5000,
"email": "admin@example.lan",
"languages": [
"en"
],
"max_toot_chars": 5000,
"pleroma": {
"metadata": {
"account_activation_required": false,
"features": [
"pleroma_api",
"akkoma_api",
"mastodon_api",
"mastodon_api_streaming",
"polls",
"v2_suggestions",
"pleroma_explicit_addressing",
"shareable_emoji_packs",
"multifetch",
"pleroma:api/v1/notifications:include_types_filter",
"editing",
"media_proxy",
"relay",
"pleroma_emoji_reactions",
"exposable_reactions",
"profile_directory",
"custom_emoji_reactions",
"pleroma:get:main/ostatus"
],
"federation": {
"enabled": true,
"exclusions": false,
"mrf_hashtag": {
"federated_timeline_removal": [],
"reject": [],
"sensitive": [
"nsfw"
]
},
"mrf_object_age": {
"actions": [
"delist",
"strip_followers"
],
"threshold": 604800
},
"mrf_policies": [
"ObjectAgePolicy",
"TagPolicy",
"HashtagPolicy",
"InlineQuotePolicy"
],
"quarantined_instances": [],
"quarantined_instances_info": {
"quarantined_instances": {}
}
},
"fields_limits": {
"max_fields": 10,
"max_remote_fields": 20,
"name_length": 512,
"value_length": 2048
},
"post_formats": [
"text/plain",
"text/html",
"text/markdown",
"text/bbcode",
"text/x.misskeymarkdown"
],
"privileged_staff": false
},
"stats": {
"mau": 83
},
"vapid_public_key": null
},
"poll_limits": {
"max_expiration": 31536000,
"max_option_chars": 200,
"max_options": 20,
"min_expiration": 0
},
"registrations": false,
"stats": {
"domain_count": 6972,
"status_count": 8081,
"user_count": 357
},
"thumbnail": "https://fe.disroot.org/instance/thumbnail.jpeg",
"title": "FEDIsroot",
"upload_limit": 16000000,
"uri": "https://fe.disroot.org",
"urls": {
"streaming_api": "wss://fe.disroot.org"
},
"version": "2.7.2 (compatible; Akkoma 3.3.1-0-gaf90a4e51)"
}

Wyświetl plik

@ -0,0 +1,44 @@
[
{
"id": "1",
"content": "<p>Updated to Soapbox v3.</p>",
"starts_at": null,
"ends_at": null,
"all_day": false,
"published_at": "2022-06-15T18:47:14.190Z",
"updated_at": "2022-06-15T18:47:18.339Z",
"read": true,
"mentions": [],
"statuses": [],
"tags": [],
"emojis": [],
"reactions": [
{
"name": "📈",
"count": 476,
"me": true
}
]
},
{
"id": "2",
"content": "<p>Rolled back to Soapbox v2 for now.</p>",
"starts_at": null,
"ends_at": null,
"all_day": false,
"published_at": "2022-07-13T11:11:50.628Z",
"updated_at": "2022-07-13T11:11:50.628Z",
"read": true,
"mentions": [],
"statuses": [],
"tags": [],
"emojis": [],
"reactions": [
{
"name": "📉",
"count": 420,
"me": false
}
]
}
]

Wyświetl plik

@ -87,7 +87,7 @@
"compose_form.poll.add_option": "Add a choice",
"compose_form.poll.duration": "Poll duration",
"compose_form.poll.option_placeholder": "Choice {number}",
"compose_form.poll.remove_option": "Remove this choice",
"compose_form.poll.remove_option": "Delete",
"compose_form.poll.type.hint": "Click to toggle poll type. Radio button (default) is single. Checkbox is multiple.",
"compose_form.publish": "Publish",
"compose_form.publish_loud": "{publish}!",
@ -243,7 +243,7 @@
"lists.edit": "Edit list",
"lists.edit.submit": "Change title",
"lists.new.create": "Add list",
"lists.new.create_title": "Create",
"lists.new.create_title": "Add list",
"lists.new.save_title": "Save Title",
"lists.new.title_placeholder": "New list title",
"lists.search": "Search among people you follow",
@ -319,6 +319,7 @@
"poll_button.add_poll": "Add a poll",
"poll_button.remove_poll": "Remove poll",
"preferences.fields.auto_play_gif_label": "Auto-play animated GIFs",
"preferences.fields.auto_play_video_label": "Auto-play videos",
"preferences.fields.boost_modal_label": "Show confirmation dialog before reposting",
"preferences.fields.delete_modal_label": "Show confirmation dialog before deleting a post",
"preferences.fields.demetricator_label": "Use Demetricator",
@ -397,7 +398,7 @@
"security.update_email.success": "Email successfully updated.",
"security.update_password.fail": "Update password failed.",
"security.update_password.success": "Password successfully updated.",
"signup_panel.subtitle": "Sign up now to discuss.",
"signup_panel.subtitle": "Sign up now to discuss what's happening.",
"signup_panel.title": "New to {site_title}?",
"status.admin_account": "Open moderation interface for @{name}",
"status.admin_status": "Open this post in the moderation interface",
@ -565,7 +566,7 @@
"compose_form.poll.add_option": "Add a choice",
"compose_form.poll.duration": "Poll duration",
"compose_form.poll.option_placeholder": "Choice {number}",
"compose_form.poll.remove_option": "Remove this choice",
"compose_form.poll.remove_option": "Delete",
"compose_form.poll.type.hint": "Click to toggle poll type. Radio button (default) is single. Checkbox is multiple.",
"compose_form.publish": "Publish",
"compose_form.publish_loud": "{publish}!",
@ -721,7 +722,7 @@
"lists.edit": "Edit list",
"lists.edit.submit": "Change title",
"lists.new.create": "Add list",
"lists.new.create_title": "Create",
"lists.new.create_title": "Add list",
"lists.new.save_title": "Save Title",
"lists.new.title_placeholder": "New list title",
"lists.search": "Search among people you follow",
@ -836,6 +837,8 @@
"registration.lead": "With an account on {instance} you\"ll be able to follow people on any server in the fediverse.",
"registration.sign_up": "Sign up",
"registration.tos": "Terms of Service",
"registration.privacy": "Privacy Policy",
"registration.acceptance": "By registering, you agree to the {terms} and {privacy}.",
"registration.reason": "Reason for Joining",
"relative_time.days": "{number}d",
"relative_time.hours": "{number}h",
@ -876,7 +879,7 @@
"security.update_email.success": "Email successfully updated.",
"security.update_password.fail": "Update password failed.",
"security.update_password.success": "Password successfully updated.",
"signup_panel.subtitle": "Sign up now to discuss.",
"signup_panel.subtitle": "Sign up now to discuss what's happening.",
"signup_panel.title": "New to {site_title}?",
"status.admin_account": "Open moderation interface for @{name}",
"status.admin_status": "Open this post in the moderation interface",

Wyświetl plik

@ -0,0 +1,183 @@
{
"account": {
"acct": "alex",
"avatar": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png",
"avatar_static": "https://media.gleasonator.com/6d64aecb17348b23aaff78db4687b9476cb0da1c07cc6a819c2e6ec7144c18b1.png",
"bot": false,
"created_at": "2020-01-08T01:25:43.000Z",
"display_name": "Alex Gleason",
"emojis": [],
"fields": [
{
"name": "Website",
"value": "<a href=\"https://alexgleason.me\" rel=\"ugc\">https://alexgleason.me</a>"
},
{
"name": "Soapbox",
"value": "<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
},
{
"name": "Donate (PayPal)",
"value": "<a href=\"https://paypal.me/gleasonator\" rel=\"ugc\">https://paypal.me/gleasonator</a>"
},
{
"name": "$BTC",
"value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n"
},
{
"name": "$ETH",
"value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717"
},
{
"name": "$DOGE",
"value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D"
},
{
"name": "$XMR",
"value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK"
}
],
"followers_count": 2465,
"following_count": 1581,
"fqn": "alex@gleasonator.com",
"header": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png",
"header_static": "https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png",
"id": "9v5bmRalQvjOy0ECcC",
"last_status_at": "2022-03-10T18:19:50",
"locked": false,
"note": "I create Fediverse software that empowers people online.<br/><br/>I&#39;m vegan btw<br/><br/>Note: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.",
"pleroma": {
"accepts_chat_messages": true,
"also_known_as": [
"https://mitra.social/users/alex"
],
"ap_id": "https://gleasonator.com/users/alex",
"background_image": null,
"birthday": "1993-07-03",
"favicon": "https://gleasonator.com/favicon.png",
"hide_favorites": true,
"hide_followers": false,
"hide_followers_count": false,
"hide_follows": false,
"hide_follows_count": false,
"is_admin": true,
"is_confirmed": true,
"is_moderator": false,
"is_suggested": true,
"relationship": {},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [
{
"name": "Website",
"value": "https://alexgleason.me"
},
{
"name": "Soapbox",
"value": "https://soapbox.pub"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
},
{
"name": "Donate (PayPal)",
"value": "https://paypal.me/gleasonator"
},
{
"name": "$BTC",
"value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n"
},
{
"name": "$ETH",
"value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717"
},
{
"name": "$DOGE",
"value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D"
},
{
"name": "$XMR",
"value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK"
}
],
"note": "I create Fediverse software that empowers people online.\r\n\r\nI'm vegan btw\r\n\r\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 23648,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": null,
"bookmarked": false,
"card": null,
"content": "<p>Good morning! Hope you have a wonderful day.</p>",
"created_at": "2020-03-23T19:33:06.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 49,
"id": "103874034845713213",
"in_reply_to_account_id": null,
"in_reply_to_id": null,
"language": null,
"media_attachments": [],
"mentions": [],
"muted": false,
"pinned": true,
"pleroma": {
"content": {
"text/plain": "What is tolerance?"
},
"conversation_id": "3023268",
"direct_conversation_id": null,
"emoji_reactions": [
{
"count": 3,
"me": false,
"name": "❤️"
}
],
"expires_at": null,
"in_reply_to_account_acct": null,
"local": true,
"parent_visible": false,
"pinned_at": "2021-11-23T01:38:44.000Z",
"quote": null,
"quote_url": null,
"quote_visible": false,
"spoiler_text": {
"text/plain": ""
},
"thread_muted": false
},
"poll": null,
"reblog": null,
"reblogged": false,
"reblogs_count": 27,
"replies_count": 15,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": null,
"uri": "https://gleasonator.com/users/alex/statuses/103874034847713213",
"url": "https://gleasonator.com/notice/103874034847713213",
"visibility": "public"
}

Wyświetl plik

@ -0,0 +1,15 @@
[
{
"account": {
"id": "ABDSjI3Q0R8aDaz1U0"
},
"content": "quoast",
"id": "AJsajx9hY4Q7IKQXEe",
"pleroma": {
"quote": {
"content": "<p>10</p>",
"id": "AJmoVikzI3SkyITyim"
}
}
}
]

Wyświetl plik

@ -0,0 +1,85 @@
{
"id": "108046224464672537",
"created_at": "2022-03-30T15:40:53.287Z",
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": false,
"spoiler_text": "",
"visibility": "self",
"language": null,
"uri": "https://truthsocial.com/users/alex/statuses/108046244464677537",
"url": "https://truthsocial.com/@alex/108046244464677537",
"replies_count": 0,
"reblogs_count": 0,
"favourites_count": 0,
"favourited": false,
"reblogged": false,
"muted": false,
"bookmarked": false,
"pinned": false,
"content": "<p>A federal agent inspects a 'lumber' truck after smelling alcohol during the prohibition period. Los Angeles, 1926 (during the Prohibition era).</p>",
"reblog": null,
"application": {
"name": "Soapbox FE",
"website": "https://soapbox.pub/"
},
"account": {
"id": "107759994408336377",
"username": "alex",
"acct": "alex",
"display_name": "Alex Gleason",
"locked": false,
"bot": false,
"discoverable": null,
"group": false,
"created_at": "2022-02-08T00:00:00.000Z",
"note": "<p>Launching Truth Social</p>",
"url": "https://truthsocial.com/@alex",
"avatar": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/avatars/107/759/994/408/336/377/original/119cb0dd1fa615b7.png",
"avatar_static": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/avatars/107/759/994/408/336/377/original/119cb0dd1fa615b7.png",
"header": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/headers/107/759/994/408/336/377/original/31f62b0453ccf554.png",
"header_static": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/headers/107/759/994/408/336/377/original/31f62b0453ccf554.png",
"followers_count": 4713,
"following_count": 43,
"statuses_count": 7,
"last_status_at": "2022-03-30",
"verified": true,
"location": "Texas",
"website": "https://soapbox.pub/",
"emojis": [],
"fields": []
},
"media_attachments": [
{
"id": "108635651287436632",
"type": "image",
"url": "https://static-assets-1.truthsocial.com/tmtg:prime-ts-assets/media_attachments/files/108/635/651/487/436/632/original/7873bda5a7ab45d3.jpeg",
"preview_url": "https://static-assets-1.truthsocial.com/tmtg:prime-ts-assets/media_attachments/files/108/635/651/487/436/632/small/7873bda5a7ab45d3.jpeg",
"external_video_id": null,
"remote_url": null,
"preview_remote_url": null,
"text_url": "https://truthsocial.com/media/_Kc-2w2Pe7knhYJV-CM",
"meta": {
"original": {
"width": 1080,
"height": 841,
"size": "1080x841",
"aspect": 1.2841854934601664
},
"small": {
"width": 907,
"height": 706,
"size": "907x706",
"aspect": 1.2847025495750708
}
},
"description": null,
"blurhash": "UIIY5?4n~q9FIUIUD%WB?bt7M{t7of%MofIU"
}
],
"mentions": [],
"tags": [],
"emojis": [],
"card": null,
"poll": null
}

Wyświetl plik

@ -0,0 +1,166 @@
import { render } from '@testing-library/react';
import { AxiosError } from 'axios';
import React from 'react';
import { IntlProvider } from 'react-intl';
import { act, screen } from 'soapbox/jest/test-helpers';
function renderApp() {
const { Toaster } = require('react-hot-toast');
const toast = require('../toast').default;
return {
toast,
...render(
<IntlProvider locale='en'>
<Toaster />,
</IntlProvider>,
),
};
}
beforeAll(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
(console.error as any).mockClear();
});
afterAll(() => {
(console.error as any).mockRestore();
});
describe('toasts', () =>{
it('renders successfully', async() => {
const { toast } = renderApp();
act(() => {
toast.success('hello');
});
expect(screen.getByTestId('toast')).toBeInTheDocument();
expect(screen.getByTestId('toast-message')).toHaveTextContent('hello');
});
describe('actionable button', () => {
it('renders the button', async() => {
const { toast } = renderApp();
act(() => {
toast.success('hello', { action: () => null, actionLabel: 'click me' });
});
expect(screen.getByTestId('toast-action')).toHaveTextContent('click me');
});
it('does not render the button', async() => {
const { toast } = renderApp();
act(() => {
toast.success('hello');
});
expect(screen.queryAllByTestId('toast-action')).toHaveLength(0);
});
});
describe('showAlertForError()', () => {
const buildError = (message: string, status: number) => new AxiosError<any>(message, String(status), undefined, null, {
data: {
error: message,
},
statusText: String(status),
status,
headers: {},
config: {},
});
describe('with a 502 status code', () => {
it('renders the correct message', async() => {
const message = 'The server is down';
const error = buildError(message, 502);
const { toast } = renderApp();
act(() => {
toast.showAlertForError(error);
});
expect(screen.getByTestId('toast')).toBeInTheDocument();
expect(screen.getByTestId('toast-message')).toHaveTextContent('The server is down');
});
});
describe('with a 404 status code', () => {
it('renders the correct message', async() => {
const error = buildError('', 404);
const { toast } = renderApp();
act(() => {
toast.showAlertForError(error);
});
expect(screen.queryAllByTestId('toast')).toHaveLength(0);
});
});
describe('with a 410 status code', () => {
it('renders the correct message', async() => {
const error = buildError('', 410);
const { toast } = renderApp();
act(() => {
toast.showAlertForError(error);
});
expect(screen.queryAllByTestId('toast')).toHaveLength(0);
});
});
describe('with an accepted status code', () => {
describe('with a message from the server', () => {
it('renders the correct message', async() => {
const message = 'custom message';
const error = buildError(message, 200);
const { toast } = renderApp();
act(() => {
toast.showAlertForError(error);
});
expect(screen.getByTestId('toast')).toBeInTheDocument();
expect(screen.getByTestId('toast-message')).toHaveTextContent(message);
});
});
describe('without a message from the server', () => {
it('renders the correct message', async() => {
const message = 'The request has been accepted for processing';
const error = buildError(message, 202);
const { toast } = renderApp();
act(() => {
toast.showAlertForError(error);
});
expect(screen.getByTestId('toast')).toBeInTheDocument();
expect(screen.getByTestId('toast-message')).toHaveTextContent(message);
});
});
});
describe('without a response', () => {
it('renders the default message', async() => {
const error = new AxiosError();
const { toast } = renderApp();
act(() => {
toast.showAlertForError(error);
});
expect(screen.getByTestId('toast')).toBeInTheDocument();
expect(screen.getByTestId('toast-message')).toHaveTextContent('An unexpected error occurred.');
});
});
});
});

Wyświetl plik

@ -2,7 +2,7 @@ import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { ReducerRecord, EditRecord } from 'soapbox/reducers/account_notes';
import { ReducerRecord, EditRecord } from 'soapbox/reducers/account-notes';
import { normalizeAccount, normalizeRelationship } from '../../normalizers';
import { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes';

Wyświetl plik

@ -2,7 +2,7 @@ import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { ListRecord, ReducerRecord } from 'soapbox/reducers/user_lists';
import { ListRecord, ReducerRecord } from 'soapbox/reducers/user-lists';
import { normalizeAccount, normalizeInstance, normalizeRelationship } from '../../normalizers';
import {

Wyświetl plik

@ -1,146 +0,0 @@
import { AxiosError } from 'axios';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { dismissAlert, showAlert, showAlertForError } from '../alerts';
const buildError = (message: string, status: number) => new AxiosError<any>(message, String(status), undefined, null, {
data: {
error: message,
},
statusText: String(status),
status,
headers: {},
config: {},
});
let store: ReturnType<typeof mockStore>;
beforeEach(() => {
const state = rootState;
store = mockStore(state);
});
describe('dismissAlert()', () => {
it('dispatches the proper actions', async() => {
const alert = 'hello world';
const expectedActions = [
{ type: 'ALERT_DISMISS', alert },
];
await store.dispatch(dismissAlert(alert as any));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('showAlert()', () => {
it('dispatches the proper actions', async() => {
const title = 'title';
const message = 'msg';
const severity = 'info';
const expectedActions = [
{ type: 'ALERT_SHOW', title, message, severity },
];
await store.dispatch(showAlert(title, message, severity));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('showAlert()', () => {
describe('with a 502 status code', () => {
it('dispatches the proper actions', async() => {
const message = 'The server is down';
const error = buildError(message, 502);
const expectedActions = [
{ type: 'ALERT_SHOW', title: '', message, severity: 'error' },
];
await store.dispatch(showAlertForError(error));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with a 404 status code', () => {
it('dispatches the proper actions', async() => {
const error = buildError('', 404);
await store.dispatch(showAlertForError(error));
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('with a 410 status code', () => {
it('dispatches the proper actions', async() => {
const error = buildError('', 410);
await store.dispatch(showAlertForError(error));
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('with an accepted status code', () => {
describe('with a message from the server', () => {
it('dispatches the proper actions', async() => {
const message = 'custom message';
const error = buildError(message, 200);
const expectedActions = [
{ type: 'ALERT_SHOW', title: '', message, severity: 'error' },
];
await store.dispatch(showAlertForError(error));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('without a message from the server', () => {
it('dispatches the proper actions', async() => {
const message = 'The request has been accepted for processing';
const error = buildError(message, 202);
const expectedActions = [
{ type: 'ALERT_SHOW', title: '', message, severity: 'error' },
];
await store.dispatch(showAlertForError(error));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
describe('without a response', () => {
it('dispatches the proper actions', async() => {
const error = new AxiosError();
const expectedActions = [
{
type: 'ALERT_SHOW',
title: {
defaultMessage: 'Oops!',
id: 'alert.unexpected.title',
},
message: {
defaultMessage: 'An unexpected error occurred.',
id: 'alert.unexpected.message',
},
severity: 'error',
},
];
await store.dispatch(showAlertForError(error));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});

Wyświetl plik

@ -0,0 +1,113 @@
import { List as ImmutableList } from 'immutable';
import { fetchAnnouncements, dismissAnnouncement, addReaction, removeReaction } from 'soapbox/actions/announcements';
import { __stub } from 'soapbox/api';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { normalizeAnnouncement, normalizeInstance } from 'soapbox/normalizers';
import type { APIEntity } from 'soapbox/types/entities';
const announcements = require('soapbox/__fixtures__/announcements.json');
describe('fetchAnnouncements()', () => {
describe('with a successful API request', () => {
it('should fetch announcements from the API', async() => {
const state = rootState
.set('instance', normalizeInstance({ version: '3.5.3' }));
const store = mockStore(state);
__stub((mock) => {
mock.onGet('/api/v1/announcements').reply(200, announcements);
});
const expectedActions = [
{ type: 'ANNOUNCEMENTS_FETCH_REQUEST', skipLoading: true },
{ type: 'ANNOUNCEMENTS_FETCH_SUCCESS', announcements, skipLoading: true },
{ type: 'POLLS_IMPORT', polls: [] },
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
{ type: 'STATUSES_IMPORT', statuses: [], expandSpoilers: false },
];
await store.dispatch(fetchAnnouncements());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
describe('dismissAnnouncement', () => {
describe('with a successful API request', () => {
it('should mark announcement as dismissed', async() => {
const store = mockStore(rootState);
__stub((mock) => {
mock.onPost('/api/v1/announcements/1/dismiss').reply(200);
});
const expectedActions = [
{ type: 'ANNOUNCEMENTS_DISMISS_REQUEST', id: '1' },
{ type: 'ANNOUNCEMENTS_DISMISS_SUCCESS', id: '1' },
];
await store.dispatch(dismissAnnouncement('1'));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
describe('addReaction', () => {
let store: ReturnType<typeof mockStore>;
beforeEach(() => {
const state = rootState
.setIn(['announcements', 'items'], ImmutableList((announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement))))
.setIn(['announcements', 'isLoading'], false);
store = mockStore(state);
});
describe('with a successful API request', () => {
it('should add reaction to a post', async() => {
__stub((mock) => {
mock.onPut('/api/v1/announcements/2/reactions/📉').reply(200);
});
const expectedActions = [
{ type: 'ANNOUNCEMENTS_REACTION_ADD_REQUEST', id: '2', name: '📉', skipLoading: true },
{ type: 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS', id: '2', name: '📉', skipLoading: true },
];
await store.dispatch(addReaction('2', '📉'));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
describe('removeReaction', () => {
let store: ReturnType<typeof mockStore>;
beforeEach(() => {
const state = rootState
.setIn(['announcements', 'items'], ImmutableList((announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement))))
.setIn(['announcements', 'isLoading'], false);
store = mockStore(state);
});
describe('with a successful API request', () => {
it('should remove reaction from a post', async() => {
__stub((mock) => {
mock.onDelete('/api/v1/announcements/2/reactions/📉').reply(200);
});
const expectedActions = [
{ type: 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST', id: '2', name: '📉', skipLoading: true },
{ type: 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS', id: '2', name: '📉', skipLoading: true },
];
await store.dispatch(removeReaction('2', '📉'));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});

Wyświetl plik

@ -1,6 +1,6 @@
import { __stub } from 'soapbox/api';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { ListRecord, ReducerRecord as UserListsRecord } from 'soapbox/reducers/user_lists';
import { ListRecord, ReducerRecord as UserListsRecord } from 'soapbox/reducers/user-lists';
import { expandBlocks, fetchBlocks } from '../blocks';

Wyświetl plik

@ -1,58 +0,0 @@
import { __stub } from 'soapbox/api';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { fetchCarouselAvatars } from '../carousels';
describe('fetchCarouselAvatars()', () => {
let store: ReturnType<typeof mockStore>;
beforeEach(() => {
store = mockStore(rootState);
});
describe('with a successful API request', () => {
let avatars: Record<string, any>[];
beforeEach(() => {
avatars = [
{ account_id: '1', acct: 'jl', account_avatar: 'https://example.com/some.jpg' },
];
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/avatars').reply(200, avatars);
});
});
it('should fetch the users from the API', async() => {
const expectedActions = [
{ type: 'CAROUSEL_AVATAR_REQUEST' },
{ type: 'CAROUSEL_AVATAR_SUCCESS', payload: avatars },
];
await store.dispatch(fetchCarouselAvatars());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/avatars').networkError();
});
});
it('should dispatch failed action', async() => {
const expectedActions = [
{ type: 'CAROUSEL_AVATAR_REQUEST' },
{ type: 'CAROUSEL_AVATAR_FAIL' },
];
await store.dispatch(fetchCarouselAvatars());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});

Wyświetl plik

@ -1,9 +1,11 @@
import { Map as ImmutableMap } from 'immutable';
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { InstanceRecord } from 'soapbox/normalizers';
import { ReducerCompose } from 'soapbox/reducers/compose';
import { uploadCompose } from '../compose';
import { uploadCompose, submitCompose } from '../compose';
import { STATUS_CREATE_REQUEST } from '../statuses';
import type { IntlShape } from 'react-intl';
@ -25,7 +27,8 @@ describe('uploadCompose()', () => {
const state = rootState
.set('me', '1234')
.set('instance', instance);
.set('instance', instance)
.setIn(['compose', 'home'], ReducerCompose());
store = mockStore(state);
files = [{
@ -42,18 +45,11 @@ describe('uploadCompose()', () => {
} as unknown as IntlShape;
const expectedActions = [
{ type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true },
{
type: 'ALERT_SHOW',
message: 'Image exceeds the current file size limit (10 Bytes)',
actionLabel: undefined,
actionLink: undefined,
severity: 'error',
},
{ type: 'COMPOSE_UPLOAD_FAIL', error: true, skipLoading: true },
{ type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true },
{ type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true },
];
await store.dispatch(uploadCompose(files, mockIntl));
await store.dispatch(uploadCompose('home', files, mockIntl));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
@ -77,7 +73,8 @@ describe('uploadCompose()', () => {
const state = rootState
.set('me', '1234')
.set('instance', instance);
.set('instance', instance)
.setIn(['compose', 'home'], ReducerCompose());
store = mockStore(state);
files = [{
@ -94,21 +91,37 @@ describe('uploadCompose()', () => {
} as unknown as IntlShape;
const expectedActions = [
{ type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true },
{
type: 'ALERT_SHOW',
message: 'Video exceeds the current file size limit (10 Bytes)',
actionLabel: undefined,
actionLink: undefined,
severity: 'error',
},
{ type: 'COMPOSE_UPLOAD_FAIL', error: true, skipLoading: true },
{ type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true },
{ type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true },
];
await store.dispatch(uploadCompose(files, mockIntl));
await store.dispatch(uploadCompose('home', files, mockIntl));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
describe('submitCompose()', () => {
it('inserts mentions from text', async() => {
const state = rootState
.set('me', '123')
.setIn(['compose', 'home'], ReducerCompose({ text: '@alex hello @mkljczk@pl.fediverse.pl @gg@汉语/漢語.com alex@alexgleason.me' }));
const store = mockStore(state);
await store.dispatch(submitCompose('home'));
const actions = store.getActions();
const statusCreateRequest = actions.find(action => action.type === STATUS_CREATE_REQUEST);
const to = statusCreateRequest!.params.to as ImmutableOrderedSet<string>;
const expected = [
'alex',
'mkljczk@pl.fediverse.pl',
'gg@汉语/漢語.com',
];
expect(to.toJS()).toEqual(expected);
});
});

Wyświetl plik

@ -0,0 +1,117 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { AccountRecord } from 'soapbox/normalizers';
import { AuthUserRecord, ReducerRecord } from '../../reducers/auth';
import {
fetchMe, patchMe,
} from '../me';
jest.mock('../../storage/kv-store', () => ({
__esModule: true,
default: {
getItemOrError: jest.fn().mockReturnValue(Promise.resolve({})),
},
}));
let store: ReturnType<typeof mockStore>;
describe('fetchMe()', () => {
describe('without a token', () => {
beforeEach(() => {
const state = rootState;
store = mockStore(state);
});
it('dispatches the correct actions', async() => {
const expectedActions = [{ type: 'ME_FETCH_SKIP' }];
await store.dispatch(fetchMe());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with a token', () => {
const accountUrl = 'accountUrl';
const token = '123';
beforeEach(() => {
const state = rootState
.set('auth', ReducerRecord({
me: accountUrl,
users: ImmutableMap({
[accountUrl]: AuthUserRecord({
'access_token': token,
}),
}),
}))
.set('accounts', ImmutableMap({
[accountUrl]: AccountRecord({
url: accountUrl,
}),
}) as any);
store = mockStore(state);
});
describe('with a successful API response', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/accounts/verify_credentials').reply(200, {});
});
});
it('dispatches the correct actions', async() => {
const expectedActions = [
{ type: 'ME_FETCH_REQUEST' },
{ type: 'AUTH_ACCOUNT_REMEMBER_REQUEST', accountUrl },
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
{
type: 'AUTH_ACCOUNT_REMEMBER_SUCCESS',
account: {},
accountUrl,
},
{ type: 'VERIFY_CREDENTIALS_REQUEST', token: '123' },
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
{ type: 'VERIFY_CREDENTIALS_SUCCESS', token: '123', account: {} },
];
await store.dispatch(fetchMe());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
});
describe('patchMe()', () => {
beforeEach(() => {
const state = rootState;
store = mockStore(state);
});
describe('with a successful API response', () => {
beforeEach(() => {
__stub((mock) => {
mock.onPatch('/api/v1/accounts/update_credentials').reply(200, {});
});
});
it('dispatches the correct actions', async() => {
const expectedActions = [
{ type: 'ME_PATCH_REQUEST' },
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
{
type: 'ME_PATCH_SUCCESS',
me: {},
},
];
await store.dispatch(patchMe({}));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});

Wyświetl plik

@ -0,0 +1,38 @@
import { OrderedMap as ImmutableOrderedMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { normalizeNotification } from 'soapbox/normalizers';
import { markReadNotifications } from '../notifications';
describe('markReadNotifications()', () => {
it('fires off marker when top notification is newer than lastRead', async() => {
__stub((mock) => mock.onPost('/api/v1/markers').reply(200, {}));
const items = ImmutableOrderedMap({
'10': normalizeNotification({ id: '10' }),
});
const state = rootState
.set('me', '123')
.setIn(['notifications', 'lastRead'], '9')
.setIn(['notifications', 'items'], items);
const store = mockStore(state);
const expectedActions = [{
type: 'MARKER_SAVE_REQUEST',
marker: {
notifications: {
last_read_id: '10',
},
},
}];
store.dispatch(markReadNotifications());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});

Wyświetl plik

@ -0,0 +1,150 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { StatusListRecord } from 'soapbox/reducers/status-lists';
import { fetchStatusQuotes, expandStatusQuotes } from '../status-quotes';
const status = {
account: {
id: 'ABDSjI3Q0R8aDaz1U0',
},
content: 'quoast',
id: 'AJsajx9hY4Q7IKQXEe',
pleroma: {
quote: {
content: '<p>10</p>',
id: 'AJmoVikzI3SkyITyim',
},
},
};
const statusId = 'AJmoVikzI3SkyITyim';
describe('fetchStatusQuotes()', () => {
let store: ReturnType<typeof mockStore>;
beforeEach(() => {
const state = rootState.set('me', '1234');
store = mockStore(state);
});
describe('with a successful API request', () => {
beforeEach(() => {
const quotes = require('soapbox/__fixtures__/status-quotes.json');
__stub((mock) => {
mock.onGet(`/api/v1/pleroma/statuses/${statusId}/quotes`).reply(200, quotes, {
link: `<https://example.com/api/v1/pleroma/statuses/${statusId}/quotes?since_id=1>; rel='prev'`,
});
});
});
it('should fetch quotes from the API', async() => {
const expectedActions = [
{ type: 'STATUS_QUOTES_FETCH_REQUEST', statusId },
{ type: 'POLLS_IMPORT', polls: [] },
{ type: 'ACCOUNTS_IMPORT', accounts: [status.account] },
{ type: 'STATUSES_IMPORT', statuses: [status], expandSpoilers: false },
{ type: 'STATUS_QUOTES_FETCH_SUCCESS', statusId, statuses: [status], next: null },
];
await store.dispatch(fetchStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/pleroma/statuses/${statusId}/quotes`).networkError();
});
});
it('should dispatch failed action', async() => {
const expectedActions = [
{ type: 'STATUS_QUOTES_FETCH_REQUEST', statusId },
{ type: 'STATUS_QUOTES_FETCH_FAIL', statusId, error: new Error('Network Error') },
];
await store.dispatch(fetchStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
describe('expandStatusQuotes()', () => {
let store: ReturnType<typeof mockStore>;
describe('without a url', () => {
beforeEach(() => {
const state = rootState
.set('me', '1234')
.set('status_lists', ImmutableMap({ [`quotes:${statusId}`]: StatusListRecord({ next: null }) }));
store = mockStore(state);
});
it('should do nothing', async() => {
await store.dispatch(expandStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('with a url', () => {
beforeEach(() => {
const state = rootState.set('me', '1234')
.set('status_lists', ImmutableMap({ [`quotes:${statusId}`]: StatusListRecord({ next: 'example' }) }));
store = mockStore(state);
});
describe('with a successful API request', () => {
beforeEach(() => {
const quotes = require('soapbox/__fixtures__/status-quotes.json');
__stub((mock) => {
mock.onGet('example').reply(200, quotes, {
link: `<https://example.com/api/v1/pleroma/statuses/${statusId}/quotes?since_id=1>; rel='prev'`,
});
});
});
it('should fetch quotes from the API', async() => {
const expectedActions = [
{ type: 'STATUS_QUOTES_EXPAND_REQUEST', statusId },
{ type: 'POLLS_IMPORT', polls: [] },
{ type: 'ACCOUNTS_IMPORT', accounts: [status.account] },
{ type: 'STATUSES_IMPORT', statuses: [status], expandSpoilers: false },
{ type: 'STATUS_QUOTES_EXPAND_SUCCESS', statusId, statuses: [status], next: null },
];
await store.dispatch(expandStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('example').networkError();
});
});
it('should dispatch failed action', async() => {
const expectedActions = [
{ type: 'STATUS_QUOTES_EXPAND_REQUEST', statusId },
{ type: 'STATUS_QUOTES_EXPAND_FAIL', statusId, error: new Error('Network Error') },
];
await store.dispatch(expandStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
});

Wyświetl plik

@ -121,6 +121,7 @@ describe('deleteStatus()', () => {
version: '0.0.0',
},
withRedraft: true,
id: 'compose-modal',
},
{ type: 'MODAL_OPEN', modalType: 'COMPOSE', modalProps: undefined },
];

Wyświetl plik

@ -0,0 +1,109 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { normalizeInstance } from 'soapbox/normalizers';
import {
fetchSuggestions,
} from '../suggestions';
let store: ReturnType<typeof mockStore>;
let state;
describe('fetchSuggestions()', () => {
describe('with Truth Social software', () => {
beforeEach(() => {
state = rootState
.set('instance', normalizeInstance({
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
pleroma: ImmutableMap({
metadata: ImmutableMap({
features: [],
}),
}),
}))
.set('me', '123');
store = mockStore(state);
});
describe('with a successful API request', () => {
const response = [
{
account_id: '1',
acct: 'jl',
account_avatar: 'https://example.com/some.jpg',
display_name: 'justin',
note: '<p>note</p>',
verified: true,
},
];
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions').reply(200, response, {
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('dispatches the correct actions', async() => {
const expectedActions = [
{ type: 'SUGGESTIONS_V2_FETCH_REQUEST', skipLoading: true },
{
type: 'ACCOUNTS_IMPORT', accounts: [{
acct: response[0].acct,
avatar: response[0].account_avatar,
avatar_static: response[0].account_avatar,
id: response[0].account_id,
note: response[0].note,
should_refetch: true,
verified: response[0].verified,
display_name: response[0].display_name,
}],
},
{
type: 'SUGGESTIONS_TRUTH_FETCH_SUCCESS',
suggestions: response,
next: undefined,
skipLoading: true,
},
{
type: 'RELATIONSHIPS_FETCH_REQUEST',
skipLoading: true,
ids: [response[0].account_id],
},
];
await store.dispatch(fetchSuggestions());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions').networkError();
});
});
it('should dispatch the correct actions', async() => {
const expectedActions = [
{ type: 'SUGGESTIONS_V2_FETCH_REQUEST', skipLoading: true },
{
type: 'SUGGESTIONS_V2_FETCH_FAIL',
error: new Error('Network Error'),
skipLoading: true,
skipAlert: true,
},
];
await store.dispatch(fetchSuggestions());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
});

Wyświetl plik

@ -1,5 +1,5 @@
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features';
import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features';
import api, { getLinks } from '../api';
@ -10,10 +10,10 @@ import {
} from './importer';
import type { AxiosError, CancelToken } from 'axios';
import type { History } from 'history';
import type { Map as ImmutableMap } from 'immutable';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Status } from 'soapbox/types/entities';
import type { History } from 'soapbox/types/history';
const ACCOUNT_CREATE_REQUEST = 'ACCOUNT_CREATE_REQUEST';
const ACCOUNT_CREATE_SUCCESS = 'ACCOUNT_CREATE_SUCCESS';
@ -130,6 +130,8 @@ const maybeRedirectLogin = (error: AxiosError, history?: History) => {
}
};
const noOp = () => new Promise(f => f(undefined));
const createAccount = (params: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ACCOUNT_CREATE_REQUEST, params });
@ -225,7 +227,12 @@ const fetchAccountFail = (id: string | null, error: AxiosError) => ({
skipAlert: true,
});
const followAccount = (id: string, options = { reblogs: true }) =>
type FollowAccountOpts = {
reblogs?: boolean
notify?: boolean
};
const followAccount = (id: string, options?: FollowAccountOpts) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return null;
@ -352,14 +359,30 @@ const unblockAccountFail = (error: AxiosError) => ({
error,
});
const muteAccount = (id: string, notifications?: boolean) =>
const muteAccount = (id: string, notifications?: boolean, duration = 0) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return null;
dispatch(muteAccountRequest(id));
const params: Record<string, any> = {
notifications,
};
if (duration) {
const state = getState();
const instance = state.instance;
const v = parseVersion(instance.version);
if (v.software === PLEROMA) {
params.expires_in = duration;
} else {
params.duration = duration;
}
}
return api(getState)
.post(`/api/v1/accounts/${id}/mute`, { notifications })
.post(`/api/v1/accounts/${id}/mute`, params)
.then(response => {
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
return dispatch(muteAccountSuccess(response.data, getState().statuses));
@ -815,11 +838,11 @@ const rejectFollowRequestFail = (id: string, error: AxiosError) => ({
const pinAccount = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
if (!isLoggedIn(getState)) return dispatch(noOp);
dispatch(pinAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => {
return api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => {
dispatch(pinAccountSuccess(response.data));
}).catch(error => {
dispatch(pinAccountFail(error));
@ -828,11 +851,11 @@ const pinAccount = (id: string) =>
const unpinAccount = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
if (!isLoggedIn(getState)) return dispatch(noOp);
dispatch(unpinAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => {
return api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => {
dispatch(unpinAccountSuccess(response.data));
}).catch(error => {
dispatch(unpinAccountFail(error));
@ -944,7 +967,7 @@ const fetchBirthdayReminders = (month: number, day: number) =>
dispatch({ type: BIRTHDAY_REMINDERS_FETCH_REQUEST, day, month, id: me });
api(getState).get('/api/v1/pleroma/birthdays', { params: { day, month } }).then(response => {
return api(getState).get('/api/v1/pleroma/birthdays', { params: { day, month } }).then(response => {
dispatch(importFetchedAccounts(response.data));
dispatch({
type: BIRTHDAY_REMINDERS_FETCH_SUCCESS,

Wyświetl plik

@ -1,12 +1,18 @@
import { defineMessages } from 'react-intl';
import { fetchRelationships } from 'soapbox/actions/accounts';
import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer';
import toast from 'soapbox/toast';
import { filterBadges, getTagDiff } from 'soapbox/utils/badges';
import { getFeatures } from 'soapbox/utils/features';
import api, { getLinks } from '../api';
import { openModal } from './modals';
import type { AxiosResponse } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
import type { APIEntity, Announcement } from 'soapbox/types/entities';
const ADMIN_CONFIG_FETCH_REQUEST = 'ADMIN_CONFIG_FETCH_REQUEST';
const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS';
@ -76,6 +82,45 @@ const ADMIN_USERS_UNSUGGEST_REQUEST = 'ADMIN_USERS_UNSUGGEST_REQUEST';
const ADMIN_USERS_UNSUGGEST_SUCCESS = 'ADMIN_USERS_UNSUGGEST_SUCCESS';
const ADMIN_USERS_UNSUGGEST_FAIL = 'ADMIN_USERS_UNSUGGEST_FAIL';
const ADMIN_USER_INDEX_EXPAND_FAIL = 'ADMIN_USER_INDEX_EXPAND_FAIL';
const ADMIN_USER_INDEX_EXPAND_REQUEST = 'ADMIN_USER_INDEX_EXPAND_REQUEST';
const ADMIN_USER_INDEX_EXPAND_SUCCESS = 'ADMIN_USER_INDEX_EXPAND_SUCCESS';
const ADMIN_USER_INDEX_FETCH_FAIL = 'ADMIN_USER_INDEX_FETCH_FAIL';
const ADMIN_USER_INDEX_FETCH_REQUEST = 'ADMIN_USER_INDEX_FETCH_REQUEST';
const ADMIN_USER_INDEX_FETCH_SUCCESS = 'ADMIN_USER_INDEX_FETCH_SUCCESS';
const ADMIN_USER_INDEX_QUERY_SET = 'ADMIN_USER_INDEX_QUERY_SET';
const ADMIN_ANNOUNCEMENTS_FETCH_FAIL = 'ADMIN_ANNOUNCEMENTS_FETCH_FAILS';
const ADMIN_ANNOUNCEMENTS_FETCH_REQUEST = 'ADMIN_ANNOUNCEMENTS_FETCH_REQUEST';
const ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS = 'ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS';
const ADMIN_ANNOUNCEMENTS_EXPAND_FAIL = 'ADMIN_ANNOUNCEMENTS_EXPAND_FAILS';
const ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST = 'ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST';
const ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS = 'ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS';
const ADMIN_ANNOUNCEMENT_CHANGE_CONTENT = 'ADMIN_ANNOUNCEMENT_CHANGE_CONTENT';
const ADMIN_ANNOUNCEMENT_CHANGE_START_TIME = 'ADMIN_ANNOUNCEMENT_CHANGE_START_TIME';
const ADMIN_ANNOUNCEMENT_CHANGE_END_TIME = 'ADMIN_ANNOUNCEMENT_CHANGE_END_TIME';
const ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY = 'ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY';
const ADMIN_ANNOUNCEMENT_CREATE_REQUEST = 'ADMIN_ANNOUNCEMENT_CREATE_REQUEST';
const ADMIN_ANNOUNCEMENT_CREATE_SUCCESS = 'ADMIN_ANNOUNCEMENT_CREATE_REQUEST';
const ADMIN_ANNOUNCEMENT_CREATE_FAIL = 'ADMIN_ANNOUNCEMENT_CREATE_FAIL';
const ADMIN_ANNOUNCEMENT_DELETE_REQUEST = 'ADMIN_ANNOUNCEMENT_DELETE_REQUEST';
const ADMIN_ANNOUNCEMENT_DELETE_SUCCESS = 'ADMIN_ANNOUNCEMENT_DELETE_REQUEST';
const ADMIN_ANNOUNCEMENT_DELETE_FAIL = 'ADMIN_ANNOUNCEMENT_DELETE_FAIL';
const ADMIN_ANNOUNCEMENT_MODAL_INIT = 'ADMIN_ANNOUNCEMENT_MODAL_INIT';
const messages = defineMessages({
announcementCreateSuccess: { id: 'admin.edit_announcement.created', defaultMessage: 'Announcement created' },
announcementDeleteSuccess: { id: 'admin.edit_announcement.deleted', defaultMessage: 'Announcement deleted' },
announcementUpdateSuccess: { id: 'admin.edit_announcement.updated', defaultMessage: 'Announcement edited' },
});
const nicknamesFromIds = (getState: () => RootState, ids: string[]) => ids.map(id => getState().accounts.get(id)!.acct);
const fetchConfig = () =>
@ -102,6 +147,19 @@ const updateConfig = (configs: Record<string, any>[]) =>
});
};
const updateSoapboxConfig = (data: Record<string, any>) =>
(dispatch: AppDispatch, _getState: () => RootState) => {
const params = [{
group: ':pleroma',
key: ':frontend_configurations',
value: [{
tuple: [':soapbox_fe', data],
}],
}];
return dispatch(updateConfig(params));
};
const fetchMastodonReports = (params: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) =>
api(getState)
@ -403,6 +461,12 @@ const tagUsers = (accountIds: string[], tags: string[]) =>
const untagUsers = (accountIds: string[], tags: string[]) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const nicknames = nicknamesFromIds(getState, accountIds);
// Legacy: allow removing legacy 'donor' tags.
if (tags.includes('badge:donor')) {
tags = [...tags, 'donor'];
}
dispatch({ type: ADMIN_USERS_UNTAG_REQUEST, accountIds, tags });
return api(getState)
.delete('/api/v1/pleroma/admin/users/tag', { data: { nicknames, tags } })
@ -413,6 +477,24 @@ const untagUsers = (accountIds: string[], tags: string[]) =>
});
};
/** Synchronizes user tags to the backend. */
const setTags = (accountId: string, oldTags: string[], newTags: string[]) =>
async(dispatch: AppDispatch) => {
const diff = getTagDiff(oldTags, newTags);
await dispatch(tagUsers([accountId], diff.added));
await dispatch(untagUsers([accountId], diff.removed));
};
/** Synchronizes badges to the backend. */
const setBadges = (accountId: string, oldTags: string[], newTags: string[]) =>
(dispatch: AppDispatch) => {
const oldBadges = filterBadges(oldTags);
const newBadges = filterBadges(newTags);
return dispatch(setTags(accountId, oldBadges, newBadges));
};
const verifyUser = (accountId: string) =>
(dispatch: AppDispatch) =>
dispatch(tagUsers([accountId], ['verified']));
@ -421,14 +503,6 @@ const unverifyUser = (accountId: string) =>
(dispatch: AppDispatch) =>
dispatch(untagUsers([accountId], ['verified']));
const setDonor = (accountId: string) =>
(dispatch: AppDispatch) =>
dispatch(tagUsers([accountId], ['donor']));
const removeDonor = (accountId: string) =>
(dispatch: AppDispatch) =>
dispatch(untagUsers([accountId], ['donor']));
const addPermission = (accountIds: string[], permissionGroup: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const nicknames = nicknamesFromIds(getState, accountIds);
@ -476,6 +550,18 @@ const demoteToUser = (accountId: string) =>
dispatch(removePermission([accountId], 'moderator')),
]);
const setRole = (accountId: string, role: 'user' | 'moderator' | 'admin') =>
(dispatch: AppDispatch) => {
switch (role) {
case 'user':
return dispatch(demoteToUser(accountId));
case 'moderator':
return dispatch(promoteToModerator(accountId));
case 'admin':
return dispatch(promoteToAdmin(accountId));
}
};
const suggestUsers = (accountIds: string[]) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const nicknames = nicknamesFromIds(getState, accountIds);
@ -502,6 +588,137 @@ const unsuggestUsers = (accountIds: string[]) =>
});
};
const setUserIndexQuery = (query: string) => ({ type: ADMIN_USER_INDEX_QUERY_SET, query });
const fetchUserIndex = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const { filters, page, query, pageSize, isLoading } = getState().admin_user_index;
if (isLoading) return;
dispatch({ type: ADMIN_USER_INDEX_FETCH_REQUEST });
dispatch(fetchUsers(filters.toJS() as string[], page + 1, query, pageSize))
.then((data: any) => {
if (data.error) {
dispatch({ type: ADMIN_USER_INDEX_FETCH_FAIL });
} else {
const { users, count, next } = (data);
dispatch({ type: ADMIN_USER_INDEX_FETCH_SUCCESS, users, count, next });
}
}).catch(() => {
dispatch({ type: ADMIN_USER_INDEX_FETCH_FAIL });
});
};
const expandUserIndex = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const { filters, page, query, pageSize, isLoading, next, loaded } = getState().admin_user_index;
if (!loaded || isLoading) return;
dispatch({ type: ADMIN_USER_INDEX_EXPAND_REQUEST });
dispatch(fetchUsers(filters.toJS() as string[], page + 1, query, pageSize, next))
.then((data: any) => {
if (data.error) {
dispatch({ type: ADMIN_USER_INDEX_EXPAND_FAIL });
} else {
const { users, count, next } = (data);
dispatch({ type: ADMIN_USER_INDEX_EXPAND_SUCCESS, users, count, next });
}
}).catch(() => {
dispatch({ type: ADMIN_USER_INDEX_EXPAND_FAIL });
});
};
const fetchAdminAnnouncements = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_REQUEST });
return api(getState)
.get('/api/pleroma/admin/announcements', { params: { limit: 50 } })
.then(({ data }) => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS, announcements: data });
return data;
}).catch(error => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_FAIL, error });
});
};
const expandAdminAnnouncements = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const page = getState().admin_announcements.page;
dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST });
return api(getState)
.get('/api/pleroma/admin/announcements', { params: { limit: 50, offset: page * 50 } })
.then(({ data }) => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS, announcements: data });
return data;
}).catch(error => {
dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_FAIL, error });
});
};
const changeAnnouncementContent = (content: string) => ({
type: ADMIN_ANNOUNCEMENT_CHANGE_CONTENT,
value: content,
});
const changeAnnouncementStartTime = (time: Date | null) => ({
type: ADMIN_ANNOUNCEMENT_CHANGE_START_TIME,
value: time,
});
const changeAnnouncementEndTime = (time: Date | null) => ({
type: ADMIN_ANNOUNCEMENT_CHANGE_END_TIME,
value: time,
});
const changeAnnouncementAllDay = (allDay: boolean) => ({
type: ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY,
value: allDay,
});
const handleCreateAnnouncement = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_REQUEST });
const { id, content, starts_at, ends_at, all_day } = getState().admin_announcements.form;
return api(getState)[id ? 'patch' : 'post'](
id ? `/api/pleroma/admin/announcements/${id}` : '/api/pleroma/admin/announcements',
{ content, starts_at, ends_at, all_day },
).then(({ data }) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_SUCCESS, announcement: data });
toast.success(id ? messages.announcementUpdateSuccess : messages.announcementCreateSuccess);
dispatch(fetchAdminAnnouncements());
return data;
}).catch(error => {
dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_FAIL, error });
});
};
const deleteAnnouncement = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_REQUEST, id });
return api(getState).delete(`/api/pleroma/admin/announcements/${id}`).then(({ data }) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_SUCCESS, id });
toast.success(messages.announcementDeleteSuccess);
dispatch(fetchAdminAnnouncements());
return data;
}).catch(error => {
dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_FAIL, id, error });
});
};
const initAnnouncementModal = (announcement?: Announcement) =>
(dispatch: AppDispatch) => {
dispatch({ type: ADMIN_ANNOUNCEMENT_MODAL_INIT, announcement });
dispatch(openModal('EDIT_ANNOUNCEMENT'));
};
export {
ADMIN_CONFIG_FETCH_REQUEST,
ADMIN_CONFIG_FETCH_SUCCESS,
@ -554,8 +771,33 @@ export {
ADMIN_USERS_UNSUGGEST_REQUEST,
ADMIN_USERS_UNSUGGEST_SUCCESS,
ADMIN_USERS_UNSUGGEST_FAIL,
ADMIN_USER_INDEX_EXPAND_FAIL,
ADMIN_USER_INDEX_EXPAND_REQUEST,
ADMIN_USER_INDEX_EXPAND_SUCCESS,
ADMIN_USER_INDEX_FETCH_FAIL,
ADMIN_USER_INDEX_FETCH_REQUEST,
ADMIN_USER_INDEX_FETCH_SUCCESS,
ADMIN_USER_INDEX_QUERY_SET,
ADMIN_ANNOUNCEMENTS_FETCH_FAIL,
ADMIN_ANNOUNCEMENTS_FETCH_REQUEST,
ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS,
ADMIN_ANNOUNCEMENTS_EXPAND_FAIL,
ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST,
ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS,
ADMIN_ANNOUNCEMENT_CHANGE_CONTENT,
ADMIN_ANNOUNCEMENT_CHANGE_START_TIME,
ADMIN_ANNOUNCEMENT_CHANGE_END_TIME,
ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY,
ADMIN_ANNOUNCEMENT_CREATE_FAIL,
ADMIN_ANNOUNCEMENT_CREATE_REQUEST,
ADMIN_ANNOUNCEMENT_CREATE_SUCCESS,
ADMIN_ANNOUNCEMENT_DELETE_FAIL,
ADMIN_ANNOUNCEMENT_DELETE_REQUEST,
ADMIN_ANNOUNCEMENT_DELETE_SUCCESS,
ADMIN_ANNOUNCEMENT_MODAL_INIT,
fetchConfig,
updateConfig,
updateSoapboxConfig,
fetchReports,
closeReports,
fetchUsers,
@ -567,15 +809,28 @@ export {
fetchModerationLog,
tagUsers,
untagUsers,
setTags,
setBadges,
verifyUser,
unverifyUser,
setDonor,
removeDonor,
addPermission,
removePermission,
promoteToAdmin,
promoteToModerator,
demoteToUser,
setRole,
suggestUsers,
unsuggestUsers,
setUserIndexQuery,
fetchUserIndex,
expandUserIndex,
fetchAdminAnnouncements,
expandAdminAnnouncements,
changeAnnouncementContent,
changeAnnouncementStartTime,
changeAnnouncementEndTime,
changeAnnouncementAllDay,
handleCreateAnnouncement,
deleteAnnouncement,
initAnnouncementModal,
};

Wyświetl plik

@ -1,75 +0,0 @@
import { defineMessages, MessageDescriptor } from 'react-intl';
import { httpErrorMessages } from 'soapbox/utils/errors';
import type { SnackbarActionSeverity } from './snackbar';
import type { AnyAction } from '@reduxjs/toolkit';
import type { AxiosError } from 'axios';
import type { NotificationObject } from 'react-notification';
const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
});
export const ALERT_SHOW = 'ALERT_SHOW';
export const ALERT_DISMISS = 'ALERT_DISMISS';
export const ALERT_CLEAR = 'ALERT_CLEAR';
const noOp = () => { };
function dismissAlert(alert: NotificationObject) {
return {
type: ALERT_DISMISS,
alert,
};
}
function showAlert(
title: MessageDescriptor | string = messages.unexpectedTitle,
message: MessageDescriptor | string = messages.unexpectedMessage,
severity: SnackbarActionSeverity = 'info',
) {
return {
type: ALERT_SHOW,
title,
message,
severity,
};
}
const showAlertForError = (error: AxiosError<any>) => (dispatch: React.Dispatch<AnyAction>, _getState: any) => {
if (error?.response) {
const { data, status, statusText } = error.response;
if (status === 502) {
return dispatch(showAlert('', 'The server is down', 'error'));
}
if (status === 404 || status === 410) {
// Skip these errors as they are reflected in the UI
return dispatch(noOp as any);
}
let message: string | undefined = statusText;
if (data?.error) {
message = data.error;
}
if (!message) {
message = httpErrorMessages.find((httpError) => httpError.code === status)?.description;
}
return dispatch(showAlert('', message, 'error'));
} else {
console.error(error);
return dispatch(showAlert(undefined, undefined, 'error'));
}
};
export {
dismissAlert,
showAlert,
showAlertForError,
};

Wyświetl plik

@ -1,14 +1,13 @@
import { defineMessages } from 'react-intl';
import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features';
import api from '../api';
import { showAlertForError } from './alerts';
import { importFetchedAccounts } from './importer';
import { patchMeSuccess } from './me';
import snackbar from './snackbar';
import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
@ -80,7 +79,7 @@ const fetchAliasesSuggestions = (q: string) =>
api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchAliasesSuggestionsReady(q, data));
}).catch(error => dispatch(showAlertForError(error)));
}).catch(error => toast.showAlertForError(error));
};
const fetchAliasesSuggestionsReady = (query: string, accounts: APIEntity[]) => ({
@ -114,7 +113,7 @@ const addToAliases = (account: Account) =>
api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, account.pleroma.get('ap_id')] })
.then((response => {
dispatch(snackbar.success(messages.createSuccess));
toast.success(messages.createSuccess);
dispatch(addToAliasesSuccess);
dispatch(patchMeSuccess(response.data));
}))
@ -129,7 +128,7 @@ const addToAliases = (account: Account) =>
alias: account.acct,
})
.then(() => {
dispatch(snackbar.success(messages.createSuccess));
toast.success(messages.createSuccess);
dispatch(addToAliasesSuccess);
dispatch(fetchAliases);
})
@ -165,7 +164,7 @@ const removeFromAliases = (account: string) =>
api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: alsoKnownAs.filter((id: string) => id !== account) })
.then(response => {
dispatch(snackbar.success(messages.removeSuccess));
toast.success(messages.removeSuccess);
dispatch(removeFromAliasesSuccess);
dispatch(patchMeSuccess(response.data));
})
@ -182,7 +181,7 @@ const removeFromAliases = (account: string) =>
},
})
.then(response => {
dispatch(snackbar.success(messages.removeSuccess));
toast.success(messages.removeSuccess);
dispatch(removeFromAliasesSuccess);
dispatch(fetchAliases);
})

Wyświetl plik

@ -0,0 +1,197 @@
import api from 'soapbox/api';
import { getFeatures } from 'soapbox/utils/features';
import { importFetchedStatuses } from './importer';
import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST';
export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS';
export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL';
export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE';
export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE';
export const ANNOUNCEMENTS_DISMISS_REQUEST = 'ANNOUNCEMENTS_DISMISS_REQUEST';
export const ANNOUNCEMENTS_DISMISS_SUCCESS = 'ANNOUNCEMENTS_DISMISS_SUCCESS';
export const ANNOUNCEMENTS_DISMISS_FAIL = 'ANNOUNCEMENTS_DISMISS_FAIL';
export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST';
export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL';
export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST';
export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS';
export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL';
export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE';
export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW';
const noOp = () => {};
export const fetchAnnouncements = (done = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const { instance } = getState();
const features = getFeatures(instance);
if (!features.announcements) return null;
dispatch(fetchAnnouncementsRequest());
return api(getState).get('/api/v1/announcements').then(response => {
dispatch(fetchAnnouncementsSuccess(response.data));
dispatch(importFetchedStatuses(response.data.map(({ statuses }: APIEntity) => statuses)));
}).catch(error => {
dispatch(fetchAnnouncementsFail(error));
}).finally(() => {
done();
});
};
export const fetchAnnouncementsRequest = () => ({
type: ANNOUNCEMENTS_FETCH_REQUEST,
skipLoading: true,
});
export const fetchAnnouncementsSuccess = (announcements: APIEntity) => ({
type: ANNOUNCEMENTS_FETCH_SUCCESS,
announcements,
skipLoading: true,
});
export const fetchAnnouncementsFail = (error: AxiosError) => ({
type: ANNOUNCEMENTS_FETCH_FAIL,
error,
skipLoading: true,
skipAlert: true,
});
export const updateAnnouncements = (announcement: APIEntity) => ({
type: ANNOUNCEMENTS_UPDATE,
announcement: announcement,
});
export const dismissAnnouncement = (announcementId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(dismissAnnouncementRequest(announcementId));
return api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => {
dispatch(dismissAnnouncementSuccess(announcementId));
}).catch(error => {
dispatch(dismissAnnouncementFail(announcementId, error));
});
};
export const dismissAnnouncementRequest = (announcementId: string) => ({
type: ANNOUNCEMENTS_DISMISS_REQUEST,
id: announcementId,
});
export const dismissAnnouncementSuccess = (announcementId: string) => ({
type: ANNOUNCEMENTS_DISMISS_SUCCESS,
id: announcementId,
});
export const dismissAnnouncementFail = (announcementId: string, error: AxiosError) => ({
type: ANNOUNCEMENTS_DISMISS_FAIL,
id: announcementId,
error,
});
export const addReaction = (announcementId: string, name: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const announcement = getState().announcements.items.find(x => x.get('id') === announcementId);
let alreadyAdded = false;
if (announcement) {
const reaction = announcement.reactions.find(x => x.name === name);
if (reaction && reaction.me) {
alreadyAdded = true;
}
}
if (!alreadyAdded) {
dispatch(addReactionRequest(announcementId, name, alreadyAdded));
}
return api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
dispatch(addReactionSuccess(announcementId, name, alreadyAdded));
}).catch(err => {
if (!alreadyAdded) {
dispatch(addReactionFail(announcementId, name, err));
}
});
};
export const addReactionRequest = (announcementId: string, name: string, alreadyAdded?: boolean) => ({
type: ANNOUNCEMENTS_REACTION_ADD_REQUEST,
id: announcementId,
name,
skipLoading: true,
});
export const addReactionSuccess = (announcementId: string, name: string, alreadyAdded?: boolean) => ({
type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS,
id: announcementId,
name,
skipLoading: true,
});
export const addReactionFail = (announcementId: string, name: string, error: AxiosError) => ({
type: ANNOUNCEMENTS_REACTION_ADD_FAIL,
id: announcementId,
name,
error,
skipLoading: true,
});
export const removeReaction = (announcementId: string, name: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(removeReactionRequest(announcementId, name));
return api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
dispatch(removeReactionSuccess(announcementId, name));
}).catch(err => {
dispatch(removeReactionFail(announcementId, name, err));
});
};
export const removeReactionRequest = (announcementId: string, name: string) => ({
type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
id: announcementId,
name,
skipLoading: true,
});
export const removeReactionSuccess = (announcementId: string, name: string) => ({
type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS,
id: announcementId,
name,
skipLoading: true,
});
export const removeReactionFail = (announcementId: string, name: string, error: AxiosError) => ({
type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
id: announcementId,
name,
error,
skipLoading: true,
});
export const updateReaction = (reaction: APIEntity) => ({
type: ANNOUNCEMENTS_REACTION_UPDATE,
reaction,
});
export const toggleShowAnnouncements = () => ({
type: ANNOUNCEMENTS_TOGGLE_SHOW,
});
export const deleteAnnouncement = (id: string) => ({
type: ANNOUNCEMENTS_DELETE,
id,
});

Wyświetl plik

@ -14,12 +14,14 @@ import { createApp } from 'soapbox/actions/apps';
import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me';
import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth';
import { startOnboarding } from 'soapbox/actions/onboarding';
import snackbar from 'soapbox/actions/snackbar';
import { custom } from 'soapbox/custom';
import KVStore from 'soapbox/storage/kv_store';
import { queryClient } from 'soapbox/queries/client';
import KVStore from 'soapbox/storage/kv-store';
import toast from 'soapbox/toast';
import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth';
import sourceCode from 'soapbox/utils/code';
import { getFeatures } from 'soapbox/utils/features';
import { normalizeUsername } from 'soapbox/utils/input';
import { getScopes } from 'soapbox/utils/scopes';
import { isStandalone } from 'soapbox/utils/state';
import api, { baseClient } from '../api';
@ -27,7 +29,6 @@ import api, { baseClient } from '../api';
import { importFetchedAccount } from './importer';
import type { AxiosError } from 'axios';
import type { Map as ImmutableMap } from 'immutable';
import type { AppDispatch, RootState } from 'soapbox/store';
export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT';
@ -49,17 +50,12 @@ const customApp = custom('app');
export const messages = defineMessages({
loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' },
awaitingApproval: { id: 'auth.awaiting_approval', defaultMessage: 'Your account is awaiting approval' },
invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' },
});
const noOp = () => new Promise(f => f(undefined));
const getScopes = (state: RootState) => {
const instance = state.instance;
const { scopes } = getFeatures(instance);
return scopes;
};
const createAppAndToken = () =>
(dispatch: AppDispatch) =>
dispatch(getAuthApp()).then(() =>
@ -92,11 +88,11 @@ const createAuthApp = () =>
const createAppToken = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const app = getState().auth.get('app');
const app = getState().auth.app;
const params = {
client_id: app.get('client_id'),
client_secret: app.get('client_secret'),
client_id: app.client_id!,
client_secret: app.client_secret!,
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
grant_type: 'client_credentials',
scope: getScopes(getState()),
@ -109,11 +105,11 @@ const createAppToken = () =>
const createUserToken = (username: string, password: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const app = getState().auth.get('app');
const app = getState().auth.app;
const params = {
client_id: app.get('client_id'),
client_secret: app.get('client_secret'),
client_id: app.client_id!,
client_secret: app.client_secret!,
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
grant_type: 'password',
username: username,
@ -125,32 +121,12 @@ const createUserToken = (username: string, password: string) =>
.then((token: Record<string, string | number>) => dispatch(authLoggedIn(token)));
};
export const refreshUserToken = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const refreshToken = getState().auth.getIn(['user', 'refresh_token']);
const app = getState().auth.get('app');
if (!refreshToken) return dispatch(noOp);
const params = {
client_id: app.get('client_id'),
client_secret: app.get('client_secret'),
refresh_token: refreshToken,
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
grant_type: 'refresh_token',
scope: getScopes(getState()),
};
return dispatch(obtainOAuthToken(params))
.then((token: Record<string, string | number>) => dispatch(authLoggedIn(token)));
};
export const otpVerify = (code: string, mfa_token: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const app = getState().auth.get('app');
const app = getState().auth.app;
return api(getState, 'app').post('/oauth/mfa/challenge', {
client_id: app.get('client_id'),
client_secret: app.get('client_secret'),
client_id: app.client_id,
client_secret: app.client_secret,
mfa_token: mfa_token,
code: code,
challenge_type: 'totp',
@ -202,21 +178,21 @@ export const rememberAuthAccount = (accountUrl: string) =>
export const loadCredentials = (token: string, accountUrl: string) =>
(dispatch: AppDispatch) => dispatch(rememberAuthAccount(accountUrl))
.then(() => {
dispatch(verifyCredentials(token, accountUrl));
})
.then(() => dispatch(verifyCredentials(token, accountUrl)))
.catch(() => dispatch(verifyCredentials(token, accountUrl)));
export const logIn = (username: string, password: string) =>
(dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => {
return dispatch(createUserToken(username, password));
return dispatch(createUserToken(normalizeUsername(username), password));
}).catch((error: AxiosError) => {
if ((error.response?.data as any).error === 'mfa_required') {
if ((error.response?.data as any)?.error === 'mfa_required') {
// If MFA is required, throw the error and handle it in the component.
throw error;
} else if ((error.response?.data as any)?.identifier === 'awaiting_approval') {
toast.error(messages.awaitingApproval);
} else {
// Return "wrong password" message.
dispatch(snackbar.error(messages.invalidCredentials));
toast.error(messages.invalidCredentials);
}
throw error;
});
@ -233,30 +209,40 @@ export const logOut = () =>
if (!account) return dispatch(noOp);
const params = {
client_id: state.auth.getIn(['app', 'client_id']),
client_secret: state.auth.getIn(['app', 'client_secret']),
token: state.auth.getIn(['users', account.url, 'access_token']),
client_id: state.auth.app.client_id!,
client_secret: state.auth.app.client_secret!,
token: state.auth.users.get(account.url)!.access_token,
};
return dispatch(revokeOAuthToken(params)).finally(() => {
dispatch({ type: AUTH_LOGGED_OUT, account, standalone });
return dispatch(snackbar.success(messages.loggedOut));
});
return dispatch(revokeOAuthToken(params))
.finally(() => {
// Clear all stored cache from React Query
queryClient.invalidateQueries();
queryClient.clear();
dispatch({ type: AUTH_LOGGED_OUT, account, standalone });
toast.success(messages.loggedOut);
});
};
export const switchAccount = (accountId: string, background = false) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const account = getState().accounts.get(accountId);
// Clear all stored cache from React Query
queryClient.invalidateQueries();
queryClient.clear();
return dispatch({ type: SWITCH_ACCOUNT, account, background });
};
export const fetchOwnAccounts = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
return state.auth.get('users').forEach((user: ImmutableMap<string, string>) => {
const account = state.accounts.get(user.get('id'));
return state.auth.users.forEach((user) => {
const account = state.accounts.get(user.id);
if (!account) {
dispatch(verifyCredentials(user.get('access_token')!, user.get('url')));
dispatch(verifyCredentials(user.access_token, user.url));
}
});
};

Wyświetl plik

@ -1,25 +0,0 @@
import { AxiosResponse } from 'axios';
import { AppDispatch, RootState } from 'soapbox/store';
import api from '../api';
const CAROUSEL_AVATAR_REQUEST = 'CAROUSEL_AVATAR_REQUEST';
const CAROUSEL_AVATAR_SUCCESS = 'CAROUSEL_AVATAR_SUCCESS';
const CAROUSEL_AVATAR_FAIL = 'CAROUSEL_AVATAR_FAIL';
const fetchCarouselAvatars = () => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: CAROUSEL_AVATAR_REQUEST });
return api(getState)
.get('/api/v1/truth/carousels/avatars')
.then((response: AxiosResponse) => dispatch({ type: CAROUSEL_AVATAR_SUCCESS, payload: response.data }))
.catch(() => dispatch({ type: CAROUSEL_AVATAR_FAIL }));
};
export {
CAROUSEL_AVATAR_REQUEST,
CAROUSEL_AVATAR_SUCCESS,
CAROUSEL_AVATAR_FAIL,
fetchCarouselAvatars,
};

Wyświetl plik

@ -6,8 +6,8 @@ import { getFeatures } from 'soapbox/utils/features';
import api, { getLinks } from '../api';
import type { History } from 'history';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { History } from 'soapbox/types/history';
const CHATS_FETCH_REQUEST = 'CHATS_FETCH_REQUEST';
const CHATS_FETCH_SUCCESS = 'CHATS_FETCH_SUCCESS';

Wyświetl plik

@ -1,18 +1,18 @@
import axios, { AxiosError, Canceler } from 'axios';
import { List as ImmutableList } from 'immutable';
import throttle from 'lodash/throttle';
import { defineMessages, IntlShape } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import api from 'soapbox/api';
import { isNativeEmoji } from 'soapbox/features/emoji';
import emojiSearch from 'soapbox/features/emoji/search';
import { tagHistory } from 'soapbox/settings';
import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures, parseVersion } from 'soapbox/utils/features';
import { formatBytes, getVideoDuration } from 'soapbox/utils/media';
import resizeImage from 'soapbox/utils/resize_image';
import resizeImage from 'soapbox/utils/resize-image';
import { showAlert, showAlertForError } from './alerts';
import { useEmoji } from './emojis';
import { importFetchedAccounts } from './importer';
import { uploadMedia, fetchMedia, updateMedia } from './media';
@ -20,11 +20,11 @@ import { openModal, closeModal } from './modals';
import { getSettings } from './settings';
import { createStatus } from './statuses';
import type { History } from 'history';
import type { AutoSuggestion } from 'soapbox/components/autosuggest_input';
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
import type { Emoji } from 'soapbox/features/emoji';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Account, APIEntity, Status } from 'soapbox/types/entities';
import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities';
import type { History } from 'soapbox/types/history';
const { CancelToken, isCancel } = axios;
@ -35,6 +35,7 @@ const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
const COMPOSE_REPLY = 'COMPOSE_REPLY';
const COMPOSE_EVENT_REPLY = 'COMPOSE_EVENT_REPLY';
const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
@ -46,6 +47,7 @@ const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
const COMPOSE_GROUP_POST = 'COMPOSE_GROUP_POST';
const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
@ -54,10 +56,6 @@ const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE';
const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
@ -90,22 +88,17 @@ const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
const messages = defineMessages({
exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' },
exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' },
exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit} seconds)' },
exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit, plural, one {# second} other {# seconds}})' },
scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' },
success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' },
editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' },
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
view: { id: 'snackbar.view', defaultMessage: 'View' },
view: { id: 'toast.view', defaultMessage: 'View' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
});
const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
const ensureComposeIsVisible = (getState: () => RootState, routerHistory: History) => {
if (!getState().compose.mounted && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) {
routerHistory.push('/posts/new');
}
};
const setComposeToStatus = (status: Status, rawText: string, spoilerText?: string, contentType?: string | false, withRedraft?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const { instance } = getState();
@ -113,6 +106,7 @@ const setComposeToStatus = (status: Status, rawText: string, spoilerText?: strin
dispatch({
type: COMPOSE_SET_STATUS,
id: 'compose-modal',
status,
rawText,
explicitAddressing,
@ -123,8 +117,9 @@ const setComposeToStatus = (status: Status, rawText: string, spoilerText?: strin
});
};
const changeCompose = (text: string) => ({
const changeCompose = (composeId: string, text: string) => ({
type: COMPOSE_CHANGE,
id: composeId,
text: text,
});
@ -136,6 +131,7 @@ const replyCompose = (status: Status) =>
dispatch({
type: COMPOSE_REPLY,
id: 'compose-modal',
status: status,
account: state.accounts.get(state.me),
explicitAddressing,
@ -146,6 +142,7 @@ const replyCompose = (status: Status) =>
const cancelReplyCompose = () => ({
type: COMPOSE_REPLY_CANCEL,
id: 'compose-modal',
});
const quoteCompose = (status: Status) =>
@ -156,6 +153,7 @@ const quoteCompose = (status: Status) =>
dispatch({
type: COMPOSE_QUOTE,
id: 'compose-modal',
status: status,
account: state.accounts.get(state.me),
explicitAddressing,
@ -166,16 +164,19 @@ const quoteCompose = (status: Status) =>
const cancelQuoteCompose = () => ({
type: COMPOSE_QUOTE_CANCEL,
id: 'compose-modal',
});
const resetCompose = () => ({
const resetCompose = (composeId = 'compose-modal') => ({
type: COMPOSE_RESET,
id: composeId,
});
const mentionCompose = (account: Account) =>
(dispatch: AppDispatch) => {
dispatch({
type: COMPOSE_MENTION,
id: 'compose-modal',
account: account,
});
@ -186,6 +187,7 @@ const directCompose = (account: Account) =>
(dispatch: AppDispatch) => {
dispatch({
type: COMPOSE_DIRECT,
id: 'compose-modal',
account: account,
});
@ -198,22 +200,26 @@ const directComposeById = (accountId: string) =>
dispatch({
type: COMPOSE_DIRECT,
id: 'compose-modal',
account: account,
});
dispatch(openModal('COMPOSE'));
};
const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, data: APIEntity, status: string) => {
const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, composeId: string, data: APIEntity, status: string, edit?: boolean) => {
if (!dispatch || !getState) return;
dispatch(insertIntoTagHistory(data.tags || [], status));
dispatch(submitComposeSuccess({ ...data }));
dispatch(snackbar.success(messages.success, messages.view, `/@${data.account.acct}/posts/${data.id}`));
dispatch(insertIntoTagHistory(composeId, data.tags || [], status));
dispatch(submitComposeSuccess(composeId, { ...data }));
toast.success(edit ? messages.editSuccess : messages.success, {
actionLabel: messages.view,
actionLink: `/@${data.account.acct}/posts/${data.id}`,
});
};
const needsDescriptions = (state: RootState) => {
const media = state.compose.media_attachments;
const needsDescriptions = (state: RootState, composeId: string) => {
const media = state.compose.get(composeId)!.media_attachments;
const missingDescriptionModal = getSettings(state).get('missingDescriptionModal');
const hasMissing = media.filter(item => !item.description).size > 0;
@ -221,8 +227,8 @@ const needsDescriptions = (state: RootState) => {
return missingDescriptionModal && hasMissing;
};
const validateSchedule = (state: RootState) => {
const schedule = state.compose.schedule;
const validateSchedule = (state: RootState, composeId: string) => {
const schedule = state.compose.get(composeId)?.schedule;
if (!schedule) return true;
const fiveMinutesFromNow = new Date(new Date().getTime() + 300000);
@ -230,18 +236,20 @@ const validateSchedule = (state: RootState) => {
return schedule.getTime() > fiveMinutesFromNow.getTime();
};
const submitCompose = (routerHistory: History, force = false) =>
const submitCompose = (composeId: string, routerHistory?: History, force = false) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const status = state.compose.text;
const media = state.compose.media_attachments;
const statusId = state.compose.id;
let to = state.compose.to;
const compose = state.compose.get(composeId)!;
if (!validateSchedule(state)) {
dispatch(snackbar.error(messages.scheduleError));
const status = compose.text;
const media = compose.media_attachments;
const statusId = compose.id;
let to = compose.to;
if (!validateSchedule(state, composeId)) {
toast.error(messages.scheduleError);
return;
}
@ -249,67 +257,71 @@ const submitCompose = (routerHistory: History, force = false) =>
return;
}
if (!force && needsDescriptions(state)) {
if (!force && needsDescriptions(state, composeId)) {
dispatch(openModal('MISSING_DESCRIPTION', {
onContinue: () => {
dispatch(closeModal('MISSING_DESCRIPTION'));
dispatch(submitCompose(routerHistory, true));
dispatch(submitCompose(composeId, routerHistory, true));
},
}));
return;
}
if (to && status) {
const mentions: string[] | null = status.match(/(?:^|\s|\.)@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/gi); // not a perfect regex
const mentions: string[] | null = status.match(/(?:^|\s)@([a-z\d_-]+(?:@[^@\s]+)?)/gi);
if (mentions)
to = to.union(mentions.map(mention => mention.trim().slice(1)));
if (mentions) {
to = to.union(mentions.map(mention => mention.trim().slice(1)));
}
dispatch(submitComposeRequest());
dispatch(submitComposeRequest(composeId));
dispatch(closeModal());
const idempotencyKey = state.compose.idempotencyKey;
const idempotencyKey = compose.idempotencyKey;
const params = {
const params: Record<string, any> = {
status,
in_reply_to_id: state.compose.in_reply_to,
quote_id: state.compose.quote,
in_reply_to_id: compose.in_reply_to,
quote_id: compose.quote,
media_ids: media.map(item => item.id),
sensitive: state.compose.sensitive,
spoiler_text: state.compose.spoiler_text,
visibility: state.compose.privacy,
content_type: state.compose.content_type,
poll: state.compose.poll,
scheduled_at: state.compose.schedule,
sensitive: compose.sensitive,
spoiler_text: compose.spoiler_text,
visibility: compose.privacy,
content_type: compose.content_type,
poll: compose.poll,
scheduled_at: compose.schedule,
to,
};
if (compose.privacy === 'group') params.group_id = compose.group_id;
dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) {
if (!statusId && data.visibility === 'direct' && getState().conversations.mounted <= 0 && routerHistory) {
routerHistory.push('/messages');
}
handleComposeSubmit(dispatch, getState, data, status);
handleComposeSubmit(dispatch, getState, composeId, data, status, !!statusId);
}).catch(function(error) {
dispatch(submitComposeFail(error));
dispatch(submitComposeFail(composeId, error));
});
};
const submitComposeRequest = () => ({
const submitComposeRequest = (composeId: string) => ({
type: COMPOSE_SUBMIT_REQUEST,
id: composeId,
});
const submitComposeSuccess = (status: APIEntity) => ({
const submitComposeSuccess = (composeId: string, status: APIEntity) => ({
type: COMPOSE_SUBMIT_SUCCESS,
id: composeId,
status: status,
});
const submitComposeFail = (error: AxiosError) => ({
const submitComposeFail = (composeId: string, error: AxiosError) => ({
type: COMPOSE_SUBMIT_FAIL,
id: composeId,
error: error,
});
const uploadCompose = (files: FileList, intl: IntlShape) =>
const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const attachmentLimit = getState().instance.configuration.getIn(['statuses', 'max_media_attachments']) as number;
@ -317,19 +329,21 @@ const uploadCompose = (files: FileList, intl: IntlShape) =>
const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined;
const maxVideoDuration = getState().instance.configuration.getIn(['media_attachments', 'video_duration_limit']) as number | undefined;
const media = getState().compose.media_attachments;
const media = getState().compose.get(composeId)?.media_attachments;
const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
if (files.length + media.size > attachmentLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit, 'error'));
const mediaCount = media ? media.size : 0;
if (files.length + mediaCount > attachmentLimit) {
toast.error(messages.uploadErrorLimit);
return;
}
dispatch(uploadComposeRequest());
dispatch(uploadComposeRequest(composeId));
Array.from(files).forEach(async(f, i) => {
if (media.size + i > attachmentLimit - 1) return;
if (mediaCount + i > attachmentLimit - 1) return;
const isImage = f.type.match(/image.*/);
const isVideo = f.type.match(/video.*/);
@ -338,19 +352,19 @@ const uploadCompose = (files: FileList, intl: IntlShape) =>
if (isImage && maxImageSize && (f.size > maxImageSize)) {
const limit = formatBytes(maxImageSize);
const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
dispatch(snackbar.error(message));
dispatch(uploadComposeFail(true));
toast.error(message);
dispatch(uploadComposeFail(composeId, true));
return;
} else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) {
const limit = formatBytes(maxVideoSize);
const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit });
dispatch(snackbar.error(message));
dispatch(uploadComposeFail(true));
toast.error(message);
dispatch(uploadComposeFail(composeId, true));
return;
} else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) {
const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration });
dispatch(snackbar.error(message));
dispatch(uploadComposeFail(true));
toast.error(message);
dispatch(uploadComposeFail(composeId, true));
return;
}
@ -364,7 +378,7 @@ const uploadCompose = (files: FileList, intl: IntlShape) =>
const onUploadProgress = ({ loaded }: any) => {
progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
dispatch(uploadComposeProgress(composeId, progress.reduce((a, v) => a + v, 0), total));
};
return dispatch(uploadMedia(data, onUploadProgress))
@ -372,98 +386,116 @@ const uploadCompose = (files: FileList, intl: IntlShape) =>
// If server-side processing of the media attachment has not completed yet,
// poll the server until it is, before showing the media attachment as uploaded
if (status === 200) {
dispatch(uploadComposeSuccess(data, f));
dispatch(uploadComposeSuccess(composeId, data, f));
} else if (status === 202) {
const poll = () => {
dispatch(fetchMedia(data.id)).then(({ status, data }) => {
if (status === 200) {
dispatch(uploadComposeSuccess(data, f));
dispatch(uploadComposeSuccess(composeId, data, f));
} else if (status === 206) {
setTimeout(() => poll(), 1000);
}
}).catch(error => dispatch(uploadComposeFail(error)));
}).catch(error => dispatch(uploadComposeFail(composeId, error)));
};
poll();
}
});
}).catch(error => dispatch(uploadComposeFail(error)));
}).catch(error => dispatch(uploadComposeFail(composeId, error)));
/* eslint-enable no-loop-func */
});
};
const changeUploadCompose = (id: string, params: Record<string, any>) =>
const changeUploadCompose = (composeId: string, id: string, params: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(changeUploadComposeRequest());
dispatch(changeUploadComposeRequest(composeId));
dispatch(updateMedia(id, params)).then(response => {
dispatch(changeUploadComposeSuccess(response.data));
dispatch(changeUploadComposeSuccess(composeId, response.data));
}).catch(error => {
dispatch(changeUploadComposeFail(id, error));
dispatch(changeUploadComposeFail(composeId, id, error));
});
};
const changeUploadComposeRequest = () => ({
const changeUploadComposeRequest = (composeId: string) => ({
type: COMPOSE_UPLOAD_CHANGE_REQUEST,
id: composeId,
skipLoading: true,
});
const changeUploadComposeSuccess = (media: APIEntity) => ({
const changeUploadComposeSuccess = (composeId: string, media: APIEntity) => ({
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
id: composeId,
media: media,
skipLoading: true,
});
const changeUploadComposeFail = (id: string, error: AxiosError) => ({
const changeUploadComposeFail = (composeId: string, id: string, error: AxiosError) => ({
type: COMPOSE_UPLOAD_CHANGE_FAIL,
composeId,
id,
error: error,
skipLoading: true,
});
const uploadComposeRequest = () => ({
const uploadComposeRequest = (composeId: string) => ({
type: COMPOSE_UPLOAD_REQUEST,
id: composeId,
skipLoading: true,
});
const uploadComposeProgress = (loaded: number, total: number) => ({
const uploadComposeProgress = (composeId: string, loaded: number, total: number) => ({
type: COMPOSE_UPLOAD_PROGRESS,
id: composeId,
loaded: loaded,
total: total,
});
const uploadComposeSuccess = (media: APIEntity, file: File) => ({
const uploadComposeSuccess = (composeId: string, media: APIEntity, file: File) => ({
type: COMPOSE_UPLOAD_SUCCESS,
id: composeId,
media: media,
file,
skipLoading: true,
});
const uploadComposeFail = (error: AxiosError | true) => ({
const uploadComposeFail = (composeId: string, error: AxiosError | true) => ({
type: COMPOSE_UPLOAD_FAIL,
id: composeId,
error: error,
skipLoading: true,
});
const undoUploadCompose = (media_id: string) => ({
const undoUploadCompose = (composeId: string, media_id: string) => ({
type: COMPOSE_UPLOAD_UNDO,
id: composeId,
media_id: media_id,
});
const clearComposeSuggestions = () => {
const groupCompose = (composeId: string, groupId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: COMPOSE_GROUP_POST,
id: composeId,
group_id: groupId,
});
};
const clearComposeSuggestions = (composeId: string) => {
if (cancelFetchComposeSuggestionsAccounts) {
cancelFetchComposeSuggestionsAccounts();
}
return {
type: COMPOSE_SUGGESTIONS_CLEAR,
id: composeId,
};
};
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => {
if (cancelFetchComposeSuggestionsAccounts) {
cancelFetchComposeSuggestionsAccounts();
cancelFetchComposeSuggestionsAccounts(composeId);
}
api(getState).get('/api/v1/accounts/search', {
cancelToken: new CancelToken(cancel => {
@ -476,53 +508,58 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) =>
},
}).then(response => {
dispatch(importFetchedAccounts(response.data));
dispatch(readyComposeSuggestionsAccounts(token, response.data));
dispatch(readyComposeSuggestionsAccounts(composeId, token, response.data));
}).catch(error => {
if (!isCancel(error)) {
dispatch(showAlertForError(error));
toast.showAlertForError(error);
}
});
}, 200, { leading: true, trailing: true });
const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, token: string) => {
const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
const state = getState();
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }, state.custom_emojis);
dispatch(readyComposeSuggestionsEmojis(token, results));
dispatch(readyComposeSuggestionsEmojis(composeId, token, results));
};
const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, token: string) => {
dispatch(updateSuggestionTags(token));
const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
const state = getState();
const currentTrends = state.trends.items;
dispatch(updateSuggestionTags(composeId, token, currentTrends));
};
const fetchComposeSuggestions = (token: string) =>
const fetchComposeSuggestions = (composeId: string, token: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
switch (token[0]) {
case ':':
fetchComposeSuggestionsEmojis(dispatch, getState, token);
fetchComposeSuggestionsEmojis(dispatch, getState, composeId, token);
break;
case '#':
fetchComposeSuggestionsTags(dispatch, getState, token);
fetchComposeSuggestionsTags(dispatch, getState, composeId, token);
break;
default:
fetchComposeSuggestionsAccounts(dispatch, getState, token);
fetchComposeSuggestionsAccounts(dispatch, getState, composeId, token);
break;
}
};
const readyComposeSuggestionsEmojis = (token: string, emojis: Emoji[]) => ({
const readyComposeSuggestionsEmojis = (composeId: string, token: string, emojis: Emoji[]) => ({
type: COMPOSE_SUGGESTIONS_READY,
id: composeId,
token,
emojis,
});
const readyComposeSuggestionsAccounts = (token: string, accounts: APIEntity[]) => ({
const readyComposeSuggestionsAccounts = (composeId: string, token: string, accounts: APIEntity[]) => ({
type: COMPOSE_SUGGESTIONS_READY,
id: composeId,
token,
accounts,
});
const selectComposeSuggestion = (position: number, token: string | null, suggestion: AutoSuggestion, path: Array<string | number>) =>
const selectComposeSuggestion = (composeId: string, position: number, token: string | null, suggestion: AutoSuggestion, path: Array<string | number>) =>
(dispatch: AppDispatch, getState: () => RootState) => {
let completion, startPosition;
@ -541,6 +578,7 @@ const selectComposeSuggestion = (position: number, token: string | null, suggest
dispatch({
type: COMPOSE_SUGGESTION_SELECT,
id: composeId,
position: startPosition,
token,
completion,
@ -548,20 +586,23 @@ const selectComposeSuggestion = (position: number, token: string | null, suggest
});
};
const updateSuggestionTags = (token: string) => ({
const updateSuggestionTags = (composeId: string, token: string, currentTrends: ImmutableList<Tag>) => ({
type: COMPOSE_SUGGESTION_TAGS_UPDATE,
id: composeId,
token,
currentTrends,
});
const updateTagHistory = (tags: string[]) => ({
const updateTagHistory = (composeId: string, tags: string[]) => ({
type: COMPOSE_TAG_HISTORY_UPDATE,
id: composeId,
tags,
});
const insertIntoTagHistory = (recognizedTags: APIEntity[], text: string) =>
const insertIntoTagHistory = (composeId: string, recognizedTags: APIEntity[], text: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const oldHistory = state.compose.tagHistory;
const oldHistory = state.compose.get(composeId)!.tagHistory;
const me = state.me;
const names = recognizedTags
.filter(tag => text.match(new RegExp(`#${tag.name}`, 'i')))
@ -573,124 +614,138 @@ const insertIntoTagHistory = (recognizedTags: APIEntity[], text: string) =>
const newHistory = names.slice(0, 1000);
tagHistory.set(me as string, newHistory);
dispatch(updateTagHistory(newHistory));
dispatch(updateTagHistory(composeId, newHistory));
};
const mountCompose = () => ({
type: COMPOSE_MOUNT,
});
const unmountCompose = () => ({
type: COMPOSE_UNMOUNT,
});
const changeComposeSensitivity = () => ({
type: COMPOSE_SENSITIVITY_CHANGE,
});
const changeComposeSpoilerness = () => ({
const changeComposeSpoilerness = (composeId: string) => ({
type: COMPOSE_SPOILERNESS_CHANGE,
id: composeId,
});
const changeComposeContentType = (value: string) => ({
const changeComposeContentType = (composeId: string, value: string) => ({
type: COMPOSE_TYPE_CHANGE,
id: composeId,
value,
});
const changeComposeSpoilerText = (text: string) => ({
const changeComposeSpoilerText = (composeId: string, text: string) => ({
type: COMPOSE_SPOILER_TEXT_CHANGE,
id: composeId,
text,
});
const changeComposeVisibility = (value: string) => ({
const changeComposeVisibility = (composeId: string, value: string) => ({
type: COMPOSE_VISIBILITY_CHANGE,
id: composeId,
value,
});
const insertEmojiCompose = (position: number, emoji: string, needsSpace: boolean) => ({
const insertEmojiCompose = (composeId: string, position: number, emoji: Emoji, needsSpace: boolean) => ({
type: COMPOSE_EMOJI_INSERT,
id: composeId,
position,
emoji,
needsSpace,
});
const changeComposing = (value: string) => ({
type: COMPOSE_COMPOSING_CHANGE,
value,
});
const addPoll = () => ({
const addPoll = (composeId: string) => ({
type: COMPOSE_POLL_ADD,
id: composeId,
});
const removePoll = () => ({
const removePoll = (composeId: string) => ({
type: COMPOSE_POLL_REMOVE,
id: composeId,
});
const addSchedule = () => ({
const addSchedule = (composeId: string) => ({
type: COMPOSE_SCHEDULE_ADD,
id: composeId,
});
const setSchedule = (date: Date) => ({
const setSchedule = (composeId: string, date: Date) => ({
type: COMPOSE_SCHEDULE_SET,
id: composeId,
date: date,
});
const removeSchedule = () => ({
const removeSchedule = (composeId: string) => ({
type: COMPOSE_SCHEDULE_REMOVE,
id: composeId,
});
const addPollOption = (title: string) => ({
const addPollOption = (composeId: string, title: string) => ({
type: COMPOSE_POLL_OPTION_ADD,
id: composeId,
title,
});
const changePollOption = (index: number, title: string) => ({
const changePollOption = (composeId: string, index: number, title: string) => ({
type: COMPOSE_POLL_OPTION_CHANGE,
id: composeId,
index,
title,
});
const removePollOption = (index: number) => ({
const removePollOption = (composeId: string, index: number) => ({
type: COMPOSE_POLL_OPTION_REMOVE,
id: composeId,
index,
});
const changePollSettings = (expiresIn?: string | number, isMultiple?: boolean) => ({
const changePollSettings = (composeId: string, expiresIn?: string | number, isMultiple?: boolean) => ({
type: COMPOSE_POLL_SETTINGS_CHANGE,
id: composeId,
expiresIn,
isMultiple,
});
const openComposeWithText = (text = '') =>
const openComposeWithText = (composeId: string, text = '') =>
(dispatch: AppDispatch) => {
dispatch(resetCompose());
dispatch(resetCompose(composeId));
dispatch(openModal('COMPOSE'));
dispatch(changeCompose(text));
dispatch(changeCompose(composeId, text));
};
const addToMentions = (accountId: string) =>
const addToMentions = (composeId: string, accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const acct = state.accounts.get(accountId)!.acct;
return dispatch({
type: COMPOSE_ADD_TO_MENTIONS,
id: composeId,
account: acct,
});
};
const removeFromMentions = (accountId: string) =>
const removeFromMentions = (composeId: string, accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const acct = state.accounts.get(accountId)!.acct;
return dispatch({
type: COMPOSE_REMOVE_FROM_MENTIONS,
id: composeId,
account: acct,
});
};
const eventDiscussionCompose = (composeId: string, status: Status) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const { explicitAddressing } = getFeatures(instance);
return dispatch({
type: COMPOSE_EVENT_REPLY,
id: composeId,
status: status,
account: state.accounts.get(state.me),
explicitAddressing,
});
};
export {
COMPOSE_CHANGE,
COMPOSE_SUBMIT_REQUEST,
@ -698,6 +753,7 @@ export {
COMPOSE_SUBMIT_FAIL,
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
COMPOSE_EVENT_REPLY,
COMPOSE_QUOTE,
COMPOSE_QUOTE_CANCEL,
COMPOSE_DIRECT,
@ -708,14 +764,12 @@ export {
COMPOSE_UPLOAD_FAIL,
COMPOSE_UPLOAD_PROGRESS,
COMPOSE_UPLOAD_UNDO,
COMPOSE_GROUP_POST,
COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY,
COMPOSE_SUGGESTION_SELECT,
COMPOSE_SUGGESTION_TAGS_UPDATE,
COMPOSE_TAG_HISTORY_UPDATE,
COMPOSE_MOUNT,
COMPOSE_UNMOUNT,
COMPOSE_SENSITIVITY_CHANGE,
COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_TYPE_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE,
@ -738,7 +792,6 @@ export {
COMPOSE_ADD_TO_MENTIONS,
COMPOSE_REMOVE_FROM_MENTIONS,
COMPOSE_SET_STATUS,
ensureComposeIsVisible,
setComposeToStatus,
changeCompose,
replyCompose,
@ -764,6 +817,7 @@ export {
uploadComposeSuccess,
uploadComposeFail,
undoUploadCompose,
groupCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
readyComposeSuggestionsEmojis,
@ -771,15 +825,11 @@ export {
selectComposeSuggestion,
updateSuggestionTags,
updateTagHistory,
mountCompose,
unmountCompose,
changeComposeSensitivity,
changeComposeSpoilerness,
changeComposeContentType,
changeComposeSpoilerText,
changeComposeVisibility,
insertEmojiCompose,
changeComposing,
addPoll,
removePoll,
addSchedule,
@ -792,4 +842,5 @@ export {
openComposeWithText,
addToMentions,
removeFromMentions,
eventDiscussionCompose,
};

Wyświetl plik

@ -0,0 +1,53 @@
import axios from 'axios';
import * as BuildConfig from 'soapbox/build-config';
import { isURL } from 'soapbox/utils/auth';
import sourceCode from 'soapbox/utils/code';
import { getScopes } from 'soapbox/utils/scopes';
import { createApp } from './apps';
import type { AppDispatch, RootState } from 'soapbox/store';
const createProviderApp = () => {
return async(dispatch: AppDispatch, getState: () => RootState) => {
const scopes = getScopes(getState());
const params = {
client_name: sourceCode.displayName,
redirect_uris: `${window.location.origin}/login/external`,
website: sourceCode.homepage,
scopes,
};
return dispatch(createApp(params));
};
};
export const prepareRequest = (provider: string) => {
return async(dispatch: AppDispatch, getState: () => RootState) => {
const baseURL = isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : '';
const scopes = getScopes(getState());
const app = await dispatch(createProviderApp());
const { client_id, redirect_uri } = app;
localStorage.setItem('soapbox:external:app', JSON.stringify(app));
localStorage.setItem('soapbox:external:baseurl', baseURL);
localStorage.setItem('soapbox:external:scopes', scopes);
const params = {
provider,
authorization: {
client_id,
redirect_uri,
scope: scopes,
},
};
const formdata = axios.toFormData(params);
const query = new URLSearchParams(formdata as any);
location.href = `${baseURL}/oauth/prepare_request?${query.toString()}`;
};
};

Wyświetl plik

@ -0,0 +1,12 @@
const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
const openDropdownMenu = () => ({ type: DROPDOWN_MENU_OPEN });
const closeDropdownMenu = () => ({ type: DROPDOWN_MENU_CLOSE });
export {
DROPDOWN_MENU_OPEN,
DROPDOWN_MENU_CLOSE,
openDropdownMenu,
closeDropdownMenu,
};

Wyświetl plik

@ -1,17 +0,0 @@
import type { DropdownPlacement } from 'soapbox/components/dropdown_menu';
const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
const openDropdownMenu = (id: number, placement: DropdownPlacement, keyboard: boolean) =>
({ type: DROPDOWN_MENU_OPEN, id, placement, keyboard });
const closeDropdownMenu = (id: number) =>
({ type: DROPDOWN_MENU_CLOSE, id });
export {
DROPDOWN_MENU_OPEN,
DROPDOWN_MENU_CLOSE,
openDropdownMenu,
closeDropdownMenu,
};

Wyświetl plik

@ -0,0 +1,746 @@
import { defineMessages, IntlShape } from 'react-intl';
import api, { getLinks } from 'soapbox/api';
import toast from 'soapbox/toast';
import { formatBytes } from 'soapbox/utils/media';
import resizeImage from 'soapbox/utils/resize-image';
import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer';
import { fetchMedia, uploadMedia } from './media';
import { closeModal, openModal } from './modals';
import {
STATUS_FETCH_SOURCE_FAIL,
STATUS_FETCH_SOURCE_REQUEST,
STATUS_FETCH_SOURCE_SUCCESS,
} from './statuses';
import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities';
const LOCATION_SEARCH_REQUEST = 'LOCATION_SEARCH_REQUEST';
const LOCATION_SEARCH_SUCCESS = 'LOCATION_SEARCH_SUCCESS';
const LOCATION_SEARCH_FAIL = 'LOCATION_SEARCH_FAIL';
const EDIT_EVENT_NAME_CHANGE = 'EDIT_EVENT_NAME_CHANGE';
const EDIT_EVENT_DESCRIPTION_CHANGE = 'EDIT_EVENT_DESCRIPTION_CHANGE';
const EDIT_EVENT_START_TIME_CHANGE = 'EDIT_EVENT_START_TIME_CHANGE';
const EDIT_EVENT_HAS_END_TIME_CHANGE = 'EDIT_EVENT_HAS_END_TIME_CHANGE';
const EDIT_EVENT_END_TIME_CHANGE = 'EDIT_EVENT_END_TIME_CHANGE';
const EDIT_EVENT_APPROVAL_REQUIRED_CHANGE = 'EDIT_EVENT_APPROVAL_REQUIRED_CHANGE';
const EDIT_EVENT_LOCATION_CHANGE = 'EDIT_EVENT_LOCATION_CHANGE';
const EVENT_BANNER_UPLOAD_REQUEST = 'EVENT_BANNER_UPLOAD_REQUEST';
const EVENT_BANNER_UPLOAD_PROGRESS = 'EVENT_BANNER_UPLOAD_PROGRESS';
const EVENT_BANNER_UPLOAD_SUCCESS = 'EVENT_BANNER_UPLOAD_SUCCESS';
const EVENT_BANNER_UPLOAD_FAIL = 'EVENT_BANNER_UPLOAD_FAIL';
const EVENT_BANNER_UPLOAD_UNDO = 'EVENT_BANNER_UPLOAD_UNDO';
const EVENT_SUBMIT_REQUEST = 'EVENT_SUBMIT_REQUEST';
const EVENT_SUBMIT_SUCCESS = 'EVENT_SUBMIT_SUCCESS';
const EVENT_SUBMIT_FAIL = 'EVENT_SUBMIT_FAIL';
const EVENT_JOIN_REQUEST = 'EVENT_JOIN_REQUEST';
const EVENT_JOIN_SUCCESS = 'EVENT_JOIN_SUCCESS';
const EVENT_JOIN_FAIL = 'EVENT_JOIN_FAIL';
const EVENT_LEAVE_REQUEST = 'EVENT_LEAVE_REQUEST';
const EVENT_LEAVE_SUCCESS = 'EVENT_LEAVE_SUCCESS';
const EVENT_LEAVE_FAIL = 'EVENT_LEAVE_FAIL';
const EVENT_PARTICIPATIONS_FETCH_REQUEST = 'EVENT_PARTICIPATIONS_FETCH_REQUEST';
const EVENT_PARTICIPATIONS_FETCH_SUCCESS = 'EVENT_PARTICIPATIONS_FETCH_SUCCESS';
const EVENT_PARTICIPATIONS_FETCH_FAIL = 'EVENT_PARTICIPATIONS_FETCH_FAIL';
const EVENT_PARTICIPATIONS_EXPAND_REQUEST = 'EVENT_PARTICIPATIONS_EXPAND_REQUEST';
const EVENT_PARTICIPATIONS_EXPAND_SUCCESS = 'EVENT_PARTICIPATIONS_EXPAND_SUCCESS';
const EVENT_PARTICIPATIONS_EXPAND_FAIL = 'EVENT_PARTICIPATIONS_EXPAND_FAIL';
const EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST';
const EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS';
const EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL = 'EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL';
const EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST';
const EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS';
const EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL';
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST';
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS';
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL';
const EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST = 'EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST';
const EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS';
const EVENT_PARTICIPATION_REQUEST_REJECT_FAIL = 'EVENT_PARTICIPATION_REQUEST_REJECT_FAIL';
const EVENT_COMPOSE_CANCEL = 'EVENT_COMPOSE_CANCEL';
const EVENT_FORM_SET = 'EVENT_FORM_SET';
const RECENT_EVENTS_FETCH_REQUEST = 'RECENT_EVENTS_FETCH_REQUEST';
const RECENT_EVENTS_FETCH_SUCCESS = 'RECENT_EVENTS_FETCH_SUCCESS';
const RECENT_EVENTS_FETCH_FAIL = 'RECENT_EVENTS_FETCH_FAIL';
const JOINED_EVENTS_FETCH_REQUEST = 'JOINED_EVENTS_FETCH_REQUEST';
const JOINED_EVENTS_FETCH_SUCCESS = 'JOINED_EVENTS_FETCH_SUCCESS';
const JOINED_EVENTS_FETCH_FAIL = 'JOINED_EVENTS_FETCH_FAIL';
const noOp = () => new Promise(f => f(undefined));
const messages = defineMessages({
exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' },
success: { id: 'compose_event.submit_success', defaultMessage: 'Your event was created' },
editSuccess: { id: 'compose_event.edit_success', defaultMessage: 'Your event was edited' },
joinSuccess: { id: 'join_event.success', defaultMessage: 'Joined the event' },
joinRequestSuccess: { id: 'join_event.request_success', defaultMessage: 'Requested to join the event' },
view: { id: 'toast.view', defaultMessage: 'View' },
authorized: { id: 'compose_event.participation_requests.authorize_success', defaultMessage: 'User accepted' },
rejected: { id: 'compose_event.participation_requests.reject_success', defaultMessage: 'User rejected' },
});
const locationSearch = (query: string, signal?: AbortSignal) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: LOCATION_SEARCH_REQUEST, query });
return api(getState).get('/api/v1/pleroma/search/location', { params: { q: query }, signal }).then(({ data: locations }) => {
dispatch({ type: LOCATION_SEARCH_SUCCESS, locations });
return locations;
}).catch(error => {
dispatch({ type: LOCATION_SEARCH_FAIL });
throw error;
});
};
const changeEditEventName = (value: string) => ({
type: EDIT_EVENT_NAME_CHANGE,
value,
});
const changeEditEventDescription = (value: string) => ({
type: EDIT_EVENT_DESCRIPTION_CHANGE,
value,
});
const changeEditEventStartTime = (value: Date) => ({
type: EDIT_EVENT_START_TIME_CHANGE,
value,
});
const changeEditEventEndTime = (value: Date) => ({
type: EDIT_EVENT_END_TIME_CHANGE,
value,
});
const changeEditEventHasEndTime = (value: boolean) => ({
type: EDIT_EVENT_HAS_END_TIME_CHANGE,
value,
});
const changeEditEventApprovalRequired = (value: boolean) => ({
type: EDIT_EVENT_APPROVAL_REQUIRED_CHANGE,
value,
});
const changeEditEventLocation = (value: string | null) =>
(dispatch: AppDispatch, getState: () => RootState) => {
let location = null;
if (value) {
location = getState().locations.get(value);
}
dispatch({
type: EDIT_EVENT_LOCATION_CHANGE,
value: location,
});
};
const uploadEventBanner = (file: File, intl: IntlShape) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined;
let progress = 0;
dispatch(uploadEventBannerRequest());
if (maxImageSize && (file.size > maxImageSize)) {
const limit = formatBytes(maxImageSize);
const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
toast.error(message);
dispatch(uploadEventBannerFail(true));
return;
}
resizeImage(file).then(file => {
const data = new FormData();
data.append('file', file);
// Account for disparity in size of original image and resized data
const onUploadProgress = ({ loaded }: any) => {
progress = loaded;
dispatch(uploadEventBannerProgress(progress));
};
return dispatch(uploadMedia(data, onUploadProgress))
.then(({ status, data }) => {
// If server-side processing of the media attachment has not completed yet,
// poll the server until it is, before showing the media attachment as uploaded
if (status === 200) {
dispatch(uploadEventBannerSuccess(data, file));
} else if (status === 202) {
const poll = () => {
dispatch(fetchMedia(data.id)).then(({ status, data }) => {
if (status === 200) {
dispatch(uploadEventBannerSuccess(data, file));
} else if (status === 206) {
setTimeout(() => poll(), 1000);
}
}).catch(error => dispatch(uploadEventBannerFail(error)));
};
poll();
}
});
}).catch(error => dispatch(uploadEventBannerFail(error)));
};
const uploadEventBannerRequest = () => ({
type: EVENT_BANNER_UPLOAD_REQUEST,
});
const uploadEventBannerProgress = (loaded: number) => ({
type: EVENT_BANNER_UPLOAD_PROGRESS,
loaded,
});
const uploadEventBannerSuccess = (media: APIEntity, file: File) => ({
type: EVENT_BANNER_UPLOAD_SUCCESS,
media,
file,
});
const uploadEventBannerFail = (error: AxiosError | true) => ({
type: EVENT_BANNER_UPLOAD_FAIL,
error,
});
const undoUploadEventBanner = () => ({
type: EVENT_BANNER_UPLOAD_UNDO,
});
const submitEvent = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const id = state.compose_event.id;
const name = state.compose_event.name;
const status = state.compose_event.status;
const banner = state.compose_event.banner;
const startTime = state.compose_event.start_time;
const endTime = state.compose_event.end_time;
const joinMode = state.compose_event.approval_required ? 'restricted' : 'free';
const location = state.compose_event.location;
if (!name || !name.length) {
return;
}
dispatch(submitEventRequest());
const params: Record<string, any> = {
name,
status,
start_time: startTime,
join_mode: joinMode,
content_type: 'text/markdown',
};
if (endTime) params.end_time = endTime;
if (banner) params.banner_id = banner.id;
if (location) params.location_id = location.origin_id;
return api(getState).request({
url: id === null ? '/api/v1/pleroma/events' : `/api/v1/pleroma/events/${id}`,
method: id === null ? 'post' : 'put',
data: params,
}).then(({ data }) => {
dispatch(closeModal('COMPOSE_EVENT'));
dispatch(importFetchedStatus(data));
dispatch(submitEventSuccess(data));
toast.success(
id ? messages.editSuccess : messages.success,
{
actionLabel: messages.view,
actionLink: `/@${data.account.acct}/events/${data.id}`,
},
);
}).catch(function(error) {
dispatch(submitEventFail(error));
});
};
const submitEventRequest = () => ({
type: EVENT_SUBMIT_REQUEST,
});
const submitEventSuccess = (status: APIEntity) => ({
type: EVENT_SUBMIT_SUCCESS,
status,
});
const submitEventFail = (error: AxiosError) => ({
type: EVENT_SUBMIT_FAIL,
error,
});
const joinEvent = (id: string, participationMessage?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const status = getState().statuses.get(id);
if (!status || !status.event || status.event.join_state) {
return dispatch(noOp);
}
dispatch(joinEventRequest(status));
return api(getState).post(`/api/v1/pleroma/events/${id}/join`, {
participation_message: participationMessage,
}).then(({ data }) => {
dispatch(importFetchedStatus(data));
dispatch(joinEventSuccess(data));
toast.success(
data.pleroma.event?.join_state === 'pending' ? messages.joinRequestSuccess : messages.joinSuccess,
{
actionLabel: messages.view,
actionLink: `/@${data.account.acct}/events/${data.id}`,
},
);
}).catch(function(error) {
dispatch(joinEventFail(error, status, status?.event?.join_state || null));
});
};
const joinEventRequest = (status: StatusEntity) => ({
type: EVENT_JOIN_REQUEST,
id: status.id,
});
const joinEventSuccess = (status: APIEntity) => ({
type: EVENT_JOIN_SUCCESS,
id: status.id,
});
const joinEventFail = (error: AxiosError, status: StatusEntity, previousState: string | null) => ({
type: EVENT_JOIN_FAIL,
error,
id: status.id,
previousState,
});
const leaveEvent = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const status = getState().statuses.get(id);
if (!status || !status.event || !status.event.join_state) {
return dispatch(noOp);
}
dispatch(leaveEventRequest(status));
return api(getState).post(`/api/v1/pleroma/events/${id}/leave`).then(({ data }) => {
dispatch(importFetchedStatus(data));
dispatch(leaveEventSuccess(data));
}).catch(function(error) {
dispatch(leaveEventFail(error, status));
});
};
const leaveEventRequest = (status: StatusEntity) => ({
type: EVENT_LEAVE_REQUEST,
id: status.id,
});
const leaveEventSuccess = (status: APIEntity) => ({
type: EVENT_LEAVE_SUCCESS,
id: status.id,
});
const leaveEventFail = (error: AxiosError, status: StatusEntity) => ({
type: EVENT_LEAVE_FAIL,
id: status.id,
error,
});
const fetchEventParticipations = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchEventParticipationsRequest(id));
return api(getState).get(`/api/v1/pleroma/events/${id}/participations`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
return dispatch(fetchEventParticipationsSuccess(id, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchEventParticipationsFail(id, error));
});
};
const fetchEventParticipationsRequest = (id: string) => ({
type: EVENT_PARTICIPATIONS_FETCH_REQUEST,
id,
});
const fetchEventParticipationsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
type: EVENT_PARTICIPATIONS_FETCH_SUCCESS,
id,
accounts,
next,
});
const fetchEventParticipationsFail = (id: string, error: AxiosError) => ({
type: EVENT_PARTICIPATIONS_FETCH_FAIL,
id,
error,
});
const expandEventParticipations = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const url = getState().user_lists.event_participations.get(id)?.next || null;
if (url === null) {
return dispatch(noOp);
}
dispatch(expandEventParticipationsRequest(id));
return api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
return dispatch(expandEventParticipationsSuccess(id, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandEventParticipationsFail(id, error));
});
};
const expandEventParticipationsRequest = (id: string) => ({
type: EVENT_PARTICIPATIONS_EXPAND_REQUEST,
id,
});
const expandEventParticipationsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
type: EVENT_PARTICIPATIONS_EXPAND_SUCCESS,
id,
accounts,
next,
});
const expandEventParticipationsFail = (id: string, error: AxiosError) => ({
type: EVENT_PARTICIPATIONS_EXPAND_FAIL,
id,
error,
});
const fetchEventParticipationRequests = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchEventParticipationRequestsRequest(id));
return api(getState).get(`/api/v1/pleroma/events/${id}/participation_requests`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(({ account }: APIEntity) => account)));
return dispatch(fetchEventParticipationRequestsSuccess(id, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchEventParticipationRequestsFail(id, error));
});
};
const fetchEventParticipationRequestsRequest = (id: string) => ({
type: EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST,
id,
});
const fetchEventParticipationRequestsSuccess = (id: string, participations: APIEntity[], next: string | null) => ({
type: EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS,
id,
participations,
next,
});
const fetchEventParticipationRequestsFail = (id: string, error: AxiosError) => ({
type: EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL,
id,
error,
});
const expandEventParticipationRequests = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const url = getState().user_lists.event_participations.get(id)?.next || null;
if (url === null) {
return dispatch(noOp);
}
dispatch(expandEventParticipationRequestsRequest(id));
return api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(({ account }: APIEntity) => account)));
return dispatch(expandEventParticipationRequestsSuccess(id, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandEventParticipationRequestsFail(id, error));
});
};
const expandEventParticipationRequestsRequest = (id: string) => ({
type: EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST,
id,
});
const expandEventParticipationRequestsSuccess = (id: string, participations: APIEntity[], next: string | null) => ({
type: EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS,
id,
participations,
next,
});
const expandEventParticipationRequestsFail = (id: string, error: AxiosError) => ({
type: EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL,
id,
error,
});
const authorizeEventParticipationRequest = (id: string, accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(authorizeEventParticipationRequestRequest(id, accountId));
return api(getState)
.post(`/api/v1/pleroma/events/${id}/participation_requests/${accountId}/authorize`)
.then(() => {
dispatch(authorizeEventParticipationRequestSuccess(id, accountId));
toast.success(messages.authorized);
})
.catch(error => dispatch(authorizeEventParticipationRequestFail(id, accountId, error)));
};
const authorizeEventParticipationRequestRequest = (id: string, accountId: string) => ({
type: EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST,
id,
accountId,
});
const authorizeEventParticipationRequestSuccess = (id: string, accountId: string) => ({
type: EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS,
id,
accountId,
});
const authorizeEventParticipationRequestFail = (id: string, accountId: string, error: AxiosError) => ({
type: EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL,
id,
accountId,
error,
});
const rejectEventParticipationRequest = (id: string, accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(rejectEventParticipationRequestRequest(id, accountId));
return api(getState)
.post(`/api/v1/pleroma/events/${id}/participation_requests/${accountId}/reject`)
.then(() => {
dispatch(rejectEventParticipationRequestSuccess(id, accountId));
toast.success(messages.rejected);
})
.catch(error => dispatch(rejectEventParticipationRequestFail(id, accountId, error)));
};
const rejectEventParticipationRequestRequest = (id: string, accountId: string) => ({
type: EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST,
id,
accountId,
});
const rejectEventParticipationRequestSuccess = (id: string, accountId: string) => ({
type: EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS,
id,
accountId,
});
const rejectEventParticipationRequestFail = (id: string, accountId: string, error: AxiosError) => ({
type: EVENT_PARTICIPATION_REQUEST_REJECT_FAIL,
id,
accountId,
error,
});
const fetchEventIcs = (id: string) =>
(dispatch: any, getState: () => RootState) =>
api(getState).get(`/api/v1/pleroma/events/${id}/ics`);
const cancelEventCompose = () => ({
type: EVENT_COMPOSE_CANCEL,
});
const editEvent = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
const status = getState().statuses.get(id)!;
dispatch({ type: STATUS_FETCH_SOURCE_REQUEST });
api(getState).get(`/api/v1/statuses/${id}/source`).then(response => {
dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS });
dispatch({
type: EVENT_FORM_SET,
status,
text: response.data.text,
location: response.data.location,
});
dispatch(openModal('COMPOSE_EVENT'));
}).catch(error => {
dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error });
});
};
const fetchRecentEvents = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (getState().status_lists.get('recent_events')?.isLoading) {
return;
}
dispatch({ type: RECENT_EVENTS_FETCH_REQUEST });
api(getState).get('/api/v1/timelines/public?only_events=true').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch({
type: RECENT_EVENTS_FETCH_SUCCESS,
statuses: response.data,
next: next ? next.uri : null,
});
}).catch(error => {
dispatch({ type: RECENT_EVENTS_FETCH_FAIL, error });
});
};
const fetchJoinedEvents = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (getState().status_lists.get('joined_events')?.isLoading) {
return;
}
dispatch({ type: JOINED_EVENTS_FETCH_REQUEST });
api(getState).get('/api/v1/pleroma/events/joined_events').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch({
type: JOINED_EVENTS_FETCH_SUCCESS,
statuses: response.data,
next: next ? next.uri : null,
});
}).catch(error => {
dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error });
});
};
export {
LOCATION_SEARCH_REQUEST,
LOCATION_SEARCH_SUCCESS,
LOCATION_SEARCH_FAIL,
EDIT_EVENT_NAME_CHANGE,
EDIT_EVENT_DESCRIPTION_CHANGE,
EDIT_EVENT_START_TIME_CHANGE,
EDIT_EVENT_END_TIME_CHANGE,
EDIT_EVENT_HAS_END_TIME_CHANGE,
EDIT_EVENT_APPROVAL_REQUIRED_CHANGE,
EDIT_EVENT_LOCATION_CHANGE,
EVENT_BANNER_UPLOAD_REQUEST,
EVENT_BANNER_UPLOAD_PROGRESS,
EVENT_BANNER_UPLOAD_SUCCESS,
EVENT_BANNER_UPLOAD_FAIL,
EVENT_BANNER_UPLOAD_UNDO,
EVENT_SUBMIT_REQUEST,
EVENT_SUBMIT_SUCCESS,
EVENT_SUBMIT_FAIL,
EVENT_JOIN_REQUEST,
EVENT_JOIN_SUCCESS,
EVENT_JOIN_FAIL,
EVENT_LEAVE_REQUEST,
EVENT_LEAVE_SUCCESS,
EVENT_LEAVE_FAIL,
EVENT_PARTICIPATIONS_FETCH_REQUEST,
EVENT_PARTICIPATIONS_FETCH_SUCCESS,
EVENT_PARTICIPATIONS_FETCH_FAIL,
EVENT_PARTICIPATIONS_EXPAND_REQUEST,
EVENT_PARTICIPATIONS_EXPAND_SUCCESS,
EVENT_PARTICIPATIONS_EXPAND_FAIL,
EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST,
EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS,
EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL,
EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST,
EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS,
EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL,
EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST,
EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS,
EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL,
EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST,
EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS,
EVENT_PARTICIPATION_REQUEST_REJECT_FAIL,
EVENT_COMPOSE_CANCEL,
EVENT_FORM_SET,
RECENT_EVENTS_FETCH_REQUEST,
RECENT_EVENTS_FETCH_SUCCESS,
RECENT_EVENTS_FETCH_FAIL,
JOINED_EVENTS_FETCH_REQUEST,
JOINED_EVENTS_FETCH_SUCCESS,
JOINED_EVENTS_FETCH_FAIL,
locationSearch,
changeEditEventName,
changeEditEventDescription,
changeEditEventStartTime,
changeEditEventEndTime,
changeEditEventHasEndTime,
changeEditEventApprovalRequired,
changeEditEventLocation,
uploadEventBanner,
uploadEventBannerRequest,
uploadEventBannerProgress,
uploadEventBannerSuccess,
uploadEventBannerFail,
undoUploadEventBanner,
submitEvent,
submitEventRequest,
submitEventSuccess,
submitEventFail,
joinEvent,
joinEventRequest,
joinEventSuccess,
joinEventFail,
leaveEvent,
leaveEventRequest,
leaveEventSuccess,
leaveEventFail,
fetchEventParticipations,
fetchEventParticipationsRequest,
fetchEventParticipationsSuccess,
fetchEventParticipationsFail,
expandEventParticipations,
expandEventParticipationsRequest,
expandEventParticipationsSuccess,
expandEventParticipationsFail,
fetchEventParticipationRequests,
fetchEventParticipationRequestsRequest,
fetchEventParticipationRequestsSuccess,
fetchEventParticipationRequestsFail,
expandEventParticipationRequests,
expandEventParticipationRequestsRequest,
expandEventParticipationRequestsSuccess,
expandEventParticipationRequestsFail,
authorizeEventParticipationRequest,
authorizeEventParticipationRequestRequest,
authorizeEventParticipationRequestSuccess,
authorizeEventParticipationRequestFail,
rejectEventParticipationRequest,
rejectEventParticipationRequestRequest,
rejectEventParticipationRequestSuccess,
rejectEventParticipationRequestFail,
fetchEventIcs,
cancelEventCompose,
editEvent,
fetchRecentEvents,
fetchJoinedEvents,
};

Wyświetl plik

@ -1,10 +1,9 @@
import { defineMessages } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import api, { getLinks } from 'soapbox/api';
import { normalizeAccount } from 'soapbox/normalizers';
import toast from 'soapbox/toast';
import type { SnackbarAction } from './snackbar';
import type { AxiosResponse } from 'axios';
import type { RootState } from 'soapbox/store';
@ -35,9 +34,9 @@ type ExportDataActions = {
| typeof EXPORT_BLOCKS_FAIL
| typeof EXPORT_MUTES_REQUEST
| typeof EXPORT_MUTES_SUCCESS
| typeof EXPORT_MUTES_FAIL,
error?: any,
} | SnackbarAction
| typeof EXPORT_MUTES_FAIL
error?: any
}
function fileExport(content: string, fileName: string) {
const fileToDownload = document.createElement('a');
@ -75,7 +74,7 @@ export const exportFollows = () => (dispatch: React.Dispatch<ExportDataActions>,
followings.unshift('Account address,Show boosts');
fileExport(followings.join('\n'), 'export_followings.csv');
dispatch(snackbar.success(messages.followersSuccess));
toast.success(messages.followersSuccess);
dispatch({ type: EXPORT_FOLLOWS_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_FOLLOWS_FAIL, error });
@ -90,7 +89,7 @@ export const exportBlocks = () => (dispatch: React.Dispatch<ExportDataActions>,
.then((blocks) => {
fileExport(blocks.join('\n'), 'export_block.csv');
dispatch(snackbar.success(messages.blocksSuccess));
toast.success(messages.blocksSuccess);
dispatch({ type: EXPORT_BLOCKS_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_BLOCKS_FAIL, error });
@ -105,7 +104,7 @@ export const exportMutes = () => (dispatch: React.Dispatch<ExportDataActions>, g
.then((mutes) => {
fileExport(mutes.join('\n'), 'export_mutes.csv');
dispatch(snackbar.success(messages.mutesSuccess));
toast.success(messages.mutesSuccess);
dispatch({ type: EXPORT_MUTES_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_MUTES_FAIL, error });

Wyświetl plik

@ -15,10 +15,11 @@ import sourceCode from 'soapbox/utils/code';
import { getWalletAndSign } from 'soapbox/utils/ethereum';
import { getFeatures } from 'soapbox/utils/features';
import { getQuirks } from 'soapbox/utils/quirks';
import { getInstanceScopes } from 'soapbox/utils/scopes';
import { baseClient } from '../api';
import type { AppDispatch } from 'soapbox/store';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Instance } from 'soapbox/types/entities';
const fetchExternalInstance = (baseURL?: string) => {
@ -37,25 +38,23 @@ const fetchExternalInstance = (baseURL?: string) => {
};
const createExternalApp = (instance: Instance, baseURL?: string) =>
(dispatch: AppDispatch) => {
(dispatch: AppDispatch, _getState: () => RootState) => {
// Mitra: skip creating the auth app
if (getQuirks(instance).noApps) return new Promise(f => f({}));
const { scopes } = getFeatures(instance);
const params = {
client_name: sourceCode.displayName,
client_name: sourceCode.displayName,
redirect_uris: `${window.location.origin}/login/external`,
website: sourceCode.homepage,
scopes,
website: sourceCode.homepage,
scopes: getInstanceScopes(instance),
};
return dispatch(createApp(params, baseURL));
};
const externalAuthorize = (instance: Instance, baseURL: string) =>
(dispatch: AppDispatch) => {
const { scopes } = getFeatures(instance);
(dispatch: AppDispatch, _getState: () => RootState) => {
const scopes = getInstanceScopes(instance);
return dispatch(createExternalApp(instance, baseURL)).then((app) => {
const { client_id, redirect_uri } = app as Record<string, string>;
@ -76,7 +75,7 @@ const externalAuthorize = (instance: Instance, baseURL: string) =>
};
const externalEthereumLogin = (instance: Instance, baseURL?: string) =>
(dispatch: AppDispatch) => {
(dispatch: AppDispatch, getState: () => RootState) => {
const loginMessage = instance.login_message;
return getWalletAndSign(loginMessage).then(({ wallet, signature }) => {
@ -89,7 +88,7 @@ const externalEthereumLogin = (instance: Instance, baseURL?: string) =>
client_secret: client_secret,
password: signature as string,
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
scope: getFeatures(instance).scopes,
scope: getInstanceScopes(instance),
};
return dispatch(obtainOAuthToken(params, baseURL))

Wyświetl plik

@ -11,25 +11,25 @@ export const FAMILIAR_FOLLOWERS_FETCH_SUCCESS = 'FAMILIAR_FOLLOWERS_FETCH_SUCCES
export const FAMILIAR_FOLLOWERS_FETCH_FAIL = 'FAMILIAR_FOLLOWERS_FETCH_FAIL';
type FamiliarFollowersFetchRequestAction = {
type: typeof FAMILIAR_FOLLOWERS_FETCH_REQUEST,
id: string,
type: typeof FAMILIAR_FOLLOWERS_FETCH_REQUEST
id: string
}
type FamiliarFollowersFetchRequestSuccessAction = {
type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
id: string,
accounts: Array<APIEntity>,
type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS
id: string
accounts: Array<APIEntity>
}
type FamiliarFollowersFetchRequestFailAction = {
type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL,
id: string,
error: any,
type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL
id: string
error: any
}
type AccountsImportAction = {
type: typeof ACCOUNTS_IMPORT,
accounts: Array<APIEntity>,
type: typeof ACCOUNTS_IMPORT
accounts: Array<APIEntity>
}
export type FamiliarFollowersActions = FamiliarFollowersFetchRequestAction | FamiliarFollowersFetchRequestSuccessAction | FamiliarFollowersFetchRequestFailAction | AccountsImportAction

Wyświetl plik

@ -1,7 +1,8 @@
import { defineMessages } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features';
import api from '../api';
@ -28,6 +29,12 @@ const fetchFilters = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (!features.filters) return;
dispatch({
type: FILTERS_FETCH_REQUEST,
skipLoading: true,
@ -59,7 +66,7 @@ const createFilter = (phrase: string, expires_at: string, context: Array<string>
expires_at,
}).then(response => {
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data });
dispatch(snackbar.success(messages.added));
toast.success(messages.added);
}).catch(error => {
dispatch({ type: FILTERS_CREATE_FAIL, error });
});
@ -70,7 +77,7 @@ const deleteFilter = (id: string) =>
dispatch({ type: FILTERS_DELETE_REQUEST });
return api(getState).delete(`/api/v1/filters/${id}`).then(response => {
dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data });
dispatch(snackbar.success(messages.removed));
toast.success(messages.removed);
}).catch(error => {
dispatch({ type: FILTERS_DELETE_FAIL, error });
});

Wyświetl plik

@ -1,143 +0,0 @@
import { isLoggedIn } from 'soapbox/utils/auth';
import api from '../api';
import type { AxiosError } from 'axios';
import type { History } from 'history';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST';
const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS';
const GROUP_CREATE_FAIL = 'GROUP_CREATE_FAIL';
const GROUP_UPDATE_REQUEST = 'GROUP_UPDATE_REQUEST';
const GROUP_UPDATE_SUCCESS = 'GROUP_UPDATE_SUCCESS';
const GROUP_UPDATE_FAIL = 'GROUP_UPDATE_FAIL';
const GROUP_EDITOR_VALUE_CHANGE = 'GROUP_EDITOR_VALUE_CHANGE';
const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET';
const GROUP_EDITOR_SETUP = 'GROUP_EDITOR_SETUP';
const submit = (routerHistory: History) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const groupId = getState().group_editor.get('groupId') as string;
const title = getState().group_editor.get('title') as string;
const description = getState().group_editor.get('description') as string;
const coverImage = getState().group_editor.get('coverImage') as any;
if (groupId === null) {
dispatch(create(title, description, coverImage, routerHistory));
} else {
dispatch(update(groupId, title, description, coverImage, routerHistory));
}
};
const create = (title: string, description: string, coverImage: File, routerHistory: History) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(createRequest());
const formData = new FormData();
formData.append('title', title);
formData.append('description', description);
if (coverImage !== null) {
formData.append('cover_image', coverImage);
}
api(getState).post('/api/v1/groups', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(({ data }) => {
dispatch(createSuccess(data));
routerHistory.push(`/groups/${data.id}`);
}).catch(err => dispatch(createFail(err)));
};
const createRequest = (id?: string) => ({
type: GROUP_CREATE_REQUEST,
id,
});
const createSuccess = (group: APIEntity) => ({
type: GROUP_CREATE_SUCCESS,
group,
});
const createFail = (error: AxiosError) => ({
type: GROUP_CREATE_FAIL,
error,
});
const update = (groupId: string, title: string, description: string, coverImage: File, routerHistory: History) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(updateRequest(groupId));
const formData = new FormData();
formData.append('title', title);
formData.append('description', description);
if (coverImage !== null) {
formData.append('cover_image', coverImage);
}
api(getState).put(`/api/v1/groups/${groupId}`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(({ data }) => {
dispatch(updateSuccess(data));
routerHistory.push(`/groups/${data.id}`);
}).catch(err => dispatch(updateFail(err)));
};
const updateRequest = (id: string) => ({
type: GROUP_UPDATE_REQUEST,
id,
});
const updateSuccess = (group: APIEntity) => ({
type: GROUP_UPDATE_SUCCESS,
group,
});
const updateFail = (error: AxiosError) => ({
type: GROUP_UPDATE_FAIL,
error,
});
const changeValue = (field: string, value: string | File) => ({
type: GROUP_EDITOR_VALUE_CHANGE,
field,
value,
});
const reset = () => ({
type: GROUP_EDITOR_RESET,
});
const setUp = (group: string) => ({
type: GROUP_EDITOR_SETUP,
group,
});
export {
GROUP_CREATE_REQUEST,
GROUP_CREATE_SUCCESS,
GROUP_CREATE_FAIL,
GROUP_UPDATE_REQUEST,
GROUP_UPDATE_SUCCESS,
GROUP_UPDATE_FAIL,
GROUP_EDITOR_VALUE_CHANGE,
GROUP_EDITOR_RESET,
GROUP_EDITOR_SETUP,
submit,
create,
createRequest,
createSuccess,
createFail,
update,
updateRequest,
updateSuccess,
updateFail,
changeValue,
reset,
setUp,
};

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