Merge remote-tracking branch 'upstream/develop' into develop

develop
miklobit 2023-03-31 22:02:56 +02:00
commit 7dc1cca40c
337 zmienionych plików z 13232 dodań i 4889 usunięć

Wyświetl plik

@ -56,6 +56,7 @@ module.exports = {
}, },
polyfills: [ polyfills: [
'es:all', // core-js 'es:all', // core-js
'fetch', // not polyfilled, but ignore it
'IntersectionObserver', // npm:intersection-observer 'IntersectionObserver', // npm:intersection-observer
'Promise', // core-js 'Promise', // core-js
'ResizeObserver', // npm:resize-observer-polyfill 'ResizeObserver', // npm:resize-observer-polyfill

Wyświetl plik

@ -157,11 +157,11 @@ docker:
# 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

Wyświetl plik

@ -7,11 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- 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.
- UI: unified design of "approve" and "reject" buttons in follow requests and waitlist.
### Fixed ### Fixed
- Posts: fixed emojis being cut off in reactions modal. - 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 ## [3.2.0] - 2023-02-15
@ -25,7 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Reactions: adds support for reacting to chat messages. - Reactions: adds support for reacting to chat messages.
- Groups: initial support for groups. - Groups: initial support for groups.
- Profile: add RSS link to user profiles. - Profile: add RSS link to user profiles.
- Posts: fix posts filtering.
- Chats: reset chat message field height after sending a message. - Chats: reset chat message field height after sending a message.
- Admin: allow to manage announcements. - Admin: allow to manage announcements.
@ -46,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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. - Modals: fix media modal automatically switching to video.
- Navigation: profile dropdown erratic behavior. - 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.

Wyświetl plik

@ -75,7 +75,7 @@ One disadvantage of this approach is that it does not help the software spread.
© Alex Gleason & other Soapbox contributors © Alex Gleason & other Soapbox contributors
© Eugen Rochko & other Mastodon contributors © Eugen Rochko & other Mastodon contributors
© Trump Media & Technology Group © Trump Media & Technology Group
© Gab AI, Inc. © Gab AI, Inc.
Soapbox is free software: you can redistribute it and/or modify Soapbox is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by

Wyświetl plik

@ -2,4 +2,4 @@
- verified.svg - Created by Alex Gleason. CC0 - verified.svg - Created by Alex Gleason. CC0
Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg

Wyświetl plik

@ -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": []
}

Wyświetl plik

@ -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,8 @@ 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 { 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';
@ -277,7 +278,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,
@ -289,9 +290,10 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
poll: compose.poll, poll: compose.poll,
scheduled_at: compose.schedule, scheduled_at: compose.schedule,
to, to,
group_id: compose.privacy === 'group' ? compose.group_id : null,
}; };
if (compose.privacy === 'group') params.group_id = compose.group_id;
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');
@ -515,7 +517,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));
}; };
@ -560,7 +564,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));

Wyświetl plik

@ -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,
}); });

Wyświetl plik

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

Wyświetl plik

@ -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 = () => ({

Wyświetl plik

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

Wyświetl plik

@ -1,5 +1,6 @@
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import { deleteEntities } from 'soapbox/entity-store/actions';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
@ -40,14 +41,6 @@ const GROUP_RELATIONSHIPS_FETCH_REQUEST = 'GROUP_RELATIONSHIPS_FETCH_REQUEST';
const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS'; const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS';
const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL'; const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL';
const GROUP_JOIN_REQUEST = 'GROUP_JOIN_REQUEST';
const GROUP_JOIN_SUCCESS = 'GROUP_JOIN_SUCCESS';
const GROUP_JOIN_FAIL = 'GROUP_JOIN_FAIL';
const GROUP_LEAVE_REQUEST = 'GROUP_LEAVE_REQUEST';
const GROUP_LEAVE_SUCCESS = 'GROUP_LEAVE_SUCCESS';
const GROUP_LEAVE_FAIL = 'GROUP_LEAVE_FAIL';
const GROUP_DELETE_STATUS_REQUEST = 'GROUP_DELETE_STATUS_REQUEST'; const GROUP_DELETE_STATUS_REQUEST = 'GROUP_DELETE_STATUS_REQUEST';
const GROUP_DELETE_STATUS_SUCCESS = 'GROUP_DELETE_STATUS_SUCCESS'; const GROUP_DELETE_STATUS_SUCCESS = 'GROUP_DELETE_STATUS_SUCCESS';
const GROUP_DELETE_STATUS_FAIL = 'GROUP_DELETE_STATUS_FAIL'; const GROUP_DELETE_STATUS_FAIL = 'GROUP_DELETE_STATUS_FAIL';
@ -148,7 +141,8 @@ const createGroup = (params: Record<string, any>, shouldReset?: boolean) =>
if (shouldReset) { if (shouldReset) {
dispatch(resetGroupEditor()); dispatch(resetGroupEditor());
} }
dispatch(closeModal('MANAGE_GROUP'));
return data;
}).catch(err => dispatch(createGroupFail(err))); }).catch(err => dispatch(createGroupFail(err)));
}; };
@ -198,7 +192,7 @@ const updateGroupFail = (error: AxiosError) => ({
}); });
const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(deleteGroupRequest(id)); dispatch(deleteEntities([id], 'Group'));
return api(getState).delete(`/api/v1/groups/${id}`) return api(getState).delete(`/api/v1/groups/${id}`)
.then(() => dispatch(deleteGroupSuccess(id))) .then(() => dispatch(deleteGroupSuccess(id)))
@ -312,70 +306,6 @@ const fetchGroupRelationshipsFail = (error: AxiosError) => ({
skipNotFound: true, skipNotFound: true,
}); });
const joinGroup = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const locked = (getState().groups.items.get(id) as any).locked || false;
dispatch(joinGroupRequest(id, locked));
return api(getState).post(`/api/v1/groups/${id}/join`).then(response => {
dispatch(joinGroupSuccess(response.data));
toast.success(locked ? messages.joinRequestSuccess : messages.joinSuccess);
}).catch(error => {
dispatch(joinGroupFail(error, locked));
});
};
const leaveGroup = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(leaveGroupRequest(id));
return api(getState).post(`/api/v1/groups/${id}/leave`).then(response => {
dispatch(leaveGroupSuccess(response.data));
toast.success(messages.leaveSuccess);
}).catch(error => {
dispatch(leaveGroupFail(error));
});
};
const joinGroupRequest = (id: string, locked: boolean) => ({
type: GROUP_JOIN_REQUEST,
id,
locked,
skipLoading: true,
});
const joinGroupSuccess = (relationship: APIEntity) => ({
type: GROUP_JOIN_SUCCESS,
relationship,
skipLoading: true,
});
const joinGroupFail = (error: AxiosError, locked: boolean) => ({
type: GROUP_JOIN_FAIL,
error,
locked,
skipLoading: true,
});
const leaveGroupRequest = (id: string) => ({
type: GROUP_LEAVE_REQUEST,
id,
skipLoading: true,
});
const leaveGroupSuccess = (relationship: APIEntity) => ({
type: GROUP_LEAVE_SUCCESS,
relationship,
skipLoading: true,
});
const leaveGroupFail = (error: AxiosError) => ({
type: GROUP_LEAVE_FAIL,
error,
skipLoading: true,
});
const groupDeleteStatus = (groupId: string, statusId: string) => const groupDeleteStatus = (groupId: string, statusId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(groupDeleteStatusRequest(groupId, statusId)); dispatch(groupDeleteStatusRequest(groupId, statusId));
@ -859,9 +789,11 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get
const note = getState().group_editor.note; const note = getState().group_editor.note;
const avatar = getState().group_editor.avatar; const avatar = getState().group_editor.avatar;
const header = getState().group_editor.header; const header = getState().group_editor.header;
const visibility = getState().group_editor.locked ? 'members_only' : 'everyone'; // Truth Social
const params: Record<string, any> = { const params: Record<string, any> = {
display_name: displayName, display_name: displayName,
group_visibility: visibility,
note, note,
}; };
@ -869,9 +801,9 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get
if (header) params.header = header; if (header) params.header = header;
if (groupId === null) { if (groupId === null) {
dispatch(createGroup(params, shouldReset)); return dispatch(createGroup(params, shouldReset));
} else { } else {
dispatch(updateGroup(groupId, params, shouldReset)); return dispatch(updateGroup(groupId, params, shouldReset));
} }
}; };
@ -895,12 +827,6 @@ export {
GROUP_RELATIONSHIPS_FETCH_REQUEST, GROUP_RELATIONSHIPS_FETCH_REQUEST,
GROUP_RELATIONSHIPS_FETCH_SUCCESS, GROUP_RELATIONSHIPS_FETCH_SUCCESS,
GROUP_RELATIONSHIPS_FETCH_FAIL, GROUP_RELATIONSHIPS_FETCH_FAIL,
GROUP_JOIN_REQUEST,
GROUP_JOIN_SUCCESS,
GROUP_JOIN_FAIL,
GROUP_LEAVE_REQUEST,
GROUP_LEAVE_SUCCESS,
GROUP_LEAVE_FAIL,
GROUP_DELETE_STATUS_REQUEST, GROUP_DELETE_STATUS_REQUEST,
GROUP_DELETE_STATUS_SUCCESS, GROUP_DELETE_STATUS_SUCCESS,
GROUP_DELETE_STATUS_FAIL, GROUP_DELETE_STATUS_FAIL,
@ -973,14 +899,6 @@ export {
fetchGroupRelationshipsRequest, fetchGroupRelationshipsRequest,
fetchGroupRelationshipsSuccess, fetchGroupRelationshipsSuccess,
fetchGroupRelationshipsFail, fetchGroupRelationshipsFail,
joinGroup,
leaveGroup,
joinGroupRequest,
joinGroupSuccess,
joinGroupFail,
leaveGroupRequest,
leaveGroupSuccess,
leaveGroupFail,
groupDeleteStatus, groupDeleteStatus,
groupDeleteStatusRequest, groupDeleteStatusRequest,
groupDeleteStatusSuccess, groupDeleteStatusSuccess,

Wyświetl plik

@ -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';
@ -18,11 +23,11 @@ const importAccount = (account: APIEntity) =>
const importAccounts = (accounts: APIEntity[]) => const importAccounts = (accounts: APIEntity[]) =>
({ type: ACCOUNTS_IMPORT, accounts }); ({ type: ACCOUNTS_IMPORT, accounts });
const importGroup = (group: APIEntity) => const importGroup = (group: Group) =>
({ type: GROUP_IMPORT, group }); importEntities([group], Entities.GROUPS);
const importGroups = (groups: APIEntity[]) => const importGroups = (groups: Group[]) =>
({ type: GROUPS_IMPORT, groups }); importEntities(groups, Entities.GROUPS);
const importStatus = (status: APIEntity, idempotencyKey?: string) => const importStatus = (status: APIEntity, idempotencyKey?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
@ -69,17 +74,8 @@ const importFetchedGroup = (group: APIEntity) =>
importFetchedGroups([group]); importFetchedGroups([group]);
const importFetchedGroups = (groups: APIEntity[]) => { const importFetchedGroups = (groups: APIEntity[]) => {
const normalGroups: APIEntity[] = []; const entities = filteredArray(groupSchema).catch([]).parse(groups);
return importGroups(entities);
const processGroup = (group: APIEntity) => {
if (!group.id) return;
normalGroups.push(group);
};
groups.forEach(processGroup);
return importGroups(normalGroups);
}; };
const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) => const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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';
enum ReportableEntities {
ACCOUNT = 'ACCOUNT',
CHAT_MESSAGE = 'CHAT_MESSAGE',
GROUP = 'GROUP',
STATUS = 'STATUS'
}
type ReportedEntity = { type ReportedEntity = {
status?: Status status?: Status
chatMessage?: ChatMessage chatMessage?: ChatMessage
group?: Group
} }
const initReport = (account: Account, entities?: ReportedEntity) => (dispatch: AppDispatch) => { const initReport = (entityType: ReportableEntities, account: Account, entities?: ReportedEntity) => (dispatch: AppDispatch) => {
const { status, chatMessage } = entities || {}; 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,

Wyświetl plik

@ -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';
@ -21,9 +22,7 @@ type SettingOpts = {
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,
@ -221,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);
@ -231,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,
@ -242,4 +247,5 @@ export {
changeSetting, changeSetting,
saveSettingsImmediate, saveSettingsImmediate,
saveSettings, saveSettings,
getLocale,
}; };

Wyświetl plik

@ -48,6 +48,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;
}; };
@ -335,6 +337,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 +370,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 +389,5 @@ export {
toggleStatusHidden, toggleStatusHidden,
translateStatus, translateStatus,
undoStatusTranslation, undoStatusTranslation,
unfilterStatus,
}; };

Wyświetl plik

@ -1,4 +1,4 @@
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';
@ -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;

Wyświetl plik

@ -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[]) => {

Wyświetl plik

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

Wyświetl plik

@ -14,10 +14,11 @@ 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 { 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
} }
@ -67,7 +68,7 @@ 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
@ -90,6 +91,7 @@ export interface IAccount {
showEdit?: boolean showEdit?: boolean
approvalStatus?: StatusApprovalStatus approvalStatus?: StatusApprovalStatus
emoji?: string emoji?: string
emojiUrl?: string
note?: string note?: string
} }
@ -115,6 +117,7 @@ const Account = ({
showEdit = false, showEdit = false,
approvalStatus, approvalStatus,
emoji, emoji,
emojiUrl,
note, note,
}: IAccount) => { }: IAccount) => {
const overflowRef = useRef<HTMLDivElement>(null); const overflowRef = useRef<HTMLDivElement>(null);
@ -192,6 +195,7 @@ const Account = ({
<Emoji <Emoji
className='absolute bottom-0 -right-1.5 h-5 w-5' className='absolute bottom-0 -right-1.5 h-5 w-5'
emoji={emoji} emoji={emoji}
src={emojiUrl}
/> />
)} )}
</LinkEl> </LinkEl>

Wyświetl plik

@ -1,6 +1,6 @@
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';

Wyświetl plik

@ -2,7 +2,7 @@ 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';

Wyświetl plik

@ -2,14 +2,13 @@ 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 {
@ -24,7 +23,7 @@ const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, addR
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 });
@ -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>

Wyświetl plik

@ -0,0 +1,139 @@
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>();
function handleAction(
present: 'authorizing' | 'rejecting',
past: 'authorized' | 'rejected',
action: () => Promise<unknown> | unknown,
): void {
if (state === present) {
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);
} else {
doAction();
}
}
}
const handleAuthorize = async () => handleAction('authorizing', 'authorized', onAuthorize);
const handleReject = async () => handleAction('rejecting', 'rejected', onReject);
useEffect(() => {
return () => {
if (timeout.current) {
clearTimeout(timeout.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'}
/>
<AuthorizeRejectButton
theme='primary'
icon={require('@tabler/icons/check.svg')}
action={handleAuthorize}
isLoading={state === 'authorizing'}
disabled={state === 'rejecting'}
/>
</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
}
const AuthorizeRejectButton: React.FC<IAuthorizeRejectButton> = ({ theme, icon, action, isLoading, disabled }) => {
return (
<div className='relative'>
<IconButton
src={isLoading ? require('@tabler/icons/player-stop-filled.svg') : icon}
onClick={action}
theme='seamless'
className={clsx('h-10 w-10 items-center justify-center border-2', {
'border-primary-500/10 hover:border-primary-500': theme === 'primary',
'border-danger-600/10 hover:border-danger-600': theme === 'danger',
})}
iconClassName={clsx('h-6 w-6', {
'text-primary-500': theme === 'primary',
'text-danger-600': theme === 'danger',
})}
disabled={disabled}
/>
{(isLoading) && (
<div
className={clsx('pointer-events-none absolute inset-0 h-10 w-10 animate-spin rounded-full border-2 border-transparent', {
'border-t-primary-500': theme === 'primary',
'border-t-danger-600': theme === 'danger',
})}
/>
)}
</div>
);
};
export { AuthorizeRejectButtons };

Wyświetl plik

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

Wyświetl plik

@ -3,7 +3,7 @@ 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, Portal } 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';
@ -12,6 +12,7 @@ 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;

Wyświetl plik

@ -4,14 +4,14 @@ 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 { 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

Wyświetl plik

@ -73,7 +73,7 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
} }
return ( return (
<li className='truncate focus-within:ring-2 focus-within:ring-primary-500'> <li className='truncate focus-visible:ring-2 focus-visible:ring-primary-500'>
<a <a
href={item.href || item.to || '#'} href={item.href || item.to || '#'}
role='button' role='button'

Wyświetl plik

@ -271,6 +271,10 @@ const DropdownMenu = (props: IDropdownMenu) => {
}; };
}, [refs.floating.current]); }, [refs.floating.current]);
if (items.length === 0) {
return null;
}
return ( return (
<> <>
{children ? ( {children ? (

Wyświetl plik

@ -1,7 +1,12 @@
import React from 'react'; import React from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { Avatar, HStack, Icon, Stack, Text } from './ui'; import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
import GroupRelationship from 'soapbox/features/group/components/group-relationship';
import GroupAvatar from './groups/group-avatar';
import { HStack, Stack, Text } from './ui';
import type { Group as GroupEntity } from 'soapbox/types/entities'; import type { Group as GroupEntity } from 'soapbox/types/entities';
@ -17,43 +22,42 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
const intl = useIntl(); const intl = useIntl();
return ( return (
<div className='overflow-hidden'> <Stack
<Stack className='rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900 sm:rounded-xl'> className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'
<div className='relative -m-[1px] mb-0 h-[120px] rounded-t-lg bg-primary-100 dark:bg-gray-800 sm:rounded-t-xl'> data-testid='group-card'
{group.header && <img className='h-full w-full rounded-t-lg object-cover sm:rounded-t-xl' src={group.header} alt={intl.formatMessage(messages.groupHeader)} />} >
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'> {/* Group Cover Image */}
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} /> <Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
</div> {group.header && (
</div> <img
<Stack className='p-3 pt-9' alignItems='center' space={3}> className='absolute inset-0 h-full w-full rounded-t-lg object-cover'
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} /> src={group.header} alt={intl.formatMessage(messages.groupHeader)}
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap> />
{group.relationship?.role === 'admin' ? ( )}
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
</HStack>
) : group.relationship?.role === 'moderator' && (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
</HStack>
)}
{group.locked ? (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
</HStack>
) : (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
</HStack>
)}
</HStack>
</Stack>
</Stack> </Stack>
</div>
{/* Group Avatar */}
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
<GroupAvatar group={group} size={64} withRing />
</div>
{/* Group Info */}
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
<HStack alignItems='center' space={1.5}>
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
{group.relationship?.pending_requests && (
<div className='h-2 w-2 rounded-full bg-secondary-500' />
)}
</HStack>
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
<GroupRelationship group={group} />
<GroupPrivacy group={group} />
<GroupMemberCount group={group} />
</HStack>
</Stack>
</Stack>
); );
}; };

Wyświetl plik

@ -0,0 +1,37 @@
import clsx from 'clsx';
import React from 'react';
import { GroupRoles } from 'soapbox/schemas/group-member';
import { Avatar } from '../ui';
import type { Group } from 'soapbox/schemas';
interface IGroupAvatar {
group: Group
size: number
withRing?: boolean
}
const GroupAvatar = (props: IGroupAvatar) => {
const { group, size, withRing = false } = props;
const isOwner = group.relationship?.role === GroupRoles.OWNER;
return (
<Avatar
className={
clsx('relative rounded-full', {
'shadow-[0_0_0_2px_theme(colors.primary.600),0_0_0_4px_theme(colors.white)]': isOwner && withRing,
'dark:shadow-[0_0_0_2px_theme(colors.primary.600),0_0_0_4px_theme(colors.gray.800)]': isOwner && withRing,
'shadow-[0_0_0_2px_theme(colors.primary.600)]': isOwner && !withRing,
'shadow-[0_0_0_2px_theme(colors.white)] dark:shadow-[0_0_0_2px_theme(colors.gray.800)]': !isOwner && withRing,
})
}
src={group.avatar}
size={size}
/>
);
};
export default GroupAvatar;

Wyświetl plik

@ -0,0 +1,99 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { Button, Divider, HStack, Popover, Stack, Text } from 'soapbox/components/ui';
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
import GroupAvatar from '../group-avatar';
import type { Group } from 'soapbox/schemas';
interface IGroupPopoverContainer {
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>
isEnabled: boolean
group: Group
}
const messages = defineMessages({
title: { id: 'group.popover.title', defaultMessage: 'Membership required' },
summary: { id: 'group.popover.summary', defaultMessage: 'You must be a member of the group in order to reply to this status.' },
action: { id: 'group.popover.action', defaultMessage: 'View Group' },
});
const GroupPopover = (props: IGroupPopoverContainer) => {
const { children, group, isEnabled } = props;
const intl = useIntl();
if (!isEnabled) {
return children;
}
return (
<Popover
interaction='click'
referenceElementClassName='cursor-pointer'
content={
<Stack space={4} className='w-80'>
<Stack
className='relative h-60 rounded-lg bg-white dark:border-primary-800 dark:bg-primary-900'
data-testid='group-card'
>
{/* Group Cover Image */}
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
{group.header && (
<img
className='absolute inset-0 h-full w-full rounded-t-lg object-cover'
src={group.header}
alt=''
/>
)}
</Stack>
{/* Group Avatar */}
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
<GroupAvatar group={group} size={64} withRing />
</div>
{/* Group Info */}
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
<GroupPrivacy group={group} />
<GroupMemberCount group={group} />
</HStack>
</Stack>
</Stack>
<Divider />
<Stack space={0.5} className='px-4'>
<Text weight='semibold'>
{intl.formatMessage(messages.title)}
</Text>
<Text theme='muted'>
{intl.formatMessage(messages.summary)}
</Text>
</Stack>
<div className='px-4 pb-4'>
<Link to={`/groups/${group.id}`}>
<Button type='button' theme='secondary' block>
{intl.formatMessage(messages.action)}
</Button>
</Link>
</div>
</Stack>
}
isFlush
children={
<div className='inline-block'>{children}</div>
}
/>
);
};
export default GroupPopover;

Wyświetl plik

@ -14,6 +14,9 @@ export interface IIcon extends React.HTMLAttributes<HTMLDivElement> {
className?: string className?: string
} }
/**
* @deprecated Use the UI Icon component directly.
*/
const Icon: React.FC<IIcon> = ({ src, alt, className, ...rest }) => { const Icon: React.FC<IIcon> = ({ src, alt, className, ...rest }) => {
return ( return (
<div <div

Wyświetl plik

@ -4,8 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
import { SelectDropdown } from '../features/forms'; import { SelectDropdown } from '../features/forms';
import Icon from './icon'; import { Icon, HStack, Select } from './ui';
import { HStack, Select } from './ui';
interface IList { interface IList {
children: React.ReactNode children: React.ReactNode
@ -58,13 +57,13 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
return ( return (
<Comp <Comp
className={clsx({ className={clsx({
'flex items-center justify-between px-3 py-2 first:rounded-t-lg last:rounded-b-lg bg-gradient-to-r from-gradient-start/10 to-gradient-end/10': true, 'flex items-center justify-between px-4 py-2 first:rounded-t-lg last:rounded-b-lg bg-gradient-to-r from-gradient-start/20 to-gradient-end/20 dark:from-gradient-start/10 dark:to-gradient-end/10': true,
'cursor-pointer hover:from-gradient-start/20 hover:to-gradient-end/20 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof onClick !== 'undefined' || typeof onSelect !== 'undefined', 'cursor-pointer hover:from-gradient-start/30 hover:to-gradient-end/30 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof onClick !== 'undefined' || typeof onSelect !== 'undefined',
})} })}
{...linkProps} {...linkProps}
> >
<div className='flex flex-col py-1.5 pr-4 rtl:pl-4 rtl:pr-0'> <div className='flex flex-col py-1.5 pr-4 rtl:pl-4 rtl:pr-0'>
<LabelComp className='text-gray-900 dark:text-gray-100' htmlFor={domId}>{label}</LabelComp> <LabelComp className='font-medium text-gray-900 dark:text-gray-100' htmlFor={domId}>{label}</LabelComp>
{hint ? ( {hint ? (
<span className='text-sm text-gray-700 dark:text-gray-600'>{hint}</span> <span className='text-sm text-gray-700 dark:text-gray-600'>{hint}</span>
@ -83,9 +82,26 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
<div className='flex flex-row items-center text-gray-700 dark:text-gray-600'> <div className='flex flex-row items-center text-gray-700 dark:text-gray-600'>
{children} {children}
{isSelected ? ( <div
<Icon src={require('@tabler/icons/check.svg')} className='ml-1 text-primary-500 dark:text-primary-400' /> className={
) : null} clsx({
'flex h-6 w-6 items-center justify-center rounded-full border-2 border-solid border-primary-500 dark:border-primary-400 transition': true,
'bg-primary-500 dark:bg-primary-400': isSelected,
'bg-transparent': !isSelected,
})
}
>
<Icon
src={require('@tabler/icons/check.svg')}
className={
clsx({
'h-4 w-4 text-white dark:text-white transition-all duration-500': true,
'opacity-0 scale-50': !isSelected,
'opacity-100 scale-100': isSelected,
})
}
/>
</div>
</div> </div>
) : null} ) : null}

Wyświetl plik

@ -6,16 +6,17 @@ import { Button } from 'soapbox/components/ui';
interface ILoadMore { interface ILoadMore {
onClick: React.MouseEventHandler onClick: React.MouseEventHandler
disabled?: boolean disabled?: boolean
visible?: Boolean visible?: boolean
className?: string
} }
const LoadMore: React.FC<ILoadMore> = ({ onClick, disabled, visible = true }) => { const LoadMore: React.FC<ILoadMore> = ({ onClick, disabled, visible = true, className }) => {
if (!visible) { if (!visible) {
return null; return null;
} }
return ( return (
<Button theme='primary' block disabled={disabled || !visible} onClick={onClick}> <Button className={className} theme='primary' block disabled={disabled || !visible} onClick={onClick}>
<FormattedMessage id='status.load_more' defaultMessage='Load more' /> <FormattedMessage id='status.load_more' defaultMessage='Load more' />
</Button> </Button>
); );

Wyświetl plik

@ -152,7 +152,14 @@ const Item: React.FC<IItem> = ({
); );
return ( return (
<div className={clsx('media-gallery__item', { standalone })} key={attachment.id} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}> <div
className={clsx('media-gallery__item', {
standalone,
'rounded-md': total > 1,
})}
key={attachment.id}
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
>
<a className='media-gallery__item-thumbnail' href={attachment.url} target='_blank' style={{ cursor: 'pointer' }}> <a className='media-gallery__item-thumbnail' href={attachment.url} target='_blank' style={{ cursor: 'pointer' }}>
<Blurhash hash={attachment.blurhash} className='media-gallery__preview' /> <Blurhash hash={attachment.blurhash} className='media-gallery__preview' />
<span className='media-gallery__item__icons'>{attachmentIcon}</span> <span className='media-gallery__item__icons'>{attachmentIcon}</span>
@ -245,7 +252,14 @@ const Item: React.FC<IItem> = ({
} }
return ( return (
<div className={clsx('media-gallery__item', `media-gallery__item--${attachment.type}`, { standalone })} key={attachment.id} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}> <div
className={clsx('media-gallery__item', `media-gallery__item--${attachment.type}`, {
standalone,
'rounded-md': total > 1,
})}
key={attachment.id}
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
>
{last && total > ATTACHMENT_LIMIT && ( {last && total > ATTACHMENT_LIMIT && (
<div className='media-gallery__item-overflow'> <div className='media-gallery__item-overflow'>
+{total - ATTACHMENT_LIMIT + 1} +{total - ATTACHMENT_LIMIT + 1}
@ -260,7 +274,7 @@ const Item: React.FC<IItem> = ({
); );
}; };
interface IMediaGallery { export interface IMediaGallery {
sensitive?: boolean sensitive?: boolean
media: ImmutableList<Attachment> media: ImmutableList<Attachment>
height?: number height?: number
@ -270,13 +284,15 @@ interface IMediaGallery {
visible?: boolean visible?: boolean
onToggleVisibility?: () => void onToggleVisibility?: () => void
displayMedia?: string displayMedia?: string
compact: boolean compact?: boolean
className?: string
} }
const MediaGallery: React.FC<IMediaGallery> = (props) => { const MediaGallery: React.FC<IMediaGallery> = (props) => {
const { const {
media, media,
defaultWidth = 0, defaultWidth = 0,
className,
onOpenMedia, onOpenMedia,
cacheWidth, cacheWidth,
compact, compact,
@ -546,7 +562,11 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
}, [node.current]); }, [node.current]);
return ( return (
<div className={clsx('media-gallery', { 'media-gallery--compact': compact })} style={sizeData.style} ref={node}> <div
className={clsx(className, 'media-gallery', { 'media-gallery--compact': compact })}
style={sizeData.style}
ref={node}
>
{children} {children}
</div> </div>
); );

Wyświetl plik

@ -0,0 +1,54 @@
import clsx from 'clsx';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { HStack, Icon, Text } from 'soapbox/components/ui';
interface IPendingItemsRow {
/** Path to navigate the user when clicked. */
to: string
/** Number of pending items. */
count: number
/** Size of the icon. */
size?: 'md' | 'lg'
}
const PendingItemsRow: React.FC<IPendingItemsRow> = ({ to, count, size = 'md' }) => {
return (
<Link to={to} className='group' data-testid='pending-items-row'>
<HStack alignItems='center' justifyContent='between'>
<HStack alignItems='center' space={2}>
<div className={clsx('rounded-full bg-primary-200 text-primary-500 dark:bg-primary-800 dark:text-primary-200', {
'p-3': size === 'lg',
'p-2.5': size === 'md',
})}
>
<Icon
src={require('@tabler/icons/exclamation-circle.svg')}
className={clsx({
'h-5 w-5': size === 'md',
'h-7 w-7': size === 'lg',
})}
/>
</div>
<Text weight='bold' size='md'>
<FormattedMessage
id='groups.pending.count'
defaultMessage='{number, plural, one {# pending request} other {# pending requests}}'
values={{ number: count }}
/>
</Text>
</HStack>
<Icon
src={require('@tabler/icons/chevron-right.svg')}
className='h-5 w-5 text-gray-600 transition-colors group-hover:text-gray-700 dark:text-gray-600 dark:group-hover:text-gray-500'
/>
</HStack>
</Link>
);
};
export { PendingItemsRow };

Wyświetl plik

@ -52,6 +52,8 @@ interface IScrollableList extends VirtuosoProps<any, any> {
alwaysPrepend?: boolean alwaysPrepend?: boolean
/** Message to display when the list is loaded but empty. */ /** Message to display when the list is loaded but empty. */
emptyMessage?: React.ReactNode emptyMessage?: React.ReactNode
/** Should the empty message be displayed in a Card */
emptyMessageCard?: boolean
/** Scrollable content. */ /** Scrollable content. */
children: Iterable<React.ReactNode> children: Iterable<React.ReactNode>
/** Callback when the list is scrolled to the top. */ /** Callback when the list is scrolled to the top. */
@ -87,6 +89,7 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
children, children,
isLoading, isLoading,
emptyMessage, emptyMessage,
emptyMessageCard = true,
showLoading, showLoading,
onRefresh, onRefresh,
onScroll, onScroll,
@ -158,13 +161,17 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
<div className='mt-2'> <div className='mt-2'>
{alwaysPrepend && prepend} {alwaysPrepend && prepend}
<Card variant='rounded' size='lg'> {isLoading ? (
{isLoading ? ( <Spinner />
<Spinner /> ) : (
) : ( <>
emptyMessage {emptyMessageCard ? (
)} <Card variant='rounded' size='lg'>
</Card> {emptyMessage}
</Card>
) : emptyMessage}
</>
)}
</div> </div>
); );
}; };

Wyświetl plik

@ -10,7 +10,7 @@ import { closeSidebar } from 'soapbox/actions/sidebar';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import { Stack } from 'soapbox/components/ui'; import { Stack } from 'soapbox/components/ui';
import ProfileStats from 'soapbox/features/ui/components/profile-stats'; import ProfileStats from 'soapbox/features/ui/components/profile-stats';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useGroupsPath, useFeatures } from 'soapbox/hooks';
import { makeGetAccount, makeGetOtherAccounts } from 'soapbox/selectors'; import { makeGetAccount, makeGetOtherAccounts } from 'soapbox/selectors';
import { Divider, HStack, Icon, IconButton, Text } from './ui'; import { Divider, HStack, Icon, IconButton, Text } from './ui';
@ -90,6 +90,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen); const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
const settings = useAppSelector((state) => getSettings(state)); const settings = useAppSelector((state) => getSettings(state));
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
const groupsPath = useGroupsPath();
const closeButtonRef = React.useRef(null); const closeButtonRef = React.useRef(null);
@ -210,7 +211,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
{features.groups && ( {features.groups && (
<SidebarLink <SidebarLink
to='/groups' to={groupsPath}
icon={require('@tabler/icons/circles.svg')} icon={require('@tabler/icons/circles.svg')}
text={intl.formatMessage(messages.groups)} text={intl.formatMessage(messages.groups)}
onClick={onClose} onClick={onClose}
@ -296,7 +297,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
/> />
)} )}
{features.filters && ( {(features.filters || features.filtersV2) && (
<SidebarLink <SidebarLink
to='/filters' to='/filters'
icon={require('@tabler/icons/filter.svg')} icon={require('@tabler/icons/filter.svg')}

Wyświetl plik

@ -4,7 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Stack } from 'soapbox/components/ui'; import { Stack } from 'soapbox/components/ui';
import { useStatContext } from 'soapbox/contexts/stat-context'; import { useStatContext } from 'soapbox/contexts/stat-context';
import ComposeButton from 'soapbox/features/ui/components/compose-button'; import ComposeButton from 'soapbox/features/ui/components/compose-button';
import { useAppSelector, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks'; import { useAppSelector, useGroupsPath, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks';
import DropdownMenu, { Menu } from './dropdown-menu'; import DropdownMenu, { Menu } from './dropdown-menu';
import SidebarNavigationLink from './sidebar-navigation-link'; import SidebarNavigationLink from './sidebar-navigation-link';
@ -25,6 +25,8 @@ const SidebarNavigation = () => {
const features = useFeatures(); const features = useFeatures();
const settings = useSettings(); const settings = useSettings();
const account = useOwnAccount(); const account = useOwnAccount();
const groupsPath = useGroupsPath();
const notificationCount = useAppSelector((state) => state.notifications.unread); const notificationCount = useAppSelector((state) => state.notifications.unread);
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
@ -135,7 +137,7 @@ const SidebarNavigation = () => {
{features.groups && ( {features.groups && (
<SidebarNavigationLink <SidebarNavigationLink
to='/groups' to={groupsPath}
icon={require('@tabler/icons/circles.svg')} icon={require('@tabler/icons/circles.svg')}
text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />} text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />}
/> />

Wyświetl plik

@ -8,11 +8,11 @@ import { launchChat } from 'soapbox/actions/chats';
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose'; import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
import { editEvent } from 'soapbox/actions/events'; import { editEvent } from 'soapbox/actions/events';
import { groupBlock, groupDeleteStatus, groupKick } from 'soapbox/actions/groups'; import { groupBlock, groupDeleteStatus, groupKick } from 'soapbox/actions/groups';
import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions'; import { toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation'; import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
import { initMuteModal } from 'soapbox/actions/mutes'; import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport } from 'soapbox/actions/reports'; import { initReport, ReportableEntities } from 'soapbox/actions/reports';
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses'; import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
import DropdownMenu from 'soapbox/components/dropdown-menu'; import DropdownMenu from 'soapbox/components/dropdown-menu';
import StatusActionButton from 'soapbox/components/status-action-button'; import StatusActionButton from 'soapbox/components/status-action-button';
@ -24,6 +24,8 @@ import { isLocal, isRemote } from 'soapbox/utils/accounts';
import copy from 'soapbox/utils/copy'; import copy from 'soapbox/utils/copy';
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts'; import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts';
import GroupPopover from './groups/popover/group-popover';
import type { Menu } from 'soapbox/components/dropdown-menu'; import type { Menu } from 'soapbox/components/dropdown-menu';
import type { Account, Group, Status } from 'soapbox/types/entities'; import type { Account, Group, Status } from 'soapbox/types/entities';
@ -45,6 +47,7 @@ const messages = defineMessages({
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' },
favourite: { id: 'status.favourite', defaultMessage: 'Like' }, favourite: { id: 'status.favourite', defaultMessage: 'Like' },
disfavourite: { id: 'status.disfavourite', defaultMessage: 'Disike' },
open: { id: 'status.open', defaultMessage: 'Expand this post' }, open: { id: 'status.open', defaultMessage: 'Expand this post' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' },
@ -161,6 +164,14 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
} }
}; };
const handleDislikeClick: React.EventHandler<React.MouseEvent> = (e) => {
if (me) {
dispatch(toggleDislike(status));
} else {
onOpenUnauthorizedModal('DISLIKE');
}
};
const handleBookmarkClick: React.EventHandler<React.MouseEvent> = (e) => { const handleBookmarkClick: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(toggleBookmark(status)); dispatch(toggleBookmark(status));
}; };
@ -254,7 +265,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
secondary: intl.formatMessage(messages.blockAndReport), secondary: intl.formatMessage(messages.blockAndReport),
onSecondary: () => { onSecondary: () => {
dispatch(blockAccount(account.id)); dispatch(blockAccount(account.id));
dispatch(initReport(account, { status })); dispatch(initReport(ReportableEntities.STATUS, account, { status }));
}, },
})); }));
}; };
@ -271,7 +282,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
}; };
const handleReport: React.EventHandler<React.MouseEvent> = (e) => { const handleReport: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(initReport(status.account as Account, { status })); dispatch(initReport(ReportableEntities.STATUS, status.account as Account, { status }));
}; };
const handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => { const handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
@ -538,7 +549,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
allowedEmoji, allowedEmoji,
).reduce((acc, cur) => acc + cur.get('count'), 0); ).reduce((acc, cur) => acc + cur.get('count'), 0);
const meEmojiReact = getReactForStatus(status, allowedEmoji) as keyof typeof reactMessages | undefined; const meEmojiReact = getReactForStatus(status, allowedEmoji);
const meEmojiName = meEmojiReact?.get('name') as keyof typeof reactMessages | undefined;
const reactMessages = { const reactMessages = {
'👍': messages.reactionLike, '👍': messages.reactionLike,
@ -550,7 +562,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
'': messages.favourite, '': messages.favourite,
}; };
const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite); const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiName || ''] || messages.favourite);
const menu = _makeMenu(publicStatus); const menu = _makeMenu(publicStatus);
let reblogIcon = require('@tabler/icons/repeat.svg'); let reblogIcon = require('@tabler/icons/repeat.svg');
@ -607,14 +619,19 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
grow={space === 'expand'} grow={space === 'expand'}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<StatusActionButton <GroupPopover
title={replyTitle} group={status.group as any}
icon={require('@tabler/icons/message-circle-2.svg')} isEnabled={replyDisabled}
onClick={handleReplyClick} >
count={replyCount} <StatusActionButton
text={withLabels ? intl.formatMessage(messages.reply) : undefined} title={replyTitle}
disabled={replyDisabled} icon={require('@tabler/icons/message-circle-2.svg')}
/> onClick={handleReplyClick}
count={replyCount}
text={withLabels ? intl.formatMessage(messages.reply) : undefined}
disabled={replyDisabled}
/>
</GroupPopover>
{(features.quotePosts && me) ? ( {(features.quotePosts && me) ? (
<DropdownMenu <DropdownMenu
@ -635,7 +652,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
icon={require('@tabler/icons/heart.svg')} icon={require('@tabler/icons/heart.svg')}
filled filled
color='accent' color='accent'
active={Boolean(meEmojiReact)} active={Boolean(meEmojiName)}
count={emojiReactCount} count={emojiReactCount}
emoji={meEmojiReact} emoji={meEmojiReact}
text={withLabels ? meEmojiTitle : undefined} text={withLabels ? meEmojiTitle : undefined}
@ -644,16 +661,29 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
) : ( ) : (
<StatusActionButton <StatusActionButton
title={intl.formatMessage(messages.favourite)} title={intl.formatMessage(messages.favourite)}
icon={require('@tabler/icons/heart.svg')} icon={features.dislikes ? require('@tabler/icons/thumb-up.svg') : require('@tabler/icons/heart.svg')}
color='accent' color='accent'
filled filled
onClick={handleFavouriteClick} onClick={handleFavouriteClick}
active={Boolean(meEmojiReact)} active={Boolean(meEmojiName)}
count={favouriteCount} count={favouriteCount}
text={withLabels ? meEmojiTitle : undefined} text={withLabels ? meEmojiTitle : undefined}
/> />
)} )}
{features.dislikes && (
<StatusActionButton
title={intl.formatMessage(messages.disfavourite)}
icon={require('@tabler/icons/thumb-down.svg')}
color='accent'
filled
onClick={handleDislikeClick}
active={status.disliked}
count={status.dislikes_count}
text={withLabels ? intl.formatMessage(messages.disfavourite) : undefined}
/>
)}
{canShare && ( {canShare && (
<StatusActionButton <StatusActionButton
title={intl.formatMessage(messages.share)} title={intl.formatMessage(messages.share)}

Wyświetl plik

@ -4,6 +4,8 @@ import React from 'react';
import { Text, Icon, Emoji } from 'soapbox/components/ui'; import { Text, Icon, Emoji } from 'soapbox/components/ui';
import { shortNumberFormat } from 'soapbox/utils/numbers'; import { shortNumberFormat } from 'soapbox/utils/numbers';
import type { Map as ImmutableMap } from 'immutable';
const COLORS = { const COLORS = {
accent: 'accent', accent: 'accent',
success: 'success', success: 'success',
@ -31,7 +33,7 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes<HTMLButtonEleme
active?: boolean active?: boolean
color?: Color color?: Color
filled?: boolean filled?: boolean
emoji?: string emoji?: ImmutableMap<string, any>
text?: React.ReactNode text?: React.ReactNode
} }
@ -42,7 +44,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
if (emoji) { if (emoji) {
return ( return (
<span className='flex h-6 w-6 items-center justify-center'> <span className='flex h-6 w-6 items-center justify-center'>
<Emoji className='h-full w-full p-0.5' emoji={emoji} /> <Emoji className='h-full w-full p-0.5' emoji={emoji.get('name')} src={emoji.get('url')} />
</span> </span>
); );
} else { } else {

Wyświetl plik

@ -60,9 +60,9 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
} }
}; };
const handleReact = (emoji: string): void => { const handleReact = (emoji: string, custom?: string): void => {
if (ownAccount) { if (ownAccount) {
dispatch(simpleEmojiReact(status, emoji)); dispatch(simpleEmojiReact(status, emoji, custom));
} else { } else {
handleUnauthorized(); handleUnauthorized();
} }
@ -71,7 +71,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
}; };
const handleClick: React.EventHandler<React.MouseEvent> = e => { const handleClick: React.EventHandler<React.MouseEvent> = e => {
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍'; const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji)?.get('name') || '👍';
if (isUserTouching()) { if (isUserTouching()) {
if (ownAccount) { if (ownAccount) {
@ -112,6 +112,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
referenceElement={referenceElement} referenceElement={referenceElement}
onReact={handleReact} onReact={handleReact}
visible={visible} visible={visible}
onClose={() => setVisible(false)}
/> />
</Portal> </Portal>
)} )}

Wyświetl plik

@ -6,6 +6,7 @@ import { openModal } from 'soapbox/actions/modals';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper'; import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
import HoverStatusWrapper from 'soapbox/components/hover-status-wrapper'; import HoverStatusWrapper from 'soapbox/components/hover-status-wrapper';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import { isPubkey } from 'soapbox/utils/nostr';
import type { Account, Status } from 'soapbox/types/entities'; import type { Account, Status } from 'soapbox/types/entities';
@ -56,7 +57,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
className='reply-mentions__account' className='reply-mentions__account'
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@{account.username} @{isPubkey(account.username) ? account.username.slice(0, 8) : account.username}
</Link> </Link>
); );

Wyświetl plik

@ -7,7 +7,7 @@ import { useHistory } from 'react-router-dom';
import { mentionCompose, replyCompose } from 'soapbox/actions/compose'; import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions'; import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { toggleStatusHidden } from 'soapbox/actions/statuses'; import { toggleStatusHidden, unfilterStatus } from 'soapbox/actions/statuses';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import TranslateButton from 'soapbox/components/translate-button'; import TranslateButton from 'soapbox/components/translate-button';
import AccountContainer from 'soapbox/containers/account-container'; import AccountContainer from 'soapbox/containers/account-container';
@ -93,6 +93,8 @@ const Status: React.FC<IStatus> = (props) => {
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`; const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
const group = actualStatus.group as GroupEntity | null; const group = actualStatus.group as GroupEntity | null;
const filtered = (status.filtered.size || actualStatus.filtered.size) > 0;
// Track height changes we know about to compensate scrolling. // Track height changes we know about to compensate scrolling.
useEffect(() => { useEffect(() => {
didShowCard.current = Boolean(!muted && !hidden && status?.card); didShowCard.current = Boolean(!muted && !hidden && status?.card);
@ -202,6 +204,8 @@ const Status: React.FC<IStatus> = (props) => {
_expandEmojiSelector(); _expandEmojiSelector();
}; };
const handleUnfilter = () => dispatch(unfilterStatus(status.filtered.size ? status.id : actualStatus.id));
const _expandEmojiSelector = (): void => { const _expandEmojiSelector = (): void => {
const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
firstEmoji?.focus(); firstEmoji?.focus();
@ -281,7 +285,7 @@ const Status: React.FC<IStatus> = (props) => {
); );
} }
if (status.filtered || actualStatus.filtered) { if (filtered && status.showFiltered) {
const minHandlers = muted ? undefined : { const minHandlers = muted ? undefined : {
moveUp: handleHotkeyMoveUp, moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown, moveDown: handleHotkeyMoveDown,
@ -291,7 +295,11 @@ const Status: React.FC<IStatus> = (props) => {
<HotKeys handlers={minHandlers}> <HotKeys handlers={minHandlers}>
<div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}> <div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
<Text theme='muted'> <Text theme='muted'>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' /> <FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {status.filtered.join(', ')}.
{' '}
<button className='text-primary-600 hover:underline dark:text-accent-blue' onClick={handleUnfilter}>
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
</button>
</Text> </Text>
</div> </div>
</HotKeys> </HotKeys>

Wyświetl plik

@ -8,8 +8,8 @@ const themes = {
tertiary: tertiary:
'bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500', 'bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300', accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300',
danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:bg-danger-800 focus:text-gray-200 dark:focus:bg-danger-600 dark:focus:text-gray-100', danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:ring-danger-500',
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80', transparent: 'border-transparent bg-transparent text-primary-600 dark:text-accent-blue dark:bg-transparent hover:bg-gray-200 dark:hover:bg-gray-800/50',
outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10', outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10',
muted: 'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500', muted: 'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
}; };
@ -39,7 +39,7 @@ const useButtonStyles = ({
size, size,
}: IButtonStyles) => { }: IButtonStyles) => {
const buttonStyle = clsx({ const buttonStyle = clsx({
'inline-flex items-center border font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 appearance-none transition-all': true, 'inline-flex items-center place-content-center border font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 appearance-none transition-all': true,
'select-none disabled:opacity-75 disabled:cursor-default': disabled, 'select-none disabled:opacity-75 disabled:cursor-default': disabled,
[`${themes[theme]}`]: true, [`${themes[theme]}`]: true,
[`${sizes[size]}`]: true, [`${sizes[size]}`]: true,

Wyświetl plik

@ -64,7 +64,7 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick }; const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick };
return ( return (
<Comp {...backAttributes} className='text-gray-900 focus:ring-2 focus:ring-primary-500 dark:text-gray-100' aria-label={intl.formatMessage(messages.back)}> <Comp {...backAttributes} className='rounded-full text-gray-900 focus:ring-2 focus:ring-primary-500 dark:text-gray-100' aria-label={intl.formatMessage(messages.back)}>
<SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6 rtl:rotate-180' /> <SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6 rtl:rotate-180' />
<span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span> <span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
</Comp> </Comp>

Wyświetl plik

@ -0,0 +1,112 @@
import React, { useEffect, useState } from 'react';
import { useDimensions } from 'soapbox/hooks';
import HStack from '../hstack/hstack';
import Icon from '../icon/icon';
interface ICarousel {
children: any
/** Optional height to force on controls */
controlsHeight?: number
/** How many items in the carousel */
itemCount: number
/** The minimum width per item */
itemWidth: number
/** Should the controls be disabled? */
isDisabled?: boolean
}
/**
* Carousel
*/
const Carousel: React.FC<ICarousel> = (props): JSX.Element => {
const { children, controlsHeight, isDisabled, itemCount, itemWidth } = props;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [ref, setContainerRef, { width: finalContainerWidth }] = useDimensions();
const containerWidth = finalContainerWidth || ref?.clientWidth;
const [pageSize, setPageSize] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(1);
const numberOfPages = Math.ceil(itemCount / pageSize);
const width = containerWidth / (Math.floor(containerWidth / itemWidth));
const hasNextPage = currentPage < numberOfPages && numberOfPages > 1;
const hasPrevPage = currentPage > 1 && numberOfPages > 1;
const handleNextPage = () => setCurrentPage((prevPage) => prevPage + 1);
const handlePrevPage = () => setCurrentPage((prevPage) => prevPage - 1);
const renderChildren = () => {
if (typeof children === 'function') {
return children({ width: width || 'auto' });
}
return children;
};
useEffect(() => {
if (containerWidth) {
setPageSize(Math.round(containerWidth / width));
}
}, [containerWidth, width]);
return (
<HStack alignItems='stretch'>
<div
className='z-10 flex w-5 items-center justify-center self-stretch rounded-l-xl bg-white dark:bg-primary-900'
style={{
height: controlsHeight || 'auto',
}}
>
<button
data-testid='prev-page'
onClick={handlePrevPage}
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
disabled={!hasPrevPage || isDisabled}
>
<Icon
src={require('@tabler/icons/chevron-left.svg')}
className='h-5 w-5 text-black dark:text-white'
/>
</button>
</div>
<div className='relative w-full overflow-hidden'>
<HStack
alignItems='center'
style={{
transform: `translateX(-${(currentPage - 1) * 100}%)`,
}}
className='transition-all duration-500 ease-out'
ref={setContainerRef}
>
{renderChildren()}
</HStack>
</div>
<div
className='z-10 flex w-5 items-center justify-center self-stretch rounded-r-xl bg-white dark:bg-primary-900'
style={{
height: controlsHeight || 'auto',
}}
>
<button
data-testid='next-page'
onClick={handleNextPage}
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
disabled={!hasNextPage || isDisabled}
>
<Icon
src={require('@tabler/icons/chevron-right.svg')}
className='h-5 w-5 text-black dark:text-white'
/>
</button>
</div>
</HStack>
);
};
export default Carousel;

Wyświetl plik

@ -7,10 +7,10 @@ import { useSoapboxConfig } from 'soapbox/hooks';
import { Card, CardBody, CardHeader, CardTitle } from '../card/card'; import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' |'className'>; type IColumnHeader = Pick<IColumn, 'label' | 'backHref' | 'className' | 'action'>;
/** Contains the column title with optional back button. */ /** Contains the column title with optional back button. */
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) => { const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className, action }) => {
const history = useHistory(); const history = useHistory();
const handleBackClick = () => { const handleBackClick = () => {
@ -29,6 +29,12 @@ const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) =
return ( return (
<CardHeader className={className} onBackClick={handleBackClick}> <CardHeader className={className} onBackClick={handleBackClick}>
<CardTitle title={label} /> <CardTitle title={label} />
{action && (
<div className='flex grow justify-end'>
{action}
</div>
)}
</CardHeader> </CardHeader>
); );
}; };
@ -48,11 +54,12 @@ export interface IColumn {
ref?: React.Ref<HTMLDivElement> ref?: React.Ref<HTMLDivElement>
/** Children to display in the column. */ /** Children to display in the column. */
children?: React.ReactNode children?: React.ReactNode
action?: React.ReactNode
} }
/** A backdrop for the main section of the UI. */ /** A backdrop for the main section of the UI. */
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => { const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
const { backHref, children, label, transparent = false, withHeader = true, className } = props; const { backHref, children, label, transparent = false, withHeader = true, className, action } = props;
const soapboxConfig = useSoapboxConfig(); const soapboxConfig = useSoapboxConfig();
return ( return (
@ -75,6 +82,7 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
label={label} label={label}
backHref={backHref} backHref={backHref}
className={clsx({ 'px-4 pt-4 sm:p-0': transparent })} className={clsx({ 'px-4 pt-4 sm:p-0': transparent })}
action={action}
/> />
)} )}

Wyświetl plik

@ -1,11 +1,12 @@
import { Placement } from '@popperjs/core'; import { shift, useFloating, Placement, offset, OffsetOptions } from '@floating-ui/react';
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { usePopper } from 'react-popper';
import { Emoji, HStack, IconButton } from 'soapbox/components/ui'; import { Emoji as EmojiComponent, HStack, IconButton } from 'soapbox/components/ui';
import { Picker } from 'soapbox/features/emoji/emoji-picker'; import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown';
import { useSoapboxConfig } from 'soapbox/hooks'; import { useClickOutside, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
import type { Emoji } from 'soapbox/features/emoji';
interface IEmojiButton { interface IEmojiButton {
/** Unicode emoji character. */ /** Unicode emoji character. */
@ -29,7 +30,7 @@ const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabInd
return ( return (
<button className={clsx(className)} onClick={handleClick} tabIndex={tabIndex}> <button className={clsx(className)} onClick={handleClick} tabIndex={tabIndex}>
<Emoji className='h-6 w-6 duration-100 hover:scale-110' emoji={emoji} /> <EmojiComponent className='h-6 w-6 duration-100 hover:scale-110' emoji={emoji} />
</button> </button>
); );
}; };
@ -37,14 +38,13 @@ const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabInd
interface IEmojiSelector { interface IEmojiSelector {
onClose?(): void onClose?(): void
/** Event handler when an emoji is clicked. */ /** Event handler when an emoji is clicked. */
onReact(emoji: string): void onReact(emoji: string, custom?: string): void
/** Element that triggers the EmojiSelector Popper */ /** Element that triggers the EmojiSelector Popper */
referenceElement: HTMLElement | null referenceElement: HTMLElement | null
placement?: Placement placement?: Placement
/** Whether the selector should be visible. */ /** Whether the selector should be visible. */
visible?: boolean visible?: boolean
/** X/Y offset of the floating picker. */ offsetOptions?: OffsetOptions
offset?: [number, number]
/** Whether to allow any emoji to be chosen. */ /** Whether to allow any emoji to be chosen. */
all?: boolean all?: boolean
} }
@ -56,81 +56,65 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
onReact, onReact,
placement = 'top', placement = 'top',
visible = false, visible = false,
offset = [-10, 0], offsetOptions,
all = true, all = true,
}): JSX.Element => { }): JSX.Element => {
const soapboxConfig = useSoapboxConfig(); const soapboxConfig = useSoapboxConfig();
const { customEmojiReacts } = useFeatures();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
// `useRef` won't trigger a re-render, while `useState` does. const { x, y, strategy, refs, update } = useFloating<HTMLElement>({
// https://popper.js.org/react-popper/v2/
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const handleClickOutside = (event: MouseEvent) => {
if (referenceElement?.contains(event.target as Node) || popperElement?.contains(event.target as Node)) {
return;
}
if (onClose) {
onClose();
}
};
const { styles, attributes, update } = usePopper(referenceElement, popperElement, {
placement, placement,
modifiers: [ middleware: [offset(offsetOptions), shift()],
{
name: 'offset',
options: {
offset,
},
},
],
}); });
const handleExpand: React.MouseEventHandler = () => { const handleExpand: React.MouseEventHandler = () => {
setExpanded(true); setExpanded(true);
}; };
const handlePickEmoji = (emoji: Emoji) => {
onReact(emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined);
};
useEffect(() => {
refs.setReference(referenceElement);
}, [referenceElement]);
useEffect(() => () => {
document.body.style.overflow = '';
}, []);
useEffect(() => { useEffect(() => {
setExpanded(false); setExpanded(false);
}, [visible]); }, [visible]);
useEffect(() => { useClickOutside(refs, () => {
document.addEventListener('mousedown', handleClickOutside); if (onClose) {
onClose();
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [referenceElement]);
useEffect(() => {
if (visible && update) {
update();
} }
}, [visible, update]); });
useEffect(() => {
if (expanded && update) {
update();
}
}, [expanded, update]);
return ( return (
<div <div
className={clsx('z-[101] transition-opacity duration-100', { className={clsx('z-[101] transition-opacity duration-100', {
'opacity-0 pointer-events-none': !visible, 'opacity-0 pointer-events-none': !visible,
})} })}
ref={setPopperElement} ref={refs.setFloating}
style={styles.popper} style={{
{...attributes.popper} position: strategy,
top: y ?? 0,
left: x ?? 0,
width: 'max-content',
}}
> >
{expanded ? ( {expanded ? (
<Picker <EmojiPickerDropdown
set='twitter' visible={expanded}
backgroundImageFn={() => require('emoji-datasource/img/twitter/sheets/32.png')} setVisible={setExpanded}
onClick={(emoji: any) => onReact(emoji.native)} update={update}
withCustom={customEmojiReacts}
onPickEmoji={handlePickEmoji}
/> />
) : ( ) : (
<HStack <HStack

Wyświetl plik

@ -10,7 +10,7 @@ interface IEmoji extends React.ImgHTMLAttributes<HTMLImageElement> {
/** A single emoji image. */ /** A single emoji image. */
const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => { const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
const { emoji, alt, ...rest } = props; const { emoji, alt, src, ...rest } = props;
const codepoints = toCodePoints(removeVS16s(emoji)); const codepoints = toCodePoints(removeVS16s(emoji));
const filename = codepoints.join('-'); const filename = codepoints.join('-');
@ -20,7 +20,7 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
<img <img
draggable='false' draggable='false'
alt={alt || emoji} alt={alt || emoji}
src={joinPublicPath(`packs/emoji/${filename}.svg`)} src={src || joinPublicPath(`packs/emoji/${filename}.svg`)}
{...rest} {...rest}
/> />
); );

Wyświetl plik

@ -86,6 +86,12 @@ const FormGroup: React.FC<IFormGroup> = (props) => {
)} )}
<div className='mt-1 dark:text-white'> <div className='mt-1 dark:text-white'>
{hintText && (
<p data-testid='form-group-hint' className='mb-0.5 text-xs text-gray-700 dark:text-gray-600'>
{hintText}
</p>
)}
{firstChild} {firstChild}
{inputChildren.filter((_, i) => i !== 0)} {inputChildren.filter((_, i) => i !== 0)}
@ -97,12 +103,6 @@ const FormGroup: React.FC<IFormGroup> = (props) => {
{errors.join(', ')} {errors.join(', ')}
</p> </p>
)} )}
{hintText && (
<p data-testid='form-group-hint' className='mt-0.5 text-xs text-gray-700 dark:text-gray-600'>
{hintText}
</p>
)}
</div> </div>
</div> </div>
); );

Wyświetl plik

@ -14,7 +14,7 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Don't render a background behind the icon. */ /** Don't render a background behind the icon. */
transparent?: boolean transparent?: boolean
/** Predefined styles to display for the button. */ /** Predefined styles to display for the button. */
theme?: 'seamless' | 'outlined' theme?: 'seamless' | 'outlined' | 'secondary'
/** Override the data-testid */ /** Override the data-testid */
'data-testid'?: string 'data-testid'?: string
} }
@ -30,6 +30,7 @@ const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef
className={clsx('flex items-center space-x-2 rounded-full p-1 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:ring-offset-0', { className={clsx('flex items-center space-x-2 rounded-full p-1 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:ring-offset-0', {
'bg-white dark:bg-transparent': !transparent, 'bg-white dark:bg-transparent': !transparent,
'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500': theme === 'outlined', 'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500': theme === 'outlined',
'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200': theme === 'secondary',
'opacity-50': filteredProps.disabled, 'opacity-50': filteredProps.disabled,
}, className)} }, className)}
{...filteredProps} {...filteredProps}

Wyświetl plik

@ -2,6 +2,7 @@ export { default as Accordion } from './accordion/accordion';
export { default as Avatar } from './avatar/avatar'; export { default as Avatar } from './avatar/avatar';
export { default as Banner } from './banner/banner'; export { default as Banner } from './banner/banner';
export { default as Button } from './button/button'; export { default as Button } from './button/button';
export { default as Carousel } from './carousel/carousel';
export { Card, CardBody, CardHeader, CardTitle } from './card/card'; export { Card, CardBody, CardHeader, CardTitle } from './card/card';
export { default as Checkbox } from './checkbox/checkbox'; export { default as Checkbox } from './checkbox/checkbox';
export { Column, ColumnHeader } from './column/column'; export { Column, ColumnHeader } from './column/column';
@ -38,6 +39,7 @@ export {
} from './menu/menu'; } from './menu/menu';
export { default as Modal } from './modal/modal'; export { default as Modal } from './modal/modal';
export { default as PhoneInput } from './phone-input/phone-input'; export { default as PhoneInput } from './phone-input/phone-input';
export { default as Popover } from './popover/popover';
export { default as Portal } from './portal/portal'; export { default as Portal } from './portal/portal';
export { default as ProgressBar } from './progress-bar/progress-bar'; export { default as ProgressBar } from './progress-bar/progress-bar';
export { default as RadioButton } from './radio-button/radio-button'; export { default as RadioButton } from './radio-button/radio-button';

Wyświetl plik

@ -84,8 +84,10 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
type={revealed ? 'text' : type} type={revealed ? 'text' : type}
ref={ref} ref={ref}
className={clsx('text-base placeholder:text-gray-600 dark:placeholder:text-gray-600', { className={clsx('text-base placeholder:text-gray-600 dark:placeholder:text-gray-600', {
'text-gray-900 dark:text-gray-100 block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500': 'block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
['normal', 'search'].includes(theme), ['normal', 'search'].includes(theme),
'text-gray-900 dark:text-gray-100': !props.disabled,
'text-gray-600': props.disabled,
'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal', 'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal',
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search', 'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search',
'pr-7 rtl:pl-7 rtl:pr-3': isPassword || append, 'pr-7 rtl:pl-7 rtl:pr-3': isPassword || append,

Wyświetl plik

@ -0,0 +1,120 @@
import {
arrow,
autoPlacement,
FloatingArrow,
offset,
useClick,
useDismiss,
useFloating,
useHover,
useInteractions,
useTransitionStyles,
} from '@floating-ui/react';
import clsx from 'clsx';
import React, { useRef, useState } from 'react';
import Portal from '../portal/portal';
interface IPopover {
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>
/** The content of the popover */
content: React.ReactNode
/** Should we remove padding on the Popover */
isFlush?: boolean
/** Should the popover trigger via click or hover */
interaction?: 'click' | 'hover'
/** Add a class to the reference (trigger) element */
referenceElementClassName?: string
}
/**
* Popover
*
* Similar to tooltip, but requires a click and is used for larger blocks
* of information.
*/
const Popover: React.FC<IPopover> = (props) => {
const { children, content, referenceElementClassName, interaction = 'hover', isFlush = false } = props;
const [isOpen, setIsOpen] = useState<boolean>(false);
const arrowRef = useRef<SVGSVGElement>(null);
const { x, y, strategy, refs, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: 'top',
middleware: [
autoPlacement({
allowedPlacements: ['top', 'bottom'],
}),
offset(10),
arrow({
element: arrowRef,
}),
],
});
const { isMounted, styles } = useTransitionStyles(context, {
initial: {
opacity: 0,
transform: 'scale(0.8)',
},
duration: {
open: 200,
close: 200,
},
});
const click = useClick(context, { enabled: interaction === 'click' });
const hover = useHover(context, { enabled: interaction === 'hover' });
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
hover,
dismiss,
]);
return (
<>
{React.cloneElement(children, {
ref: refs.setReference,
...getReferenceProps(),
className: clsx(children.props.className, referenceElementClassName),
})}
{(isMounted) && (
<Portal>
<div
ref={refs.setFloating}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
...styles,
}}
className={
clsx({
'z-40 rounded-lg bg-white shadow-2xl dark:bg-gray-900 dark:ring-2 dark:ring-primary-700': true,
'p-6': !isFlush,
})
}
{...getFloatingProps()}
>
{content}
<FloatingArrow
ref={arrowRef}
context={context}
className='-ml-2 fill-white dark:hidden' /** -ml-2 to fix offcenter arrow */
tipRadius={3}
/>
</div>
</Portal>
)}
</>
);
};
export default Popover;

Wyświetl plik

@ -11,6 +11,7 @@ const spaces = {
4: 'space-y-4', 4: 'space-y-4',
5: 'space-y-5', 5: 'space-y-5',
6: 'space-y-6', 6: 'space-y-6',
9: 'space-y-9',
10: 'space-y-10', 10: 'space-y-10',
}; };

Wyświetl plik

@ -33,6 +33,8 @@ interface IStreamfield {
onChange: (values: any[]) => void onChange: (values: any[]) => void
/** Input to render for each value. */ /** Input to render for each value. */
component: StreamfieldComponent<any> component: StreamfieldComponent<any>
/** Minimum number of allowed inputs. */
minItems?: number
/** Maximum number of allowed inputs. */ /** Maximum number of allowed inputs. */
maxItems?: number maxItems?: number
} }
@ -47,6 +49,7 @@ const Streamfield: React.FC<IStreamfield> = ({
onChange, onChange,
component: Component, component: Component,
maxItems = Infinity, maxItems = Infinity,
minItems = 0,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@ -67,10 +70,10 @@ const Streamfield: React.FC<IStreamfield> = ({
{(values.length > 0) && ( {(values.length > 0) && (
<Stack> <Stack>
{values.map((value, i) => ( {values.map((value, i) => value?._destroy ? null : (
<HStack space={2} alignItems='center'> <HStack space={2} alignItems='center'>
<Component key={i} onChange={handleChange(i)} value={value} /> <Component key={i} onChange={handleChange(i)} value={value} />
{onRemoveItem && ( {values.length > minItems && onRemoveItem && (
<IconButton <IconButton
iconClassName='h-4 w-4' iconClassName='h-4 w-4'
className='bg-transparent text-gray-400 hover:text-gray-600' className='bg-transparent text-gray-400 hover:text-gray-600'

Wyświetl plik

@ -156,7 +156,7 @@ const Tabs = ({ items, activeItem }: ITabs) => {
> >
<div className='relative'> <div className='relative'>
{count ? ( {count ? (
<span className='absolute -top-2 left-full ml-1'> <span className='absolute left-full ml-2'>
<Counter count={count} /> <Counter count={count} />
</span> </span>
) : null} ) : null}

Wyświetl plik

@ -1,5 +1,9 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import Stack from '../stack/stack';
import Text from '../text/text';
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> { interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
/** Put the cursor into the input on mount. */ /** Put the cursor into the input on mount. */
@ -28,6 +32,8 @@ interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElemen
isResizeable?: boolean isResizeable?: boolean
/** Textarea theme. */ /** Textarea theme. */
theme?: 'default' | 'transparent' theme?: 'default' | 'transparent'
/** Whether to display a character counter below the textarea. */
withCounter?: boolean
} }
/** Textarea with custom styles. */ /** Textarea with custom styles. */
@ -40,8 +46,11 @@ const Textarea = React.forwardRef(({
maxRows = 10, maxRows = 10,
minRows = 1, minRows = 1,
theme = 'default', theme = 'default',
maxLength,
value,
...props ...props
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => { }: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
const length = value?.length || 0;
const [rows, setRows] = useState<number>(autoGrow ? 1 : 4); const [rows, setRows] = useState<number>(autoGrow ? 1 : 4);
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => { const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
@ -70,20 +79,35 @@ const Textarea = React.forwardRef(({
}; };
return ( return (
<textarea <Stack space={1.5}>
{...props} <textarea
ref={ref} {...props}
rows={rows} value={value}
onChange={handleChange} ref={ref}
className={clsx('block w-full rounded-md text-gray-900 placeholder:text-gray-600 dark:text-gray-100 dark:placeholder:text-gray-600 sm:text-sm', { rows={rows}
'bg-white dark:bg-transparent shadow-sm border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500': onChange={handleChange}
theme === 'default', className={clsx('block w-full rounded-md text-gray-900 placeholder:text-gray-600 dark:text-gray-100 dark:placeholder:text-gray-600 sm:text-sm', {
'bg-transparent border-0 focus:border-0 focus:ring-0': theme === 'transparent', 'bg-white dark:bg-transparent shadow-sm border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
'font-mono': isCodeEditor, theme === 'default',
'text-red-600 border-red-600': hasError, 'bg-transparent border-0 focus:border-0 focus:ring-0': theme === 'transparent',
'resize-none': !isResizeable, 'font-mono': isCodeEditor,
})} 'text-red-600 border-red-600': hasError,
/> 'resize-none': !isResizeable,
})}
/>
{maxLength && (
<div className='text-right rtl:text-left'>
<Text size='xs' theme={maxLength - length < 0 ? 'danger' : 'muted'}>
<FormattedMessage
id='textarea.counter.label'
defaultMessage='{count} characters remaining'
values={{ count: maxLength - length }}
/>
</Text>
</div>
)}
</Stack>
); );
}, },
); );

Wyświetl plik

@ -0,0 +1,209 @@
import {
deleteEntities,
dismissEntities,
entitiesFetchFail,
entitiesFetchRequest,
entitiesFetchSuccess,
importEntities,
incrementEntities,
} from '../actions';
import reducer, { State } from '../reducer';
import { createListState } from '../utils';
import type { EntityCache } from '../types';
interface TestEntity {
id: string
msg: string
}
test('import entities', () => {
const entities: TestEntity[] = [
{ id: '1', msg: 'yolo' },
{ id: '2', msg: 'benis' },
{ id: '3', msg: 'boop' },
];
const action = importEntities(entities, 'TestEntity');
const result = reducer(undefined, action);
const cache = result.TestEntity as EntityCache<TestEntity>;
expect(cache.store['1']!.msg).toBe('yolo');
expect(Object.values(cache.lists).length).toBe(0);
});
test('import entities into a list', () => {
const entities: TestEntity[] = [
{ id: '1', msg: 'yolo' },
{ id: '2', msg: 'benis' },
{ id: '3', msg: 'boop' },
];
const action = importEntities(entities, 'TestEntity', 'thingies');
const result = reducer(undefined, action);
const cache = result.TestEntity as EntityCache<TestEntity>;
expect(cache.store['2']!.msg).toBe('benis');
expect(cache.lists.thingies!.ids.size).toBe(3);
expect(cache.lists.thingies!.state.totalCount).toBe(3);
// Now try adding an additional item.
const entities2: TestEntity[] = [
{ id: '4', msg: 'hehe' },
];
const action2 = importEntities(entities2, 'TestEntity', 'thingies');
const result2 = reducer(result, action2);
const cache2 = result2.TestEntity as EntityCache<TestEntity>;
expect(cache2.store['4']!.msg).toBe('hehe');
expect(cache2.lists.thingies!.ids.size).toBe(4);
expect(cache2.lists.thingies!.state.totalCount).toBe(4);
// Finally, update an item.
const entities3: TestEntity[] = [
{ id: '2', msg: 'yolofam' },
];
const action3 = importEntities(entities3, 'TestEntity', 'thingies');
const result3 = reducer(result2, action3);
const cache3 = result3.TestEntity as EntityCache<TestEntity>;
expect(cache3.store['2']!.msg).toBe('yolofam');
expect(cache3.lists.thingies!.ids.size).toBe(4); // unchanged
expect(cache3.lists.thingies!.state.totalCount).toBe(4);
});
test('fetching updates the list state', () => {
const action = entitiesFetchRequest('TestEntity', 'thingies');
const result = reducer(undefined, action);
expect(result.TestEntity!.lists.thingies!.state.fetching).toBe(true);
});
test('failure adds the error to the state', () => {
const error = new Error('whoopsie');
const action = entitiesFetchFail('TestEntity', 'thingies', error);
const result = reducer(undefined, action);
expect(result.TestEntity!.lists.thingies!.state.error).toBe(error);
});
test('import entities with override', () => {
const state: State = {
TestEntity: {
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
lists: {
thingies: {
ids: new Set(['1', '2', '3']),
state: { ...createListState(), totalCount: 3 },
},
},
},
};
const entities: TestEntity[] = [
{ id: '4', msg: 'yolo' },
{ id: '5', msg: 'benis' },
];
const now = new Date();
const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', {
next: undefined,
prev: undefined,
totalCount: 2,
error: null,
fetched: true,
fetching: false,
lastFetchedAt: now,
invalid: false,
}, true);
const result = reducer(state, action);
const cache = result.TestEntity as EntityCache<TestEntity>;
expect([...cache.lists.thingies!.ids]).toEqual(['4', '5']);
expect(cache.lists.thingies!.state.lastFetchedAt).toBe(now); // Also check that newState worked
});
test('deleting items', () => {
const state: State = {
TestEntity: {
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
lists: {
'': {
ids: new Set(['1', '2', '3']),
state: { ...createListState(), totalCount: 3 },
},
},
},
};
const action = deleteEntities(['3', '1'], 'TestEntity');
const result = reducer(state, action);
expect(result.TestEntity!.store).toMatchObject({ '2': { id: '2' } });
expect([...result.TestEntity!.lists['']!.ids]).toEqual(['2']);
expect(result.TestEntity!.lists['']!.state.totalCount).toBe(1);
});
test('dismiss items', () => {
const state: State = {
TestEntity: {
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
lists: {
yolo: {
ids: new Set(['1', '2', '3']),
state: { ...createListState(), totalCount: 3 },
},
},
},
};
const action = dismissEntities(['3', '1'], 'TestEntity', 'yolo');
const result = reducer(state, action);
expect(result.TestEntity!.store).toMatchObject(state.TestEntity!.store);
expect([...result.TestEntity!.lists.yolo!.ids]).toEqual(['2']);
expect(result.TestEntity!.lists.yolo!.state.totalCount).toBe(1);
});
test('increment items', () => {
const state: State = {
TestEntity: {
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
lists: {
thingies: {
ids: new Set(['1', '2', '3']),
state: { ...createListState(), totalCount: 3 },
},
},
},
};
const action = incrementEntities('TestEntity', 'thingies', 1);
const result = reducer(state, action);
expect(result.TestEntity!.lists.thingies!.state.totalCount).toBe(4);
});
test('decrement items', () => {
const state: State = {
TestEntity: {
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
lists: {
thingies: {
ids: new Set(['1', '2', '3']),
state: { ...createListState(), totalCount: 3 },
},
},
},
};
const action = incrementEntities('TestEntity', 'thingies', -1);
const result = reducer(state, action);
expect(result.TestEntity!.lists.thingies!.state.totalCount).toBe(2);
});

Wyświetl plik

@ -0,0 +1,126 @@
import type { Entity, EntityListState } from './types';
const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const;
const ENTITIES_DELETE = 'ENTITIES_DELETE' as const;
const ENTITIES_DISMISS = 'ENTITIES_DISMISS' as const;
const ENTITIES_INCREMENT = 'ENTITIES_INCREMENT' as const;
const ENTITIES_FETCH_REQUEST = 'ENTITIES_FETCH_REQUEST' as const;
const ENTITIES_FETCH_SUCCESS = 'ENTITIES_FETCH_SUCCESS' as const;
const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const;
const ENTITIES_INVALIDATE_LIST = 'ENTITIES_INVALIDATE_LIST' as const;
/** Action to import entities into the cache. */
function importEntities(entities: Entity[], entityType: string, listKey?: string) {
return {
type: ENTITIES_IMPORT,
entityType,
entities,
listKey,
};
}
interface DeleteEntitiesOpts {
preserveLists?: boolean
}
function deleteEntities(ids: Iterable<string>, entityType: string, opts: DeleteEntitiesOpts = {}) {
return {
type: ENTITIES_DELETE,
ids,
entityType,
opts,
};
}
function dismissEntities(ids: Iterable<string>, entityType: string, listKey: string) {
return {
type: ENTITIES_DISMISS,
ids,
entityType,
listKey,
};
}
function incrementEntities(entityType: string, listKey: string, diff: number) {
return {
type: ENTITIES_INCREMENT,
entityType,
listKey,
diff,
};
}
function entitiesFetchRequest(entityType: string, listKey?: string) {
return {
type: ENTITIES_FETCH_REQUEST,
entityType,
listKey,
};
}
function entitiesFetchSuccess(
entities: Entity[],
entityType: string,
listKey?: string,
newState?: EntityListState,
overwrite = false,
) {
return {
type: ENTITIES_FETCH_SUCCESS,
entityType,
entities,
listKey,
newState,
overwrite,
};
}
function entitiesFetchFail(entityType: string, listKey: string | undefined, error: any) {
return {
type: ENTITIES_FETCH_FAIL,
entityType,
listKey,
error,
};
}
function invalidateEntityList(entityType: string, listKey: string) {
return {
type: ENTITIES_INVALIDATE_LIST,
entityType,
listKey,
};
}
/** Any action pertaining to entities. */
type EntityAction =
ReturnType<typeof importEntities>
| ReturnType<typeof deleteEntities>
| ReturnType<typeof dismissEntities>
| ReturnType<typeof incrementEntities>
| ReturnType<typeof entitiesFetchRequest>
| ReturnType<typeof entitiesFetchSuccess>
| ReturnType<typeof entitiesFetchFail>
| ReturnType<typeof invalidateEntityList>;
export {
ENTITIES_IMPORT,
ENTITIES_DELETE,
ENTITIES_DISMISS,
ENTITIES_INCREMENT,
ENTITIES_FETCH_REQUEST,
ENTITIES_FETCH_SUCCESS,
ENTITIES_FETCH_FAIL,
ENTITIES_INVALIDATE_LIST,
importEntities,
deleteEntities,
dismissEntities,
incrementEntities,
entitiesFetchRequest,
entitiesFetchSuccess,
entitiesFetchFail,
invalidateEntityList,
EntityAction,
};
export type { DeleteEntitiesOpts };

Wyświetl plik

@ -0,0 +1,6 @@
export enum Entities {
ACCOUNTS = 'Accounts',
GROUPS = 'Groups',
GROUP_RELATIONSHIPS = 'GroupRelationships',
GROUP_MEMBERSHIPS = 'GroupMemberships',
}

Wyświetl plik

@ -0,0 +1,7 @@
export { useEntities } from './useEntities';
export { useEntity } from './useEntity';
export { useEntityActions } from './useEntityActions';
export { useCreateEntity } from './useCreateEntity';
export { useDeleteEntity } from './useDeleteEntity';
export { useDismissEntity } from './useDismissEntity';
export { useIncrementEntity } from './useIncrementEntity';

Wyświetl plik

@ -0,0 +1,47 @@
import type { Entity } from '../types';
import type { AxiosResponse } from 'axios';
import type z from 'zod';
type EntitySchema<TEntity extends Entity = Entity> = z.ZodType<TEntity, z.ZodTypeDef, any>;
/**
* Tells us where to find/store the entity in the cache.
* This value is accepted in hooks, but needs to be parsed into an `EntitiesPath`
* before being passed to the store.
*/
type ExpandedEntitiesPath = [
/** Name of the entity type for use in the global cache, eg `'Notification'`. */
entityType: string,
/**
* Name of a particular index of this entity type.
* Multiple params get combined into one string with a `:` separator.
*/
...listKeys: string[],
]
/** Used to look up an entity in a list. */
type EntitiesPath = [entityType: string, listKey: string]
/** Used to look up a single entity by its ID. */
type EntityPath = [entityType: string, entityId: string]
/** Callback functions for entity actions. */
interface EntityCallbacks<Value, Error = unknown> {
onSuccess?(value: Value): void
onError?(error: Error): void
}
/**
* Passed into hooks to make requests.
* Must return an Axios response.
*/
type EntityFn<T> = (value: T) => Promise<AxiosResponse>
export type {
EntitySchema,
ExpandedEntitiesPath,
EntitiesPath,
EntityPath,
EntityCallbacks,
EntityFn,
};

Wyświetl plik

@ -0,0 +1,51 @@
import { z } from 'zod';
import { useAppDispatch, useLoading } from 'soapbox/hooks';
import { importEntities } from '../actions';
import { parseEntitiesPath } from './utils';
import type { Entity } from '../types';
import type { EntityCallbacks, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
interface UseCreateEntityOpts<TEntity extends Entity = Entity> {
schema?: EntitySchema<TEntity>
}
function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
expandedPath: ExpandedEntitiesPath,
entityFn: EntityFn<Data>,
opts: UseCreateEntityOpts<TEntity> = {},
) {
const dispatch = useAppDispatch();
const [isLoading, setPromise] = useLoading();
const { entityType, listKey } = parseEntitiesPath(expandedPath);
async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity> = {}): Promise<void> {
try {
const result = await setPromise(entityFn(data));
const schema = opts.schema || z.custom<TEntity>();
const entity = schema.parse(result.data);
// TODO: optimistic updating
dispatch(importEntities([entity], entityType, listKey));
if (callbacks.onSuccess) {
callbacks.onSuccess(entity);
}
} catch (error) {
if (callbacks.onError) {
callbacks.onError(error);
}
}
}
return {
createEntity,
isLoading,
};
}
export { useCreateEntity };

Wyświetl plik

@ -0,0 +1,54 @@
import { useAppDispatch, useGetState, useLoading } from 'soapbox/hooks';
import { deleteEntities, importEntities } from '../actions';
import type { EntityCallbacks, EntityFn } from './types';
/**
* Optimistically deletes an entity from the store.
* This hook should be used to globally delete an entity from all lists.
* To remove an entity from a single list, see `useDismissEntity`.
*/
function useDeleteEntity(
entityType: string,
entityFn: EntityFn<string>,
) {
const dispatch = useAppDispatch();
const getState = useGetState();
const [isLoading, setPromise] = useLoading();
async function deleteEntity(entityId: string, callbacks: EntityCallbacks<string> = {}): Promise<void> {
// Get the entity before deleting, so we can reverse the action if the API request fails.
const entity = getState().entities[entityType]?.store[entityId];
// Optimistically delete the entity from the _store_ but keep the lists in tact.
dispatch(deleteEntities([entityId], entityType, { preserveLists: true }));
try {
await setPromise(entityFn(entityId));
// Success - finish deleting entity from the state.
dispatch(deleteEntities([entityId], entityType));
if (callbacks.onSuccess) {
callbacks.onSuccess(entityId);
}
} catch (e) {
if (entity) {
// If the API failed, reimport the entity.
dispatch(importEntities([entity], entityType));
}
if (callbacks.onError) {
callbacks.onError(e);
}
}
}
return {
deleteEntity,
isLoading,
};
}
export { useDeleteEntity };

Wyświetl plik

@ -0,0 +1,32 @@
import { useAppDispatch, useLoading } from 'soapbox/hooks';
import { dismissEntities } from '../actions';
import { parseEntitiesPath } from './utils';
import type { EntityFn, ExpandedEntitiesPath } from './types';
/**
* Removes an entity from a specific list.
* To remove an entity globally from all lists, see `useDeleteEntity`.
*/
function useDismissEntity(expandedPath: ExpandedEntitiesPath, entityFn: EntityFn<string>) {
const dispatch = useAppDispatch();
const [isLoading, setPromise] = useLoading();
const { entityType, listKey } = parseEntitiesPath(expandedPath);
// TODO: optimistic dismissing
async function dismissEntity(entityId: string) {
const result = await setPromise(entityFn(entityId));
dispatch(dismissEntities([entityId], entityType, listKey));
return result;
}
return {
dismissEntity,
isLoading,
};
}
export { useDismissEntity };

Wyświetl plik

@ -0,0 +1,177 @@
import { useEffect } from 'react';
import z from 'zod';
import { getNextLink, getPrevLink } from 'soapbox/api';
import { useApi, useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks';
import { filteredArray } from 'soapbox/schemas/utils';
import { realNumberSchema } from 'soapbox/utils/numbers';
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions';
import { parseEntitiesPath } from './utils';
import type { Entity, EntityListState } from '../types';
import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
import type { RootState } from 'soapbox/store';
/** Additional options for the hook. */
interface UseEntitiesOpts<TEntity extends Entity> {
/** A zod schema to parse the API entities. */
schema?: EntitySchema<TEntity>
/**
* Time (milliseconds) until this query becomes stale and should be refetched.
* It is 1 minute by default, and can be set to `Infinity` to opt-out of automatic fetching.
*/
staleTime?: number
/** A flag to potentially disable sending requests to the API. */
enabled?: boolean
}
/** A hook for fetching and displaying API entities. */
function useEntities<TEntity extends Entity>(
/** Tells us where to find/store the entity in the cache. */
expandedPath: ExpandedEntitiesPath,
/** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */
entityFn: EntityFn<void>,
/** Additional options for the hook. */
opts: UseEntitiesOpts<TEntity> = {},
) {
const api = useApi();
const dispatch = useAppDispatch();
const getState = useGetState();
const { entityType, listKey, path } = parseEntitiesPath(expandedPath);
const entities = useAppSelector(state => selectEntities<TEntity>(state, path));
const isEnabled = opts.enabled ?? true;
const isFetching = useListState(path, 'fetching');
const lastFetchedAt = useListState(path, 'lastFetchedAt');
const isFetched = useListState(path, 'fetched');
const isError = !!useListState(path, 'error');
const totalCount = useListState(path, 'totalCount');
const isInvalid = useListState(path, 'invalid');
const next = useListState(path, 'next');
const prev = useListState(path, 'prev');
const fetchPage = async(req: EntityFn<void>, overwrite = false): Promise<void> => {
// Get `isFetching` state from the store again to prevent race conditions.
const isFetching = selectListState(getState(), path, 'fetching');
if (isFetching) return;
dispatch(entitiesFetchRequest(entityType, listKey));
try {
const response = await req();
const schema = opts.schema || z.custom<TEntity>();
const entities = filteredArray(schema).parse(response.data);
const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']);
dispatch(entitiesFetchSuccess(entities, entityType, listKey, {
next: getNextLink(response),
prev: getPrevLink(response),
totalCount: parsedCount.success ? parsedCount.data : undefined,
fetching: false,
fetched: true,
error: null,
lastFetchedAt: new Date(),
invalid: false,
}, overwrite));
} catch (error) {
dispatch(entitiesFetchFail(entityType, listKey, error));
}
};
const fetchEntities = async(): Promise<void> => {
await fetchPage(entityFn, true);
};
const fetchNextPage = async(): Promise<void> => {
if (next) {
await fetchPage(() => api.get(next));
}
};
const fetchPreviousPage = async(): Promise<void> => {
if (prev) {
await fetchPage(() => api.get(prev));
}
};
const invalidate = () => {
dispatch(invalidateEntityList(entityType, listKey));
};
const staleTime = opts.staleTime ?? 60000;
useEffect(() => {
if (!isEnabled) return;
if (isFetching) return;
const isUnset = !lastFetchedAt;
const isStale = lastFetchedAt ? Date.now() >= lastFetchedAt.getTime() + staleTime : false;
if (isInvalid || isUnset || isStale) {
fetchEntities();
}
}, [isEnabled]);
return {
entities,
fetchEntities,
fetchNextPage,
fetchPreviousPage,
hasNextPage: !!next,
hasPreviousPage: !!prev,
totalCount,
isError,
isFetched,
isFetching,
isLoading: isFetching && entities.length === 0,
invalidate,
/** The `X-Total-Count` from the API if available, or the length of items in the store. */
count: typeof totalCount === 'number' ? totalCount : entities.length,
};
}
/** Get cache at path from Redux. */
const selectCache = (state: RootState, path: EntitiesPath) => state.entities[path[0]];
/** Get list at path from Redux. */
const selectList = (state: RootState, path: EntitiesPath) => {
const [, ...listKeys] = path;
const listKey = listKeys.join(':');
return selectCache(state, path)?.lists[listKey];
};
/** Select a particular item from a list state. */
function selectListState<K extends keyof EntityListState>(state: RootState, path: EntitiesPath, key: K) {
const listState = selectList(state, path)?.state;
return listState ? listState[key] : undefined;
}
/** Hook to get a particular item from a list state. */
function useListState<K extends keyof EntityListState>(path: EntitiesPath, key: K) {
return useAppSelector(state => selectListState(state, path, key));
}
/** Get list of entities from Redux. */
function selectEntities<TEntity extends Entity>(state: RootState, path: EntitiesPath): readonly TEntity[] {
const cache = selectCache(state, path);
const list = selectList(state, path);
const entityIds = list?.ids;
return entityIds ? (
Array.from(entityIds).reduce<TEntity[]>((result, id) => {
const entity = cache?.store[id];
if (entity) {
result.push(entity as TEntity);
}
return result;
}, [])
) : [];
}
export {
useEntities,
};

Wyświetl plik

@ -0,0 +1,62 @@
import { useEffect } from 'react';
import z from 'zod';
import { useAppDispatch, useAppSelector, useLoading } from 'soapbox/hooks';
import { importEntities } from '../actions';
import type { Entity } from '../types';
import type { EntitySchema, EntityPath, EntityFn } from './types';
/** Additional options for the hook. */
interface UseEntityOpts<TEntity extends Entity> {
/** A zod schema to parse the API entity. */
schema?: EntitySchema<TEntity>
/** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */
refetch?: boolean
}
function useEntity<TEntity extends Entity>(
path: EntityPath,
entityFn: EntityFn<void>,
opts: UseEntityOpts<TEntity> = {},
) {
const [isFetching, setPromise] = useLoading();
const dispatch = useAppDispatch();
const [entityType, entityId] = path;
const defaultSchema = z.custom<TEntity>();
const schema = opts.schema || defaultSchema;
const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined);
const isLoading = isFetching && !entity;
const fetchEntity = async () => {
try {
const response = await setPromise(entityFn());
const entity = schema.parse(response.data);
dispatch(importEntities([entity], entityType));
} catch (e) {
// do nothing
}
};
useEffect(() => {
if (!entity || opts.refetch) {
fetchEntity();
}
}, []);
return {
entity,
fetchEntity,
isFetching,
isLoading,
};
}
export {
useEntity,
};

Wyświetl plik

@ -0,0 +1,40 @@
import { useApi } from 'soapbox/hooks';
import { useCreateEntity } from './useCreateEntity';
import { useDeleteEntity } from './useDeleteEntity';
import { parseEntitiesPath } from './utils';
import type { Entity } from '../types';
import type { EntitySchema, ExpandedEntitiesPath } from './types';
interface UseEntityActionsOpts<TEntity extends Entity = Entity> {
schema?: EntitySchema<TEntity>
}
interface EntityActionEndpoints {
post?: string
delete?: string
}
function useEntityActions<TEntity extends Entity = Entity, Data = any>(
expandedPath: ExpandedEntitiesPath,
endpoints: EntityActionEndpoints,
opts: UseEntityActionsOpts<TEntity> = {},
) {
const api = useApi();
const { entityType, path } = parseEntitiesPath(expandedPath);
const { deleteEntity, isLoading: deleteLoading } =
useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId)));
const { createEntity, isLoading: createLoading } =
useCreateEntity<TEntity, Data>(path, (data) => api.post(endpoints.post!, data), opts);
return {
createEntity,
deleteEntity,
isLoading: createLoading || deleteLoading,
};
}
export { useEntityActions };

Wyświetl plik

@ -0,0 +1,37 @@
import { useAppDispatch, useLoading } from 'soapbox/hooks';
import { incrementEntities } from '../actions';
import { parseEntitiesPath } from './utils';
import type { EntityFn, ExpandedEntitiesPath } from './types';
/**
* Increases (or decreases) the `totalCount` in the entity list by the specified amount.
* This only works if the API returns an `X-Total-Count` header and your components read it.
*/
function useIncrementEntity(
expandedPath: ExpandedEntitiesPath,
diff: number,
entityFn: EntityFn<string>,
) {
const dispatch = useAppDispatch();
const [isLoading, setPromise] = useLoading();
const { entityType, listKey } = parseEntitiesPath(expandedPath);
async function incrementEntity(entityId: string): Promise<void> {
dispatch(incrementEntities(entityType, listKey, diff));
try {
await setPromise(entityFn(entityId));
} catch (e) {
dispatch(incrementEntities(entityType, listKey, diff * -1));
}
}
return {
incrementEntity,
isLoading,
};
}
export { useIncrementEntity };

Wyświetl plik

@ -0,0 +1,16 @@
import type { EntitiesPath, ExpandedEntitiesPath } from './types';
function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) {
const [entityType, ...listKeys] = expandedPath;
const listKey = (listKeys || []).join(':');
const path: EntitiesPath = [entityType, listKey];
return {
entityType,
listKey,
path,
};
}
export { parseEntitiesPath };

Wyświetl plik

@ -0,0 +1,183 @@
import produce, { enableMapSet } from 'immer';
import {
ENTITIES_IMPORT,
ENTITIES_DELETE,
ENTITIES_DISMISS,
ENTITIES_FETCH_REQUEST,
ENTITIES_FETCH_SUCCESS,
ENTITIES_FETCH_FAIL,
EntityAction,
ENTITIES_INVALIDATE_LIST,
ENTITIES_INCREMENT,
} from './actions';
import { createCache, createList, updateStore, updateList } from './utils';
import type { DeleteEntitiesOpts } from './actions';
import type { Entity, EntityCache, EntityListState } from './types';
enableMapSet();
/** Entity reducer state. */
interface State {
[entityType: string]: EntityCache | undefined
}
/** Import entities into the cache. */
const importEntities = (
state: State,
entityType: string,
entities: Entity[],
listKey?: string,
newState?: EntityListState,
overwrite = false,
): State => {
return produce(state, draft => {
const cache = draft[entityType] ?? createCache();
cache.store = updateStore(cache.store, entities);
if (typeof listKey === 'string') {
let list = cache.lists[listKey] ?? createList();
if (overwrite) {
list.ids = new Set();
}
list = updateList(list, entities);
if (newState) {
list.state = newState;
}
cache.lists[listKey] = list;
}
draft[entityType] = cache;
});
};
const deleteEntities = (
state: State,
entityType: string,
ids: Iterable<string>,
opts: DeleteEntitiesOpts,
) => {
return produce(state, draft => {
const cache = draft[entityType] ?? createCache();
for (const id of ids) {
delete cache.store[id];
if (!opts?.preserveLists) {
for (const list of Object.values(cache.lists)) {
if (list) {
list.ids.delete(id);
if (typeof list.state.totalCount === 'number') {
list.state.totalCount--;
}
}
}
}
}
draft[entityType] = cache;
});
};
const dismissEntities = (
state: State,
entityType: string,
ids: Iterable<string>,
listKey: string,
) => {
return produce(state, draft => {
const cache = draft[entityType] ?? createCache();
const list = cache.lists[listKey];
if (list) {
for (const id of ids) {
list.ids.delete(id);
if (typeof list.state.totalCount === 'number') {
list.state.totalCount--;
}
}
draft[entityType] = cache;
}
});
};
const incrementEntities = (
state: State,
entityType: string,
listKey: string,
diff: number,
) => {
return produce(state, draft => {
const cache = draft[entityType] ?? createCache();
const list = cache.lists[listKey];
if (typeof list?.state?.totalCount === 'number') {
list.state.totalCount += diff;
draft[entityType] = cache;
}
});
};
const setFetching = (
state: State,
entityType: string,
listKey: string | undefined,
isFetching: boolean,
error?: any,
) => {
return produce(state, draft => {
const cache = draft[entityType] ?? createCache();
if (typeof listKey === 'string') {
const list = cache.lists[listKey] ?? createList();
list.state.fetching = isFetching;
list.state.error = error;
cache.lists[listKey] = list;
}
draft[entityType] = cache;
});
};
const invalidateEntityList = (state: State, entityType: string, listKey: string) => {
return produce(state, draft => {
const cache = draft[entityType] ?? createCache();
const list = cache.lists[listKey] ?? createList();
list.state.invalid = true;
});
};
/** Stores various entity data and lists in a one reducer. */
function reducer(state: Readonly<State> = {}, action: EntityAction): State {
switch (action.type) {
case ENTITIES_IMPORT:
return importEntities(state, action.entityType, action.entities, action.listKey);
case ENTITIES_DELETE:
return deleteEntities(state, action.entityType, action.ids, action.opts);
case ENTITIES_DISMISS:
return dismissEntities(state, action.entityType, action.ids, action.listKey);
case ENTITIES_INCREMENT:
return incrementEntities(state, action.entityType, action.listKey, action.diff);
case ENTITIES_FETCH_SUCCESS:
return importEntities(state, action.entityType, action.entities, action.listKey, action.newState, action.overwrite);
case ENTITIES_FETCH_REQUEST:
return setFetching(state, action.entityType, action.listKey, true);
case ENTITIES_FETCH_FAIL:
return setFetching(state, action.entityType, action.listKey, false, action.error);
case ENTITIES_INVALIDATE_LIST:
return invalidateEntityList(state, action.entityType, action.listKey);
default:
return state;
}
}
export default reducer;
export type { State };

Wyświetl plik

@ -0,0 +1,56 @@
/** A Mastodon API entity. */
interface Entity {
/** Unique ID for the entity (usually the primary key in the database). */
id: string
}
/** Store of entities by ID. */
interface EntityStore<TEntity extends Entity = Entity> {
[id: string]: TEntity | undefined
}
/** List of entity IDs and fetch state. */
interface EntityList {
/** Set of entity IDs in this list. */
ids: Set<string>
/** Server state for this entity list. */
state: EntityListState
}
/** Fetch state for an entity list. */
interface EntityListState {
/** Next URL for pagination, if any. */
next: string | undefined
/** Previous URL for pagination, if any. */
prev: string | undefined
/** Total number of items according to the API. */
totalCount: number | undefined
/** Error returned from the API, if any. */
error: any
/** Whether data has already been fetched */
fetched: boolean
/** Whether data for this list is currently being fetched. */
fetching: boolean
/** Date of the last API fetch for this list. */
lastFetchedAt: Date | undefined
/** Whether the entities should be refetched on the next component mount. */
invalid: boolean
}
/** Cache data pertaining to a paritcular entity type.. */
interface EntityCache<TEntity extends Entity = Entity> {
/** Map of entities of this type. */
store: EntityStore<TEntity>
/** Lists of entity IDs for a particular purpose. */
lists: {
[listKey: string]: EntityList | undefined
}
}
export {
Entity,
EntityStore,
EntityList,
EntityListState,
EntityCache,
};

Wyświetl plik

@ -0,0 +1,57 @@
import type { Entity, EntityStore, EntityList, EntityCache, EntityListState } from './types';
/** Insert the entities into the store. */
const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => {
return entities.reduce<EntityStore>((store, entity) => {
store[entity.id] = entity;
return store;
}, { ...store });
};
/** Update the list with new entity IDs. */
const updateList = (list: EntityList, entities: Entity[]): EntityList => {
const newIds = entities.map(entity => entity.id);
const ids = new Set([...Array.from(list.ids), ...newIds]);
if (typeof list.state.totalCount === 'number') {
const sizeDiff = ids.size - list.ids.size;
list.state.totalCount += sizeDiff;
}
return {
...list,
ids,
};
};
/** Create an empty entity cache. */
const createCache = (): EntityCache => ({
store: {},
lists: {},
});
/** Create an empty entity list. */
const createList = (): EntityList => ({
ids: new Set(),
state: createListState(),
});
/** Create an empty entity list state. */
const createListState = (): EntityListState => ({
next: undefined,
prev: undefined,
totalCount: 0,
error: null,
fetched: false,
fetching: false,
lastFetchedAt: undefined,
invalid: false,
});
export {
updateStore,
updateList,
createCache,
createList,
createListState,
};

Wyświetl plik

@ -103,7 +103,7 @@ const MediaItem: React.FC<IMediaItem> = ({ attachment, onOpenMedia }) => {
} else if (attachment.type === 'audio') { } else if (attachment.type === 'audio') {
const remoteURL = attachment.remote_url || ''; const remoteURL = attachment.remote_url || '';
const fileExtensionLastIndex = remoteURL.lastIndexOf('.'); const fileExtensionLastIndex = remoteURL.lastIndexOf('.');
const fileExtension = remoteURL.substr(fileExtensionLastIndex + 1).toUpperCase(); const fileExtension = remoteURL.slice(fileExtensionLastIndex + 1).toUpperCase();
thumbnail = ( thumbnail = (
<div className='media-gallery__item-thumbnail'> <div className='media-gallery__item-thumbnail'>
<span className='media-gallery__item__icons'><Icon src={require('@tabler/icons/volume.svg')} /></span> <span className='media-gallery__item__icons'><Icon src={require('@tabler/icons/volume.svg')} /></span>

Wyświetl plik

@ -121,7 +121,7 @@ const AccountGallery = () => {
let loadOlder = null; let loadOlder = null;
if (hasMore && !(isLoading && attachments.size === 0)) { if (hasMore && !(isLoading && attachments.size === 0)) {
loadOlder = <LoadMore visible={!isLoading} onClick={handleLoadOlder} />; loadOlder = <LoadMore className='my-auto' visible={!isLoading} onClick={handleLoadOlder} />;
} }
if (unavailable) { if (unavailable) {

Wyświetl plik

@ -12,7 +12,7 @@ import { mentionCompose, directCompose } from 'soapbox/actions/compose';
import { blockDomain, unblockDomain } from 'soapbox/actions/domain-blocks'; import { blockDomain, unblockDomain } from 'soapbox/actions/domain-blocks';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { initMuteModal } from 'soapbox/actions/mutes'; import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport } from 'soapbox/actions/reports'; import { initReport, ReportableEntities } from 'soapbox/actions/reports';
import { setSearchAccount } from 'soapbox/actions/search'; import { setSearchAccount } from 'soapbox/actions/search';
import { getSettings } from 'soapbox/actions/settings'; import { getSettings } from 'soapbox/actions/settings';
import Badge from 'soapbox/components/badge'; import Badge from 'soapbox/components/badge';
@ -136,7 +136,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
secondary: intl.formatMessage(messages.blockAndReport), secondary: intl.formatMessage(messages.blockAndReport),
onSecondary: () => { onSecondary: () => {
dispatch(blockAccount(account.id)); dispatch(blockAccount(account.id));
dispatch(initReport(account)); dispatch(initReport(ReportableEntities.ACCOUNT, account));
}, },
})); }));
} }
@ -171,7 +171,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
}; };
const onReport = () => { const onReport = () => {
dispatch(initReport(account)); dispatch(initReport(ReportableEntities.ACCOUNT, account));
}; };
const onMute = () => { const onMute = () => {

Wyświetl plik

@ -1,17 +1,10 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { approveUsers } from 'soapbox/actions/admin'; import { approveUsers, deleteUsers } from 'soapbox/actions/admin';
import { rejectUserModal } from 'soapbox/actions/moderation'; import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons';
import { Stack, HStack, Text, IconButton } from 'soapbox/components/ui'; import { Stack, HStack, Text } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors'; import { makeGetAccount } from 'soapbox/selectors';
import toast from 'soapbox/toast';
const messages = defineMessages({
approved: { id: 'admin.awaiting_approval.approved_message', defaultMessage: '{acct} was approved!' },
rejected: { id: 'admin.awaiting_approval.rejected_message', defaultMessage: '{acct} was rejected.' },
});
interface IUnapprovedAccount { interface IUnapprovedAccount {
accountId: string accountId: string
@ -19,7 +12,6 @@ interface IUnapprovedAccount {
/** Displays an unapproved account for moderation purposes. */ /** Displays an unapproved account for moderation purposes. */
const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => { const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const getAccount = useCallback(makeGetAccount(), []); const getAccount = useCallback(makeGetAccount(), []);
@ -28,21 +20,8 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
if (!account) return null; if (!account) return null;
const handleApprove = () => { const handleApprove = () => dispatch(approveUsers([account.id]));
dispatch(approveUsers([account.id])) const handleReject = () => dispatch(deleteUsers([account.id]));
.then(() => {
const message = intl.formatMessage(messages.approved, { acct: `@${account.acct}` });
toast.success(message);
})
.catch(() => {});
};
const handleReject = () => {
dispatch(rejectUserModal(intl, account.id, () => {
const message = intl.formatMessage(messages.rejected, { acct: `@${account.acct}` });
toast.info(message);
}));
};
return ( return (
<HStack space={4} justifyContent='between'> <HStack space={4} justifyContent='between'>
@ -55,20 +34,13 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
</Text> </Text>
</Stack> </Stack>
<HStack space={2} alignItems='center'> <Stack justifyContent='center'>
<IconButton <AuthorizeRejectButtons
src={require('@tabler/icons/check.svg')} onAuthorize={handleApprove}
onClick={handleApprove} onReject={handleReject}
theme='outlined' countdown={3000}
iconClassName='p-1 text-gray-600 dark:text-gray-400'
/> />
<IconButton </Stack>
src={require('@tabler/icons/x.svg')}
onClick={handleReject}
theme='outlined'
iconClassName='p-1 text-gray-600 dark:text-gray-400'
/>
</HStack>
</HStack> </HStack>
); );
}; };

Wyświetl plik

@ -499,13 +499,13 @@ const Audio: React.FC<IAudio> = (props) => {
<div <div
className='video-player__seek__progress' className='video-player__seek__progress'
style={{ width: `${progress}%`, backgroundColor: _getAccentColor() }} style={{ width: `${progress}%`, backgroundColor: accentColor }}
/> />
<span <span
className={clsx('video-player__seek__handle', { active: dragging })} className={clsx('video-player__seek__handle', { active: dragging })}
tabIndex={0} tabIndex={0}
style={{ left: `${progress}%`, backgroundColor: _getAccentColor() }} style={{ left: `${progress}%`, backgroundColor: accentColor }}
onKeyDown={handleAudioKeyDown} onKeyDown={handleAudioKeyDown}
/> />
</div> </div>

Wyświetl plik

@ -28,6 +28,7 @@ const messages = defineMessages({
newsletter: { id: 'registration.newsletter', defaultMessage: 'Subscribe to newsletter.' }, newsletter: { id: 'registration.newsletter', defaultMessage: 'Subscribe to newsletter.' },
needsConfirmationHeader: { id: 'confirmations.register.needs_confirmation.header', defaultMessage: 'Confirmation needed' }, needsConfirmationHeader: { id: 'confirmations.register.needs_confirmation.header', defaultMessage: 'Confirmation needed' },
needsApprovalHeader: { id: 'confirmations.register.needs_approval.header', defaultMessage: 'Approval needed' }, needsApprovalHeader: { id: 'confirmations.register.needs_approval.header', defaultMessage: 'Approval needed' },
reasonHint: { id: 'registration.reason_hint', defaultMessage: 'This will help us review your application' },
}); });
interface IRegistrationForm { interface IRegistrationForm {
@ -296,13 +297,14 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
{needsApproval && ( {needsApproval && (
<FormGroup <FormGroup
labelText={<FormattedMessage id='registration.reason' defaultMessage='Why do you want to join?' />} labelText={<FormattedMessage id='registration.reason' defaultMessage='Why do you want to join?' />}
hintText={<FormattedMessage id='registration.reason_hint' defaultMessage='This will help us review your application' />}
> >
<Textarea <Textarea
name='reason' name='reason'
placeholder={intl.formatMessage(messages.reasonHint)}
maxLength={500} maxLength={500}
onChange={onInputChange} onChange={onInputChange}
value={params.get('reason', '')} value={params.get('reason', '')}
autoGrow
required required
/> />
</FormGroup> </FormGroup>

Wyświetl plik

@ -3,7 +3,7 @@ import { defineMessages, FormattedDate, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security'; import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security';
import { Button, Card, CardBody, CardHeader, CardTitle, Column, Spinner, Stack, Text } from 'soapbox/components/ui'; import { Button, Card, CardBody, CardHeader, CardTitle, Column, HStack, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { Token } from 'soapbox/reducers/security'; import { Token } from 'soapbox/reducers/security';
@ -59,12 +59,11 @@ const AuthToken: React.FC<IAuthToken> = ({ token, isCurrent }) => {
</Text> </Text>
)} )}
</Stack> </Stack>
<HStack justifyContent='end'>
<div className='flex justify-end'>
<Button theme={isCurrent ? 'danger' : 'primary'} onClick={handleRevoke}> <Button theme={isCurrent ? 'danger' : 'primary'} onClick={handleRevoke}>
{intl.formatMessage(messages.revoke)} {intl.formatMessage(messages.revoke)}
</Button> </Button>
</div> </HStack>
</Stack> </Stack>
</div> </div>
); );

Wyświetl plik

@ -39,7 +39,7 @@ const Account: React.FC<IAccount> = ({ accountId }) => {
date: formattedBirthday, date: formattedBirthday,
})} })}
> >
<Icon src={require('@tabler/icons/ballon.svg')} /> <Icon src={require('@tabler/icons/balloon.svg')} />
{formattedBirthday} {formattedBirthday}
</div> </div>
</HStack> </HStack>

Wyświetl plik

@ -6,13 +6,15 @@ import { openModal } from 'soapbox/actions/modals';
import { Button, Combobox, ComboboxInput, ComboboxList, ComboboxOption, ComboboxPopover, HStack, IconButton, Stack, Text } from 'soapbox/components/ui'; import { Button, Combobox, ComboboxInput, ComboboxList, ComboboxOption, ComboboxPopover, HStack, IconButton, Stack, Text } from 'soapbox/components/ui';
import { useChatContext } from 'soapbox/contexts/chat-context'; import { useChatContext } from 'soapbox/contexts/chat-context';
import UploadButton from 'soapbox/features/compose/components/upload-button'; import UploadButton from 'soapbox/features/compose/components/upload-button';
import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light'; import emojiSearch from 'soapbox/features/emoji/search';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { Attachment } from 'soapbox/types/entities'; import { Attachment } from 'soapbox/types/entities';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import ChatTextarea from './chat-textarea'; import ChatTextarea from './chat-textarea';
import type { Emoji, NativeEmoji } from 'soapbox/features/emoji';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'chat.input.placeholder', defaultMessage: 'Type a message' }, placeholder: { id: 'chat.input.placeholder', defaultMessage: 'Type a message' },
send: { id: 'chat.actions.send', defaultMessage: 'Send' }, send: { id: 'chat.actions.send', defaultMessage: 'Send' },
@ -31,7 +33,7 @@ const initialSuggestionState = {
}; };
interface Suggestion { interface Suggestion {
list: { native: string, colons: string }[] list: Emoji[]
tokenStart: number tokenStart: number
token: string token: string
} }
@ -45,7 +47,7 @@ interface IChatComposer extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaEl
resetContentKey: number | null resetContentKey: number | null
attachments?: Attachment[] attachments?: Attachment[]
onDeleteAttachment?: (i: number) => void onDeleteAttachment?: (i: number) => void
isUploading?: boolean uploadCount?: number
uploadProgress?: number uploadProgress?: number
} }
@ -63,7 +65,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
onPaste, onPaste,
attachments = [], attachments = [],
onDeleteAttachment, onDeleteAttachment,
isUploading, uploadCount = 0,
uploadProgress, uploadProgress,
}, ref) => { }, ref) => {
const intl = useIntl(); const intl = useIntl();
@ -80,6 +82,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState); const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState);
const isSuggestionsAvailable = suggestions.list.length > 0; const isSuggestionsAvailable = suggestions.list.length > 0;
const isUploading = uploadCount > 0;
const hasAttachment = attachments.length > 0; const hasAttachment = attachments.length > 0;
const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount; const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
const isSubmitDisabled = disabled || isUploading || isOverCharacterLimit || (value.length === 0 && !hasAttachment); const isSubmitDisabled = disabled || isUploading || isOverCharacterLimit || (value.length === 0 && !hasAttachment);
@ -107,7 +110,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
); );
if (token && tokenStart) { if (token && tokenStart) {
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any); const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
setSuggestions({ setSuggestions({
list: results, list: results,
token, token,
@ -198,7 +201,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
disabled={disabled} disabled={disabled}
attachments={attachments} attachments={attachments}
onDeleteAttachment={onDeleteAttachment} onDeleteAttachment={onDeleteAttachment}
isUploading={isUploading} uploadCount={uploadCount}
uploadProgress={uploadProgress} uploadProgress={uploadProgress}
/> />
{isSuggestionsAvailable ? ( {isSuggestionsAvailable ? (
@ -209,7 +212,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
key={emojiSuggestion.colons} key={emojiSuggestion.colons}
value={renderSuggestionValue(emojiSuggestion)} value={renderSuggestionValue(emojiSuggestion)}
> >
<span>{emojiSuggestion.native}</span> <span>{(emojiSuggestion as NativeEmoji).native}</span>
<span className='ml-1'> <span className='ml-1'>
{emojiSuggestion.colons} {emojiSuggestion.colons}
</span> </span>

Wyświetl plik

@ -44,7 +44,7 @@ function ChatMessageReactionWrapper(props: IChatMessageReactionWrapper) {
referenceElement={referenceElement} referenceElement={referenceElement}
onReact={handleSelect} onReact={handleSelect}
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
offset={[-10, 12]} offsetOptions={{ mainAxis: 12, crossAxis: -10 }}
all={false} all={false}
/> />
</Portal> </Portal>

Wyświetl plik

@ -2,7 +2,7 @@ import clsx from 'clsx';
import React from 'react'; import React from 'react';
import { Text } from 'soapbox/components/ui'; import { Text } from 'soapbox/components/ui';
import emojify from 'soapbox/features/emoji/emoji'; import emojify from 'soapbox/features/emoji';
import { EmojiReaction } from 'soapbox/types/entities'; import { EmojiReaction } from 'soapbox/types/entities';
interface IChatMessageReaction { interface IChatMessageReaction {
@ -42,4 +42,4 @@ const ChatMessageReaction = (props: IChatMessageReaction) => {
); );
}; };
export default ChatMessageReaction; export default ChatMessageReaction;

Wyświetl plik

@ -6,10 +6,10 @@ import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { initReport } from 'soapbox/actions/reports'; import { initReport, ReportableEntities } from 'soapbox/actions/reports';
import DropdownMenu from 'soapbox/components/dropdown-menu'; import DropdownMenu from 'soapbox/components/dropdown-menu';
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui'; import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import emojify from 'soapbox/features/emoji/emoji'; import emojify from 'soapbox/features/emoji';
import Bundle from 'soapbox/features/ui/components/bundle'; import Bundle from 'soapbox/features/ui/components/bundle';
import { MediaGallery } from 'soapbox/features/ui/util/async-components'; import { MediaGallery } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
@ -23,7 +23,8 @@ import ChatMessageReaction from './chat-message-reaction';
import ChatMessageReactionWrapper from './chat-message-reaction-wrapper/chat-message-reaction-wrapper'; import ChatMessageReactionWrapper from './chat-message-reaction-wrapper/chat-message-reaction-wrapper';
import type { Menu as IMenu } from 'soapbox/components/dropdown-menu'; import type { Menu as IMenu } from 'soapbox/components/dropdown-menu';
import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities'; import type { IMediaGallery } from 'soapbox/components/media-gallery';
import type { Account, ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
const messages = defineMessages({ const messages = defineMessages({
copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' }, copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' },
@ -112,8 +113,12 @@ const ChatMessage = (props: IChatMessage) => {
return ( return (
<Bundle fetchComponent={MediaGallery}> <Bundle fetchComponent={MediaGallery}>
{(Component: any) => ( {(Component: React.FC<IMediaGallery>) => (
<Component <Component
className={clsx({
'rounded-br-sm': isMyMessage && content,
'rounded-bl-sm': !isMyMessage && content,
})}
media={chatMessage.media_attachments} media={chatMessage.media_attachments}
onOpenMedia={onOpenMedia} onOpenMedia={onOpenMedia}
visible visible
@ -173,7 +178,7 @@ const ChatMessage = (props: IChatMessage) => {
if (features.reportChats) { if (features.reportChats) {
menu.push({ menu.push({
text: intl.formatMessage(messages.report), text: intl.formatMessage(messages.report),
action: () => dispatch(initReport(normalizeAccount(chat.account) as any, { chatMessage } as any)), action: () => dispatch(initReport(ReportableEntities.CHAT_MESSAGE, normalizeAccount(chat.account) as Account, { chatMessage })),
icon: require('@tabler/icons/flag.svg'), icon: require('@tabler/icons/flag.svg'),
}); });
} }
@ -385,4 +390,4 @@ const ChatMessage = (props: IChatMessage) => {
); );
}; };
export default ChatMessage; export default ChatMessage;

Wyświetl plik

@ -5,7 +5,7 @@ import { __stub } from 'soapbox/api';
import { ChatContext } from 'soapbox/contexts/chat-context'; import { ChatContext } from 'soapbox/contexts/chat-context';
import { StatProvider } from 'soapbox/contexts/stat-context'; import { StatProvider } from 'soapbox/contexts/stat-context';
import chats from 'soapbox/jest/fixtures/chats.json'; import chats from 'soapbox/jest/fixtures/chats.json';
import { mockStore, render, rootState, screen, waitFor } from 'soapbox/jest/test-helpers'; import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
import ChatPane from '../chat-pane'; import ChatPane from '../chat-pane';
@ -22,28 +22,28 @@ const renderComponentWithChatContext = (store = {}) => render(
); );
describe('<ChatPane />', () => { describe('<ChatPane />', () => {
describe('when there are no chats', () => { // describe('when there are no chats', () => {
let store: ReturnType<typeof mockStore>; // let store: ReturnType<typeof mockStore>;
beforeEach(() => { // beforeEach(() => {
const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.2.0)'); // const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.2.0)');
store = mockStore(state); // store = mockStore(state);
__stub((mock) => { // __stub((mock) => {
mock.onGet('/api/v1/pleroma/chats').reply(200, [], { // mock.onGet('/api/v1/pleroma/chats').reply(200, [], {
link: null, // link: null,
}); // });
}); // });
}); // });
it('renders the blankslate', async () => { // it('renders the blankslate', async () => {
renderComponentWithChatContext(store); // renderComponentWithChatContext(store);
await waitFor(() => { // await waitFor(() => {
expect(screen.getByTestId('chat-pane-blankslate')).toBeInTheDocument(); // expect(screen.getByTestId('chat-pane-blankslate')).toBeInTheDocument();
}); // });
}); // });
}); // });
describe('when the software is not Truth Social', () => { describe('when the software is not Truth Social', () => {
beforeEach(() => { beforeEach(() => {

Wyświetl plik

@ -9,18 +9,20 @@ import ChatUpload from './chat-upload';
interface IChatTextarea extends React.ComponentProps<typeof Textarea> { interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
attachments?: Attachment[] attachments?: Attachment[]
onDeleteAttachment?: (i: number) => void onDeleteAttachment?: (i: number) => void
isUploading?: boolean uploadCount?: number
uploadProgress?: number uploadProgress?: number
} }
/** Custom textarea for chats. */ /** Custom textarea for chats. */
const ChatTextarea: React.FC<IChatTextarea> = ({ const ChatTextarea: React.FC<IChatTextarea> = React.forwardRef(({
attachments, attachments,
onDeleteAttachment, onDeleteAttachment,
isUploading = false, uploadCount = 0,
uploadProgress = 0, uploadProgress = 0,
...rest ...rest
}) => { }, ref) => {
const isUploading = uploadCount > 0;
const handleDeleteAttachment = (i: number) => { const handleDeleteAttachment = (i: number) => {
return () => { return () => {
if (onDeleteAttachment) { if (onDeleteAttachment) {
@ -54,17 +56,17 @@ const ChatTextarea: React.FC<IChatTextarea> = ({
</div> </div>
))} ))}
{isUploading && ( {Array.from(Array(uploadCount)).map(() => (
<div className='ml-2 mt-2 flex'> <div className='ml-2 mt-2 flex'>
<ChatPendingUpload progress={uploadProgress} /> <ChatPendingUpload progress={uploadProgress} />
</div> </div>
)} ))}
</HStack> </HStack>
)} )}
<Textarea theme='transparent' {...rest} /> <Textarea ref={ref} theme='transparent' {...rest} />
</div> </div>
); );
}; });
export default ChatTextarea; export default ChatTextarea;

Wyświetl plik

@ -57,7 +57,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
const [content, setContent] = useState<string>(''); const [content, setContent] = useState<string>('');
const [attachments, setAttachments] = useState<Attachment[]>([]); const [attachments, setAttachments] = useState<Attachment[]>([]);
const [isUploading, setIsUploading] = useState(false); const [uploadCount, setUploadCount] = useState(0);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [resetContentKey, setResetContentKey] = useState<number>(fileKeyGen()); const [resetContentKey, setResetContentKey] = useState<number>(fileKeyGen());
const [resetFileKey, setResetFileKey] = useState<number>(fileKeyGen()); const [resetFileKey, setResetFileKey] = useState<number>(fileKeyGen());
@ -86,7 +86,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
} }
setContent(''); setContent('');
setAttachments([]); setAttachments([]);
setIsUploading(false); setUploadCount(0);
setUploadProgress(0); setUploadProgress(0);
setResetFileKey(fileKeyGen()); setResetFileKey(fileKeyGen());
setResetContentKey(fileKeyGen()); setResetContentKey(fileKeyGen());
@ -151,17 +151,21 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
return; return;
} }
setIsUploading(true); setUploadCount(files.length);
const data = new FormData(); const promises = Array.from(files).map(async(file) => {
data.append('file', files[0]); const data = new FormData();
data.append('file', file);
dispatch(uploadMedia(data, onUploadProgress)).then((response: any) => { const response = await dispatch(uploadMedia(data, onUploadProgress));
setAttachments([...attachments, normalizeAttachment(response.data)]); return normalizeAttachment(response.data);
setIsUploading(false);
}).catch(() => {
setIsUploading(false);
}); });
return Promise.all(promises)
.then((newAttachments) => {
setAttachments([...attachments, ...newAttachments]);
setUploadCount(0);
})
.catch(() => setUploadCount(0));
}; };
useEffect(() => { useEffect(() => {
@ -189,7 +193,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
onPaste={handlePaste} onPaste={handlePaste}
attachments={attachments} attachments={attachments}
onDeleteAttachment={handleRemoveFile} onDeleteAttachment={handleRemoveFile}
isUploading={isUploading} uploadCount={uploadCount}
uploadProgress={uploadProgress} uploadProgress={uploadProgress}
/> />
</Stack> </Stack>

Wyświetl plik

@ -16,6 +16,7 @@ import {
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input'; import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input';
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea'; import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
import { Button, HStack, Stack } from 'soapbox/components/ui'; import { Button, HStack, Stack } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
import { useAppDispatch, useAppSelector, useCompose, useFeatures, useInstance, usePrevious } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useCompose, useFeatures, useInstance, usePrevious } from 'soapbox/hooks';
import { isMobile } from 'soapbox/is-mobile'; import { isMobile } from 'soapbox/is-mobile';
@ -26,7 +27,6 @@ import UploadButtonContainer from '../containers/upload-button-container';
import WarningContainer from '../containers/warning-container'; import WarningContainer from '../containers/warning-container';
import { countableText } from '../util/counter'; import { countableText } from '../util/counter';
import EmojiPickerDropdown from './emoji-picker/emoji-picker-dropdown';
import MarkdownButton from './markdown-button'; import MarkdownButton from './markdown-button';
import PollButton from './poll-button'; import PollButton from './poll-button';
import PollForm from './polls/poll-form'; import PollForm from './polls/poll-form';
@ -40,7 +40,7 @@ import UploadForm from './upload-form';
import VisualCharacterCounter from './visual-character-counter'; import VisualCharacterCounter from './visual-character-counter';
import Warning from './warning'; import Warning from './warning';
import type { Emoji } from 'soapbox/components/autosuggest-emoji'; import type { Emoji } from 'soapbox/features/emoji';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
@ -116,7 +116,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
// FIXME: Make this less brittle // FIXME: Make this less brittle
getClickableArea(), getClickableArea(),
document.querySelector('.privacy-dropdown__dropdown'), document.querySelector('.privacy-dropdown__dropdown'),
document.querySelector('.emoji-picker-dropdown__menu'), document.querySelector('em-emoji-picker'),
document.getElementById('modal-overlay'), document.getElementById('modal-overlay'),
].some(element => element?.contains(e.target as any)); ].some(element => element?.contains(e.target as any));
}; };
@ -179,7 +179,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
const handleEmojiPick = (data: Emoji) => { const handleEmojiPick = (data: Emoji) => {
const position = autosuggestTextareaRef.current!.textarea!.selectionStart; const position = autosuggestTextareaRef.current!.textarea!.selectionStart;
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]); const needsSpace = !!data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
dispatch(insertEmojiCompose(id, position, data, needsSpace)); dispatch(insertEmojiCompose(id, position, data, needsSpace));
}; };
@ -226,7 +226,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
const renderButtons = useCallback(() => ( const renderButtons = useCallback(() => (
<HStack alignItems='center' space={2}> <HStack alignItems='center' space={2}>
{features.media && <UploadButtonContainer composeId={id} />} {features.media && <UploadButtonContainer composeId={id} />}
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} /> <EmojiPickerDropdown onPickEmoji={handleEmojiPick} condensed={shouldCondense} />
{features.polls && <PollButton composeId={id} />} {features.polls && <PollButton composeId={id} />}
{features.privacyScopes && !group && !groupId && <PrivacyDropdown composeId={id} />} {features.privacyScopes && !group && !groupId && <PrivacyDropdown composeId={id} />}
{features.scheduledStatuses && <ScheduleButton composeId={id} />} {features.scheduledStatuses && <ScheduleButton composeId={id} />}

Wyświetl plik

@ -1,209 +0,0 @@
import clsx from 'clsx';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import React, { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
// @ts-ignore
import Overlay from 'react-overlays/lib/Overlay';
import { createSelector } from 'reselect';
import { useEmoji } from 'soapbox/actions/emojis';
import { getSettings, changeSetting } from 'soapbox/actions/settings';
import { IconButton } from 'soapbox/components/ui';
import { EmojiPicker as EmojiPickerAsync } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import EmojiPickerMenu from './emoji-picker-menu';
import type { Emoji as EmojiType } from 'soapbox/components/autosuggest-emoji';
import type { RootState } from 'soapbox/store';
let EmojiPicker: any, Emoji: any; // load asynchronously
const perLine = 8;
const lines = 2;
const DEFAULTS = [
'+1',
'grinning',
'kissing_heart',
'heart_eyes',
'laughing',
'stuck_out_tongue_winking_eye',
'sweat_smile',
'joy',
'yum',
'disappointed',
'thinking_face',
'weary',
'sob',
'sunglasses',
'heart',
'ok_hand',
];
const getFrequentlyUsedEmojis = createSelector([
(state: RootState) => state.settings.get('frequentlyUsedEmojis', ImmutableMap()),
], emojiCounters => {
let emojis = emojiCounters
.keySeq()
.sort((a: number, b: number) => emojiCounters.get(a) - emojiCounters.get(b))
.reverse()
.slice(0, perLine * lines)
.toArray();
if (emojis.length < DEFAULTS.length) {
const uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
}
return emojis;
});
const getCustomEmojis = createSelector([
(state: RootState) => state.custom_emojis as ImmutableList<ImmutableMap<string, string>>,
], emojis => emojis.filter((e) => e.get('visible_in_picker')).sort((a, b) => {
const aShort = a.get('shortcode')!.toLowerCase();
const bShort = b.get('shortcode')!.toLowerCase();
if (aShort < bShort) {
return -1;
} else if (aShort > bShort) {
return 1;
} else {
return 0;
}
}) as ImmutableList<ImmutableMap<string, string>>);
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
people: { id: 'emoji_button.people', defaultMessage: 'People' },
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
});
interface IEmojiPickerDropdown {
onPickEmoji: (data: EmojiType) => void
button?: JSX.Element
}
const EmojiPickerDropdown: React.FC<IEmojiPickerDropdown> = ({ onPickEmoji, button }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const customEmojis = useAppSelector((state) => getCustomEmojis(state));
const skinTone = useAppSelector((state) => getSettings(state).get('skinTone') as number);
const frequentlyUsedEmojis = useAppSelector((state) => getFrequentlyUsedEmojis(state));
const [active, setActive] = useState(false);
const [loading, setLoading] = useState(false);
const [placement, setPlacement] = useState<'bottom' | 'top'>();
const target = useRef(null);
const onSkinTone = (skinTone: number) => {
dispatch(changeSetting(['skinTone'], skinTone));
};
const handlePickEmoji = (emoji: EmojiType) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
dispatch(useEmoji(emoji));
if (onPickEmoji) {
onPickEmoji(emoji);
}
};
const onShowDropdown: React.EventHandler<React.KeyboardEvent | React.MouseEvent> = (e) => {
e.stopPropagation();
setActive(true);
if (!EmojiPicker) {
setLoading(true);
EmojiPickerAsync().then(EmojiMart => {
EmojiPicker = EmojiMart.Picker;
Emoji = EmojiMart.Emoji;
setLoading(false);
}).catch(() => {
setLoading(false);
});
}
const { top } = (e.target as any).getBoundingClientRect();
setPlacement(top * 2 < innerHeight ? 'bottom' : 'top');
};
const onHideDropdown = () => {
setActive(false);
};
const onToggle: React.EventHandler<React.KeyboardEvent | React.MouseEvent> = (e) => {
if (!loading && (!(e as React.KeyboardEvent).key || (e as React.KeyboardEvent).key === 'Enter')) {
if (active) {
onHideDropdown();
} else {
onShowDropdown(e);
}
}
};
const handleKeyDown: React.KeyboardEventHandler = e => {
if (e.key === 'Escape') {
onHideDropdown();
}
};
const title = intl.formatMessage(messages.emoji);
return (
<div className='relative' onKeyDown={handleKeyDown}>
<div
ref={target}
title={title}
aria-label={title}
aria-expanded={active}
role='button'
onClick={onToggle}
onKeyDown={onToggle}
tabIndex={0}
>
{button || <IconButton
className={clsx({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
'pulse-loading': active && loading,
})}
title='😀'
src={require('@tabler/icons/mood-happy.svg')}
/>}
</div>
<Overlay show={active} placement={placement} target={target.current}>
<EmojiPickerMenu
customEmojis={customEmojis}
loading={loading}
onClose={onHideDropdown}
onPick={handlePickEmoji}
onSkinTone={onSkinTone}
skinTone={skinTone}
frequentlyUsedEmojis={frequentlyUsedEmojis}
/>
</Overlay>
</div>
);
};
export { EmojiPicker, Emoji };
export default EmojiPickerDropdown;

Wyświetl plik

@ -1,171 +0,0 @@
import clsx from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { buildCustomEmojis, categoriesFromEmojis } from '../../../emoji/emoji';
import { EmojiPicker } from './emoji-picker-dropdown';
import ModifierPicker from './modifier-picker';
import type { Emoji } from 'soapbox/components/autosuggest-emoji';
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
people: { id: 'emoji_button.people', defaultMessage: 'People' },
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
});
interface IEmojiPickerMenu {
customEmojis: ImmutableList<ImmutableMap<string, string>>
loading?: boolean
onClose: () => void
onPick: (emoji: Emoji) => void
onSkinTone: (skinTone: number) => void
skinTone?: number
frequentlyUsedEmojis?: Array<string>
style?: React.CSSProperties
}
const EmojiPickerMenu: React.FC<IEmojiPickerMenu> = ({
customEmojis,
loading = true,
onClose,
onPick,
onSkinTone,
skinTone,
frequentlyUsedEmojis = [],
style = {},
}) => {
const intl = useIntl();
const node = useRef<HTMLDivElement>(null);
const [modifierOpen, setModifierOpen] = useState(false);
const categoriesSort = [
'recent',
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
];
categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(customEmojis) as Set<string>).sort());
const handleDocumentClick = useCallback((e: MouseEvent | TouchEvent) => {
if (node.current && !node.current.contains(e.target as Node)) {
onClose();
}
}, []);
const getI18n = () => {
return {
search: intl.formatMessage(messages.emoji_search),
notfound: intl.formatMessage(messages.emoji_not_found),
categories: {
search: intl.formatMessage(messages.search_results),
recent: intl.formatMessage(messages.recent),
people: intl.formatMessage(messages.people),
nature: intl.formatMessage(messages.nature),
foods: intl.formatMessage(messages.food),
activity: intl.formatMessage(messages.activity),
places: intl.formatMessage(messages.travel),
objects: intl.formatMessage(messages.objects),
symbols: intl.formatMessage(messages.symbols),
flags: intl.formatMessage(messages.flags),
custom: intl.formatMessage(messages.custom),
},
};
};
const handleClick = (emoji: any) => {
if (!emoji.native) {
emoji.native = emoji.colons;
}
onClose();
onPick(emoji);
};
const handleModifierOpen = () => {
setModifierOpen(true);
};
const handleModifierClose = () => {
setModifierOpen(false);
};
const handleModifierChange = (modifier: number) => {
onSkinTone(modifier);
};
useEffect(() => {
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
return () => {
document.removeEventListener('click', handleDocumentClick, false);
document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any);
};
}, []);
if (loading) {
return <div style={{ width: 299 }} />;
}
const title = intl.formatMessage(messages.emoji);
return (
<div className={clsx('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={node}>
<EmojiPicker
perLine={8}
emojiSize={22}
sheetSize={32}
custom={buildCustomEmojis(customEmojis)}
color=''
emoji=''
set='twitter'
title={title}
i18n={getI18n()}
onClick={handleClick}
include={categoriesSort}
recent={frequentlyUsedEmojis}
skin={skinTone}
showPreview={false}
backgroundImageFn={backgroundImageFn}
autoFocus
emojiTooltip
/>
<ModifierPicker
active={modifierOpen}
modifier={skinTone}
onOpen={handleModifierOpen}
onClose={handleModifierClose}
onChange={handleModifierChange}
/>
</div>
);
};
export default EmojiPickerMenu;

Wyświetl plik

@ -1,73 +0,0 @@
import { supportsPassiveEvents } from 'detect-passive-events';
import React, { useCallback, useEffect, useRef } from 'react';
import { Emoji } from './emoji-picker-dropdown';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
interface IModifierPickerMenu {
active: boolean
onSelect: (modifier: number) => void
onClose: () => void
}
const ModifierPickerMenu: React.FC<IModifierPickerMenu> = ({ active, onSelect, onClose }) => {
const node = useRef<HTMLDivElement>(null);
const handleClick: React.MouseEventHandler<HTMLButtonElement> = e => {
onSelect(+e.currentTarget.getAttribute('data-index')! * 1);
};
const handleDocumentClick = useCallback(((e: MouseEvent | TouchEvent) => {
if (node.current && !node.current.contains(e.target as Node)) {
onClose();
}
}), []);
const attachListeners = () => {
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
};
const removeListeners = () => {
document.removeEventListener('click', handleDocumentClick, false);
document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any);
};
useEffect(() => {
return () => {
removeListeners();
};
}, []);
useEffect(() => {
if (active) attachListeners();
else removeListeners();
}, [active]);
return (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={node}>
<button onClick={handleClick} data-index={1}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={2}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={3}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={4}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={5}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={6}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} />
</button>
</div>
);
};
export default ModifierPickerMenu;

Wyświetl plik

@ -1,38 +0,0 @@
import React from 'react';
import { Emoji } from './emoji-picker-dropdown';
import ModifierPickerMenu from './modifier-picker-menu';
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
interface IModifierPicker {
active: boolean
modifier?: number
onOpen: () => void
onClose: () => void
onChange: (skinTone: number) => void
}
const ModifierPicker: React.FC<IModifierPicker> = ({ active, modifier, onOpen, onClose, onChange }) => {
const handleClick = () => {
if (active) {
onClose();
} else {
onOpen();
}
};
const handleSelect = (modifier: number) => {
onChange(modifier);
onClose();
};
return (
<div className='emoji-picker-dropdown__modifiers'>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={handleClick} backgroundImageFn={backgroundImageFn} />
<ModifierPickerMenu active={active} onSelect={handleSelect} onClose={onClose} />
</div>
);
};
export default ModifierPicker;

Wyświetl plik

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { Select } from 'soapbox/components/ui'; import { Select } from 'soapbox/components/ui';
@ -20,15 +20,7 @@ const DurationSelector = ({ onDurationChange }: IDurationSelector) => {
const [hours, setHours] = useState<number>(0); const [hours, setHours] = useState<number>(0);
const [minutes, setMinutes] = useState<number>(0); const [minutes, setMinutes] = useState<number>(0);
const value = useMemo(() => { const value = (days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60);
const now: any = new Date();
const future: any = new Date();
now.setDate(now.getDate() + days);
now.setMinutes(now.getMinutes() + minutes);
now.setHours(now.getHours() + hours);
return Math.round((now - future) / 1000);
}, [days, hours, minutes]);
useEffect(() => { useEffect(() => {
if (days === 7) { if (days === 7) {

Wyświetl plik

@ -6,6 +6,7 @@ import { openModal } from 'soapbox/actions/modals';
import { useAppSelector, useCompose, useFeatures } from 'soapbox/hooks'; import { useAppSelector, useCompose, useFeatures } from 'soapbox/hooks';
import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose'; import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose';
import { makeGetStatus } from 'soapbox/selectors'; import { makeGetStatus } from 'soapbox/selectors';
import { isPubkey } from 'soapbox/utils/nostr';
import type { Status as StatusEntity } from 'soapbox/types/entities'; import type { Status as StatusEntity } from 'soapbox/types/entities';
@ -52,9 +53,14 @@ const ReplyMentions: React.FC<IReplyMentions> = ({ composeId }) => {
); );
} }
const accounts = to.slice(0, 2).map((acct: string) => ( const accounts = to.slice(0, 2).map((acct: string) => {
<span className='reply-mentions__account'>@{acct.split('@')[0]}</span> const username = acct.split('@')[0];
)).toArray(); return (
<span className='reply-mentions__account'>
@{isPubkey(username) ? username.slice(0, 8) : username}
</span>
);
}).toArray();
if (to.size > 2) { if (to.size > 2) {
accounts.push( accounts.push(

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