Merge remote-tracking branch 'soapbox/develop' into follow-hashtags

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2023-05-02 23:32:43 +02:00
commit 5aaf4d75af
864 changed files with 36872 additions and 26300 deletions

View File

@ -5,6 +5,7 @@ module.exports = {
'eslint:recommended', 'eslint:recommended',
'plugin:import/typescript', 'plugin:import/typescript',
'plugin:compat/recommended', 'plugin:compat/recommended',
'plugin:tailwindcss/recommended',
], ],
env: { env: {
@ -54,13 +55,17 @@ module.exports = {
}, },
}, },
polyfills: [ polyfills: [
'es:all', 'es:all', // core-js
'fetch', 'fetch', // not polyfilled, but ignore it
'IntersectionObserver', 'IntersectionObserver', // npm:intersection-observer
'Promise', 'Promise', // core-js
'URL', 'ResizeObserver', // npm:resize-observer-polyfill
'URLSearchParams', 'URL', // core-js
'URLSearchParams', // core-js
], ],
tailwindcss: {
config: 'tailwind.config.cjs',
},
}, },
rules: { rules: {
@ -235,18 +240,7 @@ module.exports = {
}, },
], ],
'import/newline-after-import': 'error', 'import/newline-after-import': 'error',
'import/no-extraneous-dependencies': [ 'import/no-extraneous-dependencies': 'error',
'error',
// {
// devDependencies: [
// 'webpack/**',
// 'app/soapbox/test_setup.js',
// 'app/soapbox/test_helpers.js',
// 'app/**/__tests__/**',
// 'app/**/__mocks__/**',
// ],
// },
],
'import/no-unresolved': 'error', 'import/no-unresolved': 'error',
'import/no-webpack-loader-syntax': 'error', 'import/no-webpack-loader-syntax': 'error',
'import/order': [ 'import/order': [
@ -267,10 +261,30 @@ module.exports = {
}, },
], ],
'@typescript-eslint/no-duplicate-imports': 'error', '@typescript-eslint/no-duplicate-imports': 'error',
'@typescript-eslint/member-delimiter-style': [
'error',
{
multiline: {
delimiter: 'none',
},
singleline: {
delimiter: 'comma',
},
},
],
'promise/catch-or-return': 'error', 'promise/catch-or-return': 'error',
'react-hooks/rules-of-hooks': '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: [ overrides: [
{ {

View File

@ -1,4 +1,4 @@
image: node:18 image: node:20
variables: variables:
NODE_ENV: test NODE_ENV: test
@ -149,19 +149,19 @@ pages:
docker: docker:
stage: deploy stage: deploy
image: docker:20.10.22 image: docker:23.0.0
services: services:
- docker:20.10.22-dind - docker:23.0.0-dind
tags: tags:
- dind - 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 # 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: script:
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin - echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
- docker build -t $CI_REGISTRY_IMAGE . - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG .
- docker push $CI_REGISTRY_IMAGE - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
only: rules:
variables: - if: $CI_COMMIT_TAG
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME interruptible: false
release: release:
stage: release stage: release

43
.storybook/main.ts Normal file
View File

@ -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;

22
.storybook/preview.tsx Normal file
View File

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

View File

@ -1 +1 @@
nodejs 18.13.0 nodejs 20.0.0

View File

@ -10,12 +10,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Admin: redirect the homepage to any URL. - Admin: redirect the homepage to any URL.
- Compatibility: added compatibility with Friendica. - Compatibility: added compatibility with Friendica.
- Hashtags: let users follow hashtags (Mastodon). - Hashtags: let users follow hashtags (Mastodon).
- Posts: Support posts filtering on recent Mastodon versions
- Reactions: Support custom emoji reactions
- Compatbility: Support Mastodon v2 timeline filters.
- Posts: Support dislikes on Friendica.
- UI: added a character counter to some textareas.
### Changed ### Changed
- Posts: truncate Nostr pubkeys in reply mentions.
- Posts: upgraded emoji picker component.
- Posts: improved design of threads.
- UI: unified design of "approve" and "reject" buttons in follow requests and waitlist.
- UI: added sticky column header.
- UI: add specific zones the user can drag-and-drop files.
### Fixed ### Fixed
- Posts: fixed emojis being cut off in reactions modal.
- Posts: fix audio player progress bar visibility.
- Posts: added missing gap in pending status.
- Compatibility: fixed quote posting compatibility with custom Pleroma forks.
- Profile: fix "load more" button height on account gallery page.
- 18n: fixed Chinese language being detected from the browser.
- Conversations: fixed pagination (Mastodon).
- Compatibility: fix version parsing for Friendica.
## [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. - 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. - 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 ### Removed
- Admin: single user mode. Now the homepage can be redirected to any URL. - Admin: single user mode. Now the homepage can be redirected to any URL.

View File

@ -1,4 +1,4 @@
FROM node:18 as build FROM node:20 as build
WORKDIR /app WORKDIR /app
COPY package.json . COPY package.json .
COPY yarn.lock . COPY yarn.lock .

View File

@ -1,4 +1,4 @@
FROM node:18 FROM node:20
RUN apt-get update &&\ RUN apt-get update &&\
apt-get install -y inotify-tools &&\ apt-get install -y inotify-tools &&\

View File

@ -0,0 +1,16 @@
{
"note": "patriots 900000001",
"discoverable": true,
"id": "109989480368015378",
"domain": null,
"avatar": "https://media.covfefe.social/groups/avatars/109/989/480/368/015/378/original/50b0d899bc5aae13.jpg",
"avatar_static": "https://media.covfefe.social/groups/avatars/109/989/480/368/015/378/original/50b0d899bc5aae13.jpg",
"header": "https://media.covfefe.social/groups/headers/109/989/480/368/015/378/original/c5063b59f919cd4a.png",
"header_static": "https://media.covfefe.social/groups/headers/109/989/480/368/015/378/original/c5063b59f919cd4a.png",
"group_visibility": "everyone",
"created_at": "2023-03-08T00:00:00.000Z",
"display_name": "PATRIOT PATRIOTS",
"membership_required": true,
"members_count": 1,
"tags": []
}

View File

@ -228,7 +228,7 @@ const fetchAccountFail = (id: string | null, error: AxiosError) => ({
}); });
type FollowAccountOpts = { type FollowAccountOpts = {
reblogs?: boolean, reblogs?: boolean
notify?: boolean notify?: boolean
}; };

View File

@ -1,13 +1,18 @@
import { defineMessages } from 'react-intl';
import { fetchRelationships } from 'soapbox/actions/accounts'; import { fetchRelationships } from 'soapbox/actions/accounts';
import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer'; import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer';
import toast from 'soapbox/toast';
import { filterBadges, getTagDiff } from 'soapbox/utils/badges'; import { filterBadges, getTagDiff } from 'soapbox/utils/badges';
import { getFeatures } from 'soapbox/utils/features'; import { getFeatures } from 'soapbox/utils/features';
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
import { openModal } from './modals';
import type { AxiosResponse } from 'axios'; import type { AxiosResponse } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store'; 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_REQUEST = 'ADMIN_CONFIG_FETCH_REQUEST';
const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS'; const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS';
@ -77,16 +82,45 @@ const ADMIN_USERS_UNSUGGEST_REQUEST = 'ADMIN_USERS_UNSUGGEST_REQUEST';
const ADMIN_USERS_UNSUGGEST_SUCCESS = 'ADMIN_USERS_UNSUGGEST_SUCCESS'; const ADMIN_USERS_UNSUGGEST_SUCCESS = 'ADMIN_USERS_UNSUGGEST_SUCCESS';
const ADMIN_USERS_UNSUGGEST_FAIL = 'ADMIN_USERS_UNSUGGEST_FAIL'; 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_FAIL = 'ADMIN_USER_INDEX_EXPAND_FAIL';
const ADMIN_USER_INDEX_EXPAND_REQUEST = 'ADMIN_USER_INDEX_EXPAND_REQUEST'; 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_EXPAND_SUCCESS = 'ADMIN_USER_INDEX_EXPAND_SUCCESS';
const ADMIN_USER_INDEX_FETCH_FAIL = 'ADMIN_USER_INDEX_FETCH_FAIL'; 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_REQUEST = 'ADMIN_USER_INDEX_FETCH_REQUEST';
const ADMIN_USER_INDEX_FETCH_SUCCESS = 'ADMIN_USER_INDEX_FETCH_SUCCESS'; const ADMIN_USER_INDEX_FETCH_SUCCESS = 'ADMIN_USER_INDEX_FETCH_SUCCESS';
const ADMIN_USER_INDEX_QUERY_SET = 'ADMIN_USER_INDEX_QUERY_SET'; 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 nicknamesFromIds = (getState: () => RootState, ids: string[]) => ids.map(id => getState().accounts.get(id)!.acct);
const fetchConfig = () => const fetchConfig = () =>
@ -598,6 +632,93 @@ const expandUserIndex = () =>
}); });
}; };
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 { export {
ADMIN_CONFIG_FETCH_REQUEST, ADMIN_CONFIG_FETCH_REQUEST,
ADMIN_CONFIG_FETCH_SUCCESS, ADMIN_CONFIG_FETCH_SUCCESS,
@ -657,6 +778,23 @@ export {
ADMIN_USER_INDEX_FETCH_REQUEST, ADMIN_USER_INDEX_FETCH_REQUEST,
ADMIN_USER_INDEX_FETCH_SUCCESS, ADMIN_USER_INDEX_FETCH_SUCCESS,
ADMIN_USER_INDEX_QUERY_SET, 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, fetchConfig,
updateConfig, updateConfig,
updateSoapboxConfig, updateSoapboxConfig,
@ -686,4 +824,13 @@ export {
setUserIndexQuery, setUserIndexQuery,
fetchUserIndex, fetchUserIndex,
expandUserIndex, expandUserIndex,
fetchAdminAnnouncements,
expandAdminAnnouncements,
changeAnnouncementContent,
changeAnnouncementStartTime,
changeAnnouncementEndTime,
changeAnnouncementAllDay,
handleCreateAnnouncement,
deleteAnnouncement,
initAnnouncementModal,
}; };

View File

@ -50,6 +50,7 @@ const customApp = custom('app');
export const messages = defineMessages({ export const messages = defineMessages({
loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' }, 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' }, invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' },
}); });
@ -187,6 +188,8 @@ export const logIn = (username: string, password: string) =>
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. // If MFA is required, throw the error and handle it in the component.
throw error; throw error;
} else if ((error.response?.data as any)?.identifier === 'awaiting_approval') {
toast.error(messages.awaitingApproval);
} else { } else {
// Return "wrong password" message. // Return "wrong password" message.
toast.error(messages.invalidCredentials); toast.error(messages.invalidCredentials);

View File

@ -4,7 +4,8 @@ import throttle from 'lodash/throttle';
import { defineMessages, IntlShape } from 'react-intl'; import { defineMessages, IntlShape } from 'react-intl';
import api from 'soapbox/api'; import api from 'soapbox/api';
import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light'; import { isNativeEmoji } from 'soapbox/features/emoji';
import emojiSearch from 'soapbox/features/emoji/search';
import { tagHistory } from 'soapbox/settings'; import { tagHistory } from 'soapbox/settings';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth'; import { isLoggedIn } from 'soapbox/utils/auth';
@ -19,8 +20,9 @@ import { openModal, closeModal } from './modals';
import { getSettings } from './settings'; import { getSettings } from './settings';
import { createStatus } from './statuses'; import { createStatus } from './statuses';
import type { Emoji } from 'soapbox/components/autosuggest-emoji';
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input'; import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
import type { Emoji } from 'soapbox/features/emoji';
import type { Group } from 'soapbox/schemas';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities'; import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities';
import type { History } from 'soapbox/types/history'; import type { History } from 'soapbox/types/history';
@ -46,6 +48,8 @@ const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
const COMPOSE_GROUP_POST = 'COMPOSE_GROUP_POST';
const COMPOSE_SET_GROUP_TIMELINE_VISIBLE = 'COMPOSE_SET_GROUP_TIMELINE_VISIBLE';
const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
@ -86,7 +90,7 @@ const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
const messages = defineMessages({ const messages = defineMessages({
exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' }, 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})' }, 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.' }, 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' }, success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' },
editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' }, editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' },
@ -165,6 +169,14 @@ const cancelQuoteCompose = () => ({
id: 'compose-modal', id: 'compose-modal',
}); });
const groupComposeModal = (group: Group) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const composeId = `group:${group.id}`;
dispatch(groupCompose(composeId, group.id));
dispatch(openModal('COMPOSE', { composeId }));
};
const resetCompose = (composeId = 'compose-modal') => ({ const resetCompose = (composeId = 'compose-modal') => ({
type: COMPOSE_RESET, type: COMPOSE_RESET,
id: composeId, id: composeId,
@ -276,7 +288,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
const idempotencyKey = compose.idempotencyKey; const idempotencyKey = compose.idempotencyKey;
const params = { const params: Record<string, any> = {
status, status,
in_reply_to_id: compose.in_reply_to, in_reply_to_id: compose.in_reply_to,
quote_id: compose.quote, quote_id: compose.quote,
@ -290,6 +302,11 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
to, to,
}; };
if (compose.privacy === 'group') {
params.group_id = compose.group_id;
params.group_timeline_visible = compose.group_timeline_visible; // Truth Social
}
dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) { dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) {
if (!statusId && data.visibility === 'direct' && getState().conversations.mounted <= 0 && routerHistory) { if (!statusId && data.visibility === 'direct' && getState().conversations.mounted <= 0 && routerHistory) {
routerHistory.push('/messages'); routerHistory.push('/messages');
@ -470,6 +487,21 @@ const undoUploadCompose = (composeId: string, media_id: string) => ({
media_id: media_id, 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 setGroupTimelineVisible = (composeId: string, groupTimelineVisible: boolean) => ({
type: COMPOSE_SET_GROUP_TIMELINE_VISIBLE,
id: composeId,
groupTimelineVisible,
});
const clearComposeSuggestions = (composeId: string) => { const clearComposeSuggestions = (composeId: string) => {
if (cancelFetchComposeSuggestionsAccounts) { if (cancelFetchComposeSuggestionsAccounts) {
cancelFetchComposeSuggestionsAccounts(); cancelFetchComposeSuggestionsAccounts();
@ -504,7 +536,9 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId,
}, 200, { leading: true, trailing: true }); }, 200, { leading: true, trailing: true });
const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => { const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any); const state = getState();
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }, state.custom_emojis);
dispatch(readyComposeSuggestionsEmojis(composeId, token, results)); dispatch(readyComposeSuggestionsEmojis(composeId, token, results));
}; };
@ -549,7 +583,7 @@ const selectComposeSuggestion = (composeId: string, position: number, token: str
let completion, startPosition; let completion, startPosition;
if (typeof suggestion === 'object' && suggestion.id) { if (typeof suggestion === 'object' && suggestion.id) {
completion = suggestion.native || suggestion.colons; completion = isNativeEmoji(suggestion) ? suggestion.native : suggestion.colons;
startPosition = position - 1; startPosition = position - 1;
dispatch(useEmoji(suggestion)); dispatch(useEmoji(suggestion));
@ -722,7 +756,7 @@ const eventDiscussionCompose = (composeId: string, status: Status) =>
const instance = state.instance; const instance = state.instance;
const { explicitAddressing } = getFeatures(instance); const { explicitAddressing } = getFeatures(instance);
dispatch({ return dispatch({
type: COMPOSE_EVENT_REPLY, type: COMPOSE_EVENT_REPLY,
id: composeId, id: composeId,
status: status, status: status,
@ -749,6 +783,7 @@ export {
COMPOSE_UPLOAD_FAIL, COMPOSE_UPLOAD_FAIL,
COMPOSE_UPLOAD_PROGRESS, COMPOSE_UPLOAD_PROGRESS,
COMPOSE_UPLOAD_UNDO, COMPOSE_UPLOAD_UNDO,
COMPOSE_GROUP_POST,
COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTIONS_READY,
COMPOSE_SUGGESTION_SELECT, COMPOSE_SUGGESTION_SELECT,
@ -776,6 +811,7 @@ export {
COMPOSE_ADD_TO_MENTIONS, COMPOSE_ADD_TO_MENTIONS,
COMPOSE_REMOVE_FROM_MENTIONS, COMPOSE_REMOVE_FROM_MENTIONS,
COMPOSE_SET_STATUS, COMPOSE_SET_STATUS,
COMPOSE_SET_GROUP_TIMELINE_VISIBLE,
setComposeToStatus, setComposeToStatus,
changeCompose, changeCompose,
replyCompose, replyCompose,
@ -801,6 +837,9 @@ export {
uploadComposeSuccess, uploadComposeSuccess,
uploadComposeFail, uploadComposeFail,
undoUploadCompose, undoUploadCompose,
groupCompose,
groupComposeModal,
setGroupTimelineVisible,
clearComposeSuggestions, clearComposeSuggestions,
fetchComposeSuggestions, fetchComposeSuggestions,
readyComposeSuggestionsEmojis, readyComposeSuggestionsEmojis,

View File

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

View File

@ -25,7 +25,7 @@ const EMOJI_REACTS_FETCH_FAIL = 'EMOJI_REACTS_FETCH_FAIL';
const noOp = () => () => new Promise(f => f(undefined)); const noOp = () => () => new Promise(f => f(undefined));
const simpleEmojiReact = (status: Status, emoji: string) => const simpleEmojiReact = (status: Status, emoji: string, custom?: string) =>
(dispatch: AppDispatch) => { (dispatch: AppDispatch) => {
const emojiReacts: ImmutableList<ImmutableMap<string, any>> = status.pleroma.get('emoji_reactions') || ImmutableList(); const emojiReacts: ImmutableList<ImmutableMap<string, any>> = status.pleroma.get('emoji_reactions') || ImmutableList();
@ -43,7 +43,7 @@ const simpleEmojiReact = (status: Status, emoji: string) =>
if (emoji === '👍') { if (emoji === '👍') {
dispatch(favourite(status)); dispatch(favourite(status));
} else { } else {
dispatch(emojiReact(status, emoji)); dispatch(emojiReact(status, emoji, custom));
} }
}).catch(err => { }).catch(err => {
console.error(err); console.error(err);
@ -70,11 +70,11 @@ const fetchEmojiReacts = (id: string, emoji: string) =>
}); });
}; };
const emojiReact = (status: Status, emoji: string) => const emojiReact = (status: Status, emoji: string, custom?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return dispatch(noOp()); if (!isLoggedIn(getState)) return dispatch(noOp());
dispatch(emojiReactRequest(status, emoji)); dispatch(emojiReactRequest(status, emoji, custom));
return api(getState) return api(getState)
.put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`) .put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`)
@ -120,10 +120,11 @@ const fetchEmojiReactsFail = (id: string, error: AxiosError) => ({
error, error,
}); });
const emojiReactRequest = (status: Status, emoji: string) => ({ const emojiReactRequest = (status: Status, emoji: string, custom?: string) => ({
type: EMOJI_REACT_REQUEST, type: EMOJI_REACT_REQUEST,
status, status,
emoji, emoji,
custom,
skipLoading: true, skipLoading: true,
}); });

View File

@ -1,6 +1,6 @@
import { saveSettings } from './settings'; import { saveSettings } from './settings';
import type { Emoji } from 'soapbox/components/autosuggest-emoji'; import type { Emoji } from 'soapbox/features/emoji';
import type { AppDispatch } from 'soapbox/store'; import type { AppDispatch } from 'soapbox/store';
const EMOJI_USE = 'EMOJI_USE'; const EMOJI_USE = 'EMOJI_USE';

View File

@ -569,7 +569,7 @@ const rejectEventParticipationRequestFail = (id: string, accountId: string, erro
}); });
const fetchEventIcs = (id: string) => const fetchEventIcs = (id: string) =>
(dispatch: any, getState: () => RootState) => (dispatch: AppDispatch, getState: () => RootState) =>
api(getState).get(`/api/v1/pleroma/events/${id}/ics`); api(getState).get(`/api/v1/pleroma/events/${id}/ics`);
const cancelEventCompose = () => ({ const cancelEventCompose = () => ({

View File

@ -34,8 +34,8 @@ type ExportDataActions = {
| typeof EXPORT_BLOCKS_FAIL | typeof EXPORT_BLOCKS_FAIL
| typeof EXPORT_MUTES_REQUEST | typeof EXPORT_MUTES_REQUEST
| typeof EXPORT_MUTES_SUCCESS | typeof EXPORT_MUTES_SUCCESS
| typeof EXPORT_MUTES_FAIL, | typeof EXPORT_MUTES_FAIL
error?: any, error?: any
} }
function fileExport(content: string, fileName: string) { function fileExport(content: string, fileName: string) {

View File

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

View File

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

View File

@ -12,10 +12,18 @@ const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL';
const FILTER_FETCH_REQUEST = 'FILTER_FETCH_REQUEST';
const FILTER_FETCH_SUCCESS = 'FILTER_FETCH_SUCCESS';
const FILTER_FETCH_FAIL = 'FILTER_FETCH_FAIL';
const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST';
const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL'; const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL';
const FILTERS_UPDATE_REQUEST = 'FILTERS_UPDATE_REQUEST';
const FILTERS_UPDATE_SUCCESS = 'FILTERS_UPDATE_SUCCESS';
const FILTERS_UPDATE_FAIL = 'FILTERS_UPDATE_FAIL';
const FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST'; const FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST';
const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS'; const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS';
const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL'; const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL';
@ -25,22 +33,16 @@ const messages = defineMessages({
removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' }, removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' },
}); });
const fetchFilters = () => type FilterKeywords = { keyword: string, whole_word: boolean }[];
const fetchFiltersV1 = () =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (!features.filters) return;
dispatch({ dispatch({
type: FILTERS_FETCH_REQUEST, type: FILTERS_FETCH_REQUEST,
skipLoading: true, skipLoading: true,
}); });
api(getState) return api(getState)
.get('/api/v1/filters') .get('/api/v1/filters')
.then(({ data }) => dispatch({ .then(({ data }) => dispatch({
type: FILTERS_FETCH_SUCCESS, type: FILTERS_FETCH_SUCCESS,
@ -55,15 +57,105 @@ const fetchFilters = () =>
})); }));
}; };
const createFilter = (phrase: string, expires_at: string, context: Array<string>, whole_word: boolean, irreversible: boolean) => const fetchFiltersV2 = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: FILTERS_FETCH_REQUEST,
skipLoading: true,
});
return api(getState)
.get('/api/v2/filters')
.then(({ data }) => dispatch({
type: FILTERS_FETCH_SUCCESS,
filters: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTERS_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
};
const fetchFilters = (fromFiltersPage = false) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (features.filtersV2 && fromFiltersPage) return dispatch(fetchFiltersV2());
if (features.filters) return dispatch(fetchFiltersV1());
};
const fetchFilterV1 = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: FILTER_FETCH_REQUEST,
skipLoading: true,
});
return api(getState)
.get(`/api/v1/filters/${id}`)
.then(({ data }) => dispatch({
type: FILTER_FETCH_SUCCESS,
filter: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTER_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
};
const fetchFilterV2 = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: FILTER_FETCH_REQUEST,
skipLoading: true,
});
return api(getState)
.get(`/api/v2/filters/${id}`)
.then(({ data }) => dispatch({
type: FILTER_FETCH_SUCCESS,
filter: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTER_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
};
const fetchFilter = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (features.filtersV2) return dispatch(fetchFilterV2(id));
if (features.filters) return dispatch(fetchFilterV1(id));
};
const createFilterV1 = (title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_CREATE_REQUEST }); dispatch({ type: FILTERS_CREATE_REQUEST });
return api(getState).post('/api/v1/filters', { return api(getState).post('/api/v1/filters', {
phrase, phrase: keywords[0].keyword,
context, context,
irreversible, irreversible: hide,
whole_word, whole_word: keywords[0].whole_word,
expires_at, expires_in,
}).then(response => { }).then(response => {
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data });
toast.success(messages.added); toast.success(messages.added);
@ -72,7 +164,80 @@ const createFilter = (phrase: string, expires_at: string, context: Array<string>
}); });
}; };
const deleteFilter = (id: string) => const createFilterV2 = (title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords_attributes: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_CREATE_REQUEST });
return api(getState).post('/api/v2/filters', {
title,
context,
filter_action: hide ? 'hide' : 'warn',
expires_in,
keywords_attributes,
}).then(response => {
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data });
toast.success(messages.added);
}).catch(error => {
dispatch({ type: FILTERS_CREATE_FAIL, error });
});
};
const createFilter = (title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (features.filtersV2) return dispatch(createFilterV2(title, expires_in, context, hide, keywords));
return dispatch(createFilterV1(title, expires_in, context, hide, keywords));
};
const updateFilterV1 = (id: string, title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_UPDATE_REQUEST });
return api(getState).patch(`/api/v1/filters/${id}`, {
phrase: keywords[0].keyword,
context,
irreversible: hide,
whole_word: keywords[0].whole_word,
expires_in,
}).then(response => {
dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data });
toast.success(messages.added);
}).catch(error => {
dispatch({ type: FILTERS_UPDATE_FAIL, error });
});
};
const updateFilterV2 = (id: string, title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords_attributes: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_UPDATE_REQUEST });
return api(getState).patch(`/api/v2/filters/${id}`, {
title,
context,
filter_action: hide ? 'hide' : 'warn',
expires_in,
keywords_attributes,
}).then(response => {
dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data });
toast.success(messages.added);
}).catch(error => {
dispatch({ type: FILTERS_UPDATE_FAIL, error });
});
};
const updateFilter = (id: string, title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (features.filtersV2) return dispatch(updateFilterV2(id, title, expires_in, context, hide, keywords));
return dispatch(updateFilterV1(id, title, expires_in, context, hide, keywords));
};
const deleteFilterV1 = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_DELETE_REQUEST }); dispatch({ type: FILTERS_DELETE_REQUEST });
return api(getState).delete(`/api/v1/filters/${id}`).then(response => { return api(getState).delete(`/api/v1/filters/${id}`).then(response => {
@ -83,17 +248,47 @@ const deleteFilter = (id: string) =>
}); });
}; };
const deleteFilterV2 = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_DELETE_REQUEST });
return api(getState).delete(`/api/v2/filters/${id}`).then(response => {
dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data });
toast.success(messages.removed);
}).catch(error => {
dispatch({ type: FILTERS_DELETE_FAIL, error });
});
};
const deleteFilter = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (features.filtersV2) return dispatch(deleteFilterV2(id));
return dispatch(deleteFilterV1(id));
};
export { export {
FILTERS_FETCH_REQUEST, FILTERS_FETCH_REQUEST,
FILTERS_FETCH_SUCCESS, FILTERS_FETCH_SUCCESS,
FILTERS_FETCH_FAIL, FILTERS_FETCH_FAIL,
FILTER_FETCH_REQUEST,
FILTER_FETCH_SUCCESS,
FILTER_FETCH_FAIL,
FILTERS_CREATE_REQUEST, FILTERS_CREATE_REQUEST,
FILTERS_CREATE_SUCCESS, FILTERS_CREATE_SUCCESS,
FILTERS_CREATE_FAIL, FILTERS_CREATE_FAIL,
FILTERS_UPDATE_REQUEST,
FILTERS_UPDATE_SUCCESS,
FILTERS_UPDATE_FAIL,
FILTERS_DELETE_REQUEST, FILTERS_DELETE_REQUEST,
FILTERS_DELETE_SUCCESS, FILTERS_DELETE_SUCCESS,
FILTERS_DELETE_FAIL, FILTERS_DELETE_FAIL,
fetchFilters, fetchFilters,
fetchFilter,
createFilter, createFilter,
updateFilter,
deleteFilter, deleteFilter,
}; };

View File

@ -0,0 +1,794 @@
import { deleteEntities } from 'soapbox/entity-store/actions';
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedGroups, importFetchedAccounts } from './importer';
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 } from 'soapbox/types/entities';
const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST';
const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS';
const GROUP_CREATE_FAIL = 'GROUP_CREATE_FAIL';
const GROUP_UPDATE_REQUEST = 'GROUP_UPDATE_REQUEST';
const GROUP_UPDATE_SUCCESS = 'GROUP_UPDATE_SUCCESS';
const GROUP_UPDATE_FAIL = 'GROUP_UPDATE_FAIL';
const GROUP_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_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 deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(deleteEntities([id], 'Group'));
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 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,
});
export {
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_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,
deleteGroup,
deleteGroupRequest,
deleteGroupSuccess,
deleteGroupFail,
fetchGroup,
fetchGroupRequest,
fetchGroupSuccess,
fetchGroupFail,
fetchGroups,
fetchGroupsRequest,
fetchGroupsSuccess,
fetchGroupsFail,
fetchGroupRelationships,
fetchGroupRelationshipsRequest,
fetchGroupRelationshipsSuccess,
fetchGroupRelationshipsFail,
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,
};

View File

@ -27,8 +27,8 @@ type ImportDataActions = {
| typeof IMPORT_BLOCKS_FAIL | typeof IMPORT_BLOCKS_FAIL
| typeof IMPORT_MUTES_REQUEST | typeof IMPORT_MUTES_REQUEST
| typeof IMPORT_MUTES_SUCCESS | typeof IMPORT_MUTES_SUCCESS
| typeof IMPORT_MUTES_FAIL, | typeof IMPORT_MUTES_FAIL
error?: any, error?: any
config?: string config?: string
} }

View File

@ -1,3 +1,8 @@
import { importEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities';
import { Group, groupSchema } from 'soapbox/schemas';
import { filteredArray } from 'soapbox/schemas/utils';
import { getSettings } from '../settings'; import { getSettings } from '../settings';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
@ -5,42 +10,44 @@ import type { APIEntity } from 'soapbox/types/entities';
const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
const GROUP_IMPORT = 'GROUP_IMPORT';
const GROUPS_IMPORT = 'GROUPS_IMPORT';
const STATUS_IMPORT = 'STATUS_IMPORT'; const STATUS_IMPORT = 'STATUS_IMPORT';
const STATUSES_IMPORT = 'STATUSES_IMPORT'; const STATUSES_IMPORT = 'STATUSES_IMPORT';
const POLLS_IMPORT = 'POLLS_IMPORT'; const POLLS_IMPORT = 'POLLS_IMPORT';
const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP'; const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP';
export function importAccount(account: APIEntity) { const importAccount = (account: APIEntity) =>
return { type: ACCOUNT_IMPORT, account }; ({ type: ACCOUNT_IMPORT, account });
}
export function importAccounts(accounts: APIEntity[]) { const importAccounts = (accounts: APIEntity[]) =>
return { type: ACCOUNTS_IMPORT, accounts }; ({ type: ACCOUNTS_IMPORT, accounts });
}
export function importStatus(status: APIEntity, idempotencyKey?: string) { const importGroup = (group: Group) =>
return (dispatch: AppDispatch, getState: () => RootState) => { importEntities([group], Entities.GROUPS);
const importGroups = (groups: Group[]) =>
importEntities(groups, Entities.GROUPS);
const importStatus = (status: APIEntity, idempotencyKey?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const expandSpoilers = getSettings(getState()).get('expandSpoilers'); const expandSpoilers = getSettings(getState()).get('expandSpoilers');
return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers }); return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers });
}; };
}
export function importStatuses(statuses: APIEntity[]) { const importStatuses = (statuses: APIEntity[]) =>
return (dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const expandSpoilers = getSettings(getState()).get('expandSpoilers'); const expandSpoilers = getSettings(getState()).get('expandSpoilers');
return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers }); return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers });
}; };
}
export function importPolls(polls: APIEntity[]) { const importPolls = (polls: APIEntity[]) =>
return { type: POLLS_IMPORT, polls }; ({ type: POLLS_IMPORT, polls });
}
export function importFetchedAccount(account: APIEntity) { const importFetchedAccount = (account: APIEntity) =>
return importFetchedAccounts([account]); importFetchedAccounts([account]);
}
export function importFetchedAccounts(accounts: APIEntity[], args = { should_refetch: false }) { const importFetchedAccounts = (accounts: APIEntity[], args = { should_refetch: false }) => {
const { should_refetch } = args; const { should_refetch } = args;
const normalAccounts: APIEntity[] = []; const normalAccounts: APIEntity[] = [];
@ -61,10 +68,18 @@ export function importFetchedAccounts(accounts: APIEntity[], args = { should_ref
accounts.forEach(processAccount); accounts.forEach(processAccount);
return importAccounts(normalAccounts); return importAccounts(normalAccounts);
} };
export function importFetchedStatus(status: APIEntity, idempotencyKey?: string) { const importFetchedGroup = (group: APIEntity) =>
return (dispatch: AppDispatch) => { importFetchedGroups([group]);
const importFetchedGroups = (groups: APIEntity[]) => {
const entities = filteredArray(groupSchema).catch([]).parse(groups);
return importGroups(entities);
};
const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>
(dispatch: AppDispatch) => {
// Skip broken statuses // Skip broken statuses
if (isBroken(status)) return; if (isBroken(status)) return;
@ -96,10 +111,13 @@ export function importFetchedStatus(status: APIEntity, idempotencyKey?: string)
dispatch(importFetchedPoll(status.poll)); dispatch(importFetchedPoll(status.poll));
} }
if (status.group?.id) {
dispatch(importFetchedGroup(status.group));
}
dispatch(importFetchedAccount(status.account)); dispatch(importFetchedAccount(status.account));
dispatch(importStatus(status, idempotencyKey)); dispatch(importStatus(status, idempotencyKey));
}; };
}
// Sometimes Pleroma can return an empty account, // Sometimes Pleroma can return an empty account,
// or a repost can appear of a deleted account. Skip these statuses. // or a repost can appear of a deleted account. Skip these statuses.
@ -117,8 +135,8 @@ const isBroken = (status: APIEntity) => {
} }
}; };
export function importFetchedStatuses(statuses: APIEntity[]) { const importFetchedStatuses = (statuses: APIEntity[]) =>
return (dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const accounts: APIEntity[] = []; const accounts: APIEntity[] = [];
const normalStatuses: APIEntity[] = []; const normalStatuses: APIEntity[] = [];
const polls: APIEntity[] = []; const polls: APIEntity[] = [];
@ -146,6 +164,10 @@ export function importFetchedStatuses(statuses: APIEntity[]) {
if (status.poll?.id) { if (status.poll?.id) {
polls.push(status.poll); polls.push(status.poll);
} }
if (status.group?.id) {
dispatch(importFetchedGroup(status.group));
}
} }
statuses.forEach(processStatus); statuses.forEach(processStatus);
@ -154,23 +176,37 @@ export function importFetchedStatuses(statuses: APIEntity[]) {
dispatch(importFetchedAccounts(accounts)); dispatch(importFetchedAccounts(accounts));
dispatch(importStatuses(normalStatuses)); dispatch(importStatuses(normalStatuses));
}; };
}
export function importFetchedPoll(poll: APIEntity) { const importFetchedPoll = (poll: APIEntity) =>
return (dispatch: AppDispatch) => { (dispatch: AppDispatch) => {
dispatch(importPolls([poll])); dispatch(importPolls([poll]));
}; };
}
export function importErrorWhileFetchingAccountByUsername(username: string) { const importErrorWhileFetchingAccountByUsername = (username: string) =>
return { type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username }; ({ type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username });
}
export { export {
ACCOUNT_IMPORT, ACCOUNT_IMPORT,
ACCOUNTS_IMPORT, ACCOUNTS_IMPORT,
GROUP_IMPORT,
GROUPS_IMPORT,
STATUS_IMPORT, STATUS_IMPORT,
STATUSES_IMPORT, STATUSES_IMPORT,
POLLS_IMPORT, POLLS_IMPORT,
ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP,
importAccount,
importAccounts,
importGroup,
importGroups,
importStatus,
importStatuses,
importPolls,
importFetchedAccount,
importFetchedAccounts,
importFetchedGroup,
importFetchedGroups,
importFetchedStatus,
importFetchedStatuses,
importFetchedPoll,
importErrorWhileFetchingAccountByUsername,
}; };

View File

@ -20,6 +20,10 @@ const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
const DISLIKE_REQUEST = 'DISLIKE_REQUEST';
const DISLIKE_SUCCESS = 'DISLIKE_SUCCESS';
const DISLIKE_FAIL = 'DISLIKE_FAIL';
const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST';
const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS';
const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; const UNREBLOG_FAIL = 'UNREBLOG_FAIL';
@ -28,6 +32,10 @@ const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
const UNDISLIKE_REQUEST = 'UNDISLIKE_REQUEST';
const UNDISLIKE_SUCCESS = 'UNDISLIKE_SUCCESS';
const UNDISLIKE_FAIL = 'UNDISLIKE_FAIL';
const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
@ -36,6 +44,10 @@ const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
const DISLIKES_FETCH_REQUEST = 'DISLIKES_FETCH_REQUEST';
const DISLIKES_FETCH_SUCCESS = 'DISLIKES_FETCH_SUCCESS';
const DISLIKES_FETCH_FAIL = 'DISLIKES_FETCH_FAIL';
const REACTIONS_FETCH_REQUEST = 'REACTIONS_FETCH_REQUEST'; const REACTIONS_FETCH_REQUEST = 'REACTIONS_FETCH_REQUEST';
const REACTIONS_FETCH_SUCCESS = 'REACTIONS_FETCH_SUCCESS'; const REACTIONS_FETCH_SUCCESS = 'REACTIONS_FETCH_SUCCESS';
const REACTIONS_FETCH_FAIL = 'REACTIONS_FETCH_FAIL'; const REACTIONS_FETCH_FAIL = 'REACTIONS_FETCH_FAIL';
@ -96,7 +108,7 @@ const unreblog = (status: StatusEntity) =>
}; };
const toggleReblog = (status: StatusEntity) => const toggleReblog = (status: StatusEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch) => {
if (status.reblogged) { if (status.reblogged) {
dispatch(unreblog(status)); dispatch(unreblog(status));
} else { } else {
@ -169,7 +181,7 @@ const unfavourite = (status: StatusEntity) =>
}; };
const toggleFavourite = (status: StatusEntity) => const toggleFavourite = (status: StatusEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch) => {
if (status.favourited) { if (status.favourited) {
dispatch(unfavourite(status)); dispatch(unfavourite(status));
} else { } else {
@ -215,6 +227,79 @@ const unfavouriteFail = (status: StatusEntity, error: AxiosError) => ({
skipLoading: true, skipLoading: true,
}); });
const dislike = (status: StatusEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(dislikeRequest(status));
api(getState).post(`/api/friendica/statuses/${status.get('id')}/dislike`).then(function() {
dispatch(dislikeSuccess(status));
}).catch(function(error) {
dispatch(dislikeFail(status, error));
});
};
const undislike = (status: StatusEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(undislikeRequest(status));
api(getState).post(`/api/friendica/statuses/${status.get('id')}/undislike`).then(() => {
dispatch(undislikeSuccess(status));
}).catch(error => {
dispatch(undislikeFail(status, error));
});
};
const toggleDislike = (status: StatusEntity) =>
(dispatch: AppDispatch) => {
if (status.disliked) {
dispatch(undislike(status));
} else {
dispatch(dislike(status));
}
};
const dislikeRequest = (status: StatusEntity) => ({
type: DISLIKE_REQUEST,
status: status,
skipLoading: true,
});
const dislikeSuccess = (status: StatusEntity) => ({
type: DISLIKE_SUCCESS,
status: status,
skipLoading: true,
});
const dislikeFail = (status: StatusEntity, error: AxiosError) => ({
type: DISLIKE_FAIL,
status: status,
error: error,
skipLoading: true,
});
const undislikeRequest = (status: StatusEntity) => ({
type: UNDISLIKE_REQUEST,
status: status,
skipLoading: true,
});
const undislikeSuccess = (status: StatusEntity) => ({
type: UNDISLIKE_SUCCESS,
status: status,
skipLoading: true,
});
const undislikeFail = (status: StatusEntity, error: AxiosError) => ({
type: UNDISLIKE_FAIL,
status: status,
error: error,
skipLoading: true,
});
const bookmark = (status: StatusEntity) => const bookmark = (status: StatusEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(bookmarkRequest(status)); dispatch(bookmarkRequest(status));
@ -351,6 +436,38 @@ const fetchFavouritesFail = (id: string, error: AxiosError) => ({
error, error,
}); });
const fetchDislikes = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(fetchDislikesRequest(id));
api(getState).get(`/api/friendica/statuses/${id}/disliked_by`).then(response => {
dispatch(importFetchedAccounts(response.data));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
dispatch(fetchDislikesSuccess(id, response.data));
}).catch(error => {
dispatch(fetchDislikesFail(id, error));
});
};
const fetchDislikesRequest = (id: string) => ({
type: DISLIKES_FETCH_REQUEST,
id,
});
const fetchDislikesSuccess = (id: string, accounts: APIEntity[]) => ({
type: DISLIKES_FETCH_SUCCESS,
id,
accounts,
});
const fetchDislikesFail = (id: string, error: AxiosError) => ({
type: DISLIKES_FETCH_FAIL,
id,
error,
});
const fetchReactions = (id: string) => const fetchReactions = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchReactionsRequest(id)); dispatch(fetchReactionsRequest(id));
@ -498,18 +615,27 @@ export {
FAVOURITE_REQUEST, FAVOURITE_REQUEST,
FAVOURITE_SUCCESS, FAVOURITE_SUCCESS,
FAVOURITE_FAIL, FAVOURITE_FAIL,
DISLIKE_REQUEST,
DISLIKE_SUCCESS,
DISLIKE_FAIL,
UNREBLOG_REQUEST, UNREBLOG_REQUEST,
UNREBLOG_SUCCESS, UNREBLOG_SUCCESS,
UNREBLOG_FAIL, UNREBLOG_FAIL,
UNFAVOURITE_REQUEST, UNFAVOURITE_REQUEST,
UNFAVOURITE_SUCCESS, UNFAVOURITE_SUCCESS,
UNFAVOURITE_FAIL, UNFAVOURITE_FAIL,
UNDISLIKE_REQUEST,
UNDISLIKE_SUCCESS,
UNDISLIKE_FAIL,
REBLOGS_FETCH_REQUEST, REBLOGS_FETCH_REQUEST,
REBLOGS_FETCH_SUCCESS, REBLOGS_FETCH_SUCCESS,
REBLOGS_FETCH_FAIL, REBLOGS_FETCH_FAIL,
FAVOURITES_FETCH_REQUEST, FAVOURITES_FETCH_REQUEST,
FAVOURITES_FETCH_SUCCESS, FAVOURITES_FETCH_SUCCESS,
FAVOURITES_FETCH_FAIL, FAVOURITES_FETCH_FAIL,
DISLIKES_FETCH_REQUEST,
DISLIKES_FETCH_SUCCESS,
DISLIKES_FETCH_FAIL,
REACTIONS_FETCH_REQUEST, REACTIONS_FETCH_REQUEST,
REACTIONS_FETCH_SUCCESS, REACTIONS_FETCH_SUCCESS,
REACTIONS_FETCH_FAIL, REACTIONS_FETCH_FAIL,
@ -546,6 +672,15 @@ export {
unfavouriteRequest, unfavouriteRequest,
unfavouriteSuccess, unfavouriteSuccess,
unfavouriteFail, unfavouriteFail,
dislike,
undislike,
toggleDislike,
dislikeRequest,
dislikeSuccess,
dislikeFail,
undislikeRequest,
undislikeSuccess,
undislikeFail,
bookmark, bookmark,
unbookmark, unbookmark,
toggleBookmark, toggleBookmark,
@ -563,6 +698,10 @@ export {
fetchFavouritesRequest, fetchFavouritesRequest,
fetchFavouritesSuccess, fetchFavouritesSuccess,
fetchFavouritesFail, fetchFavouritesFail,
fetchDislikes,
fetchDislikesRequest,
fetchDislikesSuccess,
fetchDislikesFail,
fetchReactions, fetchReactions,
fetchReactionsRequest, fetchReactionsRequest,
fetchReactionsSuccess, fetchReactionsSuccess,

View File

@ -112,27 +112,6 @@ const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () =
})); }));
}; };
const rejectUserModal = (intl: IntlShape, accountId: string, afterConfirm = () => {}) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const acct = state.accounts.get(accountId)!.acct;
const name = state.accounts.get(accountId)!.username;
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/user-off.svg'),
heading: intl.formatMessage(messages.rejectUserHeading, { acct }),
message: intl.formatMessage(messages.rejectUserPrompt, { acct }),
confirm: intl.formatMessage(messages.rejectUserConfirm, { name }),
onConfirm: () => {
dispatch(deleteUsers([accountId]))
.then(() => {
afterConfirm();
})
.catch(() => {});
},
}));
};
const toggleStatusSensitivityModal = (intl: IntlShape, statusId: string, sensitive: boolean, afterConfirm = () => {}) => const toggleStatusSensitivityModal = (intl: IntlShape, statusId: string, sensitive: boolean, afterConfirm = () => {}) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const state = getState(); const state = getState();
@ -178,7 +157,6 @@ const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = ()
export { export {
deactivateUserModal, deactivateUserModal,
deleteUserModal, deleteUserModal,
rejectUserModal,
toggleStatusSensitivityModal, toggleStatusSensitivityModal,
deleteStatusModal, deleteStatusModal,
}; };

View File

@ -47,7 +47,7 @@ const MAX_QUEUED_NOTIFICATIONS = 40;
defineMessages({ defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, 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[]) => { const fetchRelatedRelationships = (dispatch: AppDispatch, notifications: APIEntity[]) => {

View File

@ -37,8 +37,8 @@ const subscribe = (registration: ServiceWorkerRegistration, getState: () => Root
}); });
const unsubscribe = ({ registration, subscription }: { const unsubscribe = ({ registration, subscription }: {
registration: ServiceWorkerRegistration, registration: ServiceWorkerRegistration
subscription: PushSubscription | null, subscription: PushSubscription | null
}) => }) =>
subscription ? subscription.unsubscribe().then(() => registration) : new Promise<ServiceWorkerRegistration>(r => r(registration)); subscription ? subscription.unsubscribe().then(() => registration) : new Promise<ServiceWorkerRegistration>(r => r(registration));
@ -82,8 +82,8 @@ const register = () =>
.then(getPushSubscription) .then(getPushSubscription)
// @ts-ignore // @ts-ignore
.then(({ registration, subscription }: { .then(({ registration, subscription }: {
registration: ServiceWorkerRegistration, registration: ServiceWorkerRegistration
subscription: PushSubscription | null, subscription: PushSubscription | null
}) => { }) => {
if (subscription !== null) { if (subscription !== null) {
// We have a subscription, check if it is still valid // We have a subscription, check if it is still valid

View File

@ -4,7 +4,7 @@ import { openModal } from './modals';
import type { AxiosError } from 'axios'; import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
import type { Account, ChatMessage, Status } from 'soapbox/types/entities'; import type { Account, ChatMessage, Group, Status } from 'soapbox/types/entities';
const REPORT_INIT = 'REPORT_INIT'; const REPORT_INIT = 'REPORT_INIT';
const REPORT_CANCEL = 'REPORT_CANCEL'; const REPORT_CANCEL = 'REPORT_CANCEL';
@ -20,19 +20,29 @@ const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE';
const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE'; const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE';
type ReportedEntity = { enum ReportableEntities {
status?: Status, ACCOUNT = 'ACCOUNT',
chatMessage?: ChatMessage CHAT_MESSAGE = 'CHAT_MESSAGE',
GROUP = 'GROUP',
STATUS = 'STATUS'
} }
const initReport = (account: Account, entities?: ReportedEntity) => (dispatch: AppDispatch) => { type ReportedEntity = {
const { status, chatMessage } = entities || {}; status?: Status
chatMessage?: ChatMessage
group?: Group
}
const initReport = (entityType: ReportableEntities, account: Account, entities?: ReportedEntity) => (dispatch: AppDispatch) => {
const { status, chatMessage, group } = entities || {};
dispatch({ dispatch({
type: REPORT_INIT, type: REPORT_INIT,
entityType,
account, account,
status, status,
chatMessage, chatMessage,
group,
}); });
return dispatch(openModal('REPORT')); return dispatch(openModal('REPORT'));
@ -56,7 +66,8 @@ const submitReport = () =>
return api(getState).post('/api/v1/reports', { return api(getState).post('/api/v1/reports', {
account_id: reports.getIn(['new', 'account_id']), account_id: reports.getIn(['new', 'account_id']),
status_ids: reports.getIn(['new', 'status_ids']), status_ids: reports.getIn(['new', 'status_ids']),
message_ids: [reports.getIn(['new', 'chat_message', 'id'])], message_ids: [reports.getIn(['new', 'chat_message', 'id'])].filter(Boolean),
group_id: reports.getIn(['new', 'group', 'id']),
rule_ids: reports.getIn(['new', 'rule_ids']), rule_ids: reports.getIn(['new', 'rule_ids']),
comment: reports.getIn(['new', 'comment']), comment: reports.getIn(['new', 'comment']),
forward: reports.getIn(['new', 'forward']), forward: reports.getIn(['new', 'forward']),
@ -97,6 +108,7 @@ const changeReportRule = (ruleId: string) => ({
}); });
export { export {
ReportableEntities,
REPORT_INIT, REPORT_INIT,
REPORT_CANCEL, REPORT_CANCEL,
REPORT_SUBMIT_REQUEST, REPORT_SUBMIT_REQUEST,

View File

@ -1,7 +1,7 @@
import api from '../api'; import api from '../api';
import { fetchRelationships } from './accounts'; import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatuses } from './importer'; import { importFetchedAccounts, importFetchedGroups, importFetchedStatuses } from './importer';
import type { AxiosError } from 'axios'; import type { AxiosError } from 'axios';
import type { SearchFilter } from 'soapbox/reducers/search'; import type { SearchFilter } from 'soapbox/reducers/search';
@ -83,6 +83,10 @@ const submitSearch = (filter?: SearchFilter) =>
dispatch(importFetchedStatuses(response.data.statuses)); dispatch(importFetchedStatuses(response.data.statuses));
} }
if (response.data.groups) {
dispatch(importFetchedGroups(response.data.groups));
}
dispatch(fetchSearchSuccess(response.data, value, type)); dispatch(fetchSearchSuccess(response.data, value, type));
dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id))); dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id)));
}).catch(error => { }).catch(error => {
@ -139,6 +143,10 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: (
dispatch(importFetchedStatuses(data.statuses)); dispatch(importFetchedStatuses(data.statuses));
} }
if (data.groups) {
dispatch(importFetchedGroups(data.groups));
}
dispatch(expandSearchSuccess(data, value, type)); dispatch(expandSearchSuccess(data, value, type));
dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id))); dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id)));
}).catch(error => { }).catch(error => {

View File

@ -50,7 +50,7 @@ const MOVE_ACCOUNT_FAIL = 'MOVE_ACCOUNT_FAIL';
const fetchOAuthTokens = () => const fetchOAuthTokens = () =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FETCH_TOKENS_REQUEST }); 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 }); dispatch({ type: FETCH_TOKENS_SUCCESS, tokens });
}).catch(() => { }).catch(() => {
dispatch({ type: FETCH_TOKENS_FAIL }); dispatch({ type: FETCH_TOKENS_FAIL });

View File

@ -1,9 +1,10 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
import { defineMessages } from 'react-intl'; import { defineMessage } from 'react-intl';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { patchMe } from 'soapbox/actions/me'; import { patchMe } from 'soapbox/actions/me';
import messages from 'soapbox/locales/messages';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth'; import { isLoggedIn } from 'soapbox/utils/auth';
@ -18,12 +19,10 @@ const FE_NAME = 'soapbox_fe';
/** Options when changing/saving settings. */ /** Options when changing/saving settings. */
type SettingOpts = { type SettingOpts = {
/** Whether to display an alert when settings are saved. */ /** Whether to display an alert when settings are saved. */
showAlert?: boolean, showAlert?: boolean
} }
const messages = defineMessages({ const saveSuccessMessage = defineMessage({ id: 'settings.save.success', defaultMessage: 'Your preferences have been saved!' });
saveSuccess: { id: 'settings.save.success', defaultMessage: 'Your preferences have been saved!' },
});
const defaultSettings = ImmutableMap({ const defaultSettings = ImmutableMap({
onboarded: false, onboarded: false,
@ -40,7 +39,7 @@ const defaultSettings = ImmutableMap({
defaultPrivacy: 'public', defaultPrivacy: 'public',
defaultContentType: 'text/plain', defaultContentType: 'text/plain',
themeMode: 'system', themeMode: 'system',
locale: navigator.language.split(/[-_]/)[0] || 'en', locale: navigator.language || 'en',
showExplanationBox: true, showExplanationBox: true,
explanationBox: true, explanationBox: true,
autoloadTimelines: true, autoloadTimelines: true,
@ -156,6 +155,8 @@ const defaultSettings = ImmutableMap({
}), }),
}), }),
groups: ImmutableMap({}),
trends: ImmutableMap({ trends: ImmutableMap({
show: true, show: true,
}), }),
@ -219,7 +220,7 @@ const saveSettingsImmediate = (opts?: SettingOpts) =>
dispatch({ type: SETTING_SAVE }); dispatch({ type: SETTING_SAVE });
if (opts?.showAlert) { if (opts?.showAlert) {
toast.success(messages.saveSuccess); toast.success(saveSuccessMessage);
} }
}).catch(error => { }).catch(error => {
toast.showAlertForError(error); toast.showAlertForError(error);
@ -229,6 +230,12 @@ const saveSettingsImmediate = (opts?: SettingOpts) =>
const saveSettings = (opts?: SettingOpts) => const saveSettings = (opts?: SettingOpts) =>
(dispatch: AppDispatch) => dispatch(saveSettingsImmediate(opts)); (dispatch: AppDispatch) => dispatch(saveSettingsImmediate(opts));
const getLocale = (state: RootState, fallback = 'en') => {
const localeWithVariant = (getSettings(state).get('locale') as string).replace('_', '-');
const locale = localeWithVariant.split('-')[0];
return Object.keys(messages).includes(localeWithVariant) ? localeWithVariant : Object.keys(messages).includes(locale) ? locale : fallback;
};
export { export {
SETTING_CHANGE, SETTING_CHANGE,
SETTING_SAVE, SETTING_SAVE,
@ -240,4 +247,5 @@ export {
changeSetting, changeSetting,
saveSettingsImmediate, saveSettingsImmediate,
saveSettings, saveSettings,
getLocale,
}; };

View File

@ -32,8 +32,8 @@ const getSoapboxConfig = createSelector([
} }
// If RGI reacts aren't supported, strip VS16s // If RGI reacts aren't supported, strip VS16s
// // https://git.pleroma.social/pleroma/pleroma/-/issues/2355 // https://git.pleroma.social/pleroma/pleroma/-/issues/2355
if (!features.emojiReactsRGI) { if (features.emojiReactsNonRGI) {
soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s)); soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s));
} }
}); });

View File

@ -5,6 +5,7 @@ import { shouldHaveCard } from 'soapbox/utils/status';
import api, { getNextLink } from '../api'; import api, { getNextLink } from '../api';
import { setComposeToStatus } from './compose'; import { setComposeToStatus } from './compose';
import { fetchGroupRelationships } from './groups';
import { importFetchedStatus, importFetchedStatuses } from './importer'; import { importFetchedStatus, importFetchedStatuses } from './importer';
import { openModal } from './modals'; import { openModal } from './modals';
import { deleteFromTimelines } from './timelines'; import { deleteFromTimelines } from './timelines';
@ -48,6 +49,8 @@ const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL'; const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
const STATUS_UNFILTER = 'STATUS_UNFILTER';
const statusExists = (getState: () => RootState, statusId: string) => { const statusExists = (getState: () => RootState, statusId: string) => {
return (getState().statuses.get(statusId) || null) !== null; return (getState().statuses.get(statusId) || null) !== null;
}; };
@ -122,6 +125,9 @@ const fetchStatus = (id: string) => {
return api(getState).get(`/api/v1/statuses/${id}`).then(({ data: status }) => { return api(getState).get(`/api/v1/statuses/${id}`).then(({ data: status }) => {
dispatch(importFetchedStatus(status)); dispatch(importFetchedStatus(status));
if (status.group) {
dispatch(fetchGroupRelationships([status.group.id]));
}
dispatch({ type: STATUS_FETCH_SUCCESS, status, skipLoading }); dispatch({ type: STATUS_FETCH_SUCCESS, status, skipLoading });
return status; return status;
}).catch(error => { }).catch(error => {
@ -335,6 +341,11 @@ const undoStatusTranslation = (id: string) => ({
id, id,
}); });
const unfilterStatus = (id: string) => ({
type: STATUS_UNFILTER,
id,
});
export { export {
STATUS_CREATE_REQUEST, STATUS_CREATE_REQUEST,
STATUS_CREATE_SUCCESS, STATUS_CREATE_SUCCESS,
@ -363,6 +374,7 @@ export {
STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_FAIL, STATUS_TRANSLATE_FAIL,
STATUS_TRANSLATE_UNDO, STATUS_TRANSLATE_UNDO,
STATUS_UNFILTER,
createStatus, createStatus,
editStatus, editStatus,
fetchStatus, fetchStatus,
@ -381,4 +393,5 @@ export {
toggleStatusHidden, toggleStatusHidden,
translateStatus, translateStatus,
undoStatusTranslation, undoStatusTranslation,
unfilterStatus,
}; };

View File

@ -1,8 +1,8 @@
import { getSettings } from 'soapbox/actions/settings'; import { getLocale, getSettings } from 'soapbox/actions/settings';
import messages from 'soapbox/locales/messages'; import messages from 'soapbox/locales/messages';
import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats'; import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client'; 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 { removePageItem } from 'soapbox/utils/queries';
import { play, soundCache } from 'soapbox/utils/sounds'; import { play, soundCache } from 'soapbox/utils/sounds';
@ -34,13 +34,6 @@ import type { APIEntity, Chat } from 'soapbox/types/entities';
const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE'; const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE';
const STREAMING_FOLLOW_RELATIONSHIPS_UPDATE = 'STREAMING_FOLLOW_RELATIONSHIPS_UPDATE'; const STREAMING_FOLLOW_RELATIONSHIPS_UPDATE = 'STREAMING_FOLLOW_RELATIONSHIPS_UPDATE';
const validLocale = (locale: string) => Object.keys(messages).includes(locale);
const getLocale = (state: RootState) => {
const locale = getSettings(state).get('locale') as string;
return validLocale(locale) ? locale : 'en';
};
const updateFollowRelationships = (relationships: APIEntity) => const updateFollowRelationships = (relationships: APIEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const me = getState().me; const me = getState().me;
@ -81,7 +74,7 @@ const updateChatQuery = (chat: IChat) => {
}; };
interface StreamOpts { interface StreamOpts {
statContext?: IStatContext, statContext?: IStatContext
} }
const connectTimelineStream = ( const connectTimelineStream = (
@ -170,6 +163,9 @@ const connectTimelineStream = (
} }
}); });
break; break;
case 'chat_message.reaction': // TruthSocial
updateChatMessage(JSON.parse(data.payload));
break;
case 'pleroma:follow_relationships_update': case 'pleroma:follow_relationships_update':
dispatch(updateFollowRelationships(JSON.parse(data.payload))); dispatch(updateFollowRelationships(JSON.parse(data.payload)));
break; break;

View File

@ -4,7 +4,7 @@ import { getSettings } from 'soapbox/actions/settings';
import { normalizeStatus } from 'soapbox/normalizers'; import { normalizeStatus } from 'soapbox/normalizers';
import { shouldFilter } from 'soapbox/utils/timelines'; import { shouldFilter } from 'soapbox/utils/timelines';
import api, { getLinks } from '../api'; import api, { getNextLink, getPrevLink } from '../api';
import { importFetchedStatus, importFetchedStatuses } from './importer'; import { importFetchedStatus, importFetchedStatuses } from './importer';
@ -139,7 +139,7 @@ const parseTags = (tags: Record<string, any[]> = {}, mode: 'any' | 'all' | 'none
}; };
const replaceHomeTimeline = ( const replaceHomeTimeline = (
accountId: string | null, accountId: string | undefined,
{ maxId }: Record<string, any> = {}, { maxId }: Record<string, any> = {},
done?: () => void, done?: () => void,
) => (dispatch: AppDispatch, _getState: () => RootState) => { ) => (dispatch: AppDispatch, _getState: () => RootState) => {
@ -162,7 +162,12 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
return dispatch(noOpAsync()); return dispatch(noOpAsync());
} }
if (!params.max_id && !params.pinned && (timeline.items || ImmutableOrderedSet()).size > 0) { if (
!params.max_id &&
!params.pinned &&
(timeline.items || ImmutableOrderedSet()).size > 0 &&
!path.includes('max_id=')
) {
params.since_id = timeline.getIn(['items', 0]); params.since_id = timeline.getIn(['items', 0]);
} }
@ -171,9 +176,16 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
dispatch(expandTimelineRequest(timelineId, isLoadingMore)); dispatch(expandTimelineRequest(timelineId, isLoadingMore));
return api(getState).get(path, { params }).then(response => { return api(getState).get(path, { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data)); dispatch(importFetchedStatuses(response.data));
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore)); dispatch(expandTimelineSuccess(
timelineId,
response.data,
getNextLink(response),
getPrevLink(response),
response.status === 206,
isLoadingRecent,
isLoadingMore,
));
done(); done();
}).catch(error => { }).catch(error => {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
@ -181,9 +193,26 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
}); });
}; };
const expandHomeTimeline = ({ accountId, maxId }: Record<string, any> = {}, done = noOp) => { interface ExpandHomeTimelineOpts {
const endpoint = accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home'; accountId?: string
const params: any = { max_id: maxId }; maxId?: string
url?: string
}
interface HomeTimelineParams {
max_id?: string
exclude_replies?: boolean
with_muted?: boolean
}
const expandHomeTimeline = ({ url, accountId, maxId }: ExpandHomeTimelineOpts = {}, done = noOp) => {
const endpoint = url || (accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home');
const params: HomeTimelineParams = {};
if (!url && maxId) {
params.max_id = maxId;
}
if (accountId) { if (accountId) {
params.exclude_replies = true; params.exclude_replies = true;
params.with_muted = true; params.with_muted = true;
@ -219,6 +248,12 @@ const expandListTimeline = (id: string, { maxId }: Record<string, any> = {}, don
const expandGroupTimeline = (id: string, { maxId }: Record<string, any> = {}, done = noOp) => const expandGroupTimeline = (id: string, { maxId }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done); expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done);
const expandGroupTimelineFromTag = (id: string, tagName: string, { maxId }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`group:tags:${id}:${tagName}`, `/api/v1/timelines/group/${id}/tags/${tagName}`, { max_id: maxId }, done);
const expandGroupMediaTimeline = (id: string | number, { maxId }: Record<string, any> = {}) =>
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<string, any> = {}, done = noOp) => { const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record<string, any> = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
max_id: maxId, max_id: maxId,
@ -234,11 +269,20 @@ const expandTimelineRequest = (timeline: string, isLoadingMore: boolean) => ({
skipLoading: !isLoadingMore, skipLoading: !isLoadingMore,
}); });
const expandTimelineSuccess = (timeline: string, statuses: APIEntity[], next: string | null, partial: boolean, isLoadingRecent: boolean, isLoadingMore: boolean) => ({ const expandTimelineSuccess = (
timeline: string,
statuses: APIEntity[],
next: string | undefined,
prev: string | undefined,
partial: boolean,
isLoadingRecent: boolean,
isLoadingMore: boolean,
) => ({
type: TIMELINE_EXPAND_SUCCESS, type: TIMELINE_EXPAND_SUCCESS,
timeline, timeline,
statuses, statuses,
next, next,
prev,
partial, partial,
isLoadingRecent, isLoadingRecent,
skipLoading: !isLoadingMore, skipLoading: !isLoadingMore,
@ -309,6 +353,8 @@ export {
expandAccountMediaTimeline, expandAccountMediaTimeline,
expandListTimeline, expandListTimeline,
expandGroupTimeline, expandGroupTimeline,
expandGroupTimelineFromTag,
expandGroupMediaTimeline,
expandHashtagTimeline, expandHashtagTimeline,
expandTimelineRequest, expandTimelineRequest,
expandTimelineSuccess, expandTimelineSuccess,

View File

@ -31,14 +31,14 @@ const AGE: Challenge = 'age';
export type Challenge = 'age' | 'sms' | 'email' export type Challenge = 'age' | 'sms' | 'email'
type Challenges = { type Challenges = {
email?: 0 | 1, email?: 0 | 1
sms?: 0 | 1, sms?: 0 | 1
age?: 0 | 1, age?: 0 | 1
} }
type Verification = { type Verification = {
token?: string, token?: string
challenges?: Challenges, challenges?: Challenges
challengeTypes?: Array<'age' | 'sms' | 'email'> challengeTypes?: Array<'age' | 'sms' | 'email'>
}; };

View File

@ -23,7 +23,12 @@ export const getLinks = (response: AxiosResponse): LinkHeader => {
export const getNextLink = (response: AxiosResponse) => { export const getNextLink = (response: AxiosResponse) => {
const nextLink = new LinkHeader(response.headers?.link); const nextLink = new LinkHeader(response.headers?.link);
return nextLink.refs.find((ref) => ref.uri)?.uri; return nextLink.refs.find(link => link.rel === 'next')?.uri;
};
export const getPrevLink = (response: AxiosResponse) => {
const prevLink = new LinkHeader(response.headers?.link);
return prevLink.refs.find(link => link.rel === 'prev')?.uri;
}; };
export const baseClient = (...params: any[]) => { export const baseClient = (...params: any[]) => {

View File

@ -0,0 +1,26 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { type Account, accountSchema } from 'soapbox/schemas';
import { useRelationships } from './useRelationships';
function useAccount(id: string) {
const api = useApi();
const { entity: account, ...result } = useEntity<Account>(
[Entities.ACCOUNTS, id],
() => api.get(`/api/v1/accounts/${id}`),
{ schema: accountSchema },
);
const { relationships, isLoading } = useRelationships([account?.id as string]);
return {
...result,
isLoading: result.isLoading || isLoading,
account: account ? { ...account, relationship: relationships[0] || null } : undefined,
};
}
export { useAccount };

View File

@ -0,0 +1,21 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { type Relationship, relationshipSchema } from 'soapbox/schemas';
function useRelationships(ids: string[]) {
const api = useApi();
const { entities: relationships, ...result } = useEntities<Relationship>(
[Entities.RELATIONSHIPS],
() => api.get(`/api/v1/accounts/relationships?${ids.map(id => `id[]=${id}`).join('&')}`),
{ schema: relationshipSchema, enabled: ids.filter(Boolean).length > 0 },
);
return {
...result,
relationships,
};
}
export { useRelationships };

View File

@ -0,0 +1,15 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import type { Group, GroupMember } from 'soapbox/schemas';
function useBlockGroupMember(group: Group, groupMember: GroupMember) {
const { createEntity } = useEntityActions<GroupMember>(
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
{ post: `/api/v1/groups/${group.id}/blocks` },
);
return createEntity;
}
export { useBlockGroupMember };

View File

@ -0,0 +1,22 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useApi, useOwnAccount } from 'soapbox/hooks';
import type { Group } from 'soapbox/schemas';
function useCancelMembershipRequest(group: Group) {
const api = useApi();
const me = useOwnAccount();
const { createEntity, isSubmitting } = useCreateEntity(
[Entities.GROUP_RELATIONSHIPS],
() => api.post(`/api/v1/groups/${group.id}/membership_requests/${me?.id}/reject`),
);
return {
mutate: createEntity,
isSubmitting,
};
}
export { useCancelMembershipRequest };

View File

@ -0,0 +1,33 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { groupSchema } from 'soapbox/schemas';
interface CreateGroupParams {
display_name?: string
note?: string
avatar?: File
header?: File
group_visibility?: 'members_only' | 'everyone'
discoverable?: boolean
tags?: string[]
}
function useCreateGroup() {
const api = useApi();
const { createEntity, ...rest } = useCreateEntity([Entities.GROUPS, 'search', ''], (params: CreateGroupParams) => {
return api.post('/api/v1/groups', params, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}, { schema: groupSchema });
return {
createGroup: createEntity,
...rest,
};
}
export { useCreateGroup, type CreateGroupParams };

View File

@ -0,0 +1,18 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import type { Group } from 'soapbox/schemas';
function useDeleteGroup() {
const { deleteEntity, isSubmitting } = useEntityActions<Group>(
[Entities.GROUPS],
{ delete: '/api/v1/groups/:id' },
);
return {
mutate: deleteEntity,
isSubmitting,
};
}
export { useDeleteGroup };

View File

@ -0,0 +1,19 @@
import { z } from 'zod';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import { groupMemberSchema } from 'soapbox/schemas';
import type { Group, GroupMember } from 'soapbox/schemas';
function useDemoteGroupMember(group: Group, groupMember: GroupMember) {
const { createEntity } = useEntityActions<GroupMember>(
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
{ post: `/api/v1/groups/${group.id}/demote` },
{ schema: z.array(groupMemberSchema).transform((arr) => arr[0]) },
);
return createEntity;
}
export { useDemoteGroupMember };

View File

@ -0,0 +1,24 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks';
import { type Group, groupSchema } from 'soapbox/schemas';
import { useGroupRelationship } from './useGroupRelationship';
function useGroup(groupId: string, refetch = true) {
const api = useApi();
const { entity: group, ...result } = useEntity<Group>(
[Entities.GROUPS, groupId],
() => api.get(`/api/v1/groups/${groupId}`),
{ schema: groupSchema, refetch },
);
const { entity: relationship } = useGroupRelationship(groupId);
return {
...result,
group: group ? { ...group, relationship: relationship || null } : undefined,
};
}
export { useGroup };

View File

@ -0,0 +1,17 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityLookup } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { groupSchema } from 'soapbox/schemas';
function useGroupLookup(slug: string) {
const api = useApi();
return useEntityLookup(
Entities.GROUPS,
(group) => group.slug === slug,
() => api.get(`/api/v1/groups/lookup?name=${slug}`),
{ schema: groupSchema },
);
}
export { useGroupLookup };

View File

@ -0,0 +1,14 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { statusSchema } from 'soapbox/schemas/status';
function useGroupMedia(groupId: string) {
const api = useApi();
return useEntities([Entities.STATUSES, 'groupMedia', groupId], () => {
return api.get(`/api/v1/timelines/group/${groupId}?only_media=true`);
}, { schema: statusSchema });
}
export { useGroupMedia };

View File

@ -0,0 +1,23 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { GroupMember, groupMemberSchema } from 'soapbox/schemas';
import { GroupRoles } from 'soapbox/schemas/group-member';
import { useApi } from '../../../hooks/useApi';
function useGroupMembers(groupId: string, role: GroupRoles) {
const api = useApi();
const { entities, ...result } = useEntities<GroupMember>(
[Entities.GROUP_MEMBERSHIPS, groupId, role],
() => api.get(`/api/v1/groups/${groupId}/memberships?role=${role}`),
{ schema: groupMemberSchema },
);
return {
...result,
groupMembers: entities,
};
}
export { useGroupMembers };

View File

@ -0,0 +1,46 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useDismissEntity, useEntities } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { accountSchema } from 'soapbox/schemas';
import { GroupRoles } from 'soapbox/schemas/group-member';
import { useGroupRelationship } from './useGroupRelationship';
import type { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types';
function useGroupMembershipRequests(groupId: string) {
const api = useApi();
const path: ExpandedEntitiesPath = [Entities.ACCOUNTS, 'membership_requests', groupId];
const { entity: relationship } = useGroupRelationship(groupId);
const { entities, invalidate, ...rest } = useEntities(
path,
() => api.get(`/api/v1/groups/${groupId}/membership_requests`),
{
schema: accountSchema,
enabled: relationship?.role === GroupRoles.OWNER || relationship?.role === GroupRoles.ADMIN,
},
);
const { dismissEntity: authorize } = useDismissEntity(path, async (accountId: string) => {
const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`);
invalidate();
return response;
});
const { dismissEntity: reject } = useDismissEntity(path, async (accountId: string) => {
const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`);
invalidate();
return response;
});
return {
accounts: entities,
authorize,
reject,
...rest,
};
}
export { useGroupMembershipRequests };

View File

@ -0,0 +1,32 @@
import { useEffect } from 'react';
import { z } from 'zod';
import { fetchGroupRelationshipsSuccess } from 'soapbox/actions/groups';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntity } from 'soapbox/entity-store/hooks';
import { useApi, useAppDispatch } from 'soapbox/hooks';
import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas';
function useGroupRelationship(groupId: string) {
const api = useApi();
const dispatch = useAppDispatch();
const { entity: groupRelationship, ...result } = useEntity<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, groupId],
() => api.get(`/api/v1/groups/relationships?id[]=${groupId}`),
{ schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) },
);
useEffect(() => {
if (groupRelationship?.id) {
dispatch(fetchGroupRelationshipsSuccess([groupRelationship]));
}
}, [groupRelationship?.id]);
return {
entity: groupRelationship,
...result,
};
}
export { useGroupRelationship };

View File

@ -0,0 +1,27 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks';
import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas';
function useGroupRelationships(groupIds: string[]) {
const api = useApi();
const q = groupIds.map(id => `id[]=${id}`).join('&');
const { entities, ...result } = useEntities<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, ...groupIds],
() => api.get(`/api/v1/groups/relationships?${q}`),
{ schema: groupRelationshipSchema, enabled: groupIds.length > 0 },
);
const relationships = entities.reduce<Record<string, GroupRelationship>>((map, relationship) => {
map[relationship.id] = relationship;
return map;
}, {});
return {
...result,
relationships,
};
}
export { useGroupRelationships };

View File

@ -0,0 +1,37 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi, useFeatures } from 'soapbox/hooks';
import { groupSchema } from 'soapbox/schemas';
import { useGroupRelationships } from './useGroupRelationships';
import type { Group } from 'soapbox/schemas';
function useGroupSearch(search: string) {
const api = useApi();
const features = useFeatures();
const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS, 'discover', 'search', search],
() => api.get('/api/v1/groups/search', {
params: {
q: search,
},
}),
{ enabled: features.groupsDiscovery && !!search, schema: groupSchema },
);
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
const groups = entities.map((group) => ({
...group,
relationship: relationships[group.id] || null,
}));
return {
...result,
groups,
};
}
export { useGroupSearch };

View File

@ -0,0 +1,21 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks';
import { type GroupTag, groupTagSchema } from 'soapbox/schemas';
function useGroupTag(tagId: string) {
const api = useApi();
const { entity: tag, ...result } = useEntity<GroupTag>(
[Entities.GROUP_TAGS, tagId],
() => api.get(`/api/v1/tags/${tagId }`),
{ schema: groupTagSchema },
);
return {
...result,
tag,
};
}
export { useGroupTag };

View File

@ -0,0 +1,23 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { groupTagSchema } from 'soapbox/schemas';
import type { GroupTag } from 'soapbox/schemas';
function useGroupTags(groupId: string) {
const api = useApi();
const { entities, ...result } = useEntities<GroupTag>(
[Entities.GROUP_TAGS, groupId],
() => api.get(`/api/v1/truth/trends/groups/${groupId}/tags`),
{ schema: groupTagSchema },
);
return {
...result,
tags: entities,
};
}
export { useGroupTags };

View File

@ -0,0 +1,47 @@
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'soapbox/hooks/useApi';
import { useFeatures } from 'soapbox/hooks/useFeatures';
type Validation = {
error: string
message: string
}
const ValidationKeys = {
validation: (name: string) => ['group', 'validation', name] as const,
};
function useGroupValidation(name: string = '') {
const api = useApi();
const features = useFeatures();
const getValidation = async() => {
const { data } = await api.get<Validation>('/api/v1/groups/validate', {
params: { name },
})
.catch((error) => {
if (error.response.status === 422) {
return { data: error.response.data };
}
throw error;
});
return data;
};
const queryInfo = useQuery<Validation>(ValidationKeys.validation(name), getValidation, {
enabled: features.groupsValidation && !!name,
});
return {
...queryInfo,
data: {
...queryInfo.data,
isValid: !queryInfo.data?.error ?? true,
},
};
}
export { useGroupValidation };

View File

@ -0,0 +1,31 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks';
import { useFeatures } from 'soapbox/hooks/useFeatures';
import { groupSchema, type Group } from 'soapbox/schemas/group';
import { useGroupRelationships } from './useGroupRelationships';
function useGroups(q: string = '') {
const api = useApi();
const features = useFeatures();
const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS, 'search', q],
() => api.get('/api/v1/groups', { params: { q } }),
{ enabled: features.groups, schema: groupSchema },
);
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
const groups = entities.map((group) => ({
...group,
relationship: relationships[group.id] || null,
}));
return {
...result,
groups,
};
}
export { useGroups };

View File

@ -0,0 +1,35 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi, useFeatures } from 'soapbox/hooks';
import { groupSchema } from 'soapbox/schemas';
import { useGroupRelationships } from './useGroupRelationships';
import type { Group } from 'soapbox/schemas';
function useGroupsFromTag(tagId: string) {
const api = useApi();
const features = useFeatures();
const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS, 'tags', tagId],
() => api.get(`/api/v1/tags/${tagId}/groups`),
{
schema: groupSchema,
enabled: features.groupsDiscovery,
},
);
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
const groups = entities.map((group) => ({
...group,
relationship: relationships[group.id] || null,
}));
return {
...result,
groups,
};
}
export { useGroupsFromTag };

View File

@ -0,0 +1,25 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import { groupRelationshipSchema } from 'soapbox/schemas';
import { useGroups } from './useGroups';
import type { Group, GroupRelationship } from 'soapbox/schemas';
function useJoinGroup(group: Group) {
const { invalidate } = useGroups();
const { createEntity, isSubmitting } = useEntityActions<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, group.id],
{ post: `/api/v1/groups/${group.id}/join` },
{ schema: groupRelationshipSchema },
);
return {
mutate: createEntity,
isSubmitting,
invalidate,
};
}
export { useJoinGroup };

View File

@ -0,0 +1,25 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import { groupRelationshipSchema } from 'soapbox/schemas';
import { useGroups } from './useGroups';
import type { Group, GroupRelationship } from 'soapbox/schemas';
function useLeaveGroup(group: Group) {
const { invalidate } = useGroups();
const { createEntity, isSubmitting } = useEntityActions<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, group.id],
{ post: `/api/v1/groups/${group.id}/leave` },
{ schema: groupRelationshipSchema },
);
return {
mutate: createEntity,
isSubmitting,
invalidate,
};
}
export { useLeaveGroup };

View File

@ -0,0 +1,36 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { Group, groupSchema } from 'soapbox/schemas';
import { useApi } from '../../../hooks/useApi';
import { useFeatures } from '../../../hooks/useFeatures';
import { useGroupRelationships } from './useGroupRelationships';
function usePopularGroups() {
const api = useApi();
const features = useFeatures();
const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS, 'popular'],
() => api.get('/api/v1/truth/trends/groups'),
{
schema: groupSchema,
enabled: features.groupsDiscovery,
},
);
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
const groups = entities.map((group) => ({
...group,
relationship: relationships[group.id] || null,
}));
return {
...result,
groups,
};
}
export { usePopularGroups };

View File

@ -0,0 +1,25 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi, useFeatures } from 'soapbox/hooks';
import { type GroupTag, groupTagSchema } from 'soapbox/schemas';
function usePopularTags() {
const api = useApi();
const features = useFeatures();
const { entities, ...result } = useEntities<GroupTag>(
[Entities.GROUP_TAGS],
() => api.get('/api/v1/groups/tags'),
{
schema: groupTagSchema,
enabled: features.groupsDiscovery,
},
);
return {
...result,
tags: entities,
};
}
export { usePopularTags };

View File

@ -0,0 +1,19 @@
import { z } from 'zod';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import { groupMemberSchema } from 'soapbox/schemas';
import type { Group, GroupMember } from 'soapbox/schemas';
function usePromoteGroupMember(group: Group, groupMember: GroupMember) {
const { createEntity } = useEntityActions<GroupMember>(
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
{ post: `/api/v1/groups/${group.id}/promote` },
{ schema: z.array(groupMemberSchema).transform((arr) => arr[0]) },
);
return createEntity;
}
export { usePromoteGroupMember };

View File

@ -0,0 +1,34 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi, useFeatures } from 'soapbox/hooks';
import { type Group, groupSchema } from 'soapbox/schemas';
import { useGroupRelationships } from './useGroupRelationships';
function useSuggestedGroups() {
const api = useApi();
const features = useFeatures();
const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS, 'suggested'],
() => api.get('/api/v1/truth/suggestions/groups'),
{
schema: groupSchema,
enabled: features.groupsDiscovery,
},
);
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
const groups = entities.map((group) => ({
...group,
relationship: relationships[group.id] || null,
}));
return {
...result,
groups,
};
}
export { useSuggestedGroups };

View File

@ -0,0 +1,33 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { groupSchema } from 'soapbox/schemas';
interface UpdateGroupParams {
display_name?: string
note?: string
avatar?: File
header?: File
group_visibility?: string
discoverable?: boolean
tags?: string[]
}
function useUpdateGroup(groupId: string) {
const api = useApi();
const { createEntity, ...rest } = useCreateEntity([Entities.GROUPS], (params: UpdateGroupParams) => {
return api.put(`/api/v1/groups/${groupId}`, params, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}, { schema: groupSchema });
return {
updateGroup: createEntity,
...rest,
};
}
export { useUpdateGroup };

View File

@ -0,0 +1,18 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import type { GroupTag } from 'soapbox/schemas';
function useUpdateGroupTag(groupId: string, tagId: string) {
const { updateEntity, ...rest } = useEntityActions<GroupTag>(
[Entities.GROUP_TAGS, groupId, tagId],
{ patch: `/api/v1/groups/${groupId}/tags/${tagId}` },
);
return {
updateGroupTag: updateEntity,
...rest,
};
}
export { useUpdateGroupTag };

View File

@ -0,0 +1,40 @@
/**
* Accounts
*/
export { useAccount } from './accounts/useAccount';
/**
* Groups
*/
export { useBlockGroupMember } from './groups/useBlockGroupMember';
export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest';
export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup';
export { useDeleteGroup } from './groups/useDeleteGroup';
export { useDemoteGroupMember } from './groups/useDemoteGroupMember';
export { useGroup } from './groups/useGroup';
export { useGroupLookup } from './groups/useGroupLookup';
export { useGroupMedia } from './groups/useGroupMedia';
export { useGroupMembers } from './groups/useGroupMembers';
export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests';
export { useGroupRelationship } from './groups/useGroupRelationship';
export { useGroupRelationships } from './groups/useGroupRelationships';
export { useGroupSearch } from './groups/useGroupSearch';
export { useGroupTag } from './groups/useGroupTag';
export { useGroupTags } from './groups/useGroupTags';
export { useGroupValidation } from './groups/useGroupValidation';
export { useGroups } from './groups/useGroups';
export { useGroupsFromTag } from './groups/useGroupsFromTag';
export { useJoinGroup } from './groups/useJoinGroup';
export { useLeaveGroup } from './groups/useLeaveGroup';
export { usePopularGroups } from './groups/usePopularGroups';
export { usePopularTags } from './groups/usePopularTags';
export { usePromoteGroupMember } from './groups/usePromoteGroupMember';
export { useSuggestedGroups } from './groups/useSuggestedGroups';
export { useUpdateGroup } from './groups/useUpdateGroup';
export { useUpdateGroupTag } from './groups/useUpdateGroupTag';
/**
* Relationships
*/
export { useRelationships } from './accounts/useRelationships';

View File

@ -29,6 +29,10 @@ export const getNextLink = (response: AxiosResponse): string | undefined => {
return getLinks(response).refs.find(link => link.rel === 'next')?.uri; return getLinks(response).refs.find(link => link.rel === 'next')?.uri;
}; };
export const getPrevLink = (response: AxiosResponse): string | undefined => {
return getLinks(response).refs.find(link => link.rel === 'prev')?.uri;
};
const getToken = (state: RootState, authType: string) => { const getToken = (state: RootState, authType: string) => {
return authType === 'app' ? getAppToken(state) : getAccessToken(state); return authType === 'app' ? getAppToken(state) : getAccessToken(state);
}; };

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
interface IInlineSVG { interface IInlineSVG {
loader?: JSX.Element, loader?: JSX.Element
} }
const InlineSVG: React.FC<IInlineSVG> = ({ loader }): JSX.Element => { const InlineSVG: React.FC<IInlineSVG> = ({ loader }): JSX.Element => {

View File

@ -1,16 +0,0 @@
import React from 'react';
import { render, screen } from '../../jest/test-helpers';
import EmojiSelector from '../emoji-selector';
describe('<EmojiSelector />', () => {
it('renders correctly', () => {
const children = <EmojiSelector />;
// @ts-ignore
children.__proto__.addEventListener = () => {};
render(children);
expect(screen.queryAllByRole('button')).toHaveLength(6);
});
});

View File

@ -1,4 +1,4 @@
import classNames from 'clsx'; import clsx from 'clsx';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
@ -12,9 +12,9 @@ const messages = defineMessages({
interface IAccountSearch { interface IAccountSearch {
/** Callback when a searched account is chosen. */ /** Callback when a searched account is chosen. */
onSelected: (accountId: string) => void, onSelected: (accountId: string) => void
/** Override the default placeholder of the input. */ /** Override the default placeholder of the input. */
placeholder?: string, placeholder?: string
} }
/** Input to search for accounts. */ /** Input to search for accounts. */
@ -72,17 +72,17 @@ const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, ...rest }) => {
<div <div
role='button' role='button'
tabIndex={0} tabIndex={0}
className='absolute inset-y-0 right-0 px-3 flex items-center cursor-pointer' className='absolute inset-y-0 right-0 flex cursor-pointer items-center px-3'
onClick={handleClear} onClick={handleClear}
> >
<SvgIcon <SvgIcon
src={require('@tabler/icons/search.svg')} src={require('@tabler/icons/search.svg')}
className={classNames('h-4 w-4 text-gray-400', { hidden: !isEmpty() })} className={clsx('h-4 w-4 text-gray-400', { hidden: !isEmpty() })}
/> />
<SvgIcon <SvgIcon
src={require('@tabler/icons/x.svg')} src={require('@tabler/icons/x.svg')}
className={classNames('h-4 w-4 text-gray-400', { hidden: isEmpty() })} className={clsx('h-4 w-4 text-gray-400', { hidden: isEmpty() })}
aria-label={intl.formatMessage(messages.placeholder)} aria-label={intl.formatMessage(messages.placeholder)}
/> />
</div> </div>

View File

@ -1,23 +1,31 @@
import React from 'react'; import React, { useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper'; import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
import VerificationBadge from 'soapbox/components/verification-badge'; import VerificationBadge from 'soapbox/components/verification-badge';
import ActionButton from 'soapbox/features/ui/components/action-button'; 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 { getAcct } from 'soapbox/utils/accounts';
import { displayFqn } from 'soapbox/utils/state'; import { displayFqn } from 'soapbox/utils/state';
import Badge from './badge';
import RelativeTimestamp from './relative-timestamp'; import RelativeTimestamp from './relative-timestamp';
import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui'; import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui';
import type { StatusApprovalStatus } from 'soapbox/normalizers/status';
import type { Account as AccountSchema } from 'soapbox/schemas';
import type { Account as AccountEntity } from 'soapbox/types/entities'; import type { Account as AccountEntity } from 'soapbox/types/entities';
interface IInstanceFavicon { interface IInstanceFavicon {
account: AccountEntity, account: AccountEntity | AccountSchema
disabled?: boolean, disabled?: boolean
} }
const messages = defineMessages({
bot: { id: 'account.badges.bot', defaultMessage: 'Bot' },
});
const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => { const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => {
const history = useHistory(); const history = useHistory();
@ -36,17 +44,17 @@ const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => {
return ( return (
<button <button
className='w-4 h-4 flex-none focus:ring-primary-500 focus:ring-2 focus:ring-offset-2' className='h-4 w-4 flex-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
onClick={handleClick} onClick={handleClick}
disabled={disabled} disabled={disabled}
> >
<img src={account.favicon} alt='' title={account.domain} className='w-full max-h-full' /> <img src={account.favicon} alt='' title={account.domain} className='max-h-full w-full' />
</button> </button>
); );
}; };
interface IProfilePopper { interface IProfilePopper {
condition: boolean, condition: boolean
wrapper: (children: React.ReactNode) => React.ReactNode wrapper: (children: React.ReactNode) => React.ReactNode
children: React.ReactNode children: React.ReactNode
} }
@ -60,29 +68,31 @@ const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children
}; };
export interface IAccount { export interface IAccount {
account: AccountEntity, account: AccountEntity | AccountSchema
action?: React.ReactElement, action?: React.ReactElement
actionAlignment?: 'center' | 'top', actionAlignment?: 'center' | 'top'
actionIcon?: string, actionIcon?: string
actionTitle?: string, actionTitle?: string
/** Override other actions for specificity like mute/unmute. */ /** Override other actions for specificity like mute/unmute. */
actionType?: 'muting' | 'blocking' | 'follow_request', actionType?: 'muting' | 'blocking' | 'follow_request'
avatarSize?: number, avatarSize?: number
hidden?: boolean, hidden?: boolean
hideActions?: boolean, hideActions?: boolean
id?: string, id?: string
onActionClick?: (account: any) => void, onActionClick?: (account: any) => void
showProfileHoverCard?: boolean, showProfileHoverCard?: boolean
timestamp?: string, timestamp?: string
timestampUrl?: string, timestampUrl?: string
futureTimestamp?: boolean, futureTimestamp?: boolean
withAccountNote?: boolean, withAccountNote?: boolean
withDate?: boolean, withDate?: boolean
withLinkToProfile?: boolean, withLinkToProfile?: boolean
withRelationship?: boolean, withRelationship?: boolean
showEdit?: boolean, showEdit?: boolean
emoji?: string, approvalStatus?: StatusApprovalStatus
note?: string, emoji?: string
emojiUrl?: string
note?: string
} }
const Account = ({ const Account = ({
@ -105,22 +115,19 @@ const Account = ({
withLinkToProfile = true, withLinkToProfile = true,
withRelationship = true, withRelationship = true,
showEdit = false, showEdit = false,
approvalStatus,
emoji, emoji,
emojiUrl,
note, note,
}: IAccount) => { }: IAccount) => {
const overflowRef = React.useRef<HTMLDivElement>(null); const overflowRef = useRef<HTMLDivElement>(null);
const actionRef = React.useRef<HTMLDivElement>(null); const actionRef = useRef<HTMLDivElement>(null);
// @ts-ignore
const isOnScreen = useOnScreen(overflowRef);
const [style, setStyle] = React.useState<React.CSSProperties>({ visibility: 'hidden' });
const me = useAppSelector((state) => state.me); const me = useAppSelector((state) => state.me);
const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null); const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null);
const handleAction = () => { const handleAction = () => {
// @ts-ignore onActionClick!(account);
onActionClick(account);
}; };
const renderAction = () => { const renderAction = () => {
@ -138,8 +145,8 @@ const Account = ({
src={actionIcon} src={actionIcon}
title={actionTitle} title={actionTitle}
onClick={handleAction} onClick={handleAction}
className='bg-transparent text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500' className='bg-transparent text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-500'
iconClassName='w-4 h-4' iconClassName='h-4 w-4'
/> />
); );
} }
@ -151,18 +158,7 @@ const Account = ({
return null; return null;
}; };
React.useEffect(() => { const intl = useIntl();
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]);
if (!account) { if (!account) {
return null; return null;
@ -182,9 +178,9 @@ const Account = ({
const LinkEl: any = withLinkToProfile ? Link : 'div'; const LinkEl: any = withLinkToProfile ? Link : 'div';
return ( return (
<div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}> <div data-testid='account' className='group block w-full shrink-0' ref={overflowRef}>
<HStack alignItems={actionAlignment} justifyContent='between'> <HStack alignItems={actionAlignment} justifyContent='between'>
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3}> <HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3} className='overflow-hidden'>
<ProfilePopper <ProfilePopper
condition={showProfileHoverCard} condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>} wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
@ -197,14 +193,15 @@ const Account = ({
<Avatar src={account.avatar} size={avatarSize} /> <Avatar src={account.avatar} size={avatarSize} />
{emoji && ( {emoji && (
<Emoji <Emoji
className='w-5 h-5 absolute -bottom-1.5 -right-1.5' className='absolute -right-1.5 bottom-0 h-5 w-5'
emoji={emoji} emoji={emoji}
src={emojiUrl}
/> />
)} )}
</LinkEl> </LinkEl>
</ProfilePopper> </ProfilePopper>
<div className='flex-grow'> <div className='grow overflow-hidden'>
<ProfilePopper <ProfilePopper
condition={showProfileHoverCard} condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>} wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>}
@ -214,7 +211,7 @@ const Account = ({
title={account.acct} title={account.acct}
onClick={(event: React.MouseEvent) => event.stopPropagation()} onClick={(event: React.MouseEvent) => event.stopPropagation()}
> >
<HStack space={1} alignItems='center' grow style={style}> <HStack space={1} alignItems='center' grow>
<Text <Text
size='sm' size='sm'
weight='semibold' weight='semibold'
@ -223,12 +220,14 @@ const Account = ({
/> />
{account.verified && <VerificationBadge />} {account.verified && <VerificationBadge />}
{account.bot && <Badge slug='bot' title={intl.formatMessage(messages.bot)} />}
</HStack> </HStack>
</LinkEl> </LinkEl>
</ProfilePopper> </ProfilePopper>
<Stack space={withAccountNote || note ? 1 : 0}> <Stack space={withAccountNote || note ? 1 : 0}>
<HStack alignItems='center' space={1} style={style}> <HStack alignItems='center' space={1}>
<Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text> <Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text>
{account.favicon && ( {account.favicon && (
@ -249,6 +248,18 @@ const Account = ({
</> </>
) : null} ) : null}
{approvalStatus && ['pending', 'rejected'].includes(approvalStatus) && (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
<Text tag='span' theme='muted' size='sm'>
{approvalStatus === 'pending'
? <FormattedMessage id='status.approval.pending' defaultMessage='Pending approval' />
: <FormattedMessage id='status.approval.rejected' defaultMessage='Rejected' />}
</Text>
</>
)}
{showEdit ? ( {showEdit ? (
<> <>
<Text tag='span' theme='muted' size='sm'>&middot;</Text> <Text tag='span' theme='muted' size='sm'>&middot;</Text>

View File

@ -15,8 +15,8 @@ const obfuscatedCount = (count: number) => {
}; };
interface IAnimatedNumber { interface IAnimatedNumber {
value: number; value: number
obfuscate?: boolean; obfuscate?: boolean
} }
const AnimatedNumber: React.FC<IAnimatedNumber> = ({ value, obfuscate }) => { const AnimatedNumber: React.FC<IAnimatedNumber> = ({ value, obfuscate }) => {
@ -50,7 +50,7 @@ const AnimatedNumber: React.FC<IAnimatedNumber> = ({ value, obfuscate }) => {
return ( return (
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}> <TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
{items => ( {items => (
<span className='inline-flex flex-col items-stretch relative overflow-hidden'> <span className='relative inline-flex flex-col items-stretch overflow-hidden'>
{items.map(({ key, data, style }) => ( {items.map(({ key, data, style }) => (
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span> <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span>
))} ))}

View File

@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom';
import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities'; import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities';
interface IAnnouncementContent { interface IAnnouncementContent {
announcement: AnnouncementEntity; announcement: AnnouncementEntity
} }
const AnnouncementContent: React.FC<IAnnouncementContent> = ({ announcement }) => { const AnnouncementContent: React.FC<IAnnouncementContent> = ({ announcement }) => {

View File

@ -11,10 +11,10 @@ import type { Map as ImmutableMap } from 'immutable';
import type { Announcement as AnnouncementEntity } from 'soapbox/types/entities'; import type { Announcement as AnnouncementEntity } from 'soapbox/types/entities';
interface IAnnouncement { interface IAnnouncement {
announcement: AnnouncementEntity; announcement: AnnouncementEntity
addReaction: (id: string, name: string) => void; addReaction: (id: string, name: string) => void
removeReaction: (id: string, name: string) => void; removeReaction: (id: string, name: string) => void
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>; emojiMap: ImmutableMap<string, ImmutableMap<string, string>>
} }
const Announcement: React.FC<IAnnouncement> = ({ announcement, addReaction, removeReaction, emojiMap }) => { const Announcement: React.FC<IAnnouncement> = ({ announcement, addReaction, removeReaction, emojiMap }) => {

View File

@ -1,4 +1,4 @@
import classNames from 'clsx'; import clsx from 'clsx';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
@ -52,7 +52,7 @@ const AnnouncementsPanel = () => {
key={i} key={i}
tabIndex={0} tabIndex={0}
onClick={() => setIndex(i)} 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, '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-gray-200 hover:bg-gray-300': i !== index,
'bg-primary-600': i === index, 'bg-primary-600': i === index,

View File

@ -1,15 +1,15 @@
import React from 'react'; import React from 'react';
import unicodeMapping from 'soapbox/features/emoji/emoji-unicode-mapping-light'; import unicodeMapping from 'soapbox/features/emoji/mapping';
import { useSettings } from 'soapbox/hooks'; import { useSettings } from 'soapbox/hooks';
import { joinPublicPath } from 'soapbox/utils/static'; import { joinPublicPath } from 'soapbox/utils/static';
import type { Map as ImmutableMap } from 'immutable'; import type { Map as ImmutableMap } from 'immutable';
interface IEmoji { interface IEmoji {
emoji: string; emoji: string
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>; emojiMap: ImmutableMap<string, ImmutableMap<string, string>>
hovered: boolean; hovered: boolean
} }
const Emoji: React.FC<IEmoji> = ({ emoji, emojiMap, hovered }) => { const Emoji: React.FC<IEmoji> = ({ emoji, emojiMap, hovered }) => {
@ -24,7 +24,7 @@ const Emoji: React.FC<IEmoji> = ({ emoji, emojiMap, hovered }) => {
return ( return (
<img <img
draggable='false' draggable='false'
className='emojione block m-0' className='emojione m-0 block'
alt={emoji} alt={emoji}
title={title} title={title}
src={joinPublicPath(`packs/emoji/${filename}.svg`)} src={joinPublicPath(`packs/emoji/${filename}.svg`)}
@ -37,7 +37,7 @@ const Emoji: React.FC<IEmoji> = ({ emoji, emojiMap, hovered }) => {
return ( return (
<img <img
draggable='false' draggable='false'
className='emojione block m-0' className='emojione m-0 block'
alt={shortCode} alt={shortCode}
title={shortCode} title={shortCode}
src={filename as string} src={filename as string}

View File

@ -1,8 +1,8 @@
import classNames from 'clsx'; import clsx from 'clsx';
import React, { useState } from 'react'; import React, { useState } from 'react';
import AnimatedNumber from 'soapbox/components/animated-number'; import AnimatedNumber from 'soapbox/components/animated-number';
import unicodeMapping from 'soapbox/features/emoji/emoji-unicode-mapping-light'; import unicodeMapping from 'soapbox/features/emoji/mapping';
import Emoji from './emoji'; import Emoji from './emoji';
@ -10,12 +10,12 @@ import type { Map as ImmutableMap } from 'immutable';
import type { AnnouncementReaction } from 'soapbox/types/entities'; import type { AnnouncementReaction } from 'soapbox/types/entities';
interface IReaction { interface IReaction {
announcementId: string; announcementId: string
reaction: AnnouncementReaction; reaction: AnnouncementReaction
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>; emojiMap: ImmutableMap<string, ImmutableMap<string, string>>
addReaction: (id: string, name: string) => void; addReaction: (id: string, name: string) => void
removeReaction: (id: string, name: string) => void; removeReaction: (id: string, name: string) => void
style: React.CSSProperties; style: React.CSSProperties
} }
const Reaction: React.FC<IReaction> = ({ announcementId, reaction, addReaction, removeReaction, emojiMap, style }) => { const Reaction: React.FC<IReaction> = ({ announcementId, reaction, addReaction, removeReaction, emojiMap, style }) => {
@ -43,7 +43,7 @@ const Reaction: React.FC<IReaction> = ({ announcementId, reaction, addReaction,
return ( return (
<button <button
className={classNames('flex shrink-0 items-center gap-1.5 bg-gray-100 dark:bg-primary-900 rounded-sm px-1.5 py-1 transition-colors', { className={clsx('flex shrink-0 items-center gap-1.5 rounded-sm bg-gray-100 px-1.5 py-1 transition-colors dark:bg-primary-900', {
'bg-gray-200 dark:bg-primary-800': hovered, 'bg-gray-200 dark:bg-primary-800': hovered,
'bg-primary-200 dark:bg-primary-500': reaction.me, 'bg-primary-200 dark:bg-primary-500': reaction.me,
})} })}

View File

@ -1,30 +1,29 @@
import classNames from 'clsx'; import clsx from 'clsx';
import React from 'react'; import React from 'react';
import { TransitionMotion, spring } from 'react-motion'; import { TransitionMotion, spring } from 'react-motion';
import { Icon } from 'soapbox/components/ui'; import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
import EmojiPickerDropdown from 'soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown';
import { useSettings } from 'soapbox/hooks'; import { useSettings } from 'soapbox/hooks';
import Reaction from './reaction'; import Reaction from './reaction';
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import type { Emoji } from 'soapbox/components/autosuggest-emoji'; import type { Emoji, NativeEmoji } from 'soapbox/features/emoji';
import type { AnnouncementReaction } from 'soapbox/types/entities'; import type { AnnouncementReaction } from 'soapbox/types/entities';
interface IReactionsBar { interface IReactionsBar {
announcementId: string; announcementId: string
reactions: ImmutableList<AnnouncementReaction>; reactions: ImmutableList<AnnouncementReaction>
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>; emojiMap: ImmutableMap<string, ImmutableMap<string, string>>
addReaction: (id: string, name: string) => void; addReaction: (id: string, name: string) => void
removeReaction: (id: string, name: string) => void; removeReaction: (id: string, name: string) => void
} }
const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, addReaction, removeReaction, emojiMap }) => { const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, addReaction, removeReaction, emojiMap }) => {
const reduceMotion = useSettings().get('reduceMotion'); const reduceMotion = useSettings().get('reduceMotion');
const handleEmojiPick = (data: Emoji) => { const handleEmojiPick = (data: Emoji) => {
addReaction(announcementId, data.native.replace(/:/g, '')); addReaction(announcementId, (data as NativeEmoji).native.replace(/:/g, ''));
}; };
const willEnter = () => ({ scale: reduceMotion ? 1 : 0 }); const willEnter = () => ({ scale: reduceMotion ? 1 : 0 });
@ -42,7 +41,7 @@ const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, addR
return ( return (
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}> <TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
{items => ( {items => (
<div className={classNames('flex flex-wrap items-center gap-1', { 'reactions-bar--empty': visibleReactions.isEmpty() })}> <div className={clsx('flex flex-wrap items-center gap-1', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
{items.map(({ key, data, style }) => ( {items.map(({ key, data, style }) => (
<Reaction <Reaction
key={key} key={key}
@ -55,7 +54,7 @@ const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, addR
/> />
))} ))}
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={handleEmojiPick} button={<Icon className='h-4 w-4 text-gray-400 hover:text-gray-600 dark:hover:text-white' src={require('@tabler/icons/plus.svg')} />} />} {visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={handleEmojiPick} />}
</div> </div>
)} )}
</TransitionMotion> </TransitionMotion>

View File

@ -0,0 +1,180 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { HStack, IconButton, Text } from 'soapbox/components/ui';
interface IAuthorizeRejectButtons {
onAuthorize(): Promise<unknown> | unknown
onReject(): Promise<unknown> | unknown
countdown?: number
}
/** Buttons to approve or reject a pending item, usually an account. */
const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize, onReject, countdown }) => {
const [state, setState] = useState<'authorizing' | 'rejecting' | 'authorized' | 'rejected' | 'pending'>('pending');
const timeout = useRef<NodeJS.Timeout>();
const interval = useRef<ReturnType<typeof setInterval>>();
const [progress, setProgress] = useState<number>(0);
const startProgressInterval = () => {
let startValue = 1;
interval.current = setInterval(() => {
startValue++;
const newValue = startValue * 3.6; // get to 360 (deg)
setProgress(newValue);
if (newValue >= 360) {
clearInterval(interval.current as NodeJS.Timeout);
setProgress(0);
}
}, (countdown as number) / 100);
};
function handleAction(
present: 'authorizing' | 'rejecting',
past: 'authorized' | 'rejected',
action: () => Promise<unknown> | unknown,
): void {
if (state === present) {
if (interval.current) {
clearInterval(interval.current);
}
if (timeout.current) {
clearTimeout(timeout.current);
}
setState('pending');
} else {
const doAction = async () => {
try {
await action();
setState(past);
} catch (e) {
console.error(e);
}
};
if (typeof countdown === 'number') {
setState(present);
timeout.current = setTimeout(doAction, countdown);
startProgressInterval();
} else {
doAction();
}
}
}
const handleAuthorize = async () => handleAction('authorizing', 'authorized', onAuthorize);
const handleReject = async () => handleAction('rejecting', 'rejected', onReject);
const renderStyle = (selectedState: typeof state) => {
if (state === 'authorizing' && selectedState === 'authorizing') {
return {
background: `conic-gradient(rgb(var(--color-primary-500)) ${progress}deg, rgb(var(--color-primary-500) / 0.1) 0deg)`,
};
} else if (state === 'rejecting' && selectedState === 'rejecting') {
return {
background: `conic-gradient(rgb(var(--color-danger-600)) ${progress}deg, rgb(var(--color-danger-600) / 0.1) 0deg)`,
};
}
return {};
};
useEffect(() => {
return () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
if (interval.current) {
clearInterval(interval.current);
}
};
}, []);
switch (state) {
case 'authorized':
return (
<ActionEmblem text={<FormattedMessage id='authorize.success' defaultMessage='Approved' />} />
);
case 'rejected':
return (
<ActionEmblem text={<FormattedMessage id='reject.success' defaultMessage='Rejected' />} />
);
default:
return (
<HStack space={3} alignItems='center'>
<AuthorizeRejectButton
theme='danger'
icon={require('@tabler/icons/x.svg')}
action={handleReject}
isLoading={state === 'rejecting'}
disabled={state === 'authorizing'}
style={renderStyle('rejecting')}
/>
<AuthorizeRejectButton
theme='primary'
icon={require('@tabler/icons/check.svg')}
action={handleAuthorize}
isLoading={state === 'authorizing'}
disabled={state === 'rejecting'}
style={renderStyle('authorizing')}
/>
</HStack>
);
}
};
interface IActionEmblem {
text: React.ReactNode
}
const ActionEmblem: React.FC<IActionEmblem> = ({ text }) => {
return (
<div className='rounded-full bg-gray-100 px-4 py-2 dark:bg-gray-800'>
<Text theme='muted' size='sm'>
{text}
</Text>
</div>
);
};
interface IAuthorizeRejectButton {
theme: 'primary' | 'danger'
icon: string
action(): void
isLoading?: boolean
disabled?: boolean
style: React.CSSProperties
}
const AuthorizeRejectButton: React.FC<IAuthorizeRejectButton> = ({ theme, icon, action, isLoading, style, disabled }) => {
return (
<div className='relative'>
<div
style={style}
className={
clsx({
'flex h-11 w-11 items-center justify-center rounded-full': true,
'bg-danger-600/10': theme === 'danger',
'bg-primary-500/10': theme === 'primary',
})
}
>
<IconButton
src={isLoading ? require('@tabler/icons/player-stop-filled.svg') : icon}
onClick={action}
theme='seamless'
className='h-10 w-10 items-center justify-center bg-white focus:!ring-0 dark:!bg-gray-900'
iconClassName={clsx('h-6 w-6', {
'text-primary-500': theme === 'primary',
'text-danger-600': theme === 'danger',
})}
disabled={disabled}
/>
</div>
</div>
);
};
export { AuthorizeRejectButtons };

View File

@ -12,16 +12,16 @@ import type { InputThemes } from 'soapbox/components/ui/input/input';
const noOp = () => { }; const noOp = () => { };
interface IAutosuggestAccountInput { interface IAutosuggestAccountInput {
onChange: React.ChangeEventHandler<HTMLInputElement>, onChange: React.ChangeEventHandler<HTMLInputElement>
onSelected: (accountId: string) => void, onSelected: (accountId: string) => void
autoFocus?: boolean, autoFocus?: boolean
value: string, value: string
limit?: number, limit?: number
className?: string, className?: string
autoSelect?: boolean, autoSelect?: boolean
menu?: Menu, menu?: Menu
onKeyDown?: React.KeyboardEventHandler, onKeyDown?: React.KeyboardEventHandler
theme?: InputThemes, theme?: InputThemes
} }
const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({

View File

@ -1,38 +1,30 @@
import React from 'react'; import React from 'react';
import unicodeMapping from 'soapbox/features/emoji/emoji-unicode-mapping-light'; import { isCustomEmoji } from 'soapbox/features/emoji';
import unicodeMapping from 'soapbox/features/emoji/mapping';
import { joinPublicPath } from 'soapbox/utils/static'; import { joinPublicPath } from 'soapbox/utils/static';
export type Emoji = { import type { Emoji } from 'soapbox/features/emoji';
id: string,
custom: boolean,
imageUrl: string,
native: string,
colons: string,
}
type UnicodeMapping = {
filename: string,
}
interface IAutosuggestEmoji { interface IAutosuggestEmoji {
emoji: Emoji, emoji: Emoji
} }
const AutosuggestEmoji: React.FC<IAutosuggestEmoji> = ({ emoji }) => { const AutosuggestEmoji: React.FC<IAutosuggestEmoji> = ({ emoji }) => {
let url; let url, alt;
if (emoji.custom) { if (isCustomEmoji(emoji)) {
url = emoji.imageUrl; url = emoji.imageUrl;
alt = emoji.colons;
} else { } else {
// @ts-ignore const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
const mapping: UnicodeMapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
if (!mapping) { if (!mapping) {
return null; return null;
} }
url = joinPublicPath(`packs/emoji/${mapping.filename}.svg`); url = joinPublicPath(`packs/emoji/${mapping.unified}.svg`);
alt = emoji.native;
} }
return ( return (
@ -40,7 +32,7 @@ const AutosuggestEmoji: React.FC<IAutosuggestEmoji> = ({ emoji }) => {
<img <img
className='emojione' className='emojione'
src={url} src={url}
alt={emoji.native || emoji.colons} alt={alt}
/> />
{emoji.colons} {emoji.colons}

View File

@ -1,39 +1,39 @@
import { Portal } from '@reach/portal'; import clsx from 'clsx';
import classNames from 'clsx';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import React from 'react'; import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import AutosuggestEmoji, { Emoji } from 'soapbox/components/autosuggest-emoji'; import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import { Input } from 'soapbox/components/ui'; import { Input, Portal } from 'soapbox/components/ui';
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account'; import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
import { isRtl } from 'soapbox/rtl'; import { isRtl } from 'soapbox/rtl';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu'; import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu';
import type { InputThemes } from 'soapbox/components/ui/input/input'; import type { InputThemes } from 'soapbox/components/ui/input/input';
import type { Emoji } from 'soapbox/features/emoji';
export type AutoSuggestion = string | Emoji; export type AutoSuggestion = string | Emoji;
export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputElement>, 'onChange' | 'onKeyUp' | 'onKeyDown'> { export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputElement>, 'onChange' | 'onKeyUp' | 'onKeyDown'> {
value: string, value: string
suggestions: ImmutableList<any>, suggestions: ImmutableList<any>
disabled?: boolean, disabled?: boolean
placeholder?: string, placeholder?: string
onSuggestionSelected: (tokenStart: number, lastToken: string | null, suggestion: AutoSuggestion) => void, onSuggestionSelected: (tokenStart: number, lastToken: string | null, suggestion: AutoSuggestion) => void
onSuggestionsClearRequested: () => void, onSuggestionsClearRequested: () => void
onSuggestionsFetchRequested: (token: string) => void, onSuggestionsFetchRequested: (token: string) => void
autoFocus: boolean, autoFocus: boolean
autoSelect: boolean, autoSelect: boolean
className?: string, className?: string
id?: string, id?: string
searchTokens: string[], searchTokens: string[]
maxLength?: number, maxLength?: number
menu?: Menu, menu?: Menu
renderSuggestion?: React.FC<{ id: string }>, renderSuggestion?: React.FC<{ id: string }>
hidePortal?: boolean, hidePortal?: boolean
theme?: InputThemes, theme?: InputThemes
} }
export default class AutosuggestInput extends ImmutablePureComponent<IAutosuggestInput> { export default class AutosuggestInput extends ImmutablePureComponent<IAutosuggestInput> {
@ -199,7 +199,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
tabIndex={0} tabIndex={0}
key={key} key={key}
data-index={i} data-index={i}
className={classNames({ className={clsx({
'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 group': true, 'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 group': true,
'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800': i === selectedSuggestion, 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800': i === selectedSuggestion,
})} })}
@ -235,7 +235,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
return menu.map((item, i) => ( return menu.map((item, i) => (
<a <a
className={classNames('flex items-center space-x-2 px-4 py-2.5 text-sm cursor-pointer text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800', { selected: suggestions.size - selectedSuggestion === i })} className={clsx('flex cursor-pointer items-center space-x-2 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-100 focus:bg-gray-100 dark:text-gray-500 dark:hover:bg-gray-800 dark:focus:bg-primary-800', { selected: suggestions.size - selectedSuggestion === i })}
href='#' href='#'
role='button' role='button'
tabIndex={0} tabIndex={0}
@ -302,7 +302,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
<Portal key='portal'> <Portal key='portal'>
<div <div
style={this.setPortalPosition()} style={this.setPortalPosition()}
className={classNames({ className={clsx({
'fixed w-full z-[1001] shadow bg-white dark:bg-gray-900 rounded-lg py-1 dark:ring-2 dark:ring-primary-700 focus:outline-none': true, 'fixed w-full z-[1001] shadow bg-white dark:bg-gray-900 rounded-lg py-1 dark:ring-2 dark:ring-primary-700 focus:outline-none': true,
hidden: !visible, hidden: !visible,
block: visible, block: visible,

View File

@ -19,7 +19,7 @@ export const ADDRESS_ICONS: Record<string, string> = {
}; };
interface IAutosuggestLocation { interface IAutosuggestLocation {
id: string, id: string
} }
const AutosuggestLocation: React.FC<IAutosuggestLocation> = ({ id }) => { const AutosuggestLocation: React.FC<IAutosuggestLocation> = ({ id }) => {

View File

@ -1,36 +1,36 @@
import { Portal } from '@reach/portal'; import clsx from 'clsx';
import classNames from 'clsx';
import React from 'react'; import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import { Portal } from 'soapbox/components/ui';
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
import { isRtl } from 'soapbox/rtl';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import AutosuggestAccount from '../features/compose/components/autosuggest-account'; import AutosuggestEmoji from './autosuggest-emoji';
import { isRtl } from '../rtl';
import AutosuggestEmoji, { Emoji } from './autosuggest-emoji';
import type { List as ImmutableList } from 'immutable'; import type { List as ImmutableList } from 'immutable';
import type { Emoji } from 'soapbox/features/emoji';
interface IAutosuggesteTextarea { interface IAutosuggesteTextarea {
id?: string, id?: string
value: string, value: string
suggestions: ImmutableList<string>, suggestions: ImmutableList<string>
disabled: boolean, disabled: boolean
placeholder: string, placeholder: string
onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void, onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void
onSuggestionsClearRequested: () => void, onSuggestionsClearRequested: () => void
onSuggestionsFetchRequested: (token: string | number) => void, onSuggestionsFetchRequested: (token: string | number) => void
onChange: React.ChangeEventHandler<HTMLTextAreaElement>, onChange: React.ChangeEventHandler<HTMLTextAreaElement>
onKeyUp?: React.KeyboardEventHandler<HTMLTextAreaElement>, onKeyUp?: React.KeyboardEventHandler<HTMLTextAreaElement>
onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>, onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>
onPaste: (files: FileList) => void, onPaste: (files: FileList) => void
autoFocus: boolean, autoFocus: boolean
onFocus: () => void, onFocus: () => void
onBlur?: () => void, onBlur?: () => void
condensed?: boolean, condensed?: boolean
children: React.ReactNode, children: React.ReactNode
} }
class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea> { class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea> {
@ -157,7 +157,8 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
if (lastTokenUpdated && !valueUpdated) { if (lastTokenUpdated && !valueUpdated) {
return false; return false;
} else { } else {
return super.shouldComponentUpdate!(nextProps, nextState, undefined); // https://stackoverflow.com/a/35962835
return super.shouldComponentUpdate!.bind(this)(nextProps, nextState, undefined);
} }
} }
@ -200,7 +201,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
tabIndex={0} tabIndex={0}
key={key} key={key}
data-index={i} data-index={i}
className={classNames({ className={clsx({
'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 group': true, 'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 group': true,
'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800': i === selectedSuggestion, 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800': i === selectedSuggestion,
})} })}
@ -243,7 +244,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
<Textarea <Textarea
ref={this.setTextarea} ref={this.setTextarea}
className={classNames('transition-[min-height] motion-reduce:transition-none dark:bg-transparent px-0 border-0 text-gray-800 dark:text-white placeholder:text-gray-600 dark:placeholder:text-gray-600 resize-none w-full focus:shadow-none focus:border-0 focus:ring-0', { className={clsx('w-full resize-none border-0 px-0 text-gray-800 transition-[min-height] placeholder:text-gray-600 focus:border-0 focus:shadow-none focus:ring-0 motion-reduce:transition-none dark:bg-transparent dark:text-white dark:placeholder:text-gray-600', {
'min-h-[40px]': condensed, 'min-h-[40px]': condensed,
'min-h-[100px]': !condensed, 'min-h-[100px]': !condensed,
})} })}
@ -270,7 +271,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
<Portal key='portal'> <Portal key='portal'>
<div <div
style={this.setPortalPosition()} style={this.setPortalPosition()}
className={classNames({ className={clsx({
'fixed z-1000 shadow bg-white dark:bg-gray-900 rounded-lg py-1 space-y-0 dark:ring-2 dark:ring-primary-700 focus:outline-none': true, 'fixed z-1000 shadow bg-white dark:bg-gray-900 rounded-lg py-1 space-y-0 dark:ring-2 dark:ring-primary-700 focus:outline-none': true,
hidden: suggestionsHidden || suggestions.isEmpty(), hidden: suggestionsHidden || suggestions.isEmpty(),
block: !suggestionsHidden && !suggestions.isEmpty(), block: !suggestionsHidden && !suggestions.isEmpty(),

View File

@ -1,9 +1,9 @@
import classNames from 'clsx'; import clsx from 'clsx';
import React from 'react'; import React from 'react';
interface IBadge { interface IBadge {
title: React.ReactNode, title: React.ReactNode
slug: string, slug: string
} }
/** Badge to display on a user's profile. */ /** Badge to display on a user's profile. */
const Badge: React.FC<IBadge> = ({ title, slug }) => { const Badge: React.FC<IBadge> = ({ title, slug }) => {
@ -12,13 +12,13 @@ const Badge: React.FC<IBadge> = ({ title, slug }) => {
return ( return (
<span <span
data-testid='badge' data-testid='badge'
className={classNames('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', { className={clsx('inline-flex items-center rounded px-2 py-0.5 text-xs font-medium', {
'bg-fuchsia-700 text-white': slug === 'patron', 'bg-fuchsia-700 text-white': slug === 'patron',
'bg-emerald-800 text-white': slug === 'badge:donor', 'bg-emerald-800 text-white': slug === 'badge:donor',
'bg-black text-white': slug === 'admin', 'bg-black text-white': slug === 'admin',
'bg-cyan-600 text-white': slug === 'moderator', 'bg-cyan-600 text-white': slug === 'moderator',
'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100': fallback, 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100': fallback,
'bg-white bg-opacity-75 text-gray-900': slug === 'opaque', 'bg-white/75 text-gray-900': slug === 'opaque',
})} })}
> >
{title} {title}

View File

@ -15,9 +15,9 @@ const messages = defineMessages({
}); });
interface IBirthdayInput { interface IBirthdayInput {
value?: string, value?: string
onChange: (value: string) => void, onChange: (value: string) => void
required?: boolean, required?: boolean
} }
const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required }) => { const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required }) => {
@ -56,15 +56,15 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
nextYearButtonDisabled, nextYearButtonDisabled,
date, date,
}: { }: {
decreaseMonth(): void, decreaseMonth(): void
increaseMonth(): void, increaseMonth(): void
prevMonthButtonDisabled: boolean, prevMonthButtonDisabled: boolean
nextMonthButtonDisabled: boolean, nextMonthButtonDisabled: boolean
decreaseYear(): void, decreaseYear(): void
increaseYear(): void, increaseYear(): void
prevYearButtonDisabled: boolean, prevYearButtonDisabled: boolean
nextYearButtonDisabled: boolean, nextYearButtonDisabled: boolean
date: Date, date: Date
}) => { }) => {
return ( return (
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>
@ -113,7 +113,7 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
const handleChange = (date: Date) => onChange(date ? new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) : ''); const handleChange = (date: Date) => onChange(date ? new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) : '');
return ( return (
<div className='mt-1 relative rounded-md shadow-sm'> <div className='relative mt-1 rounded-md shadow-sm'>
<BundleContainer fetchComponent={DatePicker}> <BundleContainer fetchComponent={DatePicker}>
{Component => (<Component {Component => (<Component
selected={selected} selected={selected}

View File

@ -3,18 +3,18 @@ import React, { useRef, useEffect } from 'react';
interface IBlurhash { interface IBlurhash {
/** Hash to render */ /** Hash to render */
hash: string | null | undefined, hash: string | null | undefined
/** Width of the blurred region in pixels. Defaults to 32. */ /** Width of the blurred region in pixels. Defaults to 32. */
width?: number, width?: number
/** Height of the blurred region in pixels. Defaults to width. */ /** Height of the blurred region in pixels. Defaults to width. */
height?: number, height?: number
/** /**
* Whether dummy mode is enabled. If enabled, nothing is rendered * Whether dummy mode is enabled. If enabled, nothing is rendered
* and canvas left untouched. * and canvas left untouched.
*/ */
dummy?: boolean, dummy?: boolean
/** className of the canvas element. */ /** className of the canvas element. */
className?: string, className?: string
} }
/** /**

View File

@ -5,7 +5,7 @@ import { Button, HStack, Input } from './ui';
interface ICopyableInput { interface ICopyableInput {
/** Text to be copied. */ /** Text to be copied. */
value: string, value: string
} }
/** An input with copy abilities. */ /** An input with copy abilities. */
@ -29,7 +29,7 @@ const CopyableInput: React.FC<ICopyableInput> = ({ value }) => {
type='text' type='text'
value={value} value={value}
className='rounded-r-none rtl:rounded-l-none rtl:rounded-r-lg' className='rounded-r-none rtl:rounded-l-none rtl:rounded-r-lg'
outerClassName='flex-grow' outerClassName='grow'
onClick={selectInput} onClick={selectInput}
readOnly readOnly
/> />

View File

@ -5,8 +5,6 @@ import { useSoapboxConfig } from 'soapbox/hooks';
import { getAcct } from '../utils/accounts'; import { getAcct } from '../utils/accounts';
import Icon from './icon';
import RelativeTimestamp from './relative-timestamp';
import { HStack, Text } from './ui'; import { HStack, Text } from './ui';
import VerificationBadge from './verification-badge'; import VerificationBadge from './verification-badge';
@ -15,20 +13,12 @@ import type { Account } from 'soapbox/types/entities';
interface IDisplayName { interface IDisplayName {
account: Account account: Account
withSuffix?: boolean withSuffix?: boolean
withDate?: boolean
children?: React.ReactNode children?: React.ReactNode
} }
const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = true, withDate = false }) => { const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = true }) => {
const { displayFqn = false } = useSoapboxConfig(); const { displayFqn = false } = useSoapboxConfig();
const { created_at: createdAt, verified } = account; const { verified } = account;
const joinedAt = createdAt ? (
<div className='account__joined-at'>
<Icon src={require('@tabler/icons/clock.svg')} />
<RelativeTimestamp timestamp={createdAt} />
</div>
) : null;
const displayName = ( const displayName = (
<HStack space={1} alignItems='center' grow> <HStack space={1} alignItems='center' grow>
@ -40,7 +30,6 @@ const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = t
/> />
{verified && <VerificationBadge />} {verified && <VerificationBadge />}
{withDate && joinedAt}
</HStack> </HStack>
); );

View File

@ -12,7 +12,7 @@ const messages = defineMessages({
}); });
interface IDomain { interface IDomain {
domain: string, domain: string
} }
const Domain: React.FC<IDomain> = ({ domain }) => { const Domain: React.FC<IDomain> = ({ domain }) => {

View File

@ -1,420 +0,0 @@
import classNames from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import React from 'react';
import { spring } from 'react-motion';
// @ts-ignore: TODO: upgrade react-overlays. v3.1 and above have TS definitions
import Overlay from 'react-overlays/lib/Overlay';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { Counter, IconButton } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import Motion from 'soapbox/features/ui/util/optional-motion';
import type { Status } from 'soapbox/types/entities';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
let id = 0;
export interface MenuItem {
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
middleClick?: React.EventHandler<React.MouseEvent>,
text: string,
href?: string,
to?: string,
newTab?: boolean,
isLogout?: boolean,
icon?: string,
count?: number,
destructive?: boolean,
meta?: string,
active?: boolean,
}
export type Menu = Array<MenuItem | null>;
interface IDropdownMenu extends RouteComponentProps {
items: Menu,
onClose: () => void,
style?: React.CSSProperties,
placement?: DropdownPlacement,
arrowOffsetLeft?: string,
arrowOffsetTop?: string,
openedViaKeyboard: boolean,
}
interface IDropdownMenuState {
mounted: boolean,
}
class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState> {
static defaultProps: Partial<IDropdownMenu> = {
style: {},
placement: 'bottom',
};
state = {
mounted: false,
};
node: HTMLDivElement | null = null;
focusedItem: HTMLAnchorElement | null = null;
handleDocumentClick = (e: Event) => {
if (this.node && !this.node.contains(e.target as Node)) {
this.props.onClose();
}
};
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('keydown', this.handleKeyDown, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus({ preventScroll: true });
}
this.setState({ mounted: true });
}
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick);
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('touchend', this.handleDocumentClick);
}
setRef: React.RefCallback<HTMLDivElement> = c => {
this.node = c;
};
setFocusRef: React.RefCallback<HTMLAnchorElement> = c => {
this.focusedItem = c;
};
handleKeyDown = (e: KeyboardEvent) => {
if (!this.node) return;
const items = Array.from(this.node.getElementsByTagName('a'));
const index = items.indexOf(document.activeElement as any);
let element = null;
switch (e.key) {
case 'ArrowDown':
element = items[index + 1] || items[0];
break;
case 'ArrowUp':
element = items[index - 1] || items[items.length - 1];
break;
case 'Tab':
if (e.shiftKey) {
element = items[index - 1] || items[items.length - 1];
} else {
element = items[index + 1] || items[0];
}
break;
case 'Home':
element = items[0];
break;
case 'End':
element = items[items.length - 1];
break;
case 'Escape':
this.props.onClose();
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
};
handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
};
handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
if (!item) return;
const { action, to } = item;
this.props.onClose();
e.stopPropagation();
if (to) {
e.preventDefault();
this.props.history.push(to);
} else if (typeof action === 'function') {
e.preventDefault();
action(e);
}
};
handleMiddleClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
if (!item) return;
const { middleClick } = item;
this.props.onClose();
if (e.button === 1 && typeof middleClick === 'function') {
e.preventDefault();
middleClick(e);
}
};
handleAuxClick: React.EventHandler<React.MouseEvent> = e => {
if (e.button === 1) {
this.handleMiddleClick(e);
}
};
renderItem(option: MenuItem | null, i: number): JSX.Element {
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, href, to, newTab, isLogout, icon, count, destructive } = option;
return (
<li className={classNames('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}>
<a
href={href || to || '#'}
role='button'
tabIndex={0}
ref={i === 0 ? this.setFocusRef : null}
onClick={this.handleClick}
onAuxClick={this.handleAuxClick}
onKeyPress={this.handleItemKeyPress}
data-index={i}
target={newTab ? '_blank' : undefined}
data-method={isLogout ? 'delete' : undefined}
title={text}
>
{icon && <SvgIcon src={icon} className='mr-3 rtl:ml-3 rtl:mr-0 h-5 w-5 flex-none' />}
<span className='truncate'>{text}</span>
{count ? (
<span className='ml-auto h-5 w-5 flex-none'>
<Counter count={count} />
</span>
) : null}
</a>
</li>
);
}
render() {
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
const { mounted } = this.state;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 1, scaleY: 1 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div
className={`dropdown-menu ${placement}`}
style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }}
ref={this.setRef}
data-testid='dropdown-menu'
>
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
<ul>
{items.map((option, i) => this.renderItem(option, i))}
</ul>
</div>
)}
</Motion>
);
}
}
const RouterDropdownMenu = withRouter(DropdownMenu);
export interface IDropdown extends RouteComponentProps {
icon?: string,
src?: string,
items: Menu,
size?: number,
active?: boolean,
pressed?: boolean,
title?: string,
disabled?: boolean,
status?: Status,
isUserTouching?: () => boolean,
isModalOpen?: boolean,
onOpen?: (
id: number,
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
dropdownPlacement: DropdownPlacement,
keyboard: boolean,
) => void,
onClose?: (id: number) => void,
dropdownPlacement?: string,
openDropdownId?: number | null,
openedViaKeyboard?: boolean,
text?: string,
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
children?: JSX.Element,
dropdownMenuStyle?: React.CSSProperties,
}
interface IDropdownState {
id: number,
open: boolean,
}
export type DropdownPlacement = 'top' | 'bottom';
class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
static defaultProps: Partial<IDropdown> = {
title: 'Menu',
};
state = {
id: id++,
open: false,
};
target: HTMLButtonElement | null = null;
activeElement: Element | null = null;
handleClick: React.EventHandler<React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>> = e => {
const { onOpen, onShiftClick, openDropdownId } = this.props;
e.stopPropagation();
if (onShiftClick && e.shiftKey) {
e.preventDefault();
onShiftClick(e);
} else if (this.state.id === openDropdownId) {
this.handleClose();
} else if (onOpen) {
const { top } = e.currentTarget.getBoundingClientRect();
const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top';
onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click');
}
};
handleClose = () => {
if (this.activeElement && this.activeElement === this.target) {
(this.activeElement as HTMLButtonElement).focus();
this.activeElement = null;
}
if (this.props.onClose) {
this.props.onClose(this.state.id);
}
};
handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
};
handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (e) => {
switch (e.key) {
case ' ':
case 'Enter':
this.handleMouseDown(e);
break;
}
};
handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (e) => {
switch (e.key) {
case ' ':
case 'Enter':
this.handleClick(e);
e.stopPropagation();
e.preventDefault();
break;
}
};
handleItemClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
if (!item) return;
const { action, to } = item;
this.handleClose();
e.preventDefault();
e.stopPropagation();
if (typeof action === 'function') {
action(e);
} else if (to) {
this.props.history?.push(to);
}
};
setTargetRef: React.RefCallback<HTMLButtonElement> = c => {
this.target = c;
};
findTarget = () => {
return this.target;
};
componentWillUnmount = () => {
if (this.state.id === this.props.openDropdownId) {
this.handleClose();
}
};
render() {
const { src = require('@tabler/icons/dots.svg'), items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text, children, dropdownMenuStyle } = this.props;
const open = this.state.id === openDropdownId;
return (
<>
{children ? (
React.cloneElement(children, {
disabled,
onClick: this.handleClick,
onMouseDown: this.handleMouseDown,
onKeyDown: this.handleButtonKeyDown,
onKeyPress: this.handleKeyPress,
ref: this.setTargetRef,
})
) : (
<IconButton
disabled={disabled}
className={classNames({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
'text-gray-700 dark:text-gray-500': open,
})}
title={title}
src={src}
aria-pressed={pressed}
text={text}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
ref={this.setTargetRef}
/>
)}
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
<RouterDropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} style={dropdownMenuStyle} />
</Overlay>
</>
);
}
}
export default withRouter(Dropdown);

View File

@ -0,0 +1,109 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import { useHistory } from 'react-router-dom';
import { Counter, Icon } from '../ui';
export interface MenuItem {
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>
active?: boolean
count?: number
destructive?: boolean
href?: string
icon?: string
meta?: string
middleClick?(event: React.MouseEvent): void
target?: React.HTMLAttributeAnchorTarget
text: string
to?: string
}
interface IDropdownMenuItem {
index: number
item: MenuItem | null
onClick?(): void
}
const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
const history = useHistory();
const itemRef = useRef<HTMLAnchorElement>(null);
const handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = (event) => {
event.stopPropagation();
if (!item) return;
if (onClick) onClick();
if (item.to) {
event.preventDefault();
history.push(item.to);
} else if (typeof item.action === 'function') {
event.preventDefault();
item.action(event);
}
};
const handleAuxClick: React.EventHandler<React.MouseEvent> = (event) => {
if (!item) return;
if (onClick) onClick();
if (event.button === 1 && item.middleClick) {
item.middleClick(event);
}
};
const handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = (event) => {
if (event.key === 'Enter' || event.key === ' ') {
handleClick(event);
}
};
useEffect(() => {
const firstItem = index === 0;
if (itemRef.current && firstItem) {
itemRef.current.focus({ preventScroll: true });
}
}, [itemRef.current, index]);
if (item === null) {
return <li className='mx-2 my-1 h-[2px] bg-gray-100 dark:bg-gray-800' />;
}
return (
<li className='truncate focus-visible:ring-2 focus-visible:ring-primary-500'>
<a
href={item.href || item.to || '#'}
role='button'
tabIndex={0}
ref={itemRef}
data-index={index}
onClick={handleClick}
onAuxClick={handleAuxClick}
onKeyPress={handleItemKeyPress}
target={item.target}
title={item.text}
className={
clsx({
'flex px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none cursor-pointer': true,
'text-danger-600 dark:text-danger-400': item.destructive,
})
}
>
{item.icon && <Icon src={item.icon} className='mr-3 h-5 w-5 flex-none rtl:ml-3 rtl:mr-0' />}
<span className='truncate'>{item.text}</span>
{item.count ? (
<span className='ml-auto h-5 w-5 flex-none'>
<Counter count={item.count} />
</span>
) : null}
</a>
</li>
);
};
export default DropdownMenuItem;

View File

@ -0,0 +1,346 @@
import { offset, Placement, useFloating, flip, arrow } from '@floating-ui/react';
import clsx from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useHistory } from 'react-router-dom';
import {
closeDropdownMenu as closeDropdownMenuRedux,
openDropdownMenu,
} from 'soapbox/actions/dropdown-menu';
import { closeModal, openModal } from 'soapbox/actions/modals';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is-mobile';
import { IconButton, Portal } from '../ui';
import DropdownMenuItem, { MenuItem } from './dropdown-menu-item';
import type { Status } from 'soapbox/types/entities';
export type Menu = Array<MenuItem | null>;
interface IDropdownMenu {
children?: React.ReactElement
disabled?: boolean
items: Menu
onClose?: () => void
onOpen?: () => void
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>
placement?: Placement
src?: string
status?: Status
title?: string
}
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const DropdownMenu = (props: IDropdownMenu) => {
const {
children,
disabled,
items,
onClose,
onOpen,
onShiftClick,
placement: initialPlacement = 'top',
src = require('@tabler/icons/dots.svg'),
title = 'Menu',
...filteredProps
} = props;
const dispatch = useAppDispatch();
const history = useHistory();
const [isOpen, setIsOpen] = useState<boolean>(false);
const isOpenRedux = useAppSelector(state => state.dropdown_menu.isOpen);
const arrowRef = useRef<HTMLDivElement>(null);
const activeElement = useRef<Element | null>(null);
const isOnMobile = isUserTouching();
const { x, y, strategy, refs, middlewareData, placement } = useFloating<HTMLButtonElement>({
placement: initialPlacement,
middleware: [
offset(12),
flip(),
arrow({
element: arrowRef,
}),
],
});
const handleClick: React.EventHandler<
React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
> = (event) => {
event.stopPropagation();
if (onShiftClick && event.shiftKey) {
event.preventDefault();
onShiftClick(event);
return;
}
if (isOpen) {
handleClose();
} else {
handleOpen();
}
};
/**
* On mobile screens, let's replace the Popper dropdown with a Modal.
*/
const handleOpen = () => {
if (isOnMobile) {
dispatch(
openModal('ACTIONS', {
status: filteredProps.status,
actions: items,
onClick: handleItemClick,
}),
);
} else {
dispatch(openDropdownMenu());
setIsOpen(true);
}
if (onOpen) {
onOpen();
}
};
const handleClose = () => {
if (activeElement.current && activeElement.current === refs.reference.current) {
(activeElement.current as any).focus();
activeElement.current = null;
}
if (isOnMobile) {
dispatch(closeModal('ACTIONS'));
} else {
closeDropdownMenu();
setIsOpen(false);
}
if (onClose) {
onClose();
}
};
const closeDropdownMenu = () => {
if (isOpenRedux) {
dispatch(closeDropdownMenuRedux());
}
};
const handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
if (!isOpen) {
activeElement.current = document.activeElement;
}
};
const handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (event) => {
switch (event.key) {
case ' ':
case 'Enter':
handleMouseDown(event);
break;
}
};
const handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (event) => {
switch (event.key) {
case ' ':
case 'Enter':
event.stopPropagation();
event.preventDefault();
handleClick(event);
break;
}
};
const handleItemClick: React.EventHandler<React.MouseEvent> = (event) => {
event.preventDefault();
event.stopPropagation();
const i = Number(event.currentTarget.getAttribute('data-index'));
const item = items[i];
if (!item) return;
const { action, to } = item;
handleClose();
if (typeof action === 'function') {
action(event);
} else if (to) {
history.push(to);
}
};
const handleDocumentClick = (event: Event) => {
if (refs.floating.current && !refs.floating.current.contains(event.target as Node)) {
handleClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!refs.floating.current) return;
const items = Array.from(refs.floating.current.getElementsByTagName('a'));
const index = items.indexOf(document.activeElement as any);
let element = null;
switch (e.key) {
case 'ArrowDown':
element = items[index + 1] || items[0];
break;
case 'ArrowUp':
element = items[index - 1] || items[items.length - 1];
break;
case 'Tab':
if (e.shiftKey) {
element = items[index - 1] || items[items.length - 1];
} else {
element = items[index + 1] || items[0];
}
break;
case 'Home':
element = items[0];
break;
case 'End':
element = items[items.length - 1];
break;
case 'Escape':
handleClose();
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
};
const arrowProps: React.CSSProperties = useMemo(() => {
if (middlewareData.arrow) {
const { x, y } = middlewareData.arrow;
const staticPlacement = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[placement.split('-')[0]];
return {
left: x !== null ? `${x}px` : '',
top: y !== null ? `${y}px` : '',
// Ensure the static side gets unset when
// flipping to other placements' axes.
right: '',
bottom: '',
[staticPlacement as string]: `${(-(arrowRef.current?.offsetWidth || 0)) / 2}px`,
transform: 'rotate(45deg)',
};
}
return {};
}, [middlewareData.arrow, placement]);
useEffect(() => {
return () => {
closeDropdownMenu();
};
}, []);
useEffect(() => {
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('keydown', handleKeyDown, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
return () => {
document.removeEventListener('click', handleDocumentClick);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('touchend', handleDocumentClick);
};
}, [refs.floating.current]);
if (items.length === 0) {
return null;
}
return (
<>
{children ? (
React.cloneElement(children, {
disabled,
onClick: handleClick,
onMouseDown: handleMouseDown,
onKeyDown: handleButtonKeyDown,
onKeyPress: handleKeyPress,
ref: refs.setReference,
})
) : (
<IconButton
disabled={disabled}
className={clsx({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
'text-gray-700 dark:text-gray-500': isOpen,
})}
title={title}
src={src}
onClick={handleClick}
onMouseDown={handleMouseDown}
onKeyDown={handleButtonKeyDown}
onKeyPress={handleKeyPress}
ref={refs.setReference}
/>
)}
{isOpen ? (
<Portal>
<div
data-testid='dropdown-menu'
ref={refs.setFloating}
className={
clsx('z-[1001] w-56 rounded-md bg-white py-1 shadow-lg transition-opacity duration-100 focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700', {
'opacity-0 pointer-events-none': !isOpen,
})
}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
}}
>
<ul>
{items.map((item, idx) => (
<DropdownMenuItem
key={idx}
item={item}
index={idx}
onClick={handleClose}
/>
))}
</ul>
{/* Arrow */}
<div
ref={arrowRef}
style={arrowProps}
className='pointer-events-none absolute z-[-1] h-3 w-3 bg-white dark:bg-gray-900'
/>
</div>
</Portal>
) : null}
</>
);
};
export default DropdownMenu;

View File

@ -0,0 +1,3 @@
export { default } from './dropdown-menu';
export type { Menu } from './dropdown-menu';
export type { MenuItem } from './dropdown-menu-item';

View File

@ -1,142 +0,0 @@
// import classNames from 'clsx';
import React from 'react';
import { HotKeys } from 'react-hotkeys';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { EmojiSelector as RealEmojiSelector } from 'soapbox/components/ui';
import type { List as ImmutableList } from 'immutable';
import type { RootState } from 'soapbox/store';
const mapStateToProps = (state: RootState) => ({
allowedEmoji: getSoapboxConfig(state).allowedEmoji,
});
interface IEmojiSelector {
allowedEmoji: ImmutableList<string>,
onReact: (emoji: string) => void,
onUnfocus: () => void,
visible: boolean,
focused?: boolean,
}
class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
static defaultProps: Partial<IEmojiSelector> = {
onReact: () => { },
onUnfocus: () => { },
visible: false,
};
node?: HTMLDivElement = undefined;
handleBlur: React.FocusEventHandler<HTMLDivElement> = e => {
const { focused, onUnfocus } = this.props;
if (focused && (!e.currentTarget || !e.currentTarget.classList.contains('emoji-react-selector__emoji'))) {
onUnfocus();
}
};
_selectPreviousEmoji = (i: number): void => {
if (!this.node) return;
if (i !== 0) {
const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`);
button?.focus();
} else {
const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:last-child');
button?.focus();
}
};
_selectNextEmoji = (i: number) => {
if (!this.node) return;
if (i !== this.props.allowedEmoji.size - 1) {
const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`);
button?.focus();
} else {
const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:first-child');
button?.focus();
}
};
handleKeyDown = (i: number): React.KeyboardEventHandler => e => {
const { onUnfocus } = this.props;
switch (e.key) {
case 'Tab':
e.preventDefault();
if (e.shiftKey) this._selectPreviousEmoji(i);
else this._selectNextEmoji(i);
break;
case 'Left':
case 'ArrowLeft':
this._selectPreviousEmoji(i);
break;
case 'Right':
case 'ArrowRight':
this._selectNextEmoji(i);
break;
case 'Escape':
onUnfocus();
break;
}
};
handleReact = (emoji: string) => (): void => {
const { onReact, focused, onUnfocus } = this.props;
onReact(emoji);
if (focused) {
onUnfocus();
}
};
handlers = {
open: () => { },
};
setRef = (c: HTMLDivElement): void => {
this.node = c;
};
render() {
const { visible, focused, allowedEmoji, onReact } = this.props;
return (
<HotKeys handlers={this.handlers}>
{/*<div
className={classNames('flex absolute bg-white dark:bg-gray-500 px-2 py-3 rounded-full shadow-md opacity-0 pointer-events-none duration-100 w-max', { 'opacity-100 pointer-events-auto z-[999]': visible || focused })}
onBlur={this.handleBlur}
ref={this.setRef}
>
{allowedEmoji.map((emoji, i) => (
<button
key={i}
className='emoji-react-selector__emoji'
onClick={this.handleReact(emoji)}
onKeyDown={this.handleKeyDown(i)}
tabIndex={(visible || focused) ? 0 : -1}
>
<Emoji emoji={emoji} />
</button>
))}
</div>*/}
<RealEmojiSelector
emojis={allowedEmoji.toArray()}
onReact={onReact}
visible={visible}
focused={focused}
/>
</HotKeys>
);
}
}
export default connect(mapStateToProps)(EmojiSelector);

View File

@ -31,10 +31,10 @@ interface Props extends ReturnType<typeof mapStateToProps> {
} }
type State = { type State = {
hasError: boolean, hasError: boolean
error: any, error: any
componentStack: any, componentStack: any
browser?: Bowser.Parser.Parser, browser?: Bowser.Parser.Parser
} }
class ErrorBoundary extends React.PureComponent<Props, State> { class ErrorBoundary extends React.PureComponent<Props, State> {
@ -113,17 +113,17 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
const errorText = this.getErrorText(); const errorText = this.getErrorText();
return ( return (
<div className='h-screen pt-16 pb-12 flex flex-col bg-white dark:bg-primary-900'> <div className='flex h-screen flex-col bg-white pb-12 pt-16 dark:bg-primary-900'>
<main className='flex-grow flex flex-col justify-center max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8'> <main className='mx-auto flex w-full max-w-7xl grow flex-col justify-center px-4 sm:px-6 lg:px-8'>
<div className='flex-shrink-0 flex justify-center'> <div className='flex shrink-0 justify-center'>
<a href='/' className='inline-flex'> <a href='/' className='inline-flex'>
<SiteLogo alt='Logo' className='h-12 w-auto cursor-pointer' /> <SiteLogo alt='Logo' className='h-12 w-auto cursor-pointer' />
</a> </a>
</div> </div>
<div className='py-8'> <div className='py-8'>
<div className='text-center max-w-xl mx-auto space-y-2'> <div className='mx-auto max-w-xl space-y-2 text-center'>
<h1 className='text-3xl font-extrabold text-gray-900 dark:text-gray-500 tracking-tight sm:text-4xl'> <h1 className='text-3xl font-extrabold tracking-tight text-gray-900 dark:text-gray-500 sm:text-4xl'>
<FormattedMessage id='alert.unexpected.message' defaultMessage='Something went wrong.' /> <FormattedMessage id='alert.unexpected.message' defaultMessage='Something went wrong.' />
</h1> </h1>
<p className='text-lg text-gray-700 dark:text-gray-600'> <p className='text-lg text-gray-700 dark:text-gray-600'>
@ -132,7 +132,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
defaultMessage="We're sorry for the interruption. If the problem persists, please reach out to our support team. You may also try to {clearCookies} (this will log you out)." defaultMessage="We're sorry for the interruption. If the problem persists, please reach out to our support team. You may also try to {clearCookies} (this will log you out)."
values={{ values={{
clearCookies: ( clearCookies: (
<a href='/' onClick={this.clearCookies} className='text-primary-600 dark:text-accent-blue hover:underline'> <a href='/' onClick={this.clearCookies} className='text-primary-600 hover:underline dark:text-accent-blue'>
<FormattedMessage <FormattedMessage
id='alert.unexpected.clear_cookies' id='alert.unexpected.clear_cookies'
defaultMessage='clear cookies and browser data' defaultMessage='clear cookies and browser data'
@ -150,7 +150,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
</Text> </Text>
<div className='mt-10'> <div className='mt-10'>
<a href='/' className='text-base font-medium text-primary-600 dark:text-accent-blue hover:underline'> <a href='/' className='text-base font-medium text-primary-600 hover:underline dark:text-accent-blue'>
<FormattedMessage id='alert.unexpected.return_home' defaultMessage='Return Home' /> <FormattedMessage id='alert.unexpected.return_home' defaultMessage='Return Home' />
<span aria-hidden='true'> &rarr;</span> <span aria-hidden='true'> &rarr;</span>
</a> </a>
@ -158,11 +158,11 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
</div> </div>
{!isProduction && ( {!isProduction && (
<div className='py-16 max-w-lg mx-auto space-y-4'> <div className='mx-auto max-w-lg space-y-4 py-16'>
{errorText && ( {errorText && (
<textarea <textarea
ref={this.setTextareaRef} ref={this.setTextareaRef}
className='h-48 p-4 shadow-sm bg-gray-100 text-gray-900 dark:text-gray-100 dark:bg-gray-800 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 dark:border-gray-700 rounded-md font-mono' className='block h-48 w-full rounded-md border-gray-300 bg-gray-100 p-4 font-mono text-gray-900 shadow-sm focus:border-primary-500 focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 sm:text-sm'
value={errorText} value={errorText}
onClick={this.handleCopy} onClick={this.handleCopy}
readOnly readOnly
@ -180,11 +180,11 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
</div> </div>
</main> </main>
<footer className='flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8'> <footer className='mx-auto w-full max-w-7xl shrink-0 px-4 sm:px-6 lg:px-8'>
<HStack justifyContent='center' space={4} element='nav'> <HStack justifyContent='center' space={4} element='nav'>
{links.get('status') && ( {links.get('status') && (
<> <>
<a href={links.get('status')} className='text-sm font-medium text-gray-700 dark:text-gray-600 hover:underline'> <a href={links.get('status')} className='text-sm font-medium text-gray-700 hover:underline dark:text-gray-600'>
<FormattedMessage id='alert.unexpected.links.status' defaultMessage='Status' /> <FormattedMessage id='alert.unexpected.links.status' defaultMessage='Status' />
</a> </a>
</> </>
@ -193,7 +193,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
{links.get('help') && ( {links.get('help') && (
<> <>
<span className='inline-block border-l border-gray-300' aria-hidden='true' /> <span className='inline-block border-l border-gray-300' aria-hidden='true' />
<a href={links.get('help')} className='text-sm font-medium text-gray-700 dark:text-gray-600 hover:underline'> <a href={links.get('help')} className='text-sm font-medium text-gray-700 hover:underline dark:text-gray-600'>
<FormattedMessage id='alert.unexpected.links.help' defaultMessage='Help Center' /> <FormattedMessage id='alert.unexpected.links.help' defaultMessage='Help Center' />
</a> </a>
</> </>
@ -202,7 +202,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
{links.get('support') && ( {links.get('support') && (
<> <>
<span className='inline-block border-l border-gray-300' aria-hidden='true' /> <span className='inline-block border-l border-gray-300' aria-hidden='true' />
<a href={links.get('support')} className='text-sm font-medium text-gray-700 dark:text-gray-600 hover:underline'> <a href={links.get('support')} className='text-sm font-medium text-gray-700 hover:underline dark:text-gray-600'>
<FormattedMessage id='alert.unexpected.links.support' defaultMessage='Support' /> <FormattedMessage id='alert.unexpected.links.support' defaultMessage='Support' />
</a> </a>
</> </>

View File

@ -1,4 +1,4 @@
import classNames from 'clsx'; import clsx from 'clsx';
import React from 'react'; import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@ -13,7 +13,7 @@ import VerificationBadge from './verification-badge';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
const messages = defineMessages({ const messages = defineMessages({
bannerHeader: { id: 'event.banner', defaultMessage: 'Event banner' }, eventBanner: { id: 'event.banner', defaultMessage: 'Event banner' },
leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' }, leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' },
leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' }, leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' },
}); });
@ -51,12 +51,12 @@ const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction,
)); ));
return ( return (
<div className={classNames('w-full rounded-lg bg-gray-100 dark:bg-primary-800 relative overflow-hidden', className)}> <div className={clsx('relative w-full overflow-hidden rounded-lg bg-gray-100 dark:bg-primary-800', className)}>
<div className='absolute top-28 right-3'> <div className='absolute right-3 top-28'>
{floatingAction && action} {floatingAction && action}
</div> </div>
<div className='bg-primary-200 dark:bg-gray-600 h-40'> <div className='h-40 bg-primary-200 dark:bg-gray-600'>
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.bannerHeader)} />} {banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.eventBanner)} />}
</div> </div>
<Stack className='p-2.5' space={2}> <Stack className='p-2.5' space={2}>
<HStack space={2} alignItems='center' justifyContent='between'> <HStack space={2} alignItems='center' justifyContent='between'>
@ -65,7 +65,7 @@ const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction,
{!floatingAction && action} {!floatingAction && action}
</HStack> </HStack>
<div className='flex gap-y-1 gap-x-2 flex-wrap text-gray-700 dark:text-gray-600'> <div className='flex flex-wrap gap-x-2 gap-y-1 text-gray-700 dark:text-gray-600'>
<HStack alignItems='center' space={2}> <HStack alignItems='center' space={2}>
<Icon src={require('@tabler/icons/user.svg')} /> <Icon src={require('@tabler/icons/user.svg')} />
<HStack space={1} alignItems='center' grow> <HStack space={1} alignItems='center' grow>

View File

@ -3,14 +3,14 @@ import React, { useEffect, useRef } from 'react';
import { isIOS } from 'soapbox/is-mobile'; import { isIOS } from 'soapbox/is-mobile';
interface IExtendedVideoPlayer { interface IExtendedVideoPlayer {
src: string, src: string
alt?: string, alt?: string
width?: number, width?: number
height?: number, height?: number
time?: number, time?: number
controls?: boolean, controls?: boolean
muted?: boolean, muted?: boolean
onClick?: () => void, onClick?: () => void
} }
const ExtendedVideoPlayer: React.FC<IExtendedVideoPlayer> = ({ src, alt, time, controls, muted, onClick }) => { const ExtendedVideoPlayer: React.FC<IExtendedVideoPlayer> = ({ src, alt, time, controls, muted, onClick }) => {

View File

@ -5,13 +5,13 @@
* @see soapbox/components/icon * @see soapbox/components/icon
*/ */
import classNames from 'clsx'; import clsx from 'clsx';
import React from 'react'; import React from 'react';
export interface IForkAwesomeIcon extends React.HTMLAttributes<HTMLLIElement> { export interface IForkAwesomeIcon extends React.HTMLAttributes<HTMLLIElement> {
id: string, id: string
className?: string, className?: string
fixedWidth?: boolean, fixedWidth?: boolean
} }
const ForkAwesomeIcon: React.FC<IForkAwesomeIcon> = ({ id, className, fixedWidth, ...rest }) => { const ForkAwesomeIcon: React.FC<IForkAwesomeIcon> = ({ id, className, fixedWidth, ...rest }) => {
@ -25,7 +25,7 @@ const ForkAwesomeIcon: React.FC<IForkAwesomeIcon> = ({ id, className, fixedWidth
<i <i
role='img' role='img'
// alt={alt} // alt={alt}
className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })} className={clsx('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })}
{...rest} {...rest}
/> />
); );

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