diff --git a/.eslintignore b/.eslintignore index 0c17e6907..1ab6f8d8c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,4 +5,4 @@ /tmp/** /coverage/** /custom/** -!.eslintrc.js +!.eslintrc.cjs diff --git a/.eslintrc.js b/.eslintrc.cjs similarity index 85% rename from .eslintrc.js rename to .eslintrc.cjs index 0ecb15a5b..eea505a45 100644 --- a/.eslintrc.js +++ b/.eslintrc.cjs @@ -5,6 +5,7 @@ module.exports = { 'eslint:recommended', 'plugin:import/typescript', 'plugin:compat/recommended', + 'plugin:tailwindcss/recommended', ], env: { @@ -18,11 +19,10 @@ module.exports = { ATTACHMENT_HOST: false, }, - parser: 'babel-eslint', + parser: '@babel/eslint-parser', plugins: [ 'react', - 'jsdoc', 'jsx-a11y', 'import', 'promise', @@ -43,7 +43,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)$', @@ -54,13 +54,16 @@ module.exports = { }, }, polyfills: [ - 'es:all', - 'fetch', - 'IntersectionObserver', - 'Promise', - 'URL', - 'URLSearchParams', + '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: { @@ -235,18 +238,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': [ @@ -267,10 +259,30 @@ 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: [ { @@ -281,23 +293,5 @@ module.exports = { }, parser: '@typescript-eslint/parser', }, - { - // Only enforce JSDoc comments on UI components for now. - // https://www.npmjs.com/package/eslint-plugin-jsdoc - files: ['app/soapbox/components/ui/**/*'], - rules: { - 'jsdoc/require-jsdoc': ['error', { - publicOnly: true, - require: { - ArrowFunctionExpression: true, - ClassDeclaration: true, - ClassExpression: true, - FunctionDeclaration: true, - FunctionExpression: true, - MethodDefinition: true, - }, - }], - }, - }, ], }; diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 51f878342..9516ec2d6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -18,6 +18,7 @@ stages: - deps - test - deploy + - release deps: stage: deps @@ -47,10 +48,12 @@ lint-js: changes: - "**/*.js" - "**/*.jsx" + - "**/*.cjs" + - "**/*.mjs" - "**/*.ts" - "**/*.tsx" - ".eslintignore" - - ".eslintrc.js" + - ".eslintrc.cjs" lint-sass: stage: test @@ -71,7 +74,7 @@ jest: - "app/soapbox/**/*" - "webpack/**/*" - "custom/**/*" - - "jest.config.js" + - "jest.config.cjs" - "package.json" - "yarn.lock" - ".gitlab-ci.yml" @@ -146,19 +149,27 @@ pages: docker: stage: deploy - image: docker:20.10.17 + image: docker:23.0.0 services: - - docker:20.10.17-dind + - 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 . - - docker push $CI_REGISTRY_IMAGE - only: - variables: - - $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME + - 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 diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 5dcd5926a..97bad7f28 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,5 +1,8 @@ { "*.js": "eslint --cache", + "*.cjs": "eslint --cache", + "*.mjs": "eslint --cache", "*.ts": "eslint --cache", + "*.tsx": "eslint --cache", "app/styles/**/*.scss": "stylelint" } diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 000000000..bb4c1d232 --- /dev/null +++ b/.storybook/main.ts @@ -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; \ No newline at end of file diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 000000000..df2195f0c --- /dev/null +++ b/.storybook/preview.tsx @@ -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) => ( + +); + +addDecorator(withProvider); + +export const parameters = { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, +}; diff --git a/.stylelintrc.json b/.stylelintrc.json index c8a71a164..1a610d5eb 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -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" } } diff --git a/.tool-versions b/.tool-versions index f0c37ee48..ab43e6ab2 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs 18.2.0 +nodejs 18.14.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index d7ca13345..1b3f69961 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "css.validate": false, "editor.insertSpaces": true, "editor.tabSize": 2, "files.associations": { @@ -15,5 +16,6 @@ "fileMatch": ["renovate.json"], "url": "https://docs.renovatebot.com/renovate-schema.json" } - ] + ], + "scss.validate": false } diff --git a/CHANGELOG.md b/CHANGELOG.md index 05162696f..a9ac41b44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,16 +6,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 diff --git a/app/application.ts b/app/application.ts deleted file mode 100644 index 9610b4f9a..000000000 --- a/app/application.ts +++ /dev/null @@ -1,17 +0,0 @@ -import loadPolyfills from './soapbox/load-polyfills'; - -// Load iframe event listener -require('./soapbox/iframe'); - -// @ts-ignore -require.context('./assets/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); -}); diff --git a/app/assets/fonts/OpenDyslexic/LICENSE b/app/assets/fonts/OpenDyslexic/LICENSE deleted file mode 100644 index bb867823f..000000000 --- a/app/assets/fonts/OpenDyslexic/LICENSE +++ /dev/null @@ -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. diff --git a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 b/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 deleted file mode 100644 index aa7bcdea9..000000000 Binary files a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 and /dev/null differ diff --git a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold.woff2 b/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold.woff2 deleted file mode 100644 index 2f04ad119..000000000 Binary files a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Bold.woff2 and /dev/null differ diff --git a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Italic.woff2 b/app/assets/fonts/OpenDyslexic/OpenDyslexic-Italic.woff2 deleted file mode 100644 index 00c19082d..000000000 Binary files a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Italic.woff2 and /dev/null differ diff --git a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Regular.woff2 b/app/assets/fonts/OpenDyslexic/OpenDyslexic-Regular.woff2 deleted file mode 100644 index 47e26d82a..000000000 Binary files a/app/assets/fonts/OpenDyslexic/OpenDyslexic-Regular.woff2 and /dev/null differ diff --git a/app/index.ejs b/app/index.ejs index b0d109e1f..e7c9b7330 100644 --- a/app/index.ejs +++ b/app/index.ejs @@ -5,7 +5,6 @@ - <%= snippets %> diff --git a/app/soapbox/actions/__tests__/me.test.ts b/app/soapbox/actions/__tests__/me.test.ts index c75d128f0..d4dc1d31f 100644 --- a/app/soapbox/actions/__tests__/me.test.ts +++ b/app/soapbox/actions/__tests__/me.test.ts @@ -2,7 +2,9 @@ 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'; @@ -38,18 +40,18 @@ describe('fetchMe()', () => { beforeEach(() => { const state = rootState - .set('auth', ImmutableMap({ + .set('auth', ReducerRecord({ me: accountUrl, users: ImmutableMap({ - [accountUrl]: ImmutableMap({ + [accountUrl]: AuthUserRecord({ 'access_token': token, }), }), })) .set('accounts', ImmutableMap({ - [accountUrl]: { + [accountUrl]: AccountRecord({ url: accountUrl, - }, + }), }) as any); store = mockStore(state); }); @@ -112,4 +114,4 @@ describe('patchMe()', () => { expect(actions).toEqual(expectedActions); }); }); -}); \ No newline at end of file +}); diff --git a/app/soapbox/actions/accounts.ts b/app/soapbox/actions/accounts.ts index 14db17275..a389e29cd 100644 --- a/app/soapbox/actions/accounts.ts +++ b/app/soapbox/actions/accounts.ts @@ -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'; @@ -228,7 +228,7 @@ const fetchAccountFail = (id: string | null, error: AxiosError) => ({ }); type FollowAccountOpts = { - reblogs?: boolean, + reblogs?: boolean notify?: boolean }; diff --git a/app/soapbox/actions/admin.ts b/app/soapbox/actions/admin.ts index 3cd5a25ba..52be03ac2 100644 --- a/app/soapbox/actions/admin.ts +++ b/app/soapbox/actions/admin.ts @@ -1,13 +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'; @@ -77,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 = () => @@ -544,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, @@ -596,6 +771,30 @@ 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, @@ -622,4 +821,16 @@ export { setRole, suggestUsers, unsuggestUsers, + setUserIndexQuery, + fetchUserIndex, + expandUserIndex, + fetchAdminAnnouncements, + expandAdminAnnouncements, + changeAnnouncementContent, + changeAnnouncementStartTime, + changeAnnouncementEndTime, + changeAnnouncementAllDay, + handleCreateAnnouncement, + deleteAnnouncement, + initAnnouncementModal, }; diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index 8e7a00d02..06fe848e2 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -20,8 +20,8 @@ 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'; @@ -29,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'; @@ -51,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(() => @@ -94,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()), @@ -111,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, @@ -127,32 +121,12 @@ const createUserToken = (username: string, password: string) => .then((token: Record) => 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) => 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', @@ -211,9 +185,11 @@ export const logIn = (username: string, password: string) => (dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => { 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. toast.error(messages.invalidCredentials); @@ -233,9 +209,9 @@ 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)) @@ -263,10 +239,10 @@ export const switchAccount = (accountId: string, background = false) => export const fetchOwnAccounts = () => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - return state.auth.get('users').forEach((user: ImmutableMap) => { - 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)); } }); }; diff --git a/app/soapbox/actions/chats.ts b/app/soapbox/actions/chats.ts index 67b796408..f4ca85abe 100644 --- a/app/soapbox/actions/chats.ts +++ b/app/soapbox/actions/chats.ts @@ -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'; diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index 2ac11fc5b..9816135e5 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -19,11 +19,12 @@ import { openModal, closeModal } from './modals'; import { getSettings } from './settings'; import { createStatus } from './statuses'; -import type { History } from 'history'; +import type { EditorState } from 'lexical'; import type { Emoji } from 'soapbox/components/autosuggest-emoji'; import type { AutoSuggestion } from 'soapbox/components/autosuggest-input'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities'; +import type { History } from 'soapbox/types/history'; const { CancelToken, isCancel } = axios; @@ -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'; @@ -83,10 +85,12 @@ const COMPOSE_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS'; const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; +const COMPOSE_EDITOR_STATE_SET = 'COMPOSE_EDITOR_STATE_SET'; + 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' }, @@ -279,7 +283,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false const idempotencyKey = compose.idempotencyKey; - const params = { + const params: Record = { status, in_reply_to_id: compose.in_reply_to, quote_id: compose.quote, @@ -293,6 +297,8 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false 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'); @@ -473,6 +479,15 @@ const undoUploadCompose = (composeId: string, media_id: string) => ({ media_id: media_id, }); +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(); @@ -725,7 +740,7 @@ const eventDiscussionCompose = (composeId: string, status: Status) => const instance = state.instance; const { explicitAddressing } = getFeatures(instance); - dispatch({ + return dispatch({ type: COMPOSE_EVENT_REPLY, id: composeId, status: status, @@ -734,6 +749,12 @@ const eventDiscussionCompose = (composeId: string, status: Status) => }); }; +const setEditorState = (composeId: string, editorState: EditorState | string | null) => ({ + type: COMPOSE_EDITOR_STATE_SET, + id: composeId, + editorState: editorState, +}); + export { COMPOSE_CHANGE, COMPOSE_SUBMIT_REQUEST, @@ -752,6 +773,7 @@ export { COMPOSE_UPLOAD_FAIL, COMPOSE_UPLOAD_PROGRESS, COMPOSE_UPLOAD_UNDO, + COMPOSE_GROUP_POST, COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTION_SELECT, @@ -779,6 +801,7 @@ export { COMPOSE_ADD_TO_MENTIONS, COMPOSE_REMOVE_FROM_MENTIONS, COMPOSE_SET_STATUS, + COMPOSE_EDITOR_STATE_SET, setComposeToStatus, changeCompose, replyCompose, @@ -804,6 +827,7 @@ export { uploadComposeSuccess, uploadComposeFail, undoUploadCompose, + groupCompose, clearComposeSuggestions, fetchComposeSuggestions, readyComposeSuggestionsEmojis, @@ -829,4 +853,5 @@ export { addToMentions, removeFromMentions, eventDiscussionCompose, + setEditorState, }; diff --git a/app/soapbox/actions/consumer-auth.ts b/app/soapbox/actions/consumer-auth.ts index 171941577..72c928dba 100644 --- a/app/soapbox/actions/consumer-auth.ts +++ b/app/soapbox/actions/consumer-auth.ts @@ -3,7 +3,7 @@ import axios from 'axios'; import * as BuildConfig from 'soapbox/build-config'; import { isURL } from 'soapbox/utils/auth'; import sourceCode from 'soapbox/utils/code'; -import { getFeatures } from 'soapbox/utils/features'; +import { getScopes } from 'soapbox/utils/scopes'; import { createApp } from './apps'; @@ -11,8 +11,7 @@ import type { AppDispatch, RootState } from 'soapbox/store'; const createProviderApp = () => { return async(dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const { scopes } = getFeatures(state.instance); + const scopes = getScopes(getState()); const params = { client_name: sourceCode.displayName, @@ -29,8 +28,7 @@ export const prepareRequest = (provider: string) => { return async(dispatch: AppDispatch, getState: () => RootState) => { const baseURL = isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : ''; - const state = getState(); - const { scopes } = getFeatures(state.instance); + const scopes = getScopes(getState()); const app = await dispatch(createProviderApp()); const { client_id, redirect_uri } = app; diff --git a/app/soapbox/actions/dropdown-menu.ts b/app/soapbox/actions/dropdown-menu.ts index 0c6fc8536..cad73f364 100644 --- a/app/soapbox/actions/dropdown-menu.ts +++ b/app/soapbox/actions/dropdown-menu.ts @@ -1,13 +1,8 @@ -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 }); +const openDropdownMenu = () => ({ type: DROPDOWN_MENU_OPEN }); +const closeDropdownMenu = () => ({ type: DROPDOWN_MENU_CLOSE }); export { DROPDOWN_MENU_OPEN, diff --git a/app/soapbox/actions/export-data.ts b/app/soapbox/actions/export-data.ts index 1ddab9103..519725ea2 100644 --- a/app/soapbox/actions/export-data.ts +++ b/app/soapbox/actions/export-data.ts @@ -34,8 +34,8 @@ type ExportDataActions = { | typeof EXPORT_BLOCKS_FAIL | typeof EXPORT_MUTES_REQUEST | typeof EXPORT_MUTES_SUCCESS - | typeof EXPORT_MUTES_FAIL, - error?: any, + | typeof EXPORT_MUTES_FAIL + error?: any } function fileExport(content: string, fileName: string) { diff --git a/app/soapbox/actions/external-auth.ts b/app/soapbox/actions/external-auth.ts index 064e100c9..87840cfe7 100644 --- a/app/soapbox/actions/external-auth.ts +++ b/app/soapbox/actions/external-auth.ts @@ -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; @@ -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)) diff --git a/app/soapbox/actions/familiar-followers.ts b/app/soapbox/actions/familiar-followers.ts index ec6eca6d8..2d8aa6786 100644 --- a/app/soapbox/actions/familiar-followers.ts +++ b/app/soapbox/actions/familiar-followers.ts @@ -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, + type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS + id: string + accounts: Array } 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, + type: typeof ACCOUNTS_IMPORT + accounts: Array } export type FamiliarFollowersActions = FamiliarFollowersFetchRequestAction | FamiliarFollowersFetchRequestSuccessAction | FamiliarFollowersFetchRequestFailAction | AccountsImportAction diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts new file mode 100644 index 000000000..9715396f3 --- /dev/null +++ b/app/soapbox/actions/groups.ts @@ -0,0 +1,1046 @@ +import { defineMessages } from 'react-intl'; + +import toast from 'soapbox/toast'; + +import api, { getLinks } from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedGroups, importFetchedAccounts } from './importer'; +import { closeModal, openModal } from './modals'; +import { deleteFromTimelines } from './timelines'; + +import type { AxiosError } from 'axios'; +import type { GroupRole } from 'soapbox/reducers/group-memberships'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity, Group } from 'soapbox/types/entities'; + +const GROUP_EDITOR_SET = 'GROUP_EDITOR_SET'; + +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_DELETE_REQUEST = 'GROUP_DELETE_REQUEST'; +const GROUP_DELETE_SUCCESS = 'GROUP_DELETE_SUCCESS'; +const GROUP_DELETE_FAIL = 'GROUP_DELETE_FAIL'; + +const GROUP_FETCH_REQUEST = 'GROUP_FETCH_REQUEST'; +const GROUP_FETCH_SUCCESS = 'GROUP_FETCH_SUCCESS'; +const GROUP_FETCH_FAIL = 'GROUP_FETCH_FAIL'; + +const GROUPS_FETCH_REQUEST = 'GROUPS_FETCH_REQUEST'; +const GROUPS_FETCH_SUCCESS = 'GROUPS_FETCH_SUCCESS'; +const GROUPS_FETCH_FAIL = 'GROUPS_FETCH_FAIL'; + +const GROUP_RELATIONSHIPS_FETCH_REQUEST = 'GROUP_RELATIONSHIPS_FETCH_REQUEST'; +const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS'; +const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL'; + +const GROUP_JOIN_REQUEST = 'GROUP_JOIN_REQUEST'; +const GROUP_JOIN_SUCCESS = 'GROUP_JOIN_SUCCESS'; +const GROUP_JOIN_FAIL = 'GROUP_JOIN_FAIL'; + +const GROUP_LEAVE_REQUEST = 'GROUP_LEAVE_REQUEST'; +const GROUP_LEAVE_SUCCESS = 'GROUP_LEAVE_SUCCESS'; +const GROUP_LEAVE_FAIL = 'GROUP_LEAVE_FAIL'; + +const GROUP_DELETE_STATUS_REQUEST = 'GROUP_DELETE_STATUS_REQUEST'; +const GROUP_DELETE_STATUS_SUCCESS = 'GROUP_DELETE_STATUS_SUCCESS'; +const GROUP_DELETE_STATUS_FAIL = 'GROUP_DELETE_STATUS_FAIL'; + +const GROUP_KICK_REQUEST = 'GROUP_KICK_REQUEST'; +const GROUP_KICK_SUCCESS = 'GROUP_KICK_SUCCESS'; +const GROUP_KICK_FAIL = 'GROUP_KICK_FAIL'; + +const GROUP_BLOCKS_FETCH_REQUEST = 'GROUP_BLOCKS_FETCH_REQUEST'; +const GROUP_BLOCKS_FETCH_SUCCESS = 'GROUP_BLOCKS_FETCH_SUCCESS'; +const GROUP_BLOCKS_FETCH_FAIL = 'GROUP_BLOCKS_FETCH_FAIL'; + +const GROUP_BLOCKS_EXPAND_REQUEST = 'GROUP_BLOCKS_EXPAND_REQUEST'; +const GROUP_BLOCKS_EXPAND_SUCCESS = 'GROUP_BLOCKS_EXPAND_SUCCESS'; +const GROUP_BLOCKS_EXPAND_FAIL = 'GROUP_BLOCKS_EXPAND_FAIL'; + +const GROUP_BLOCK_REQUEST = 'GROUP_BLOCK_REQUEST'; +const GROUP_BLOCK_SUCCESS = 'GROUP_BLOCK_SUCCESS'; +const GROUP_BLOCK_FAIL = 'GROUP_BLOCK_FAIL'; + +const GROUP_UNBLOCK_REQUEST = 'GROUP_UNBLOCK_REQUEST'; +const GROUP_UNBLOCK_SUCCESS = 'GROUP_UNBLOCK_SUCCESS'; +const GROUP_UNBLOCK_FAIL = 'GROUP_UNBLOCK_FAIL'; + +const GROUP_PROMOTE_REQUEST = 'GROUP_PROMOTE_REQUEST'; +const GROUP_PROMOTE_SUCCESS = 'GROUP_PROMOTE_SUCCESS'; +const GROUP_PROMOTE_FAIL = 'GROUP_PROMOTE_FAIL'; + +const GROUP_DEMOTE_REQUEST = 'GROUP_DEMOTE_REQUEST'; +const GROUP_DEMOTE_SUCCESS = 'GROUP_DEMOTE_SUCCESS'; +const GROUP_DEMOTE_FAIL = 'GROUP_DEMOTE_FAIL'; + +const GROUP_MEMBERSHIPS_FETCH_REQUEST = 'GROUP_MEMBERSHIPS_FETCH_REQUEST'; +const GROUP_MEMBERSHIPS_FETCH_SUCCESS = 'GROUP_MEMBERSHIPS_FETCH_SUCCESS'; +const GROUP_MEMBERSHIPS_FETCH_FAIL = 'GROUP_MEMBERSHIPS_FETCH_FAIL'; + +const GROUP_MEMBERSHIPS_EXPAND_REQUEST = 'GROUP_MEMBERSHIPS_EXPAND_REQUEST'; +const GROUP_MEMBERSHIPS_EXPAND_SUCCESS = 'GROUP_MEMBERSHIPS_EXPAND_SUCCESS'; +const GROUP_MEMBERSHIPS_EXPAND_FAIL = 'GROUP_MEMBERSHIPS_EXPAND_FAIL'; + +const GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST = 'GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST'; +const GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS = 'GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS'; +const GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL = 'GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL'; + +const GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST = 'GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST'; +const GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS = 'GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS'; +const GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL = 'GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL'; + +const GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST = 'GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST'; +const GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS'; +const GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL = 'GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL'; + +const GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST = 'GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST'; +const GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS'; +const GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL = 'GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL'; + +const GROUP_EDITOR_TITLE_CHANGE = 'GROUP_EDITOR_TITLE_CHANGE'; +const GROUP_EDITOR_DESCRIPTION_CHANGE = 'GROUP_EDITOR_DESCRIPTION_CHANGE'; +const GROUP_EDITOR_PRIVACY_CHANGE = 'GROUP_EDITOR_PRIVACY_CHANGE'; +const GROUP_EDITOR_MEDIA_CHANGE = 'GROUP_EDITOR_MEDIA_CHANGE'; + +const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET'; + +const messages = defineMessages({ + success: { id: 'manage_group.submit_success', defaultMessage: 'The group was created' }, + editSuccess: { id: 'manage_group.edit_success', defaultMessage: 'The group was edited' }, + joinSuccess: { id: 'group.join.success', defaultMessage: 'Joined the group' }, + joinRequestSuccess: { id: 'group.join.request_success', defaultMessage: 'Requested to join the group' }, + leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' }, + view: { id: 'toast.view', defaultMessage: 'View' }, +}); + +const editGroup = (group: Group) => (dispatch: AppDispatch) => { + dispatch({ + type: GROUP_EDITOR_SET, + group, + }); + dispatch(openModal('MANAGE_GROUP')); +}; + +const createGroup = (params: Record, shouldReset?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(createGroupRequest()); + + return api(getState).post('/api/v1/groups', params, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + .then(({ data }) => { + dispatch(importFetchedGroups([data])); + dispatch(createGroupSuccess(data)); + toast.success(messages.success, { + actionLabel: messages.view, + actionLink: `/groups/${data.id}`, + }); + + if (shouldReset) { + dispatch(resetGroupEditor()); + } + dispatch(closeModal('MANAGE_GROUP')); + }).catch(err => dispatch(createGroupFail(err))); + }; + +const createGroupRequest = () => ({ + type: GROUP_CREATE_REQUEST, +}); + +const createGroupSuccess = (group: APIEntity) => ({ + type: GROUP_CREATE_SUCCESS, + group, +}); + +const createGroupFail = (error: AxiosError) => ({ + type: GROUP_CREATE_FAIL, + error, +}); + +const updateGroup = (id: string, params: Record, shouldReset?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(updateGroupRequest()); + + return api(getState).put(`/api/v1/groups/${id}`, params) + .then(({ data }) => { + dispatch(importFetchedGroups([data])); + dispatch(updateGroupSuccess(data)); + toast.success(messages.editSuccess); + + if (shouldReset) { + dispatch(resetGroupEditor()); + } + dispatch(closeModal('MANAGE_GROUP')); + }).catch(err => dispatch(updateGroupFail(err))); + }; + +const updateGroupRequest = () => ({ + type: GROUP_UPDATE_REQUEST, +}); + +const updateGroupSuccess = (group: APIEntity) => ({ + type: GROUP_UPDATE_SUCCESS, + group, +}); + +const updateGroupFail = (error: AxiosError) => ({ + type: GROUP_UPDATE_FAIL, + error, +}); + +const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(deleteGroupRequest(id)); + + return api(getState).delete(`/api/v1/groups/${id}`) + .then(() => dispatch(deleteGroupSuccess(id))) + .catch(err => dispatch(deleteGroupFail(id, err))); +}; + +const deleteGroupRequest = (id: string) => ({ + type: GROUP_DELETE_REQUEST, + id, +}); + +const deleteGroupSuccess = (id: string) => ({ + type: GROUP_DELETE_SUCCESS, + id, +}); + +const deleteGroupFail = (id: string, error: AxiosError) => ({ + type: GROUP_DELETE_FAIL, + id, + error, +}); + +const fetchGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchGroupRelationships([id])); + dispatch(fetchGroupRequest(id)); + + return api(getState).get(`/api/v1/groups/${id}`) + .then(({ data }) => { + dispatch(importFetchedGroups([data])); + dispatch(fetchGroupSuccess(data)); + }) + .catch(err => dispatch(fetchGroupFail(id, err))); +}; + +const fetchGroupRequest = (id: string) => ({ + type: GROUP_FETCH_REQUEST, + id, +}); + +const fetchGroupSuccess = (group: APIEntity) => ({ + type: GROUP_FETCH_SUCCESS, + group, +}); + +const fetchGroupFail = (id: string, error: AxiosError) => ({ + type: GROUP_FETCH_FAIL, + id, + error, +}); + +const fetchGroups = () => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchGroupsRequest()); + + return api(getState).get('/api/v1/groups') + .then(({ data }) => { + dispatch(importFetchedGroups(data)); + dispatch(fetchGroupsSuccess(data)); + dispatch(fetchGroupRelationships(data.map((item: APIEntity) => item.id))); + }).catch(err => dispatch(fetchGroupsFail(err))); +}; + +const fetchGroupsRequest = () => ({ + type: GROUPS_FETCH_REQUEST, +}); + +const fetchGroupsSuccess = (groups: APIEntity[]) => ({ + type: GROUPS_FETCH_SUCCESS, + groups, +}); + +const fetchGroupsFail = (error: AxiosError) => ({ + type: GROUPS_FETCH_FAIL, + error, +}); + +const fetchGroupRelationships = (groupIds: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const loadedRelationships = state.group_relationships; + const newGroupIds = groupIds.filter(id => loadedRelationships.get(id, null) === null); + + if (!state.me || newGroupIds.length === 0) { + return; + } + + dispatch(fetchGroupRelationshipsRequest(newGroupIds)); + + return api(getState).get(`/api/v1/groups/relationships?${newGroupIds.map(id => `id[]=${id}`).join('&')}`).then(response => { + dispatch(fetchGroupRelationshipsSuccess(response.data)); + }).catch(error => { + dispatch(fetchGroupRelationshipsFail(error)); + }); + }; + +const fetchGroupRelationshipsRequest = (ids: string[]) => ({ + type: GROUP_RELATIONSHIPS_FETCH_REQUEST, + ids, + skipLoading: true, +}); + +const fetchGroupRelationshipsSuccess = (relationships: APIEntity[]) => ({ + type: GROUP_RELATIONSHIPS_FETCH_SUCCESS, + relationships, + skipLoading: true, +}); + +const fetchGroupRelationshipsFail = (error: AxiosError) => ({ + type: GROUP_RELATIONSHIPS_FETCH_FAIL, + error, + skipLoading: true, + skipNotFound: true, +}); + +const joinGroup = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const locked = (getState().groups.items.get(id) as any).locked || false; + + dispatch(joinGroupRequest(id, locked)); + + return api(getState).post(`/api/v1/groups/${id}/join`).then(response => { + dispatch(joinGroupSuccess(response.data)); + toast.success(locked ? messages.joinRequestSuccess : messages.joinSuccess); + }).catch(error => { + dispatch(joinGroupFail(error, locked)); + }); + }; + +const leaveGroup = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(leaveGroupRequest(id)); + + return api(getState).post(`/api/v1/groups/${id}/leave`).then(response => { + dispatch(leaveGroupSuccess(response.data)); + toast.success(messages.leaveSuccess); + }).catch(error => { + dispatch(leaveGroupFail(error)); + }); + }; + +const joinGroupRequest = (id: string, locked: boolean) => ({ + type: GROUP_JOIN_REQUEST, + id, + locked, + skipLoading: true, +}); + +const joinGroupSuccess = (relationship: APIEntity) => ({ + type: GROUP_JOIN_SUCCESS, + relationship, + skipLoading: true, +}); + +const joinGroupFail = (error: AxiosError, locked: boolean) => ({ + type: GROUP_JOIN_FAIL, + error, + locked, + skipLoading: true, +}); + +const leaveGroupRequest = (id: string) => ({ + type: GROUP_LEAVE_REQUEST, + id, + skipLoading: true, +}); + +const leaveGroupSuccess = (relationship: APIEntity) => ({ + type: GROUP_LEAVE_SUCCESS, + relationship, + skipLoading: true, +}); + +const leaveGroupFail = (error: AxiosError) => ({ + type: GROUP_LEAVE_FAIL, + error, + skipLoading: true, +}); + +const groupDeleteStatus = (groupId: string, statusId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupDeleteStatusRequest(groupId, statusId)); + + return api(getState).delete(`/api/v1/groups/${groupId}/statuses/${statusId}`) + .then(() => { + dispatch(deleteFromTimelines(statusId)); + dispatch(groupDeleteStatusSuccess(groupId, statusId)); + }).catch(err => dispatch(groupDeleteStatusFail(groupId, statusId, err))); + }; + +const groupDeleteStatusRequest = (groupId: string, statusId: string) => ({ + type: GROUP_DELETE_STATUS_REQUEST, + groupId, + statusId, +}); + +const groupDeleteStatusSuccess = (groupId: string, statusId: string) => ({ + type: GROUP_DELETE_STATUS_SUCCESS, + groupId, + statusId, +}); + +const groupDeleteStatusFail = (groupId: string, statusId: string, error: AxiosError) => ({ + type: GROUP_DELETE_STATUS_SUCCESS, + groupId, + statusId, + error, +}); + +const groupKick = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupKickRequest(groupId, accountId)); + + return api(getState).post(`/api/v1/groups/${groupId}/kick`, { account_ids: [accountId] }) + .then(() => dispatch(groupKickSuccess(groupId, accountId))) + .catch(err => dispatch(groupKickFail(groupId, accountId, err))); + }; + +const groupKickRequest = (groupId: string, accountId: string) => ({ + type: GROUP_KICK_REQUEST, + groupId, + accountId, +}); + +const groupKickSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_KICK_SUCCESS, + groupId, + accountId, +}); + +const groupKickFail = (groupId: string, accountId: string, error: AxiosError) => ({ + type: GROUP_KICK_SUCCESS, + groupId, + accountId, + error, +}); + +const fetchGroupBlocks = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchGroupBlocksRequest(id)); + + return api(getState).get(`/api/v1/groups/${id}/blocks`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchGroupBlocksSuccess(id, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchGroupBlocksFail(id, error)); + }); + }; + +const fetchGroupBlocksRequest = (id: string) => ({ + type: GROUP_BLOCKS_FETCH_REQUEST, + id, +}); + +const fetchGroupBlocksSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_BLOCKS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchGroupBlocksFail = (id: string, error: AxiosError) => ({ + type: GROUP_BLOCKS_FETCH_FAIL, + id, + error, + skipNotFound: true, +}); + +const expandGroupBlocks = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().user_lists.group_blocks.get(id)?.next || null; + + if (url === null) { + return; + } + + dispatch(expandGroupBlocksRequest(id)); + + return api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandGroupBlocksSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandGroupBlocksFail(id, error)); + }); + }; + +const expandGroupBlocksRequest = (id: string) => ({ + type: GROUP_BLOCKS_EXPAND_REQUEST, + id, +}); + +const expandGroupBlocksSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_BLOCKS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandGroupBlocksFail = (id: string, error: AxiosError) => ({ + type: GROUP_BLOCKS_EXPAND_FAIL, + id, + error, +}); + +const groupBlock = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupBlockRequest(groupId, accountId)); + + return api(getState).post(`/api/v1/groups/${groupId}/blocks`, { account_ids: [accountId] }) + .then(() => dispatch(groupBlockSuccess(groupId, accountId))) + .catch(err => dispatch(groupBlockFail(groupId, accountId, err))); + }; + +const groupBlockRequest = (groupId: string, accountId: string) => ({ + type: GROUP_BLOCK_REQUEST, + groupId, + accountId, +}); + +const groupBlockSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_BLOCK_SUCCESS, + groupId, + accountId, +}); + +const groupBlockFail = (groupId: string, accountId: string, error: AxiosError) => ({ + type: GROUP_BLOCK_FAIL, + groupId, + accountId, + error, +}); + +const groupUnblock = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupUnblockRequest(groupId, accountId)); + + return api(getState).delete(`/api/v1/groups/${groupId}/blocks?account_ids[]=${accountId}`) + .then(() => dispatch(groupUnblockSuccess(groupId, accountId))) + .catch(err => dispatch(groupUnblockFail(groupId, accountId, err))); + }; + +const groupUnblockRequest = (groupId: string, accountId: string) => ({ + type: GROUP_UNBLOCK_REQUEST, + groupId, + accountId, +}); + +const groupUnblockSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_UNBLOCK_SUCCESS, + groupId, + accountId, +}); + +const groupUnblockFail = (groupId: string, accountId: string, error: AxiosError) => ({ + type: GROUP_UNBLOCK_FAIL, + groupId, + accountId, + error, +}); + +const groupPromoteAccount = (groupId: string, accountId: string, role: GroupRole) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupPromoteAccountRequest(groupId, accountId)); + + return api(getState).post(`/api/v1/groups/${groupId}/promote`, { account_ids: [accountId], role: role }) + .then((response) => dispatch(groupPromoteAccountSuccess(groupId, accountId, response.data))) + .catch(err => dispatch(groupPromoteAccountFail(groupId, accountId, err))); + }; + +const groupPromoteAccountRequest = (groupId: string, accountId: string) => ({ + type: GROUP_PROMOTE_REQUEST, + groupId, + accountId, +}); + +const groupPromoteAccountSuccess = (groupId: string, accountId: string, memberships: APIEntity[]) => ({ + type: GROUP_PROMOTE_SUCCESS, + groupId, + accountId, + memberships, +}); + +const groupPromoteAccountFail = (groupId: string, accountId: string, error: AxiosError) => ({ + type: GROUP_PROMOTE_FAIL, + groupId, + accountId, + error, +}); + +const groupDemoteAccount = (groupId: string, accountId: string, role: GroupRole) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(groupDemoteAccountRequest(groupId, accountId)); + + return api(getState).post(`/api/v1/groups/${groupId}/demote`, { account_ids: [accountId], role: role }) + .then((response) => dispatch(groupDemoteAccountSuccess(groupId, accountId, response.data))) + .catch(err => dispatch(groupDemoteAccountFail(groupId, accountId, err))); + }; + +const groupDemoteAccountRequest = (groupId: string, accountId: string) => ({ + type: GROUP_DEMOTE_REQUEST, + groupId, + accountId, +}); + +const groupDemoteAccountSuccess = (groupId: string, accountId: string, memberships: APIEntity[]) => ({ + type: GROUP_DEMOTE_SUCCESS, + groupId, + accountId, + memberships, +}); + +const groupDemoteAccountFail = (groupId: string, accountId: string, error: AxiosError) => ({ + type: GROUP_DEMOTE_FAIL, + groupId, + accountId, + error, +}); + +const fetchGroupMemberships = (id: string, role: GroupRole) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchGroupMembershipsRequest(id, role)); + + return api(getState).get(`/api/v1/groups/${id}/memberships`, { params: { role } }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data.map((membership: APIEntity) => membership.account))); + dispatch(fetchGroupMembershipsSuccess(id, role, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchGroupMembershipsFail(id, role, error)); + }); + }; + +const fetchGroupMembershipsRequest = (id: string, role: GroupRole) => ({ + type: GROUP_MEMBERSHIPS_FETCH_REQUEST, + id, + role, +}); + +const fetchGroupMembershipsSuccess = (id: string, role: GroupRole, memberships: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIPS_FETCH_SUCCESS, + id, + role, + memberships, + next, +}); + +const fetchGroupMembershipsFail = (id: string, role: GroupRole, error: AxiosError) => ({ + type: GROUP_MEMBERSHIPS_FETCH_FAIL, + id, + role, + error, + skipNotFound: true, +}); + +const expandGroupMemberships = (id: string, role: GroupRole) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().group_memberships.get(role).get(id)?.next || null; + + if (url === null) { + return; + } + + dispatch(expandGroupMembershipsRequest(id, role)); + + return api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data.map((membership: APIEntity) => membership.account))); + dispatch(expandGroupMembershipsSuccess(id, role, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandGroupMembershipsFail(id, role, error)); + }); + }; + +const expandGroupMembershipsRequest = (id: string, role: GroupRole) => ({ + type: GROUP_MEMBERSHIPS_EXPAND_REQUEST, + id, + role, +}); + +const expandGroupMembershipsSuccess = (id: string, role: GroupRole, memberships: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIPS_EXPAND_SUCCESS, + id, + role, + memberships, + next, +}); + +const expandGroupMembershipsFail = (id: string, role: GroupRole, error: AxiosError) => ({ + type: GROUP_MEMBERSHIPS_EXPAND_FAIL, + id, + role, + error, +}); + +const fetchGroupMembershipRequests = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchGroupMembershipRequestsRequest(id)); + + return api(getState).get(`/api/v1/groups/${id}/membership_requests`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchGroupMembershipRequestsSuccess(id, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchGroupMembershipRequestsFail(id, error)); + }); + }; + +const fetchGroupMembershipRequestsRequest = (id: string) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST, + id, +}); + +const fetchGroupMembershipRequestsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchGroupMembershipRequestsFail = (id: string, error: AxiosError) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL, + id, + error, + skipNotFound: true, +}); + +const expandGroupMembershipRequests = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().user_lists.membership_requests.get(id)?.next || null; + + if (url === null) { + return; + } + + dispatch(expandGroupMembershipRequestsRequest(id)); + + return api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandGroupMembershipRequestsSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandGroupMembershipRequestsFail(id, error)); + }); + }; + +const expandGroupMembershipRequestsRequest = (id: string) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST, + id, +}); + +const expandGroupMembershipRequestsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandGroupMembershipRequestsFail = (id: string, error: AxiosError) => ({ + type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL, + id, + error, +}); + +const authorizeGroupMembershipRequest = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(authorizeGroupMembershipRequestRequest(groupId, accountId)); + + return api(getState) + .post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`) + .then(() => dispatch(authorizeGroupMembershipRequestSuccess(groupId, accountId))) + .catch(error => dispatch(authorizeGroupMembershipRequestFail(groupId, accountId, error))); + }; + +const authorizeGroupMembershipRequestRequest = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST, + groupId, + accountId, +}); + +const authorizeGroupMembershipRequestSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS, + groupId, + accountId, +}); + +const authorizeGroupMembershipRequestFail = (groupId: string, accountId: string, error: AxiosError) => ({ + type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL, + groupId, + accountId, + error, +}); + +const rejectGroupMembershipRequest = (groupId: string, accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(rejectGroupMembershipRequestRequest(groupId, accountId)); + + return api(getState) + .post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`) + .then(() => dispatch(rejectGroupMembershipRequestSuccess(groupId, accountId))) + .catch(error => dispatch(rejectGroupMembershipRequestFail(groupId, accountId, error))); + }; + +const rejectGroupMembershipRequestRequest = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST, + groupId, + accountId, +}); + +const rejectGroupMembershipRequestSuccess = (groupId: string, accountId: string) => ({ + type: GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS, + groupId, + accountId, +}); + +const rejectGroupMembershipRequestFail = (groupId: string, accountId: string, error?: AxiosError) => ({ + type: GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL, + groupId, + accountId, + error, +}); + +const changeGroupEditorTitle = (value: string) => ({ + type: GROUP_EDITOR_TITLE_CHANGE, + value, +}); + +const changeGroupEditorDescription = (value: string) => ({ + type: GROUP_EDITOR_DESCRIPTION_CHANGE, + value, +}); + +const changeGroupEditorPrivacy = (value: boolean) => ({ + type: GROUP_EDITOR_PRIVACY_CHANGE, + value, +}); + +const changeGroupEditorMedia = (mediaType: 'header' | 'avatar', file: File) => ({ + type: GROUP_EDITOR_MEDIA_CHANGE, + mediaType, + value: file, +}); + +const resetGroupEditor = () => ({ + type: GROUP_EDITOR_RESET, +}); + +const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => { + const groupId = getState().group_editor.groupId; + const displayName = getState().group_editor.displayName; + const note = getState().group_editor.note; + const avatar = getState().group_editor.avatar; + const header = getState().group_editor.header; + + const params: Record = { + display_name: displayName, + note, + }; + + if (avatar) params.avatar = avatar; + if (header) params.header = header; + + if (groupId === null) { + dispatch(createGroup(params, shouldReset)); + } else { + dispatch(updateGroup(groupId, params, shouldReset)); + } +}; + +export { + GROUP_EDITOR_SET, + GROUP_CREATE_REQUEST, + GROUP_CREATE_SUCCESS, + GROUP_CREATE_FAIL, + GROUP_UPDATE_REQUEST, + GROUP_UPDATE_SUCCESS, + GROUP_UPDATE_FAIL, + GROUP_DELETE_REQUEST, + GROUP_DELETE_SUCCESS, + GROUP_DELETE_FAIL, + GROUP_FETCH_REQUEST, + GROUP_FETCH_SUCCESS, + GROUP_FETCH_FAIL, + GROUPS_FETCH_REQUEST, + GROUPS_FETCH_SUCCESS, + GROUPS_FETCH_FAIL, + GROUP_RELATIONSHIPS_FETCH_REQUEST, + GROUP_RELATIONSHIPS_FETCH_SUCCESS, + GROUP_RELATIONSHIPS_FETCH_FAIL, + GROUP_JOIN_REQUEST, + GROUP_JOIN_SUCCESS, + GROUP_JOIN_FAIL, + GROUP_LEAVE_REQUEST, + GROUP_LEAVE_SUCCESS, + GROUP_LEAVE_FAIL, + GROUP_DELETE_STATUS_REQUEST, + GROUP_DELETE_STATUS_SUCCESS, + GROUP_DELETE_STATUS_FAIL, + GROUP_KICK_REQUEST, + GROUP_KICK_SUCCESS, + GROUP_KICK_FAIL, + GROUP_BLOCKS_FETCH_REQUEST, + GROUP_BLOCKS_FETCH_SUCCESS, + GROUP_BLOCKS_FETCH_FAIL, + GROUP_BLOCKS_EXPAND_REQUEST, + GROUP_BLOCKS_EXPAND_SUCCESS, + GROUP_BLOCKS_EXPAND_FAIL, + GROUP_BLOCK_REQUEST, + GROUP_BLOCK_SUCCESS, + GROUP_BLOCK_FAIL, + GROUP_UNBLOCK_REQUEST, + GROUP_UNBLOCK_SUCCESS, + GROUP_UNBLOCK_FAIL, + GROUP_PROMOTE_REQUEST, + GROUP_PROMOTE_SUCCESS, + GROUP_PROMOTE_FAIL, + GROUP_DEMOTE_REQUEST, + GROUP_DEMOTE_SUCCESS, + GROUP_DEMOTE_FAIL, + GROUP_MEMBERSHIPS_FETCH_REQUEST, + GROUP_MEMBERSHIPS_FETCH_SUCCESS, + GROUP_MEMBERSHIPS_FETCH_FAIL, + GROUP_MEMBERSHIPS_EXPAND_REQUEST, + GROUP_MEMBERSHIPS_EXPAND_SUCCESS, + GROUP_MEMBERSHIPS_EXPAND_FAIL, + GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST, + GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS, + GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL, + GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST, + GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS, + GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL, + GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST, + GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS, + GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL, + GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST, + GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS, + GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL, + GROUP_EDITOR_TITLE_CHANGE, + GROUP_EDITOR_DESCRIPTION_CHANGE, + GROUP_EDITOR_PRIVACY_CHANGE, + GROUP_EDITOR_MEDIA_CHANGE, + GROUP_EDITOR_RESET, + editGroup, + createGroup, + createGroupRequest, + createGroupSuccess, + createGroupFail, + updateGroup, + updateGroupRequest, + updateGroupSuccess, + updateGroupFail, + deleteGroup, + deleteGroupRequest, + deleteGroupSuccess, + deleteGroupFail, + fetchGroup, + fetchGroupRequest, + fetchGroupSuccess, + fetchGroupFail, + fetchGroups, + fetchGroupsRequest, + fetchGroupsSuccess, + fetchGroupsFail, + fetchGroupRelationships, + fetchGroupRelationshipsRequest, + fetchGroupRelationshipsSuccess, + fetchGroupRelationshipsFail, + joinGroup, + leaveGroup, + joinGroupRequest, + joinGroupSuccess, + joinGroupFail, + leaveGroupRequest, + leaveGroupSuccess, + leaveGroupFail, + groupDeleteStatus, + groupDeleteStatusRequest, + groupDeleteStatusSuccess, + groupDeleteStatusFail, + groupKick, + groupKickRequest, + groupKickSuccess, + groupKickFail, + fetchGroupBlocks, + fetchGroupBlocksRequest, + fetchGroupBlocksSuccess, + fetchGroupBlocksFail, + expandGroupBlocks, + expandGroupBlocksRequest, + expandGroupBlocksSuccess, + expandGroupBlocksFail, + groupBlock, + groupBlockRequest, + groupBlockSuccess, + groupBlockFail, + groupUnblock, + groupUnblockRequest, + groupUnblockSuccess, + groupUnblockFail, + groupPromoteAccount, + groupPromoteAccountRequest, + groupPromoteAccountSuccess, + groupPromoteAccountFail, + groupDemoteAccount, + groupDemoteAccountRequest, + groupDemoteAccountSuccess, + groupDemoteAccountFail, + fetchGroupMemberships, + fetchGroupMembershipsRequest, + fetchGroupMembershipsSuccess, + fetchGroupMembershipsFail, + expandGroupMemberships, + expandGroupMembershipsRequest, + expandGroupMembershipsSuccess, + expandGroupMembershipsFail, + fetchGroupMembershipRequests, + fetchGroupMembershipRequestsRequest, + fetchGroupMembershipRequestsSuccess, + fetchGroupMembershipRequestsFail, + expandGroupMembershipRequests, + expandGroupMembershipRequestsRequest, + expandGroupMembershipRequestsSuccess, + expandGroupMembershipRequestsFail, + authorizeGroupMembershipRequest, + authorizeGroupMembershipRequestRequest, + authorizeGroupMembershipRequestSuccess, + authorizeGroupMembershipRequestFail, + rejectGroupMembershipRequest, + rejectGroupMembershipRequestRequest, + rejectGroupMembershipRequestSuccess, + rejectGroupMembershipRequestFail, + changeGroupEditorTitle, + changeGroupEditorDescription, + changeGroupEditorPrivacy, + changeGroupEditorMedia, + resetGroupEditor, + submitGroupEditor, +}; diff --git a/app/soapbox/actions/import-data.ts b/app/soapbox/actions/import-data.ts index 90f81e7e7..023529453 100644 --- a/app/soapbox/actions/import-data.ts +++ b/app/soapbox/actions/import-data.ts @@ -27,8 +27,8 @@ type ImportDataActions = { | typeof IMPORT_BLOCKS_FAIL | typeof IMPORT_MUTES_REQUEST | typeof IMPORT_MUTES_SUCCESS - | typeof IMPORT_MUTES_FAIL, - error?: any, + | typeof IMPORT_MUTES_FAIL + error?: any config?: string } diff --git a/app/soapbox/actions/importer/index.ts b/app/soapbox/actions/importer/index.ts index a5d86c9b0..8750a5d61 100644 --- a/app/soapbox/actions/importer/index.ts +++ b/app/soapbox/actions/importer/index.ts @@ -5,42 +5,44 @@ import type { APIEntity } from 'soapbox/types/entities'; const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; +const GROUP_IMPORT = 'GROUP_IMPORT'; +const GROUPS_IMPORT = 'GROUPS_IMPORT'; const STATUS_IMPORT = 'STATUS_IMPORT'; const STATUSES_IMPORT = 'STATUSES_IMPORT'; const POLLS_IMPORT = 'POLLS_IMPORT'; const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP'; -export function importAccount(account: APIEntity) { - return { type: ACCOUNT_IMPORT, account }; -} +const importAccount = (account: APIEntity) => + ({ type: ACCOUNT_IMPORT, account }); -export function importAccounts(accounts: APIEntity[]) { - return { type: ACCOUNTS_IMPORT, accounts }; -} +const importAccounts = (accounts: APIEntity[]) => + ({ type: ACCOUNTS_IMPORT, accounts }); -export function importStatus(status: APIEntity, idempotencyKey?: string) { - return (dispatch: AppDispatch, getState: () => RootState) => { +const importGroup = (group: APIEntity) => + ({ type: GROUP_IMPORT, group }); + +const importGroups = (groups: APIEntity[]) => + ({ type: GROUPS_IMPORT, groups }); + +const importStatus = (status: APIEntity, idempotencyKey?: string) => + (dispatch: AppDispatch, getState: () => RootState) => { const expandSpoilers = getSettings(getState()).get('expandSpoilers'); return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers }); }; -} -export function importStatuses(statuses: APIEntity[]) { - return (dispatch: AppDispatch, getState: () => RootState) => { +const importStatuses = (statuses: APIEntity[]) => + (dispatch: AppDispatch, getState: () => RootState) => { const expandSpoilers = getSettings(getState()).get('expandSpoilers'); return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers }); }; -} -export function importPolls(polls: APIEntity[]) { - return { type: POLLS_IMPORT, polls }; -} +const importPolls = (polls: APIEntity[]) => + ({ type: POLLS_IMPORT, polls }); -export function importFetchedAccount(account: APIEntity) { - return importFetchedAccounts([account]); -} +const importFetchedAccount = (account: APIEntity) => + importFetchedAccounts([account]); -export function importFetchedAccounts(accounts: APIEntity[], args = { should_refetch: false }) { +const importFetchedAccounts = (accounts: APIEntity[], args = { should_refetch: false }) => { const { should_refetch } = args; const normalAccounts: APIEntity[] = []; @@ -61,10 +63,27 @@ export function importFetchedAccounts(accounts: APIEntity[], args = { should_ref accounts.forEach(processAccount); return importAccounts(normalAccounts); -} +}; -export function importFetchedStatus(status: APIEntity, idempotencyKey?: string) { - return (dispatch: AppDispatch) => { +const importFetchedGroup = (group: APIEntity) => + importFetchedGroups([group]); + +const importFetchedGroups = (groups: APIEntity[]) => { + const normalGroups: APIEntity[] = []; + + const processGroup = (group: APIEntity) => { + if (!group.id) return; + + normalGroups.push(group); + }; + + groups.forEach(processGroup); + + return importGroups(normalGroups); +}; + +const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) => + (dispatch: AppDispatch) => { // Skip broken statuses if (isBroken(status)) return; @@ -96,10 +115,13 @@ export function importFetchedStatus(status: APIEntity, idempotencyKey?: string) dispatch(importFetchedPoll(status.poll)); } + if (status.group?.id) { + dispatch(importFetchedGroup(status.group)); + } + dispatch(importFetchedAccount(status.account)); dispatch(importStatus(status, idempotencyKey)); }; -} // Sometimes Pleroma can return an empty account, // or a repost can appear of a deleted account. Skip these statuses. @@ -117,8 +139,8 @@ const isBroken = (status: APIEntity) => { } }; -export function importFetchedStatuses(statuses: APIEntity[]) { - return (dispatch: AppDispatch, getState: () => RootState) => { +const importFetchedStatuses = (statuses: APIEntity[]) => + (dispatch: AppDispatch, getState: () => RootState) => { const accounts: APIEntity[] = []; const normalStatuses: APIEntity[] = []; const polls: APIEntity[] = []; @@ -146,6 +168,10 @@ export function importFetchedStatuses(statuses: APIEntity[]) { if (status.poll?.id) { polls.push(status.poll); } + + if (status.group?.id) { + dispatch(importFetchedGroup(status.group)); + } } statuses.forEach(processStatus); @@ -154,23 +180,37 @@ export function importFetchedStatuses(statuses: APIEntity[]) { dispatch(importFetchedAccounts(accounts)); dispatch(importStatuses(normalStatuses)); }; -} -export function importFetchedPoll(poll: APIEntity) { - return (dispatch: AppDispatch) => { +const importFetchedPoll = (poll: APIEntity) => + (dispatch: AppDispatch) => { dispatch(importPolls([poll])); }; -} -export function importErrorWhileFetchingAccountByUsername(username: string) { - return { type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username }; -} +const importErrorWhileFetchingAccountByUsername = (username: string) => + ({ type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username }); export { ACCOUNT_IMPORT, ACCOUNTS_IMPORT, + GROUP_IMPORT, + GROUPS_IMPORT, STATUS_IMPORT, STATUSES_IMPORT, POLLS_IMPORT, ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, + importAccount, + importAccounts, + importGroup, + importGroups, + importStatus, + importStatuses, + importPolls, + importFetchedAccount, + importFetchedAccounts, + importFetchedGroup, + importFetchedGroups, + importFetchedStatus, + importFetchedStatuses, + importFetchedPoll, + importErrorWhileFetchingAccountByUsername, }; diff --git a/app/soapbox/actions/instance.ts b/app/soapbox/actions/instance.ts index ca1fc3ef5..9738718b0 100644 --- a/app/soapbox/actions/instance.ts +++ b/app/soapbox/actions/instance.ts @@ -10,12 +10,12 @@ import api from '../api'; const getMeUrl = (state: RootState) => { const me = state.me; - return state.accounts.getIn([me, 'url']); + return state.accounts.get(me)?.url; }; /** Figure out the appropriate instance to fetch depending on the state */ export const getHost = (state: RootState) => { - const accountUrl = getMeUrl(state) || getAuthUserUrl(state); + const accountUrl = getMeUrl(state) || getAuthUserUrl(state) as string; try { return new URL(accountUrl).host; diff --git a/app/soapbox/actions/me.ts b/app/soapbox/actions/me.ts index 17beae21d..a8b275200 100644 --- a/app/soapbox/actions/me.ts +++ b/app/soapbox/actions/me.ts @@ -6,7 +6,7 @@ import api from '../api'; import { loadCredentials } from './auth'; import { importFetchedAccount } from './importer'; -import type { AxiosError, AxiosRequestHeaders } from 'axios'; +import type { AxiosError, RawAxiosRequestHeaders } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { APIEntity } from 'soapbox/types/entities'; @@ -30,8 +30,8 @@ const getMeUrl = (state: RootState) => { const getMeToken = (state: RootState) => { // Fallback for upgrading IDs to URLs - const accountUrl = getMeUrl(state) || state.auth.get('me'); - return state.auth.getIn(['users', accountUrl, 'access_token']); + const accountUrl = getMeUrl(state) || state.auth.me; + return state.auth.users.get(accountUrl!)?.access_token; }; const fetchMe = () => @@ -46,7 +46,7 @@ const fetchMe = () => } dispatch(fetchMeRequest()); - return dispatch(loadCredentials(token, accountUrl)) + return dispatch(loadCredentials(token, accountUrl!)) .catch(error => dispatch(fetchMeFail(error))); }; @@ -66,7 +66,7 @@ const patchMe = (params: Record, isFormData = false) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(patchMeRequest()); - const headers: AxiosRequestHeaders = isFormData ? { + const headers: RawAxiosRequestHeaders = isFormData ? { 'Content-Type': 'multipart/form-data', } : {}; diff --git a/app/soapbox/actions/notifications.ts b/app/soapbox/actions/notifications.ts index 6ac655143..7b91b64d8 100644 --- a/app/soapbox/actions/notifications.ts +++ b/app/soapbox/actions/notifications.ts @@ -47,7 +47,7 @@ const MAX_QUEUED_NOTIFICATIONS = 40; defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, - group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, + group: { id: 'notifications.group', defaultMessage: '{count, plural, one {# notification} other {# notifications}}' }, }); const fetchRelatedRelationships = (dispatch: AppDispatch, notifications: APIEntity[]) => { @@ -107,7 +107,10 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record< // Desktop notifications try { - if (showAlert && !filtered) { + // eslint-disable-next-line compat/compat + const isNotificationsEnabled = window.Notification?.permission === 'granted'; + + if (showAlert && !filtered && isNotificationsEnabled) { const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : ''); diff --git a/app/soapbox/actions/push-notifications/registerer.ts b/app/soapbox/actions/push-notifications/registerer.ts index 3a9d4fb9e..2ea7fbab8 100644 --- a/app/soapbox/actions/push-notifications/registerer.ts +++ b/app/soapbox/actions/push-notifications/registerer.ts @@ -37,8 +37,8 @@ const subscribe = (registration: ServiceWorkerRegistration, getState: () => Root }); const unsubscribe = ({ registration, subscription }: { - registration: ServiceWorkerRegistration, - subscription: PushSubscription | null, + registration: ServiceWorkerRegistration + subscription: PushSubscription | null }) => subscription ? subscription.unsubscribe().then(() => registration) : new Promise(r => r(registration)); @@ -82,8 +82,8 @@ const register = () => .then(getPushSubscription) // @ts-ignore .then(({ registration, subscription }: { - registration: ServiceWorkerRegistration, - subscription: PushSubscription | null, + registration: ServiceWorkerRegistration + subscription: PushSubscription | null }) => { if (subscription !== null) { // We have a subscription, check if it is still valid diff --git a/app/soapbox/actions/reports.ts b/app/soapbox/actions/reports.ts index 46b51cd55..d6a24a8c8 100644 --- a/app/soapbox/actions/reports.ts +++ b/app/soapbox/actions/reports.ts @@ -21,7 +21,7 @@ const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE'; const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE'; type ReportedEntity = { - status?: Status, + status?: Status chatMessage?: ChatMessage } diff --git a/app/soapbox/actions/search.ts b/app/soapbox/actions/search.ts index a2f165ac0..6d64b6534 100644 --- a/app/soapbox/actions/search.ts +++ b/app/soapbox/actions/search.ts @@ -1,7 +1,7 @@ import api from '../api'; import { fetchRelationships } from './accounts'; -import { importFetchedAccounts, importFetchedStatuses } from './importer'; +import { importFetchedAccounts, importFetchedGroups, importFetchedStatuses } from './importer'; import type { AxiosError } from 'axios'; import type { SearchFilter } from 'soapbox/reducers/search'; @@ -83,6 +83,10 @@ const submitSearch = (filter?: SearchFilter) => dispatch(importFetchedStatuses(response.data.statuses)); } + if (response.data.groups) { + dispatch(importFetchedGroups(response.data.groups)); + } + dispatch(fetchSearchSuccess(response.data, value, type)); dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id))); }).catch(error => { @@ -139,6 +143,10 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: ( dispatch(importFetchedStatuses(data.statuses)); } + if (data.groups) { + dispatch(importFetchedGroups(data.groups)); + } + dispatch(expandSearchSuccess(data, value, type)); dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id))); }).catch(error => { diff --git a/app/soapbox/actions/security.ts b/app/soapbox/actions/security.ts index 48304aa23..4448104b7 100644 --- a/app/soapbox/actions/security.ts +++ b/app/soapbox/actions/security.ts @@ -50,7 +50,7 @@ const MOVE_ACCOUNT_FAIL = 'MOVE_ACCOUNT_FAIL'; const fetchOAuthTokens = () => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: FETCH_TOKENS_REQUEST }); - return api(getState).get('/api/oauth_tokens.json').then(({ data: tokens }) => { + return api(getState).get('/api/oauth_tokens').then(({ data: tokens }) => { dispatch({ type: FETCH_TOKENS_SUCCESS, tokens }); }).catch(() => { dispatch({ type: FETCH_TOKENS_FAIL }); diff --git a/app/soapbox/actions/settings.ts b/app/soapbox/actions/settings.ts index a52ba2255..79ffe1975 100644 --- a/app/soapbox/actions/settings.ts +++ b/app/soapbox/actions/settings.ts @@ -18,7 +18,7 @@ const FE_NAME = 'soapbox_fe'; /** Options when changing/saving settings. */ type SettingOpts = { /** Whether to display an alert when settings are saved. */ - showAlert?: boolean, + showAlert?: boolean } const messages = defineMessages({ @@ -47,7 +47,6 @@ const defaultSettings = ImmutableMap({ autoloadMore: true, systemFont: false, - dyslexicFont: false, demetricator: false, isDeveloper: false, @@ -157,6 +156,8 @@ const defaultSettings = ImmutableMap({ }), }), + groups: ImmutableMap({}), + trends: ImmutableMap({ show: true, }), diff --git a/app/soapbox/actions/soapbox.ts b/app/soapbox/actions/soapbox.ts index 725ff1ae3..790997199 100644 --- a/app/soapbox/actions/soapbox.ts +++ b/app/soapbox/actions/soapbox.ts @@ -32,8 +32,8 @@ const getSoapboxConfig = createSelector([ } // If RGI reacts aren't supported, strip VS16s - // // https://git.pleroma.social/pleroma/pleroma/-/issues/2355 - if (!features.emojiReactsRGI) { + // https://git.pleroma.social/pleroma/pleroma/-/issues/2355 + if (features.emojiReactsNonRGI) { soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s)); } }); diff --git a/app/soapbox/actions/statuses.ts b/app/soapbox/actions/statuses.ts index f9bfca3b0..047d61d71 100644 --- a/app/soapbox/actions/statuses.ts +++ b/app/soapbox/actions/statuses.ts @@ -68,7 +68,7 @@ const createStatus = (params: Record, idempotencyKey: string, statu } dispatch(importFetchedStatus(status, idempotencyKey)); - dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey }); + dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey, editing: !!statusId }); // Poll the backend for the updated card if (status.expectsCard) { diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts index d0ceb6595..c9095e021 100644 --- a/app/soapbox/actions/streaming.ts +++ b/app/soapbox/actions/streaming.ts @@ -2,7 +2,7 @@ import { getSettings } from 'soapbox/actions/settings'; import messages from 'soapbox/locales/messages'; import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats'; import { queryClient } from 'soapbox/queries/client'; -import { getUnreadChatsCount, updateChatListItem } from 'soapbox/utils/chats'; +import { getUnreadChatsCount, updateChatListItem, updateChatMessage } from 'soapbox/utils/chats'; import { removePageItem } from 'soapbox/utils/queries'; import { play, soundCache } from 'soapbox/utils/sounds'; @@ -81,7 +81,7 @@ const updateChatQuery = (chat: IChat) => { }; interface StreamOpts { - statContext?: IStatContext, + statContext?: IStatContext } const connectTimelineStream = ( @@ -170,6 +170,9 @@ const connectTimelineStream = ( } }); break; + case 'chat_message.reaction': // TruthSocial + updateChatMessage(JSON.parse(data.payload)); + break; case 'pleroma:follow_relationships_update': dispatch(updateFollowRelationships(JSON.parse(data.payload))); break; diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts index 5e4bd26d6..7ae023338 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -219,6 +219,9 @@ const expandListTimeline = (id: string, { maxId }: Record = {}, don const expandGroupTimeline = (id: string, { maxId }: Record = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done); +const expandGroupMediaTimeline = (id: string | number, { maxId }: Record = {}) => + expandTimeline(`group:${id}:media`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: true, limit: 40, with_muted: true }); + const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record = {}, done = noOp) => { return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId, @@ -309,6 +312,7 @@ export { expandAccountMediaTimeline, expandListTimeline, expandGroupTimeline, + expandGroupMediaTimeline, expandHashtagTimeline, expandTimelineRequest, expandTimelineSuccess, diff --git a/app/soapbox/actions/verification.ts b/app/soapbox/actions/verification.ts index d038a79a9..2273bb499 100644 --- a/app/soapbox/actions/verification.ts +++ b/app/soapbox/actions/verification.ts @@ -31,14 +31,14 @@ const AGE: Challenge = 'age'; export type Challenge = 'age' | 'sms' | 'email' type Challenges = { - email?: 0 | 1, - sms?: 0 | 1, - age?: 0 | 1, + email?: 0 | 1 + sms?: 0 | 1 + age?: 0 | 1 } type Verification = { - token?: string, - challenges?: Challenges, + token?: string + challenges?: Challenges challengeTypes?: Array<'age' | 'sms' | 'email'> }; diff --git a/app/soapbox/api/index.ts b/app/soapbox/api/index.ts index 2a221d0d1..c7fcb6230 100644 --- a/app/soapbox/api/index.ts +++ b/app/soapbox/api/index.ts @@ -43,7 +43,7 @@ const maybeParseJSON = (data: string) => { const getAuthBaseURL = createSelector([ (state: RootState, me: string | false | null) => state.accounts.getIn([me, 'url']), - (state: RootState, _me: string | false | null) => state.auth.get('me'), + (state: RootState, _me: string | false | null) => state.auth.me, ], (accountUrl, authUserUrl) => { const baseURL = parseBaseURL(accountUrl) || parseBaseURL(authUserUrl); return baseURL !== window.location.origin ? baseURL : ''; diff --git a/app/soapbox/base-polyfills.ts b/app/soapbox/base-polyfills.ts deleted file mode 100644 index a6e92bb3c..000000000 --- a/app/soapbox/base-polyfills.ts +++ /dev/null @@ -1,50 +0,0 @@ -'use strict'; - -import 'intl'; -import 'intl/locale-data/jsonp/en'; -import 'es6-symbol/implement'; -// @ts-ignore: No types -import includes from 'array-includes'; -// @ts-ignore: No types -import isNaN from 'is-nan'; -import assign from 'object-assign'; -// @ts-ignore: No types -import values from 'object.values'; - -import { decode as decodeBase64 } from './utils/base64'; - -if (!Array.prototype.includes) { - includes.shim(); -} - -if (!Object.assign) { - Object.assign = assign; -} - -if (!Object.values) { - values.shim(); -} - -if (!Number.isNaN) { - Number.isNaN = isNaN; -} - -if (!HTMLCanvasElement.prototype.toBlob) { - const BASE64_MARKER = ';base64,'; - - Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { - value(callback: any, type = 'image/png', quality: any) { - const dataURL = this.toDataURL(type, quality); - let data; - - if (dataURL.includes(BASE64_MARKER)) { - const [, base64] = dataURL.split(BASE64_MARKER); - data = decodeBase64(base64); - } else { - [, data] = dataURL.split(','); - } - - callback(new Blob([data], { type })); - }, - }); -} diff --git a/app/soapbox/components/__mocks__/react-inlinesvg.tsx b/app/soapbox/components/__mocks__/react-inlinesvg.tsx index 1d4fde154..1317dcbcb 100644 --- a/app/soapbox/components/__mocks__/react-inlinesvg.tsx +++ b/app/soapbox/components/__mocks__/react-inlinesvg.tsx @@ -1,7 +1,7 @@ import React from 'react'; interface IInlineSVG { - loader?: JSX.Element, + loader?: JSX.Element } const InlineSVG: React.FC = ({ loader }): JSX.Element => { diff --git a/app/soapbox/components/__tests__/avatar-overlay.test.tsx b/app/soapbox/components/__tests__/avatar-overlay.test.tsx deleted file mode 100644 index 4e83dd071..000000000 --- a/app/soapbox/components/__tests__/avatar-overlay.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; - -import { normalizeAccount } from 'soapbox/normalizers'; - -import { render, screen } from '../../jest/test-helpers'; -import AvatarOverlay from '../avatar-overlay'; - -import type { ReducerAccount } from 'soapbox/reducers/accounts'; - -describe(' { - const account = normalizeAccount({ - username: 'alice', - acct: 'alice', - display_name: 'Alice', - avatar: '/animated/alice.gif', - avatar_static: '/static/alice.jpg', - }) as ReducerAccount; - - const friend = normalizeAccount({ - username: 'eve', - acct: 'eve@blackhat.lair', - display_name: 'Evelyn', - avatar: '/animated/eve.gif', - avatar_static: '/static/eve.jpg', - }) as ReducerAccount; - - it('renders a overlay avatar', () => { - render(); - expect(screen.queryAllByRole('img')).toHaveLength(2); - }); -}); diff --git a/app/soapbox/components/__tests__/avatar.test.tsx b/app/soapbox/components/__tests__/avatar.test.tsx deleted file mode 100644 index 56f592925..000000000 --- a/app/soapbox/components/__tests__/avatar.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; - -import { normalizeAccount } from 'soapbox/normalizers'; - -import { render, screen } from '../../jest/test-helpers'; -import Avatar from '../avatar'; - -import type { ReducerAccount } from 'soapbox/reducers/accounts'; - -describe('', () => { - const account = normalizeAccount({ - username: 'alice', - acct: 'alice', - display_name: 'Alice', - avatar: '/animated/alice.gif', - avatar_static: '/static/alice.jpg', - }) as ReducerAccount; - - const size = 100; - - // describe('Autoplay', () => { - // it('renders an animated avatar', () => { - // render(); - - // expect(screen.getByRole('img').getAttribute('src')).toBe(account.get('avatar')); - // }); - // }); - - describe('Still', () => { - it('renders a still avatar', () => { - render(); - - expect(screen.getByRole('img').getAttribute('src')).toBe(account.get('avatar')); - }); - }); - - // TODO add autoplay test if possible -}); diff --git a/app/soapbox/components/__tests__/emoji-selector.test.tsx b/app/soapbox/components/__tests__/emoji-selector.test.tsx deleted file mode 100644 index b382a4b94..000000000 --- a/app/soapbox/components/__tests__/emoji-selector.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; - -import { render, screen } from '../../jest/test-helpers'; -import EmojiSelector from '../emoji-selector'; - -describe('', () => { - it('renders correctly', () => { - const children = ; - // @ts-ignore - children.__proto__.addEventListener = () => {}; - - render(children); - - expect(screen.queryAllByRole('button')).toHaveLength(6); - }); -}); diff --git a/app/soapbox/components/account-search.tsx b/app/soapbox/components/account-search.tsx index c519b0243..cbaab0f18 100644 --- a/app/soapbox/components/account-search.tsx +++ b/app/soapbox/components/account-search.tsx @@ -1,4 +1,4 @@ -import classNames from 'clsx'; +import clsx from 'clsx'; import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; @@ -12,9 +12,9 @@ const messages = defineMessages({ interface IAccountSearch { /** Callback when a searched account is chosen. */ - onSelected: (accountId: string) => void, + onSelected: (accountId: string) => void /** Override the default placeholder of the input. */ - placeholder?: string, + placeholder?: string } /** Input to search for accounts. */ @@ -72,17 +72,17 @@ const AccountSearch: React.FC = ({ onSelected, ...rest }) => {
diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index 322a98119..0a435f48f 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -1,28 +1,38 @@ -import React from 'react'; +import React, { useRef } from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper'; import VerificationBadge from 'soapbox/components/verification-badge'; import ActionButton from 'soapbox/features/ui/components/action-button'; -import { useAppSelector, useOnScreen } from 'soapbox/hooks'; +import { useAppSelector } from 'soapbox/hooks'; import { getAcct } from 'soapbox/utils/accounts'; import { displayFqn } from 'soapbox/utils/state'; +import Badge from './badge'; import RelativeTimestamp from './relative-timestamp'; import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui'; +import type { StatusApprovalStatus } from 'soapbox/normalizers/status'; import type { Account as AccountEntity } from 'soapbox/types/entities'; interface IInstanceFavicon { - account: AccountEntity, + account: AccountEntity + disabled?: boolean } -const InstanceFavicon: React.FC = ({ account }) => { +const messages = defineMessages({ + bot: { id: 'account.badges.bot', defaultMessage: 'Bot' }, +}); + +const InstanceFavicon: React.FC = ({ account, disabled }) => { const history = useHistory(); const handleClick: React.MouseEventHandler = (e) => { e.stopPropagation(); + if (disabled) return; + const timelineUrl = `/timeline/${account.domain}`; if (!(e.ctrlKey || e.metaKey)) { history.push(timelineUrl); @@ -32,44 +42,55 @@ const InstanceFavicon: React.FC = ({ account }) => { }; return ( - ); }; interface IProfilePopper { - condition: boolean, - wrapper: (children: any) => React.ReactElement + condition: boolean + wrapper: (children: React.ReactNode) => React.ReactNode + children: React.ReactNode } -const ProfilePopper: React.FC = ({ condition, wrapper, children }): any => - condition ? wrapper(children) : children; +const ProfilePopper: React.FC = ({ condition, wrapper, children }) => { + return ( + <> + {condition ? wrapper(children) : children} + + ); +}; -interface IAccount { - account: AccountEntity, - action?: React.ReactElement, - actionAlignment?: 'center' | 'top', - actionIcon?: string, - actionTitle?: string, +export interface IAccount { + account: AccountEntity + action?: React.ReactElement + actionAlignment?: 'center' | 'top' + actionIcon?: string + actionTitle?: string /** Override other actions for specificity like mute/unmute. */ - actionType?: 'muting' | 'blocking' | 'follow_request', - avatarSize?: number, - hidden?: boolean, - hideActions?: boolean, - id?: string, - onActionClick?: (account: any) => void, - showProfileHoverCard?: boolean, - timestamp?: string, - timestampUrl?: string, - futureTimestamp?: boolean, - withAccountNote?: boolean, - withDate?: boolean, - withLinkToProfile?: boolean, - withRelationship?: boolean, - showEdit?: boolean, - emoji?: string, - note?: string, + actionType?: 'muting' | 'blocking' | 'follow_request' + avatarSize?: number + hidden?: boolean + hideActions?: boolean + id?: string + onActionClick?: (account: any) => void + showProfileHoverCard?: boolean + timestamp?: string + timestampUrl?: string + futureTimestamp?: boolean + withAccountNote?: boolean + withDate?: boolean + withLinkToProfile?: boolean + withRelationship?: boolean + showEdit?: boolean + approvalStatus?: StatusApprovalStatus + emoji?: string + note?: string } const Account = ({ @@ -92,22 +113,18 @@ const Account = ({ withLinkToProfile = true, withRelationship = true, showEdit = false, + approvalStatus, emoji, note, }: IAccount) => { - const overflowRef = React.useRef(null); - const actionRef = React.useRef(null); - // @ts-ignore - const isOnScreen = useOnScreen(overflowRef); - - const [style, setStyle] = React.useState({ visibility: 'hidden' }); + const overflowRef = useRef(null); + const actionRef = useRef(null); const me = useAppSelector((state) => state.me); const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null); const handleAction = () => { - // @ts-ignore - onActionClick(account); + onActionClick!(account); }; const renderAction = () => { @@ -125,8 +142,8 @@ const Account = ({ src={actionIcon} title={actionTitle} onClick={handleAction} - className='bg-transparent text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500' - iconClassName='w-4 h-4' + className='bg-transparent text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-500' + iconClassName='h-4 w-4' /> ); } @@ -138,18 +155,7 @@ const Account = ({ return null; }; - React.useEffect(() => { - const style: React.CSSProperties = {}; - const actionWidth = actionRef.current?.clientWidth || 0; - - if (overflowRef.current) { - style.maxWidth = overflowRef.current.clientWidth - 30 - avatarSize - actionWidth; - } else { - style.visibility = 'hidden'; - } - - setStyle(style); - }, [isOnScreen, overflowRef, actionRef]); + const intl = useIntl(); if (!account) { return null; @@ -169,9 +175,9 @@ const Account = ({ const LinkEl: any = withLinkToProfile ? Link : 'div'; return ( -
+
- + {children}} @@ -184,14 +190,14 @@ const Account = ({ {emoji && ( )} -
+
{children}} @@ -201,7 +207,7 @@ const Account = ({ title={account.acct} onClick={(event: React.MouseEvent) => event.stopPropagation()} > - + {account.verified && } + + {account.bot && } - + @{username} {account.favicon && ( - + )} {(timestamp) ? ( @@ -236,6 +244,18 @@ const Account = ({ ) : null} + {approvalStatus && ['pending', 'rejected'].includes(approvalStatus) && ( + <> + · + + + {approvalStatus === 'pending' + ? + : } + + + )} + {showEdit ? ( <> · diff --git a/app/soapbox/components/animated-number.tsx b/app/soapbox/components/animated-number.tsx index 0f6908fde..199a8b4db 100644 --- a/app/soapbox/components/animated-number.tsx +++ b/app/soapbox/components/animated-number.tsx @@ -15,8 +15,8 @@ const obfuscatedCount = (count: number) => { }; interface IAnimatedNumber { - value: number; - obfuscate?: boolean; + value: number + obfuscate?: boolean } const AnimatedNumber: React.FC = ({ value, obfuscate }) => { @@ -50,7 +50,7 @@ const AnimatedNumber: React.FC = ({ value, obfuscate }) => { return ( {items => ( - + {items.map(({ key, data, style }) => ( 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : } ))} diff --git a/app/soapbox/components/announcements/announcement-content.tsx b/app/soapbox/components/announcements/announcement-content.tsx index f4265d1fd..459f88e64 100644 --- a/app/soapbox/components/announcements/announcement-content.tsx +++ b/app/soapbox/components/announcements/announcement-content.tsx @@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom'; import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities'; interface IAnnouncementContent { - announcement: AnnouncementEntity; + announcement: AnnouncementEntity } const AnnouncementContent: React.FC = ({ announcement }) => { diff --git a/app/soapbox/components/announcements/announcement.tsx b/app/soapbox/components/announcements/announcement.tsx index ea96b37fd..62d5a1170 100644 --- a/app/soapbox/components/announcements/announcement.tsx +++ b/app/soapbox/components/announcements/announcement.tsx @@ -11,10 +11,10 @@ import type { Map as ImmutableMap } from 'immutable'; import type { Announcement as AnnouncementEntity } from 'soapbox/types/entities'; interface IAnnouncement { - announcement: AnnouncementEntity; - addReaction: (id: string, name: string) => void; - removeReaction: (id: string, name: string) => void; - emojiMap: ImmutableMap>; + announcement: AnnouncementEntity + addReaction: (id: string, name: string) => void + removeReaction: (id: string, name: string) => void + emojiMap: ImmutableMap> } const Announcement: React.FC = ({ announcement, addReaction, removeReaction, emojiMap }) => { diff --git a/app/soapbox/components/announcements/announcements-panel.tsx b/app/soapbox/components/announcements/announcements-panel.tsx index 2febea40d..800328678 100644 --- a/app/soapbox/components/announcements/announcements-panel.tsx +++ b/app/soapbox/components/announcements/announcements-panel.tsx @@ -1,4 +1,4 @@ -import classNames from 'clsx'; +import clsx from 'clsx'; import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import React, { useState } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -52,7 +52,7 @@ const AnnouncementsPanel = () => { key={i} tabIndex={0} onClick={() => setIndex(i)} - className={classNames({ + className={clsx({ 'w-2 h-2 rounded-full focus:ring-primary-600 focus:ring-2 focus:ring-offset-2': true, 'bg-gray-200 hover:bg-gray-300': i !== index, 'bg-primary-600': i === index, diff --git a/app/soapbox/components/announcements/emoji.tsx b/app/soapbox/components/announcements/emoji.tsx index 64266639d..ecc28fcf8 100644 --- a/app/soapbox/components/announcements/emoji.tsx +++ b/app/soapbox/components/announcements/emoji.tsx @@ -7,9 +7,9 @@ import { joinPublicPath } from 'soapbox/utils/static'; import type { Map as ImmutableMap } from 'immutable'; interface IEmoji { - emoji: string; - emojiMap: ImmutableMap>; - hovered: boolean; + emoji: string + emojiMap: ImmutableMap> + hovered: boolean } const Emoji: React.FC = ({ emoji, emojiMap, hovered }) => { @@ -24,7 +24,7 @@ const Emoji: React.FC = ({ emoji, emojiMap, hovered }) => { return ( {emoji} = ({ emoji, emojiMap, hovered }) => { return ( {shortCode}>; - addReaction: (id: string, name: string) => void; - removeReaction: (id: string, name: string) => void; - style: React.CSSProperties; + announcementId: string + reaction: AnnouncementReaction + emojiMap: ImmutableMap> + addReaction: (id: string, name: string) => void + removeReaction: (id: string, name: string) => void + style: React.CSSProperties } const Reaction: React.FC = ({ announcementId, reaction, addReaction, removeReaction, emojiMap, style }) => { @@ -43,7 +43,7 @@ const Reaction: React.FC = ({ announcementId, reaction, addReaction, return (