sforkowany z mirror/soapbox
Merge remote-tracking branch 'upstream/develop' into develop
commit
7dc1cca40c
|
@ -56,6 +56,7 @@ module.exports = {
|
|||
},
|
||||
polyfills: [
|
||||
'es:all', // core-js
|
||||
'fetch', // not polyfilled, but ignore it
|
||||
'IntersectionObserver', // npm:intersection-observer
|
||||
'Promise', // core-js
|
||||
'ResizeObserver', // npm:resize-observer-polyfill
|
||||
|
|
|
@ -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
|
||||
script:
|
||||
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
|
||||
- docker build -t $CI_REGISTRY_IMAGE .
|
||||
- docker push $CI_REGISTRY_IMAGE
|
||||
only:
|
||||
variables:
|
||||
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
|
||||
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG .
|
||||
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
interruptible: false
|
||||
|
||||
release:
|
||||
stage: release
|
||||
|
|
17
CHANGELOG.md
17
CHANGELOG.md
|
@ -7,11 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
|
@ -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.
|
||||
- Groups: initial support for groups.
|
||||
- Profile: add RSS link to user profiles.
|
||||
- Posts: fix posts filtering.
|
||||
- Chats: reset chat message field height after sending a message.
|
||||
- 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.
|
||||
- Modals: fix media modal automatically switching to video.
|
||||
- Navigation: profile dropdown erratic behavior.
|
||||
- Posts: fix posts filtering.
|
||||
|
||||
### Removed
|
||||
- Admin: single user mode. Now the homepage can be redirected to any URL.
|
||||
|
|
|
@ -75,7 +75,7 @@ One disadvantage of this approach is that it does not help the software spread.
|
|||
© Alex Gleason & other Soapbox contributors
|
||||
© Eugen Rochko & other Mastodon contributors
|
||||
© Trump Media & Technology Group
|
||||
© Gab AI, Inc.
|
||||
© Gab AI, Inc.
|
||||
|
||||
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
|
||||
|
|
|
@ -2,4 +2,4 @@
|
|||
|
||||
- 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
|
||||
|
|
|
@ -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": []
|
||||
}
|
|
@ -4,7 +4,8 @@ import throttle from 'lodash/throttle';
|
|||
import { defineMessages, IntlShape } from 'react-intl';
|
||||
|
||||
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 toast from 'soapbox/toast';
|
||||
import { isLoggedIn } from 'soapbox/utils/auth';
|
||||
|
@ -19,8 +20,8 @@ import { openModal, closeModal } from './modals';
|
|||
import { getSettings } from './settings';
|
||||
import { createStatus } from './statuses';
|
||||
|
||||
import type { Emoji } from 'soapbox/components/autosuggest-emoji';
|
||||
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||
import type { Emoji } from 'soapbox/features/emoji';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities';
|
||||
import type { History } from 'soapbox/types/history';
|
||||
|
@ -277,7 +278,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
|
|||
|
||||
const idempotencyKey = compose.idempotencyKey;
|
||||
|
||||
const params = {
|
||||
const params: Record<string, any> = {
|
||||
status,
|
||||
in_reply_to_id: compose.in_reply_to,
|
||||
quote_id: compose.quote,
|
||||
|
@ -289,9 +290,10 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
|
|||
poll: compose.poll,
|
||||
scheduled_at: compose.schedule,
|
||||
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) {
|
||||
if (!statusId && data.visibility === 'direct' && getState().conversations.mounted <= 0 && routerHistory) {
|
||||
routerHistory.push('/messages');
|
||||
|
@ -515,7 +517,9 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId,
|
|||
}, 200, { leading: true, trailing: true });
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
|
@ -560,7 +564,7 @@ const selectComposeSuggestion = (composeId: string, position: number, token: str
|
|||
let completion, startPosition;
|
||||
|
||||
if (typeof suggestion === 'object' && suggestion.id) {
|
||||
completion = suggestion.native || suggestion.colons;
|
||||
completion = isNativeEmoji(suggestion) ? suggestion.native : suggestion.colons;
|
||||
startPosition = position - 1;
|
||||
|
||||
dispatch(useEmoji(suggestion));
|
||||
|
|
|
@ -25,7 +25,7 @@ const EMOJI_REACTS_FETCH_FAIL = 'EMOJI_REACTS_FETCH_FAIL';
|
|||
|
||||
const noOp = () => () => new Promise(f => f(undefined));
|
||||
|
||||
const simpleEmojiReact = (status: Status, emoji: string) =>
|
||||
const simpleEmojiReact = (status: Status, emoji: string, custom?: string) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
const emojiReacts: ImmutableList<ImmutableMap<string, any>> = status.pleroma.get('emoji_reactions') || ImmutableList();
|
||||
|
||||
|
@ -43,7 +43,7 @@ const simpleEmojiReact = (status: Status, emoji: string) =>
|
|||
if (emoji === '👍') {
|
||||
dispatch(favourite(status));
|
||||
} else {
|
||||
dispatch(emojiReact(status, emoji));
|
||||
dispatch(emojiReact(status, emoji, custom));
|
||||
}
|
||||
}).catch(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) => {
|
||||
if (!isLoggedIn(getState)) return dispatch(noOp());
|
||||
|
||||
dispatch(emojiReactRequest(status, emoji));
|
||||
dispatch(emojiReactRequest(status, emoji, custom));
|
||||
|
||||
return api(getState)
|
||||
.put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`)
|
||||
|
@ -120,10 +120,11 @@ const fetchEmojiReactsFail = (id: string, error: AxiosError) => ({
|
|||
error,
|
||||
});
|
||||
|
||||
const emojiReactRequest = (status: Status, emoji: string) => ({
|
||||
const emojiReactRequest = (status: Status, emoji: string, custom?: string) => ({
|
||||
type: EMOJI_REACT_REQUEST,
|
||||
status,
|
||||
emoji,
|
||||
custom,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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';
|
||||
|
||||
const EMOJI_USE = 'EMOJI_USE';
|
||||
|
|
|
@ -569,7 +569,7 @@ const rejectEventParticipationRequestFail = (id: string, accountId: string, erro
|
|||
});
|
||||
|
||||
const fetchEventIcs = (id: string) =>
|
||||
(dispatch: any, getState: () => RootState) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) =>
|
||||
api(getState).get(`/api/v1/pleroma/events/${id}/ics`);
|
||||
|
||||
const cancelEventCompose = () => ({
|
||||
|
|
|
@ -12,10 +12,18 @@ const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
|
|||
const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
|
||||
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_SUCCESS = 'FILTERS_CREATE_SUCCESS';
|
||||
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_SUCCESS = 'FILTERS_DELETE_SUCCESS';
|
||||
const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL';
|
||||
|
@ -25,22 +33,16 @@ const messages = defineMessages({
|
|||
removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' },
|
||||
});
|
||||
|
||||
const fetchFilters = () =>
|
||||
type FilterKeywords = { keyword: string, whole_word: boolean }[];
|
||||
|
||||
const fetchFiltersV1 = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (!isLoggedIn(getState)) return;
|
||||
|
||||
const state = getState();
|
||||
const instance = state.instance;
|
||||
const features = getFeatures(instance);
|
||||
|
||||
if (!features.filters) return;
|
||||
|
||||
dispatch({
|
||||
type: FILTERS_FETCH_REQUEST,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
api(getState)
|
||||
return api(getState)
|
||||
.get('/api/v1/filters')
|
||||
.then(({ data }) => dispatch({
|
||||
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({ type: FILTERS_CREATE_REQUEST });
|
||||
return api(getState).post('/api/v1/filters', {
|
||||
phrase,
|
||||
phrase: keywords[0].keyword,
|
||||
context,
|
||||
irreversible,
|
||||
whole_word,
|
||||
expires_at,
|
||||
irreversible: hide,
|
||||
whole_word: keywords[0].whole_word,
|
||||
expires_in,
|
||||
}).then(response => {
|
||||
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data });
|
||||
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({ type: FILTERS_DELETE_REQUEST });
|
||||
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 {
|
||||
FILTERS_FETCH_REQUEST,
|
||||
FILTERS_FETCH_SUCCESS,
|
||||
FILTERS_FETCH_FAIL,
|
||||
FILTER_FETCH_REQUEST,
|
||||
FILTER_FETCH_SUCCESS,
|
||||
FILTER_FETCH_FAIL,
|
||||
FILTERS_CREATE_REQUEST,
|
||||
FILTERS_CREATE_SUCCESS,
|
||||
FILTERS_CREATE_FAIL,
|
||||
FILTERS_UPDATE_REQUEST,
|
||||
FILTERS_UPDATE_SUCCESS,
|
||||
FILTERS_UPDATE_FAIL,
|
||||
FILTERS_DELETE_REQUEST,
|
||||
FILTERS_DELETE_SUCCESS,
|
||||
FILTERS_DELETE_FAIL,
|
||||
fetchFilters,
|
||||
fetchFilter,
|
||||
createFilter,
|
||||
updateFilter,
|
||||
deleteFilter,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import { deleteEntities } from 'soapbox/entity-store/actions';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
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_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL';
|
||||
|
||||
const GROUP_JOIN_REQUEST = 'GROUP_JOIN_REQUEST';
|
||||
const GROUP_JOIN_SUCCESS = 'GROUP_JOIN_SUCCESS';
|
||||
const GROUP_JOIN_FAIL = 'GROUP_JOIN_FAIL';
|
||||
|
||||
const GROUP_LEAVE_REQUEST = 'GROUP_LEAVE_REQUEST';
|
||||
const GROUP_LEAVE_SUCCESS = 'GROUP_LEAVE_SUCCESS';
|
||||
const GROUP_LEAVE_FAIL = 'GROUP_LEAVE_FAIL';
|
||||
|
||||
const GROUP_DELETE_STATUS_REQUEST = 'GROUP_DELETE_STATUS_REQUEST';
|
||||
const GROUP_DELETE_STATUS_SUCCESS = 'GROUP_DELETE_STATUS_SUCCESS';
|
||||
const GROUP_DELETE_STATUS_FAIL = 'GROUP_DELETE_STATUS_FAIL';
|
||||
|
@ -148,7 +141,8 @@ const createGroup = (params: Record<string, any>, shouldReset?: boolean) =>
|
|||
if (shouldReset) {
|
||||
dispatch(resetGroupEditor());
|
||||
}
|
||||
dispatch(closeModal('MANAGE_GROUP'));
|
||||
|
||||
return data;
|
||||
}).catch(err => dispatch(createGroupFail(err)));
|
||||
};
|
||||
|
||||
|
@ -198,7 +192,7 @@ const updateGroupFail = (error: AxiosError) => ({
|
|||
});
|
||||
|
||||
const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(deleteGroupRequest(id));
|
||||
dispatch(deleteEntities([id], 'Group'));
|
||||
|
||||
return api(getState).delete(`/api/v1/groups/${id}`)
|
||||
.then(() => dispatch(deleteGroupSuccess(id)))
|
||||
|
@ -312,70 +306,6 @@ const fetchGroupRelationshipsFail = (error: AxiosError) => ({
|
|||
skipNotFound: true,
|
||||
});
|
||||
|
||||
const joinGroup = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const locked = (getState().groups.items.get(id) as any).locked || false;
|
||||
|
||||
dispatch(joinGroupRequest(id, locked));
|
||||
|
||||
return api(getState).post(`/api/v1/groups/${id}/join`).then(response => {
|
||||
dispatch(joinGroupSuccess(response.data));
|
||||
toast.success(locked ? messages.joinRequestSuccess : messages.joinSuccess);
|
||||
}).catch(error => {
|
||||
dispatch(joinGroupFail(error, locked));
|
||||
});
|
||||
};
|
||||
|
||||
const leaveGroup = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(leaveGroupRequest(id));
|
||||
|
||||
return api(getState).post(`/api/v1/groups/${id}/leave`).then(response => {
|
||||
dispatch(leaveGroupSuccess(response.data));
|
||||
toast.success(messages.leaveSuccess);
|
||||
}).catch(error => {
|
||||
dispatch(leaveGroupFail(error));
|
||||
});
|
||||
};
|
||||
|
||||
const joinGroupRequest = (id: string, locked: boolean) => ({
|
||||
type: GROUP_JOIN_REQUEST,
|
||||
id,
|
||||
locked,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
const joinGroupSuccess = (relationship: APIEntity) => ({
|
||||
type: GROUP_JOIN_SUCCESS,
|
||||
relationship,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
const joinGroupFail = (error: AxiosError, locked: boolean) => ({
|
||||
type: GROUP_JOIN_FAIL,
|
||||
error,
|
||||
locked,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
const leaveGroupRequest = (id: string) => ({
|
||||
type: GROUP_LEAVE_REQUEST,
|
||||
id,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
const leaveGroupSuccess = (relationship: APIEntity) => ({
|
||||
type: GROUP_LEAVE_SUCCESS,
|
||||
relationship,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
const leaveGroupFail = (error: AxiosError) => ({
|
||||
type: GROUP_LEAVE_FAIL,
|
||||
error,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
const groupDeleteStatus = (groupId: string, statusId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(groupDeleteStatusRequest(groupId, statusId));
|
||||
|
@ -859,9 +789,11 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get
|
|||
const note = getState().group_editor.note;
|
||||
const avatar = getState().group_editor.avatar;
|
||||
const header = getState().group_editor.header;
|
||||
const visibility = getState().group_editor.locked ? 'members_only' : 'everyone'; // Truth Social
|
||||
|
||||
const params: Record<string, any> = {
|
||||
display_name: displayName,
|
||||
group_visibility: visibility,
|
||||
note,
|
||||
};
|
||||
|
||||
|
@ -869,9 +801,9 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get
|
|||
if (header) params.header = header;
|
||||
|
||||
if (groupId === null) {
|
||||
dispatch(createGroup(params, shouldReset));
|
||||
return dispatch(createGroup(params, shouldReset));
|
||||
} else {
|
||||
dispatch(updateGroup(groupId, params, shouldReset));
|
||||
return dispatch(updateGroup(groupId, params, shouldReset));
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -895,12 +827,6 @@ export {
|
|||
GROUP_RELATIONSHIPS_FETCH_REQUEST,
|
||||
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
|
||||
GROUP_RELATIONSHIPS_FETCH_FAIL,
|
||||
GROUP_JOIN_REQUEST,
|
||||
GROUP_JOIN_SUCCESS,
|
||||
GROUP_JOIN_FAIL,
|
||||
GROUP_LEAVE_REQUEST,
|
||||
GROUP_LEAVE_SUCCESS,
|
||||
GROUP_LEAVE_FAIL,
|
||||
GROUP_DELETE_STATUS_REQUEST,
|
||||
GROUP_DELETE_STATUS_SUCCESS,
|
||||
GROUP_DELETE_STATUS_FAIL,
|
||||
|
@ -973,14 +899,6 @@ export {
|
|||
fetchGroupRelationshipsRequest,
|
||||
fetchGroupRelationshipsSuccess,
|
||||
fetchGroupRelationshipsFail,
|
||||
joinGroup,
|
||||
leaveGroup,
|
||||
joinGroupRequest,
|
||||
joinGroupSuccess,
|
||||
joinGroupFail,
|
||||
leaveGroupRequest,
|
||||
leaveGroupSuccess,
|
||||
leaveGroupFail,
|
||||
groupDeleteStatus,
|
||||
groupDeleteStatusRequest,
|
||||
groupDeleteStatusSuccess,
|
||||
|
|
|
@ -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 type { AppDispatch, RootState } from 'soapbox/store';
|
||||
|
@ -18,11 +23,11 @@ const importAccount = (account: APIEntity) =>
|
|||
const importAccounts = (accounts: APIEntity[]) =>
|
||||
({ type: ACCOUNTS_IMPORT, accounts });
|
||||
|
||||
const importGroup = (group: APIEntity) =>
|
||||
({ type: GROUP_IMPORT, group });
|
||||
const importGroup = (group: Group) =>
|
||||
importEntities([group], Entities.GROUPS);
|
||||
|
||||
const importGroups = (groups: APIEntity[]) =>
|
||||
({ type: GROUPS_IMPORT, groups });
|
||||
const importGroups = (groups: Group[]) =>
|
||||
importEntities(groups, Entities.GROUPS);
|
||||
|
||||
const importStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
|
@ -69,17 +74,8 @@ const importFetchedGroup = (group: APIEntity) =>
|
|||
importFetchedGroups([group]);
|
||||
|
||||
const importFetchedGroups = (groups: APIEntity[]) => {
|
||||
const normalGroups: APIEntity[] = [];
|
||||
|
||||
const processGroup = (group: APIEntity) => {
|
||||
if (!group.id) return;
|
||||
|
||||
normalGroups.push(group);
|
||||
};
|
||||
|
||||
groups.forEach(processGroup);
|
||||
|
||||
return importGroups(normalGroups);
|
||||
const entities = filteredArray(groupSchema).catch([]).parse(groups);
|
||||
return importGroups(entities);
|
||||
};
|
||||
|
||||
const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||
|
|
|
@ -20,6 +20,10 @@ const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
|
|||
const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
|
||||
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_SUCCESS = 'UNREBLOG_SUCCESS';
|
||||
const UNREBLOG_FAIL = 'UNREBLOG_FAIL';
|
||||
|
@ -28,6 +32,10 @@ const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
|
|||
const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
|
||||
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_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
|
||||
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_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_SUCCESS = 'REACTIONS_FETCH_SUCCESS';
|
||||
const REACTIONS_FETCH_FAIL = 'REACTIONS_FETCH_FAIL';
|
||||
|
@ -96,7 +108,7 @@ const unreblog = (status: StatusEntity) =>
|
|||
};
|
||||
|
||||
const toggleReblog = (status: StatusEntity) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
(dispatch: AppDispatch) => {
|
||||
if (status.reblogged) {
|
||||
dispatch(unreblog(status));
|
||||
} else {
|
||||
|
@ -169,7 +181,7 @@ const unfavourite = (status: StatusEntity) =>
|
|||
};
|
||||
|
||||
const toggleFavourite = (status: StatusEntity) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
(dispatch: AppDispatch) => {
|
||||
if (status.favourited) {
|
||||
dispatch(unfavourite(status));
|
||||
} else {
|
||||
|
@ -215,6 +227,79 @@ const unfavouriteFail = (status: StatusEntity, error: AxiosError) => ({
|
|||
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) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(bookmarkRequest(status));
|
||||
|
@ -351,6 +436,38 @@ const fetchFavouritesFail = (id: string, error: AxiosError) => ({
|
|||
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) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchReactionsRequest(id));
|
||||
|
@ -498,18 +615,27 @@ export {
|
|||
FAVOURITE_REQUEST,
|
||||
FAVOURITE_SUCCESS,
|
||||
FAVOURITE_FAIL,
|
||||
DISLIKE_REQUEST,
|
||||
DISLIKE_SUCCESS,
|
||||
DISLIKE_FAIL,
|
||||
UNREBLOG_REQUEST,
|
||||
UNREBLOG_SUCCESS,
|
||||
UNREBLOG_FAIL,
|
||||
UNFAVOURITE_REQUEST,
|
||||
UNFAVOURITE_SUCCESS,
|
||||
UNFAVOURITE_FAIL,
|
||||
UNDISLIKE_REQUEST,
|
||||
UNDISLIKE_SUCCESS,
|
||||
UNDISLIKE_FAIL,
|
||||
REBLOGS_FETCH_REQUEST,
|
||||
REBLOGS_FETCH_SUCCESS,
|
||||
REBLOGS_FETCH_FAIL,
|
||||
FAVOURITES_FETCH_REQUEST,
|
||||
FAVOURITES_FETCH_SUCCESS,
|
||||
FAVOURITES_FETCH_FAIL,
|
||||
DISLIKES_FETCH_REQUEST,
|
||||
DISLIKES_FETCH_SUCCESS,
|
||||
DISLIKES_FETCH_FAIL,
|
||||
REACTIONS_FETCH_REQUEST,
|
||||
REACTIONS_FETCH_SUCCESS,
|
||||
REACTIONS_FETCH_FAIL,
|
||||
|
@ -546,6 +672,15 @@ export {
|
|||
unfavouriteRequest,
|
||||
unfavouriteSuccess,
|
||||
unfavouriteFail,
|
||||
dislike,
|
||||
undislike,
|
||||
toggleDislike,
|
||||
dislikeRequest,
|
||||
dislikeSuccess,
|
||||
dislikeFail,
|
||||
undislikeRequest,
|
||||
undislikeSuccess,
|
||||
undislikeFail,
|
||||
bookmark,
|
||||
unbookmark,
|
||||
toggleBookmark,
|
||||
|
@ -563,6 +698,10 @@ export {
|
|||
fetchFavouritesRequest,
|
||||
fetchFavouritesSuccess,
|
||||
fetchFavouritesFail,
|
||||
fetchDislikes,
|
||||
fetchDislikesRequest,
|
||||
fetchDislikesSuccess,
|
||||
fetchDislikesFail,
|
||||
fetchReactions,
|
||||
fetchReactionsRequest,
|
||||
fetchReactionsSuccess,
|
||||
|
|
|
@ -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 = () => {}) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
|
@ -178,7 +157,6 @@ const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = ()
|
|||
export {
|
||||
deactivateUserModal,
|
||||
deleteUserModal,
|
||||
rejectUserModal,
|
||||
toggleStatusSensitivityModal,
|
||||
deleteStatusModal,
|
||||
};
|
||||
|
|
|
@ -4,7 +4,7 @@ import { openModal } from './modals';
|
|||
|
||||
import type { AxiosError } from 'axios';
|
||||
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_CANCEL = 'REPORT_CANCEL';
|
||||
|
@ -20,19 +20,29 @@ const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE';
|
|||
|
||||
const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE';
|
||||
|
||||
enum ReportableEntities {
|
||||
ACCOUNT = 'ACCOUNT',
|
||||
CHAT_MESSAGE = 'CHAT_MESSAGE',
|
||||
GROUP = 'GROUP',
|
||||
STATUS = 'STATUS'
|
||||
}
|
||||
|
||||
type ReportedEntity = {
|
||||
status?: Status
|
||||
chatMessage?: ChatMessage
|
||||
group?: Group
|
||||
}
|
||||
|
||||
const initReport = (account: Account, entities?: ReportedEntity) => (dispatch: AppDispatch) => {
|
||||
const { status, chatMessage } = entities || {};
|
||||
const initReport = (entityType: ReportableEntities, account: Account, entities?: ReportedEntity) => (dispatch: AppDispatch) => {
|
||||
const { status, chatMessage, group } = entities || {};
|
||||
|
||||
dispatch({
|
||||
type: REPORT_INIT,
|
||||
entityType,
|
||||
account,
|
||||
status,
|
||||
chatMessage,
|
||||
group,
|
||||
});
|
||||
|
||||
return dispatch(openModal('REPORT'));
|
||||
|
@ -56,7 +66,8 @@ const submitReport = () =>
|
|||
return api(getState).post('/api/v1/reports', {
|
||||
account_id: reports.getIn(['new', 'account_id']),
|
||||
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']),
|
||||
comment: reports.getIn(['new', 'comment']),
|
||||
forward: reports.getIn(['new', 'forward']),
|
||||
|
@ -97,6 +108,7 @@ const changeReportRule = (ruleId: string) => ({
|
|||
});
|
||||
|
||||
export {
|
||||
ReportableEntities,
|
||||
REPORT_INIT,
|
||||
REPORT_CANCEL,
|
||||
REPORT_SUBMIT_REQUEST,
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
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 { v4 as uuid } from 'uuid';
|
||||
|
||||
import { patchMe } from 'soapbox/actions/me';
|
||||
import messages from 'soapbox/locales/messages';
|
||||
import toast from 'soapbox/toast';
|
||||
import { isLoggedIn } from 'soapbox/utils/auth';
|
||||
|
||||
|
@ -21,9 +22,7 @@ type SettingOpts = {
|
|||
showAlert?: boolean
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
saveSuccess: { id: 'settings.save.success', defaultMessage: 'Your preferences have been saved!' },
|
||||
});
|
||||
const saveSuccessMessage = defineMessage({ id: 'settings.save.success', defaultMessage: 'Your preferences have been saved!' });
|
||||
|
||||
const defaultSettings = ImmutableMap({
|
||||
onboarded: false,
|
||||
|
@ -40,7 +39,7 @@ const defaultSettings = ImmutableMap({
|
|||
defaultPrivacy: 'public',
|
||||
defaultContentType: 'text/plain',
|
||||
themeMode: 'system',
|
||||
locale: navigator.language.split(/[-_]/)[0] || 'en',
|
||||
locale: navigator.language || 'en',
|
||||
showExplanationBox: true,
|
||||
explanationBox: true,
|
||||
autoloadTimelines: true,
|
||||
|
@ -221,7 +220,7 @@ const saveSettingsImmediate = (opts?: SettingOpts) =>
|
|||
dispatch({ type: SETTING_SAVE });
|
||||
|
||||
if (opts?.showAlert) {
|
||||
toast.success(messages.saveSuccess);
|
||||
toast.success(saveSuccessMessage);
|
||||
}
|
||||
}).catch(error => {
|
||||
toast.showAlertForError(error);
|
||||
|
@ -231,6 +230,12 @@ const saveSettingsImmediate = (opts?: SettingOpts) =>
|
|||
const saveSettings = (opts?: SettingOpts) =>
|
||||
(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 {
|
||||
SETTING_CHANGE,
|
||||
SETTING_SAVE,
|
||||
|
@ -242,4 +247,5 @@ export {
|
|||
changeSetting,
|
||||
saveSettingsImmediate,
|
||||
saveSettings,
|
||||
getLocale,
|
||||
};
|
||||
|
|
|
@ -48,6 +48,8 @@ const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
|
|||
const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
|
||||
const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
|
||||
|
||||
const STATUS_UNFILTER = 'STATUS_UNFILTER';
|
||||
|
||||
const statusExists = (getState: () => RootState, statusId: string) => {
|
||||
return (getState().statuses.get(statusId) || null) !== null;
|
||||
};
|
||||
|
@ -335,6 +337,11 @@ const undoStatusTranslation = (id: string) => ({
|
|||
id,
|
||||
});
|
||||
|
||||
const unfilterStatus = (id: string) => ({
|
||||
type: STATUS_UNFILTER,
|
||||
id,
|
||||
});
|
||||
|
||||
export {
|
||||
STATUS_CREATE_REQUEST,
|
||||
STATUS_CREATE_SUCCESS,
|
||||
|
@ -363,6 +370,7 @@ export {
|
|||
STATUS_TRANSLATE_SUCCESS,
|
||||
STATUS_TRANSLATE_FAIL,
|
||||
STATUS_TRANSLATE_UNDO,
|
||||
STATUS_UNFILTER,
|
||||
createStatus,
|
||||
editStatus,
|
||||
fetchStatus,
|
||||
|
@ -381,4 +389,5 @@ export {
|
|||
toggleStatusHidden,
|
||||
translateStatus,
|
||||
undoStatusTranslation,
|
||||
unfilterStatus,
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { getLocale, getSettings } from 'soapbox/actions/settings';
|
||||
import messages from 'soapbox/locales/messages';
|
||||
import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
|
@ -34,13 +34,6 @@ import type { APIEntity, Chat } from 'soapbox/types/entities';
|
|||
const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_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) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const me = getState().me;
|
||||
|
|
|
@ -23,7 +23,12 @@ export const getLinks = (response: AxiosResponse): LinkHeader => {
|
|||
|
||||
export const getNextLink = (response: AxiosResponse) => {
|
||||
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[]) => {
|
||||
|
|
|
@ -29,6 +29,10 @@ export const getNextLink = (response: AxiosResponse): string | undefined => {
|
|||
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) => {
|
||||
return authType === 'app' ? getAppToken(state) : getAccessToken(state);
|
||||
};
|
||||
|
|
|
@ -14,10 +14,11 @@ import RelativeTimestamp from './relative-timestamp';
|
|||
import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui';
|
||||
|
||||
import type { StatusApprovalStatus } from 'soapbox/normalizers/status';
|
||||
import type { Account as AccountSchema } from 'soapbox/schemas';
|
||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IInstanceFavicon {
|
||||
account: AccountEntity
|
||||
account: AccountEntity | AccountSchema
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
|
@ -67,7 +68,7 @@ const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children
|
|||
};
|
||||
|
||||
export interface IAccount {
|
||||
account: AccountEntity
|
||||
account: AccountEntity | AccountSchema
|
||||
action?: React.ReactElement
|
||||
actionAlignment?: 'center' | 'top'
|
||||
actionIcon?: string
|
||||
|
@ -90,6 +91,7 @@ export interface IAccount {
|
|||
showEdit?: boolean
|
||||
approvalStatus?: StatusApprovalStatus
|
||||
emoji?: string
|
||||
emojiUrl?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
|
@ -115,6 +117,7 @@ const Account = ({
|
|||
showEdit = false,
|
||||
approvalStatus,
|
||||
emoji,
|
||||
emojiUrl,
|
||||
note,
|
||||
}: IAccount) => {
|
||||
const overflowRef = useRef<HTMLDivElement>(null);
|
||||
|
@ -192,6 +195,7 @@ const Account = ({
|
|||
<Emoji
|
||||
className='absolute bottom-0 -right-1.5 h-5 w-5'
|
||||
emoji={emoji}
|
||||
src={emojiUrl}
|
||||
/>
|
||||
)}
|
||||
</LinkEl>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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 { joinPublicPath } from 'soapbox/utils/static';
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import clsx from 'clsx';
|
|||
import React, { useState } from 'react';
|
||||
|
||||
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';
|
||||
|
||||
|
|
|
@ -2,14 +2,13 @@ import clsx from 'clsx';
|
|||
import React from 'react';
|
||||
import { TransitionMotion, spring } from 'react-motion';
|
||||
|
||||
import { Icon } from 'soapbox/components/ui';
|
||||
import EmojiPickerDropdown from 'soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown';
|
||||
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
|
||||
import { useSettings } from 'soapbox/hooks';
|
||||
|
||||
import Reaction from './reaction';
|
||||
|
||||
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';
|
||||
|
||||
interface IReactionsBar {
|
||||
|
@ -24,7 +23,7 @@ const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, addR
|
|||
const reduceMotion = useSettings().get('reduceMotion');
|
||||
|
||||
const handleEmojiPick = (data: Emoji) => {
|
||||
addReaction(announcementId, data.native.replace(/:/g, ''));
|
||||
addReaction(announcementId, (data as NativeEmoji).native.replace(/:/g, ''));
|
||||
};
|
||||
|
||||
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>
|
||||
)}
|
||||
</TransitionMotion>
|
||||
|
|
|
@ -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 };
|
|
@ -1,38 +1,30 @@
|
|||
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';
|
||||
|
||||
export type Emoji = {
|
||||
id: string
|
||||
custom: boolean
|
||||
imageUrl: string
|
||||
native: string
|
||||
colons: string
|
||||
}
|
||||
|
||||
type UnicodeMapping = {
|
||||
filename: string
|
||||
}
|
||||
import type { Emoji } from 'soapbox/features/emoji';
|
||||
|
||||
interface IAutosuggestEmoji {
|
||||
emoji: Emoji
|
||||
}
|
||||
|
||||
const AutosuggestEmoji: React.FC<IAutosuggestEmoji> = ({ emoji }) => {
|
||||
let url;
|
||||
let url, alt;
|
||||
|
||||
if (emoji.custom) {
|
||||
if (isCustomEmoji(emoji)) {
|
||||
url = emoji.imageUrl;
|
||||
alt = emoji.colons;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const mapping: UnicodeMapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
|
||||
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
|
||||
|
||||
if (!mapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
url = joinPublicPath(`packs/emoji/${mapping.filename}.svg`);
|
||||
url = joinPublicPath(`packs/emoji/${mapping.unified}.svg`);
|
||||
alt = emoji.native;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -40,7 +32,7 @@ const AutosuggestEmoji: React.FC<IAutosuggestEmoji> = ({ emoji }) => {
|
|||
<img
|
||||
className='emojione'
|
||||
src={url}
|
||||
alt={emoji.native || emoji.colons}
|
||||
alt={alt}
|
||||
/>
|
||||
|
||||
{emoji.colons}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { List as ImmutableList } from 'immutable';
|
|||
import React from 'react';
|
||||
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 { Input, Portal } from 'soapbox/components/ui';
|
||||
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 { InputThemes } from 'soapbox/components/ui/input/input';
|
||||
import type { Emoji } from 'soapbox/features/emoji';
|
||||
|
||||
export type AutoSuggestion = string | Emoji;
|
||||
|
||||
|
|
|
@ -4,14 +4,14 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
import { Portal } from 'soapbox/components/ui';
|
||||
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
|
||||
import { isRtl } from 'soapbox/rtl';
|
||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||
|
||||
import AutosuggestAccount from '../features/compose/components/autosuggest-account';
|
||||
import { isRtl } from '../rtl';
|
||||
|
||||
import AutosuggestEmoji, { Emoji } from './autosuggest-emoji';
|
||||
import AutosuggestEmoji from './autosuggest-emoji';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { Emoji } from 'soapbox/features/emoji';
|
||||
|
||||
interface IAutosuggesteTextarea {
|
||||
id?: string
|
||||
|
|
|
@ -73,7 +73,7 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
|
|||
}
|
||||
|
||||
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
|
||||
href={item.href || item.to || '#'}
|
||||
role='button'
|
||||
|
|
|
@ -271,6 +271,10 @@ const DropdownMenu = (props: IDropdownMenu) => {
|
|||
};
|
||||
}, [refs.floating.current]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children ? (
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
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';
|
||||
|
||||
|
@ -17,43 +22,42 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
|||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden'>
|
||||
<Stack className='rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900 sm:rounded-xl'>
|
||||
<div className='relative -m-[1px] mb-0 h-[120px] rounded-t-lg bg-primary-100 dark:bg-gray-800 sm:rounded-t-xl'>
|
||||
{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'>
|
||||
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
|
||||
</div>
|
||||
</div>
|
||||
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
<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
|
||||
className='relative h-[240px] rounded-lg border border-solid border-gray-300 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={intl.formatMessage(messages.groupHeader)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -14,6 +14,9 @@ export interface IIcon extends React.HTMLAttributes<HTMLDivElement> {
|
|||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the UI Icon component directly.
|
||||
*/
|
||||
const Icon: React.FC<IIcon> = ({ src, alt, className, ...rest }) => {
|
||||
return (
|
||||
<div
|
||||
|
|
|
@ -4,8 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
|
||||
import { SelectDropdown } from '../features/forms';
|
||||
|
||||
import Icon from './icon';
|
||||
import { HStack, Select } from './ui';
|
||||
import { Icon, HStack, Select } from './ui';
|
||||
|
||||
interface IList {
|
||||
children: React.ReactNode
|
||||
|
@ -58,13 +57,13 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
|
|||
return (
|
||||
<Comp
|
||||
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,
|
||||
'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',
|
||||
'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/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}
|
||||
>
|
||||
<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 ? (
|
||||
<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'>
|
||||
{children}
|
||||
|
||||
{isSelected ? (
|
||||
<Icon src={require('@tabler/icons/check.svg')} className='ml-1 text-primary-500 dark:text-primary-400' />
|
||||
) : null}
|
||||
<div
|
||||
className={
|
||||
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>
|
||||
) : null}
|
||||
|
||||
|
|
|
@ -6,16 +6,17 @@ import { Button } from 'soapbox/components/ui';
|
|||
interface ILoadMore {
|
||||
onClick: React.MouseEventHandler
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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' />
|
||||
</Button>
|
||||
);
|
||||
|
|
|
@ -152,7 +152,14 @@ const Item: React.FC<IItem> = ({
|
|||
);
|
||||
|
||||
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' }}>
|
||||
<Blurhash hash={attachment.blurhash} className='media-gallery__preview' />
|
||||
<span className='media-gallery__item__icons'>{attachmentIcon}</span>
|
||||
|
@ -245,7 +252,14 @@ const Item: React.FC<IItem> = ({
|
|||
}
|
||||
|
||||
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 && (
|
||||
<div className='media-gallery__item-overflow'>
|
||||
+{total - ATTACHMENT_LIMIT + 1}
|
||||
|
@ -260,7 +274,7 @@ const Item: React.FC<IItem> = ({
|
|||
);
|
||||
};
|
||||
|
||||
interface IMediaGallery {
|
||||
export interface IMediaGallery {
|
||||
sensitive?: boolean
|
||||
media: ImmutableList<Attachment>
|
||||
height?: number
|
||||
|
@ -270,13 +284,15 @@ interface IMediaGallery {
|
|||
visible?: boolean
|
||||
onToggleVisibility?: () => void
|
||||
displayMedia?: string
|
||||
compact: boolean
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MediaGallery: React.FC<IMediaGallery> = (props) => {
|
||||
const {
|
||||
media,
|
||||
defaultWidth = 0,
|
||||
className,
|
||||
onOpenMedia,
|
||||
cacheWidth,
|
||||
compact,
|
||||
|
@ -546,7 +562,11 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
|
|||
}, [node.current]);
|
||||
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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 };
|
|
@ -52,6 +52,8 @@ interface IScrollableList extends VirtuosoProps<any, any> {
|
|||
alwaysPrepend?: boolean
|
||||
/** Message to display when the list is loaded but empty. */
|
||||
emptyMessage?: React.ReactNode
|
||||
/** Should the empty message be displayed in a Card */
|
||||
emptyMessageCard?: boolean
|
||||
/** Scrollable content. */
|
||||
children: Iterable<React.ReactNode>
|
||||
/** Callback when the list is scrolled to the top. */
|
||||
|
@ -87,6 +89,7 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
|||
children,
|
||||
isLoading,
|
||||
emptyMessage,
|
||||
emptyMessageCard = true,
|
||||
showLoading,
|
||||
onRefresh,
|
||||
onScroll,
|
||||
|
@ -158,13 +161,17 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
|||
<div className='mt-2'>
|
||||
{alwaysPrepend && prepend}
|
||||
|
||||
<Card variant='rounded' size='lg'>
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
emptyMessage
|
||||
)}
|
||||
</Card>
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
{emptyMessageCard ? (
|
||||
<Card variant='rounded' size='lg'>
|
||||
{emptyMessage}
|
||||
</Card>
|
||||
) : emptyMessage}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -10,7 +10,7 @@ import { closeSidebar } from 'soapbox/actions/sidebar';
|
|||
import Account from 'soapbox/components/account';
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
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 { 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 settings = useAppSelector((state) => getSettings(state));
|
||||
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
|
||||
const groupsPath = useGroupsPath();
|
||||
|
||||
const closeButtonRef = React.useRef(null);
|
||||
|
||||
|
@ -210,7 +211,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
|||
|
||||
{features.groups && (
|
||||
<SidebarLink
|
||||
to='/groups'
|
||||
to={groupsPath}
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
text={intl.formatMessage(messages.groups)}
|
||||
onClick={onClose}
|
||||
|
@ -296,7 +297,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{features.filters && (
|
||||
{(features.filters || features.filtersV2) && (
|
||||
<SidebarLink
|
||||
to='/filters'
|
||||
icon={require('@tabler/icons/filter.svg')}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||
import { Stack } from 'soapbox/components/ui';
|
||||
import { useStatContext } from 'soapbox/contexts/stat-context';
|
||||
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 SidebarNavigationLink from './sidebar-navigation-link';
|
||||
|
@ -25,6 +25,8 @@ const SidebarNavigation = () => {
|
|||
const features = useFeatures();
|
||||
const settings = useSettings();
|
||||
const account = useOwnAccount();
|
||||
const groupsPath = useGroupsPath();
|
||||
|
||||
const notificationCount = useAppSelector((state) => state.notifications.unread);
|
||||
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
|
||||
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
|
||||
|
@ -135,7 +137,7 @@ const SidebarNavigation = () => {
|
|||
|
||||
{features.groups && (
|
||||
<SidebarNavigationLink
|
||||
to='/groups'
|
||||
to={groupsPath}
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />}
|
||||
/>
|
||||
|
|
|
@ -8,11 +8,11 @@ import { launchChat } from 'soapbox/actions/chats';
|
|||
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
|
||||
import { editEvent } from 'soapbox/actions/events';
|
||||
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 { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||
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 DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
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 { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts';
|
||||
|
||||
import GroupPopover from './groups/popover/group-popover';
|
||||
|
||||
import type { Menu } from 'soapbox/components/dropdown-menu';
|
||||
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' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Like' },
|
||||
disfavourite: { id: 'status.disfavourite', defaultMessage: 'Disike' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this post' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: '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) => {
|
||||
dispatch(toggleBookmark(status));
|
||||
};
|
||||
|
@ -254,7 +265,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
secondary: intl.formatMessage(messages.blockAndReport),
|
||||
onSecondary: () => {
|
||||
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) => {
|
||||
dispatch(initReport(status.account as Account, { status }));
|
||||
dispatch(initReport(ReportableEntities.STATUS, status.account as Account, { status }));
|
||||
};
|
||||
|
||||
const handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
|
@ -538,7 +549,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
allowedEmoji,
|
||||
).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 = {
|
||||
'👍': messages.reactionLike,
|
||||
|
@ -550,7 +562,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
'': messages.favourite,
|
||||
};
|
||||
|
||||
const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite);
|
||||
const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiName || ''] || messages.favourite);
|
||||
|
||||
const menu = _makeMenu(publicStatus);
|
||||
let reblogIcon = require('@tabler/icons/repeat.svg');
|
||||
|
@ -607,14 +619,19 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
grow={space === 'expand'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<StatusActionButton
|
||||
title={replyTitle}
|
||||
icon={require('@tabler/icons/message-circle-2.svg')}
|
||||
onClick={handleReplyClick}
|
||||
count={replyCount}
|
||||
text={withLabels ? intl.formatMessage(messages.reply) : undefined}
|
||||
disabled={replyDisabled}
|
||||
/>
|
||||
<GroupPopover
|
||||
group={status.group as any}
|
||||
isEnabled={replyDisabled}
|
||||
>
|
||||
<StatusActionButton
|
||||
title={replyTitle}
|
||||
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) ? (
|
||||
<DropdownMenu
|
||||
|
@ -635,7 +652,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
icon={require('@tabler/icons/heart.svg')}
|
||||
filled
|
||||
color='accent'
|
||||
active={Boolean(meEmojiReact)}
|
||||
active={Boolean(meEmojiName)}
|
||||
count={emojiReactCount}
|
||||
emoji={meEmojiReact}
|
||||
text={withLabels ? meEmojiTitle : undefined}
|
||||
|
@ -644,16 +661,29 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
) : (
|
||||
<StatusActionButton
|
||||
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'
|
||||
filled
|
||||
onClick={handleFavouriteClick}
|
||||
active={Boolean(meEmojiReact)}
|
||||
active={Boolean(meEmojiName)}
|
||||
count={favouriteCount}
|
||||
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 && (
|
||||
<StatusActionButton
|
||||
title={intl.formatMessage(messages.share)}
|
||||
|
|
|
@ -4,6 +4,8 @@ import React from 'react';
|
|||
import { Text, Icon, Emoji } from 'soapbox/components/ui';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
const COLORS = {
|
||||
accent: 'accent',
|
||||
success: 'success',
|
||||
|
@ -31,7 +33,7 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes<HTMLButtonEleme
|
|||
active?: boolean
|
||||
color?: Color
|
||||
filled?: boolean
|
||||
emoji?: string
|
||||
emoji?: ImmutableMap<string, any>
|
||||
text?: React.ReactNode
|
||||
}
|
||||
|
||||
|
@ -42,7 +44,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
|
|||
if (emoji) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
} else {
|
||||
|
|
|
@ -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) {
|
||||
dispatch(simpleEmojiReact(status, emoji));
|
||||
dispatch(simpleEmojiReact(status, emoji, custom));
|
||||
} else {
|
||||
handleUnauthorized();
|
||||
}
|
||||
|
@ -71,7 +71,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
|
|||
};
|
||||
|
||||
const handleClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍';
|
||||
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji)?.get('name') || '👍';
|
||||
|
||||
if (isUserTouching()) {
|
||||
if (ownAccount) {
|
||||
|
@ -112,6 +112,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
|
|||
referenceElement={referenceElement}
|
||||
onReact={handleReact}
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { openModal } from 'soapbox/actions/modals';
|
|||
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
|
||||
import HoverStatusWrapper from 'soapbox/components/hover-status-wrapper';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { isPubkey } from 'soapbox/utils/nostr';
|
||||
|
||||
import type { Account, Status } from 'soapbox/types/entities';
|
||||
|
||||
|
@ -56,7 +57,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
|
|||
className='reply-mentions__account'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{account.username}
|
||||
@{isPubkey(account.username) ? account.username.slice(0, 8) : account.username}
|
||||
</Link>
|
||||
);
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import { useHistory } from 'react-router-dom';
|
|||
import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
|
||||
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
|
||||
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 TranslateButton from 'soapbox/components/translate-button';
|
||||
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 group = actualStatus.group as GroupEntity | null;
|
||||
|
||||
const filtered = (status.filtered.size || actualStatus.filtered.size) > 0;
|
||||
|
||||
// Track height changes we know about to compensate scrolling.
|
||||
useEffect(() => {
|
||||
didShowCard.current = Boolean(!muted && !hidden && status?.card);
|
||||
|
@ -202,6 +204,8 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
_expandEmojiSelector();
|
||||
};
|
||||
|
||||
const handleUnfilter = () => dispatch(unfilterStatus(status.filtered.size ? status.id : actualStatus.id));
|
||||
|
||||
const _expandEmojiSelector = (): void => {
|
||||
const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
|
||||
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 : {
|
||||
moveUp: handleHotkeyMoveUp,
|
||||
moveDown: handleHotkeyMoveDown,
|
||||
|
@ -291,7 +295,11 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
<HotKeys handlers={minHandlers}>
|
||||
<div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
|
||||
<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>
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
|
|
@ -8,8 +8,8 @@ const themes = {
|
|||
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',
|
||||
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',
|
||||
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80',
|
||||
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 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',
|
||||
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,
|
||||
}: IButtonStyles) => {
|
||||
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,
|
||||
[`${themes[theme]}`]: true,
|
||||
[`${sizes[size]}`]: true,
|
||||
|
|
|
@ -64,7 +64,7 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
|
|||
const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick };
|
||||
|
||||
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' />
|
||||
<span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
|
||||
</Comp>
|
||||
|
|
|
@ -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;
|
|
@ -7,10 +7,10 @@ import { useSoapboxConfig } from 'soapbox/hooks';
|
|||
|
||||
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. */
|
||||
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) => {
|
||||
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className, action }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const handleBackClick = () => {
|
||||
|
@ -29,6 +29,12 @@ const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) =
|
|||
return (
|
||||
<CardHeader className={className} onBackClick={handleBackClick}>
|
||||
<CardTitle title={label} />
|
||||
|
||||
{action && (
|
||||
<div className='flex grow justify-end'>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
@ -48,11 +54,12 @@ export interface IColumn {
|
|||
ref?: React.Ref<HTMLDivElement>
|
||||
/** Children to display in the column. */
|
||||
children?: React.ReactNode
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
/** A backdrop for the main section of the UI. */
|
||||
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();
|
||||
|
||||
return (
|
||||
|
@ -75,6 +82,7 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
|
|||
label={label}
|
||||
backHref={backHref}
|
||||
className={clsx({ 'px-4 pt-4 sm:p-0': transparent })}
|
||||
action={action}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { Placement } from '@popperjs/core';
|
||||
import { shift, useFloating, Placement, offset, OffsetOptions } from '@floating-ui/react';
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { usePopper } from 'react-popper';
|
||||
|
||||
import { Emoji, HStack, IconButton } from 'soapbox/components/ui';
|
||||
import { Picker } from 'soapbox/features/emoji/emoji-picker';
|
||||
import { useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { Emoji as EmojiComponent, HStack, IconButton } from 'soapbox/components/ui';
|
||||
import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown';
|
||||
import { useClickOutside, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
|
||||
|
||||
import type { Emoji } from 'soapbox/features/emoji';
|
||||
|
||||
interface IEmojiButton {
|
||||
/** Unicode emoji character. */
|
||||
|
@ -29,7 +30,7 @@ const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabInd
|
|||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
@ -37,14 +38,13 @@ const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabInd
|
|||
interface IEmojiSelector {
|
||||
onClose?(): void
|
||||
/** Event handler when an emoji is clicked. */
|
||||
onReact(emoji: string): void
|
||||
onReact(emoji: string, custom?: string): void
|
||||
/** Element that triggers the EmojiSelector Popper */
|
||||
referenceElement: HTMLElement | null
|
||||
placement?: Placement
|
||||
/** Whether the selector should be visible. */
|
||||
visible?: boolean
|
||||
/** X/Y offset of the floating picker. */
|
||||
offset?: [number, number]
|
||||
offsetOptions?: OffsetOptions
|
||||
/** Whether to allow any emoji to be chosen. */
|
||||
all?: boolean
|
||||
}
|
||||
|
@ -56,81 +56,65 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
|
|||
onReact,
|
||||
placement = 'top',
|
||||
visible = false,
|
||||
offset = [-10, 0],
|
||||
offsetOptions,
|
||||
all = true,
|
||||
}): JSX.Element => {
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
const { customEmojiReacts } = useFeatures();
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// `useRef` won't trigger a re-render, while `useState` does.
|
||||
// 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, {
|
||||
const { x, y, strategy, refs, update } = useFloating<HTMLElement>({
|
||||
placement,
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset,
|
||||
},
|
||||
},
|
||||
],
|
||||
middleware: [offset(offsetOptions), shift()],
|
||||
});
|
||||
|
||||
const handleExpand: React.MouseEventHandler = () => {
|
||||
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(() => {
|
||||
setExpanded(false);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [referenceElement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && update) {
|
||||
update();
|
||||
useClickOutside(refs, () => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, [visible, update]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded && update) {
|
||||
update();
|
||||
}
|
||||
}, [expanded, update]);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('z-[101] transition-opacity duration-100', {
|
||||
'opacity-0 pointer-events-none': !visible,
|
||||
})}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
ref={refs.setFloating}
|
||||
style={{
|
||||
position: strategy,
|
||||
top: y ?? 0,
|
||||
left: x ?? 0,
|
||||
width: 'max-content',
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<Picker
|
||||
set='twitter'
|
||||
backgroundImageFn={() => require('emoji-datasource/img/twitter/sheets/32.png')}
|
||||
onClick={(emoji: any) => onReact(emoji.native)}
|
||||
<EmojiPickerDropdown
|
||||
visible={expanded}
|
||||
setVisible={setExpanded}
|
||||
update={update}
|
||||
withCustom={customEmojiReacts}
|
||||
onPickEmoji={handlePickEmoji}
|
||||
/>
|
||||
) : (
|
||||
<HStack
|
||||
|
|
|
@ -10,7 +10,7 @@ interface IEmoji extends React.ImgHTMLAttributes<HTMLImageElement> {
|
|||
|
||||
/** A single emoji image. */
|
||||
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 filename = codepoints.join('-');
|
||||
|
||||
|
@ -20,7 +20,7 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
|
|||
<img
|
||||
draggable='false'
|
||||
alt={alt || emoji}
|
||||
src={joinPublicPath(`packs/emoji/${filename}.svg`)}
|
||||
src={src || joinPublicPath(`packs/emoji/${filename}.svg`)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -86,6 +86,12 @@ const FormGroup: React.FC<IFormGroup> = (props) => {
|
|||
)}
|
||||
|
||||
<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}
|
||||
{inputChildren.filter((_, i) => i !== 0)}
|
||||
|
||||
|
@ -97,12 +103,6 @@ const FormGroup: React.FC<IFormGroup> = (props) => {
|
|||
{errors.join(', ')}
|
||||
</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>
|
||||
);
|
||||
|
|
|
@ -14,7 +14,7 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|||
/** Don't render a background behind the icon. */
|
||||
transparent?: boolean
|
||||
/** Predefined styles to display for the button. */
|
||||
theme?: 'seamless' | 'outlined'
|
||||
theme?: 'seamless' | 'outlined' | 'secondary'
|
||||
/** Override the data-testid */
|
||||
'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', {
|
||||
'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-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,
|
||||
}, className)}
|
||||
{...filteredProps}
|
||||
|
|
|
@ -2,6 +2,7 @@ export { default as Accordion } from './accordion/accordion';
|
|||
export { default as Avatar } from './avatar/avatar';
|
||||
export { default as Banner } from './banner/banner';
|
||||
export { default as Button } from './button/button';
|
||||
export { default as Carousel } from './carousel/carousel';
|
||||
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
||||
export { default as Checkbox } from './checkbox/checkbox';
|
||||
export { Column, ColumnHeader } from './column/column';
|
||||
|
@ -38,6 +39,7 @@ export {
|
|||
} from './menu/menu';
|
||||
export { default as Modal } from './modal/modal';
|
||||
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 ProgressBar } from './progress-bar/progress-bar';
|
||||
export { default as RadioButton } from './radio-button/radio-button';
|
||||
|
|
|
@ -84,8 +84,10 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
|||
type={revealed ? 'text' : type}
|
||||
ref={ref}
|
||||
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),
|
||||
'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-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,
|
||||
|
|
|
@ -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;
|
|
@ -11,6 +11,7 @@ const spaces = {
|
|||
4: 'space-y-4',
|
||||
5: 'space-y-5',
|
||||
6: 'space-y-6',
|
||||
9: 'space-y-9',
|
||||
10: 'space-y-10',
|
||||
};
|
||||
|
||||
|
|
|
@ -33,6 +33,8 @@ interface IStreamfield {
|
|||
onChange: (values: any[]) => void
|
||||
/** Input to render for each value. */
|
||||
component: StreamfieldComponent<any>
|
||||
/** Minimum number of allowed inputs. */
|
||||
minItems?: number
|
||||
/** Maximum number of allowed inputs. */
|
||||
maxItems?: number
|
||||
}
|
||||
|
@ -47,6 +49,7 @@ const Streamfield: React.FC<IStreamfield> = ({
|
|||
onChange,
|
||||
component: Component,
|
||||
maxItems = Infinity,
|
||||
minItems = 0,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
|
@ -67,10 +70,10 @@ const Streamfield: React.FC<IStreamfield> = ({
|
|||
|
||||
{(values.length > 0) && (
|
||||
<Stack>
|
||||
{values.map((value, i) => (
|
||||
{values.map((value, i) => value?._destroy ? null : (
|
||||
<HStack space={2} alignItems='center'>
|
||||
<Component key={i} onChange={handleChange(i)} value={value} />
|
||||
{onRemoveItem && (
|
||||
{values.length > minItems && onRemoveItem && (
|
||||
<IconButton
|
||||
iconClassName='h-4 w-4'
|
||||
className='bg-transparent text-gray-400 hover:text-gray-600'
|
||||
|
|
|
@ -156,7 +156,7 @@ const Tabs = ({ items, activeItem }: ITabs) => {
|
|||
>
|
||||
<div className='relative'>
|
||||
{count ? (
|
||||
<span className='absolute -top-2 left-full ml-1'>
|
||||
<span className='absolute left-full ml-2'>
|
||||
<Counter count={count} />
|
||||
</span>
|
||||
) : null}
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
import clsx from 'clsx';
|
||||
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'> {
|
||||
/** Put the cursor into the input on mount. */
|
||||
|
@ -28,6 +32,8 @@ interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElemen
|
|||
isResizeable?: boolean
|
||||
/** Textarea theme. */
|
||||
theme?: 'default' | 'transparent'
|
||||
/** Whether to display a character counter below the textarea. */
|
||||
withCounter?: boolean
|
||||
}
|
||||
|
||||
/** Textarea with custom styles. */
|
||||
|
@ -40,8 +46,11 @@ const Textarea = React.forwardRef(({
|
|||
maxRows = 10,
|
||||
minRows = 1,
|
||||
theme = 'default',
|
||||
maxLength,
|
||||
value,
|
||||
...props
|
||||
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
|
||||
const length = value?.length || 0;
|
||||
const [rows, setRows] = useState<number>(autoGrow ? 1 : 4);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
|
@ -70,20 +79,35 @@ const Textarea = React.forwardRef(({
|
|||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
ref={ref}
|
||||
rows={rows}
|
||||
onChange={handleChange}
|
||||
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-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':
|
||||
theme === 'default',
|
||||
'bg-transparent border-0 focus:border-0 focus:ring-0': theme === 'transparent',
|
||||
'font-mono': isCodeEditor,
|
||||
'text-red-600 border-red-600': hasError,
|
||||
'resize-none': !isResizeable,
|
||||
})}
|
||||
/>
|
||||
<Stack space={1.5}>
|
||||
<textarea
|
||||
{...props}
|
||||
value={value}
|
||||
ref={ref}
|
||||
rows={rows}
|
||||
onChange={handleChange}
|
||||
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-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':
|
||||
theme === 'default',
|
||||
'bg-transparent border-0 focus:border-0 focus:ring-0': theme === 'transparent',
|
||||
'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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
});
|
|
@ -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 };
|
|
@ -0,0 +1,6 @@
|
|||
export enum Entities {
|
||||
ACCOUNTS = 'Accounts',
|
||||
GROUPS = 'Groups',
|
||||
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
||||
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
||||
}
|
|
@ -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';
|
|
@ -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,
|
||||
};
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -103,7 +103,7 @@ const MediaItem: React.FC<IMediaItem> = ({ attachment, onOpenMedia }) => {
|
|||
} else if (attachment.type === 'audio') {
|
||||
const remoteURL = attachment.remote_url || '';
|
||||
const fileExtensionLastIndex = remoteURL.lastIndexOf('.');
|
||||
const fileExtension = remoteURL.substr(fileExtensionLastIndex + 1).toUpperCase();
|
||||
const fileExtension = remoteURL.slice(fileExtensionLastIndex + 1).toUpperCase();
|
||||
thumbnail = (
|
||||
<div className='media-gallery__item-thumbnail'>
|
||||
<span className='media-gallery__item__icons'><Icon src={require('@tabler/icons/volume.svg')} /></span>
|
||||
|
|
|
@ -121,7 +121,7 @@ const AccountGallery = () => {
|
|||
let loadOlder = null;
|
||||
|
||||
if (hasMore && !(isLoading && attachments.size === 0)) {
|
||||
loadOlder = <LoadMore visible={!isLoading} onClick={handleLoadOlder} />;
|
||||
loadOlder = <LoadMore className='my-auto' visible={!isLoading} onClick={handleLoadOlder} />;
|
||||
}
|
||||
|
||||
if (unavailable) {
|
||||
|
|
|
@ -12,7 +12,7 @@ import { mentionCompose, directCompose } from 'soapbox/actions/compose';
|
|||
import { blockDomain, unblockDomain } from 'soapbox/actions/domain-blocks';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
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 { getSettings } from 'soapbox/actions/settings';
|
||||
import Badge from 'soapbox/components/badge';
|
||||
|
@ -136,7 +136,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
secondary: intl.formatMessage(messages.blockAndReport),
|
||||
onSecondary: () => {
|
||||
dispatch(blockAccount(account.id));
|
||||
dispatch(initReport(account));
|
||||
dispatch(initReport(ReportableEntities.ACCOUNT, account));
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
@ -171,7 +171,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
};
|
||||
|
||||
const onReport = () => {
|
||||
dispatch(initReport(account));
|
||||
dispatch(initReport(ReportableEntities.ACCOUNT, account));
|
||||
};
|
||||
|
||||
const onMute = () => {
|
||||
|
|
|
@ -1,17 +1,10 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { approveUsers } from 'soapbox/actions/admin';
|
||||
import { rejectUserModal } from 'soapbox/actions/moderation';
|
||||
import { Stack, HStack, Text, IconButton } from 'soapbox/components/ui';
|
||||
import { approveUsers, deleteUsers } from 'soapbox/actions/admin';
|
||||
import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons';
|
||||
import { Stack, HStack, Text } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
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 {
|
||||
accountId: string
|
||||
|
@ -19,7 +12,6 @@ interface IUnapprovedAccount {
|
|||
|
||||
/** Displays an unapproved account for moderation purposes. */
|
||||
const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
|
@ -28,21 +20,8 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
|
|||
|
||||
if (!account) return null;
|
||||
|
||||
const handleApprove = () => {
|
||||
dispatch(approveUsers([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);
|
||||
}));
|
||||
};
|
||||
const handleApprove = () => dispatch(approveUsers([account.id]));
|
||||
const handleReject = () => dispatch(deleteUsers([account.id]));
|
||||
|
||||
return (
|
||||
<HStack space={4} justifyContent='between'>
|
||||
|
@ -55,20 +34,13 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
|
|||
</Text>
|
||||
</Stack>
|
||||
|
||||
<HStack space={2} alignItems='center'>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/check.svg')}
|
||||
onClick={handleApprove}
|
||||
theme='outlined'
|
||||
iconClassName='p-1 text-gray-600 dark:text-gray-400'
|
||||
<Stack justifyContent='center'>
|
||||
<AuthorizeRejectButtons
|
||||
onAuthorize={handleApprove}
|
||||
onReject={handleReject}
|
||||
countdown={3000}
|
||||
/>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
onClick={handleReject}
|
||||
theme='outlined'
|
||||
iconClassName='p-1 text-gray-600 dark:text-gray-400'
|
||||
/>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -499,13 +499,13 @@ const Audio: React.FC<IAudio> = (props) => {
|
|||
|
||||
<div
|
||||
className='video-player__seek__progress'
|
||||
style={{ width: `${progress}%`, backgroundColor: _getAccentColor() }}
|
||||
style={{ width: `${progress}%`, backgroundColor: accentColor }}
|
||||
/>
|
||||
|
||||
<span
|
||||
className={clsx('video-player__seek__handle', { active: dragging })}
|
||||
tabIndex={0}
|
||||
style={{ left: `${progress}%`, backgroundColor: _getAccentColor() }}
|
||||
style={{ left: `${progress}%`, backgroundColor: accentColor }}
|
||||
onKeyDown={handleAudioKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -28,6 +28,7 @@ const messages = defineMessages({
|
|||
newsletter: { id: 'registration.newsletter', defaultMessage: 'Subscribe to newsletter.' },
|
||||
needsConfirmationHeader: { id: 'confirmations.register.needs_confirmation.header', defaultMessage: 'Confirmation 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 {
|
||||
|
@ -296,13 +297,14 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
|
|||
{needsApproval && (
|
||||
<FormGroup
|
||||
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
|
||||
name='reason'
|
||||
placeholder={intl.formatMessage(messages.reasonHint)}
|
||||
maxLength={500}
|
||||
onChange={onInputChange}
|
||||
value={params.get('reason', '')}
|
||||
autoGrow
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
|
|
@ -3,7 +3,7 @@ import { defineMessages, FormattedDate, useIntl } from 'react-intl';
|
|||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
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 { Token } from 'soapbox/reducers/security';
|
||||
|
||||
|
@ -59,12 +59,11 @@ const AuthToken: React.FC<IAuthToken> = ({ token, isCurrent }) => {
|
|||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<HStack justifyContent='end'>
|
||||
<Button theme={isCurrent ? 'danger' : 'primary'} onClick={handleRevoke}>
|
||||
{intl.formatMessage(messages.revoke)}
|
||||
</Button>
|
||||
</div>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -39,7 +39,7 @@ const Account: React.FC<IAccount> = ({ accountId }) => {
|
|||
date: formattedBirthday,
|
||||
})}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/ballon.svg')} />
|
||||
<Icon src={require('@tabler/icons/balloon.svg')} />
|
||||
{formattedBirthday}
|
||||
</div>
|
||||
</HStack>
|
||||
|
|
|
@ -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 { useChatContext } from 'soapbox/contexts/chat-context';
|
||||
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 { Attachment } from 'soapbox/types/entities';
|
||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||
|
||||
import ChatTextarea from './chat-textarea';
|
||||
|
||||
import type { Emoji, NativeEmoji } from 'soapbox/features/emoji';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'chat.input.placeholder', defaultMessage: 'Type a message' },
|
||||
send: { id: 'chat.actions.send', defaultMessage: 'Send' },
|
||||
|
@ -31,7 +33,7 @@ const initialSuggestionState = {
|
|||
};
|
||||
|
||||
interface Suggestion {
|
||||
list: { native: string, colons: string }[]
|
||||
list: Emoji[]
|
||||
tokenStart: number
|
||||
token: string
|
||||
}
|
||||
|
@ -45,7 +47,7 @@ interface IChatComposer extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaEl
|
|||
resetContentKey: number | null
|
||||
attachments?: Attachment[]
|
||||
onDeleteAttachment?: (i: number) => void
|
||||
isUploading?: boolean
|
||||
uploadCount?: number
|
||||
uploadProgress?: number
|
||||
}
|
||||
|
||||
|
@ -63,7 +65,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
|||
onPaste,
|
||||
attachments = [],
|
||||
onDeleteAttachment,
|
||||
isUploading,
|
||||
uploadCount = 0,
|
||||
uploadProgress,
|
||||
}, ref) => {
|
||||
const intl = useIntl();
|
||||
|
@ -80,6 +82,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
|||
const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState);
|
||||
const isSuggestionsAvailable = suggestions.list.length > 0;
|
||||
|
||||
const isUploading = uploadCount > 0;
|
||||
const hasAttachment = attachments.length > 0;
|
||||
const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
|
||||
const isSubmitDisabled = disabled || isUploading || isOverCharacterLimit || (value.length === 0 && !hasAttachment);
|
||||
|
@ -107,7 +110,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
|||
);
|
||||
|
||||
if (token && tokenStart) {
|
||||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any);
|
||||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
|
||||
setSuggestions({
|
||||
list: results,
|
||||
token,
|
||||
|
@ -198,7 +201,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
|||
disabled={disabled}
|
||||
attachments={attachments}
|
||||
onDeleteAttachment={onDeleteAttachment}
|
||||
isUploading={isUploading}
|
||||
uploadCount={uploadCount}
|
||||
uploadProgress={uploadProgress}
|
||||
/>
|
||||
{isSuggestionsAvailable ? (
|
||||
|
@ -209,7 +212,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
|||
key={emojiSuggestion.colons}
|
||||
value={renderSuggestionValue(emojiSuggestion)}
|
||||
>
|
||||
<span>{emojiSuggestion.native}</span>
|
||||
<span>{(emojiSuggestion as NativeEmoji).native}</span>
|
||||
<span className='ml-1'>
|
||||
{emojiSuggestion.colons}
|
||||
</span>
|
||||
|
|
|
@ -44,7 +44,7 @@ function ChatMessageReactionWrapper(props: IChatMessageReactionWrapper) {
|
|||
referenceElement={referenceElement}
|
||||
onReact={handleSelect}
|
||||
onClose={() => setIsOpen(false)}
|
||||
offset={[-10, 12]}
|
||||
offsetOptions={{ mainAxis: 12, crossAxis: -10 }}
|
||||
all={false}
|
||||
/>
|
||||
</Portal>
|
||||
|
|
|
@ -2,7 +2,7 @@ import clsx from 'clsx';
|
|||
import React from 'react';
|
||||
|
||||
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';
|
||||
|
||||
interface IChatMessageReaction {
|
||||
|
@ -42,4 +42,4 @@ const ChatMessageReaction = (props: IChatMessageReaction) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default ChatMessageReaction;
|
||||
export default ChatMessageReaction;
|
||||
|
|
|
@ -6,10 +6,10 @@ import React, { useMemo, useState } from 'react';
|
|||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
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 { 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 { MediaGallery } from 'soapbox/features/ui/util/async-components';
|
||||
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 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({
|
||||
copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' },
|
||||
|
@ -112,8 +113,12 @@ const ChatMessage = (props: IChatMessage) => {
|
|||
|
||||
return (
|
||||
<Bundle fetchComponent={MediaGallery}>
|
||||
{(Component: any) => (
|
||||
{(Component: React.FC<IMediaGallery>) => (
|
||||
<Component
|
||||
className={clsx({
|
||||
'rounded-br-sm': isMyMessage && content,
|
||||
'rounded-bl-sm': !isMyMessage && content,
|
||||
})}
|
||||
media={chatMessage.media_attachments}
|
||||
onOpenMedia={onOpenMedia}
|
||||
visible
|
||||
|
@ -173,7 +178,7 @@ const ChatMessage = (props: IChatMessage) => {
|
|||
if (features.reportChats) {
|
||||
menu.push({
|
||||
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'),
|
||||
});
|
||||
}
|
||||
|
@ -385,4 +390,4 @@ const ChatMessage = (props: IChatMessage) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default ChatMessage;
|
||||
export default ChatMessage;
|
||||
|
|
|
@ -5,7 +5,7 @@ import { __stub } from 'soapbox/api';
|
|||
import { ChatContext } from 'soapbox/contexts/chat-context';
|
||||
import { StatProvider } from 'soapbox/contexts/stat-context';
|
||||
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';
|
||||
|
||||
|
@ -22,28 +22,28 @@ const renderComponentWithChatContext = (store = {}) => render(
|
|||
);
|
||||
|
||||
describe('<ChatPane />', () => {
|
||||
describe('when there are no chats', () => {
|
||||
let store: ReturnType<typeof mockStore>;
|
||||
// describe('when there are no chats', () => {
|
||||
// let store: ReturnType<typeof mockStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.2.0)');
|
||||
store = mockStore(state);
|
||||
// beforeEach(() => {
|
||||
// const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.2.0)');
|
||||
// store = mockStore(state);
|
||||
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/pleroma/chats').reply(200, [], {
|
||||
link: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
// __stub((mock) => {
|
||||
// mock.onGet('/api/v1/pleroma/chats').reply(200, [], {
|
||||
// link: null,
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
||||
it('renders the blankslate', async () => {
|
||||
renderComponentWithChatContext(store);
|
||||
// it('renders the blankslate', async () => {
|
||||
// renderComponentWithChatContext(store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-pane-blankslate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
// await waitFor(() => {
|
||||
// expect(screen.getByTestId('chat-pane-blankslate')).toBeInTheDocument();
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
||||
describe('when the software is not Truth Social', () => {
|
||||
beforeEach(() => {
|
||||
|
|
|
@ -9,18 +9,20 @@ import ChatUpload from './chat-upload';
|
|||
interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
|
||||
attachments?: Attachment[]
|
||||
onDeleteAttachment?: (i: number) => void
|
||||
isUploading?: boolean
|
||||
uploadCount?: number
|
||||
uploadProgress?: number
|
||||
}
|
||||
|
||||
/** Custom textarea for chats. */
|
||||
const ChatTextarea: React.FC<IChatTextarea> = ({
|
||||
const ChatTextarea: React.FC<IChatTextarea> = React.forwardRef(({
|
||||
attachments,
|
||||
onDeleteAttachment,
|
||||
isUploading = false,
|
||||
uploadCount = 0,
|
||||
uploadProgress = 0,
|
||||
...rest
|
||||
}) => {
|
||||
}, ref) => {
|
||||
const isUploading = uploadCount > 0;
|
||||
|
||||
const handleDeleteAttachment = (i: number) => {
|
||||
return () => {
|
||||
if (onDeleteAttachment) {
|
||||
|
@ -54,17 +56,17 @@ const ChatTextarea: React.FC<IChatTextarea> = ({
|
|||
</div>
|
||||
))}
|
||||
|
||||
{isUploading && (
|
||||
{Array.from(Array(uploadCount)).map(() => (
|
||||
<div className='ml-2 mt-2 flex'>
|
||||
<ChatPendingUpload progress={uploadProgress} />
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<Textarea theme='transparent' {...rest} />
|
||||
<Textarea ref={ref} theme='transparent' {...rest} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default ChatTextarea;
|
||||
|
|
|
@ -57,7 +57,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
|||
|
||||
const [content, setContent] = useState<string>('');
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadCount, setUploadCount] = useState(0);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [resetContentKey, setResetContentKey] = useState<number>(fileKeyGen());
|
||||
const [resetFileKey, setResetFileKey] = useState<number>(fileKeyGen());
|
||||
|
@ -86,7 +86,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
|||
}
|
||||
setContent('');
|
||||
setAttachments([]);
|
||||
setIsUploading(false);
|
||||
setUploadCount(0);
|
||||
setUploadProgress(0);
|
||||
setResetFileKey(fileKeyGen());
|
||||
setResetContentKey(fileKeyGen());
|
||||
|
@ -151,17 +151,21 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadCount(files.length);
|
||||
|
||||
const data = new FormData();
|
||||
data.append('file', files[0]);
|
||||
|
||||
dispatch(uploadMedia(data, onUploadProgress)).then((response: any) => {
|
||||
setAttachments([...attachments, normalizeAttachment(response.data)]);
|
||||
setIsUploading(false);
|
||||
}).catch(() => {
|
||||
setIsUploading(false);
|
||||
const promises = Array.from(files).map(async(file) => {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
const response = await dispatch(uploadMedia(data, onUploadProgress));
|
||||
return normalizeAttachment(response.data);
|
||||
});
|
||||
|
||||
return Promise.all(promises)
|
||||
.then((newAttachments) => {
|
||||
setAttachments([...attachments, ...newAttachments]);
|
||||
setUploadCount(0);
|
||||
})
|
||||
.catch(() => setUploadCount(0));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -189,7 +193,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
|||
onPaste={handlePaste}
|
||||
attachments={attachments}
|
||||
onDeleteAttachment={handleRemoveFile}
|
||||
isUploading={isUploading}
|
||||
uploadCount={uploadCount}
|
||||
uploadProgress={uploadProgress}
|
||||
/>
|
||||
</Stack>
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
|
||||
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 { isMobile } from 'soapbox/is-mobile';
|
||||
|
||||
|
@ -26,7 +27,6 @@ import UploadButtonContainer from '../containers/upload-button-container';
|
|||
import WarningContainer from '../containers/warning-container';
|
||||
import { countableText } from '../util/counter';
|
||||
|
||||
import EmojiPickerDropdown from './emoji-picker/emoji-picker-dropdown';
|
||||
import MarkdownButton from './markdown-button';
|
||||
import PollButton from './poll-button';
|
||||
import PollForm from './polls/poll-form';
|
||||
|
@ -40,7 +40,7 @@ import UploadForm from './upload-form';
|
|||
import VisualCharacterCounter from './visual-character-counter';
|
||||
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';
|
||||
|
||||
|
@ -116,7 +116,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
// FIXME: Make this less brittle
|
||||
getClickableArea(),
|
||||
document.querySelector('.privacy-dropdown__dropdown'),
|
||||
document.querySelector('.emoji-picker-dropdown__menu'),
|
||||
document.querySelector('em-emoji-picker'),
|
||||
document.getElementById('modal-overlay'),
|
||||
].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 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));
|
||||
};
|
||||
|
@ -226,7 +226,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
const renderButtons = useCallback(() => (
|
||||
<HStack alignItems='center' space={2}>
|
||||
{features.media && <UploadButtonContainer composeId={id} />}
|
||||
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
|
||||
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} condensed={shouldCondense} />
|
||||
{features.polls && <PollButton composeId={id} />}
|
||||
{features.privacyScopes && !group && !groupId && <PrivacyDropdown composeId={id} />}
|
||||
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Select } from 'soapbox/components/ui';
|
||||
|
@ -20,15 +20,7 @@ const DurationSelector = ({ onDurationChange }: IDurationSelector) => {
|
|||
const [hours, setHours] = useState<number>(0);
|
||||
const [minutes, setMinutes] = useState<number>(0);
|
||||
|
||||
const value = useMemo(() => {
|
||||
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]);
|
||||
const value = (days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60);
|
||||
|
||||
useEffect(() => {
|
||||
if (days === 7) {
|
||||
|
|
|
@ -6,6 +6,7 @@ import { openModal } from 'soapbox/actions/modals';
|
|||
import { useAppSelector, useCompose, useFeatures } from 'soapbox/hooks';
|
||||
import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
import { isPubkey } from 'soapbox/utils/nostr';
|
||||
|
||||
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) => (
|
||||
<span className='reply-mentions__account'>@{acct.split('@')[0]}</span>
|
||||
)).toArray();
|
||||
const accounts = to.slice(0, 2).map((acct: string) => {
|
||||
const username = acct.split('@')[0];
|
||||
return (
|
||||
<span className='reply-mentions__account'>
|
||||
@{isPubkey(username) ? username.slice(0, 8) : username}
|
||||
</span>
|
||||
);
|
||||
}).toArray();
|
||||
|
||||
if (to.size > 2) {
|
||||
accounts.push(
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue