diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 953c7e252..786d52d13 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -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 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f4b01b942..9516ec2d6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ee3bcfd..ee32cbb92 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/README.md b/README.md index 2504de278..f0a74b88f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/assets/icons/COPYING.md b/app/assets/icons/COPYING.md index 1dcc928d9..a5dbe7d98 100644 --- a/app/assets/icons/COPYING.md +++ b/app/assets/icons/COPYING.md @@ -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 diff --git a/app/soapbox/__fixtures__/group-truthsocial.json b/app/soapbox/__fixtures__/group-truthsocial.json new file mode 100644 index 000000000..f874f6892 --- /dev/null +++ b/app/soapbox/__fixtures__/group-truthsocial.json @@ -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": [] +} \ No newline at end of file diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index 75ff84ae5..8be60bfc7 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -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 = { 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)); diff --git a/app/soapbox/actions/emoji-reacts.ts b/app/soapbox/actions/emoji-reacts.ts index ac205d38d..746a7372f 100644 --- a/app/soapbox/actions/emoji-reacts.ts +++ b/app/soapbox/actions/emoji-reacts.ts @@ -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> = 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, }); diff --git a/app/soapbox/actions/emojis.ts b/app/soapbox/actions/emojis.ts index 46f591b06..f3d33ac61 100644 --- a/app/soapbox/actions/emojis.ts +++ b/app/soapbox/actions/emojis.ts @@ -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'; diff --git a/app/soapbox/actions/events.ts b/app/soapbox/actions/events.ts index d4ec49491..44a9ae207 100644 --- a/app/soapbox/actions/events.ts +++ b/app/soapbox/actions/events.ts @@ -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 = () => ({ diff --git a/app/soapbox/actions/filters.ts b/app/soapbox/actions/filters.ts index 7e663f88d..ee3508682 100644 --- a/app/soapbox/actions/filters.ts +++ b/app/soapbox/actions/filters.ts @@ -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, 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, 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 }); }; -const deleteFilter = (id: string) => +const createFilterV2 = (title: string, expires_in: string | null, context: Array, 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, 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, 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, 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, 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, -}; \ No newline at end of file +}; diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts index 9715396f3..8a6ad065e 100644 --- a/app/soapbox/actions/groups.ts +++ b/app/soapbox/actions/groups.ts @@ -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, 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 = { 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, diff --git a/app/soapbox/actions/importer/index.ts b/app/soapbox/actions/importer/index.ts index 8750a5d61..ec0ec3121 100644 --- a/app/soapbox/actions/importer/index.ts +++ b/app/soapbox/actions/importer/index.ts @@ -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) => diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index 9e43d0f40..40d981139 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -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, diff --git a/app/soapbox/actions/moderation.tsx b/app/soapbox/actions/moderation.tsx index 5b0a4a5f2..c236a2986 100644 --- a/app/soapbox/actions/moderation.tsx +++ b/app/soapbox/actions/moderation.tsx @@ -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, }; diff --git a/app/soapbox/actions/reports.ts b/app/soapbox/actions/reports.ts index d6a24a8c8..f51ef1f0a 100644 --- a/app/soapbox/actions/reports.ts +++ b/app/soapbox/actions/reports.ts @@ -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, diff --git a/app/soapbox/actions/settings.ts b/app/soapbox/actions/settings.ts index 79ffe1975..fdc6f394c 100644 --- a/app/soapbox/actions/settings.ts +++ b/app/soapbox/actions/settings.ts @@ -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, }; diff --git a/app/soapbox/actions/statuses.ts b/app/soapbox/actions/statuses.ts index 047d61d71..b14108de2 100644 --- a/app/soapbox/actions/statuses.ts +++ b/app/soapbox/actions/statuses.ts @@ -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, }; diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts index c9095e021..f3e393f27 100644 --- a/app/soapbox/actions/streaming.ts +++ b/app/soapbox/actions/streaming.ts @@ -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; diff --git a/app/soapbox/api/__mocks__/index.ts b/app/soapbox/api/__mocks__/index.ts index 92175d076..d0931a397 100644 --- a/app/soapbox/api/__mocks__/index.ts +++ b/app/soapbox/api/__mocks__/index.ts @@ -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[]) => { diff --git a/app/soapbox/api/index.ts b/app/soapbox/api/index.ts index c7fcb6230..fc19e7c41 100644 --- a/app/soapbox/api/index.ts +++ b/app/soapbox/api/index.ts @@ -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); }; diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index 0a435f48f..20150b79d 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -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 = ({ 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(null); @@ -192,6 +195,7 @@ const Account = ({ )} diff --git a/app/soapbox/components/announcements/emoji.tsx b/app/soapbox/components/announcements/emoji.tsx index ecc28fcf8..0059e02b7 100644 --- a/app/soapbox/components/announcements/emoji.tsx +++ b/app/soapbox/components/announcements/emoji.tsx @@ -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'; diff --git a/app/soapbox/components/announcements/reaction.tsx b/app/soapbox/components/announcements/reaction.tsx index c5ea60212..0d7bd973f 100644 --- a/app/soapbox/components/announcements/reaction.tsx +++ b/app/soapbox/components/announcements/reaction.tsx @@ -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'; diff --git a/app/soapbox/components/announcements/reactions-bar.tsx b/app/soapbox/components/announcements/reactions-bar.tsx index ebe651056..55b72c59a 100644 --- a/app/soapbox/components/announcements/reactions-bar.tsx +++ b/app/soapbox/components/announcements/reactions-bar.tsx @@ -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 = ({ 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 = ({ announcementId, reactions, addR /> ))} - {visibleReactions.size < 8 && } />} + {visibleReactions.size < 8 && } )} diff --git a/app/soapbox/components/authorize-reject-buttons.tsx b/app/soapbox/components/authorize-reject-buttons.tsx new file mode 100644 index 000000000..9edd44189 --- /dev/null +++ b/app/soapbox/components/authorize-reject-buttons.tsx @@ -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 + onReject(): Promise | unknown + countdown?: number +} + +/** Buttons to approve or reject a pending item, usually an account. */ +const AuthorizeRejectButtons: React.FC = ({ onAuthorize, onReject, countdown }) => { + const [state, setState] = useState<'authorizing' | 'rejecting' | 'authorized' | 'rejected' | 'pending'>('pending'); + const timeout = useRef(); + + function handleAction( + present: 'authorizing' | 'rejecting', + past: 'authorized' | 'rejected', + action: () => Promise | 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 ( + } /> + ); + case 'rejected': + return ( + } /> + ); + default: + return ( + + + + + ); + } +}; + +interface IActionEmblem { + text: React.ReactNode +} + +const ActionEmblem: React.FC = ({ text }) => { + return ( +
+ + {text} + +
+ ); +}; + +interface IAuthorizeRejectButton { + theme: 'primary' | 'danger' + icon: string + action(): void + isLoading?: boolean + disabled?: boolean +} + +const AuthorizeRejectButton: React.FC = ({ theme, icon, action, isLoading, disabled }) => { + return ( +
+ + {(isLoading) && ( +
+ )} +
+ ); +}; + +export { AuthorizeRejectButtons }; \ No newline at end of file diff --git a/app/soapbox/components/autosuggest-emoji.tsx b/app/soapbox/components/autosuggest-emoji.tsx index 9893fa345..4f4471ecf 100644 --- a/app/soapbox/components/autosuggest-emoji.tsx +++ b/app/soapbox/components/autosuggest-emoji.tsx @@ -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 = ({ 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 = ({ emoji }) => { {emoji.native {emoji.colons} diff --git a/app/soapbox/components/autosuggest-input.tsx b/app/soapbox/components/autosuggest-input.tsx index dba457ee8..074acfef4 100644 --- a/app/soapbox/components/autosuggest-input.tsx +++ b/app/soapbox/components/autosuggest-input.tsx @@ -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; diff --git a/app/soapbox/components/autosuggest-textarea.tsx b/app/soapbox/components/autosuggest-textarea.tsx index df791d91a..e0be3c958 100644 --- a/app/soapbox/components/autosuggest-textarea.tsx +++ b/app/soapbox/components/autosuggest-textarea.tsx @@ -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 diff --git a/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx b/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx index 3de906dc8..f34240d8a 100644 --- a/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx +++ b/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx @@ -73,7 +73,7 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => { } return ( -
  • +
  • { }; }, [refs.floating.current]); + if (items.length === 0) { + return null; + } + return ( <> {children ? ( diff --git a/app/soapbox/components/group-card.tsx b/app/soapbox/components/group-card.tsx index 15d8cf497..7b9fa6458 100644 --- a/app/soapbox/components/group-card.tsx +++ b/app/soapbox/components/group-card.tsx @@ -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 = ({ group }) => { const intl = useIntl(); return ( -
    - -
    - {group.header && {intl.formatMessage(messages.groupHeader)}} -
    - -
    -
    - - - - {group.relationship?.role === 'admin' ? ( - - - - - ) : group.relationship?.role === 'moderator' && ( - - - - - )} - {group.locked ? ( - - - - - ) : ( - - - - - )} - - + + {/* Group Cover Image */} + + {group.header && ( + {intl.formatMessage(messages.groupHeader)} + )} -
    + + {/* Group Avatar */} +
    + +
    + + {/* Group Info */} + + + + + {group.relationship?.pending_requests && ( +
    + )} + + + + + + + + + ); }; diff --git a/app/soapbox/components/groups/group-avatar.tsx b/app/soapbox/components/groups/group-avatar.tsx new file mode 100644 index 000000000..9b3213bb9 --- /dev/null +++ b/app/soapbox/components/groups/group-avatar.tsx @@ -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 ( + + ); +}; + +export default GroupAvatar; \ No newline at end of file diff --git a/app/soapbox/components/groups/popover/group-popover.tsx b/app/soapbox/components/groups/popover/group-popover.tsx new file mode 100644 index 000000000..776506f99 --- /dev/null +++ b/app/soapbox/components/groups/popover/group-popover.tsx @@ -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> + 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 ( + + + {/* Group Cover Image */} + + {group.header && ( + + )} + + + {/* Group Avatar */} +
    + +
    + + {/* Group Info */} + + + + + + + + +
    + + + + + + {intl.formatMessage(messages.title)} + + + {intl.formatMessage(messages.summary)} + + + +
    + + + +
    + + } + isFlush + children={ +
    {children}
    + } + /> + ); +}; + +export default GroupPopover; \ No newline at end of file diff --git a/app/soapbox/components/icon.tsx b/app/soapbox/components/icon.tsx index 300265ea5..421d937dd 100644 --- a/app/soapbox/components/icon.tsx +++ b/app/soapbox/components/icon.tsx @@ -14,6 +14,9 @@ export interface IIcon extends React.HTMLAttributes { className?: string } +/** + * @deprecated Use the UI Icon component directly. + */ const Icon: React.FC = ({ src, alt, className, ...rest }) => { return (
    = ({ label, hint, children, onClick, onSelec return (
    - {label} + {label} {hint ? ( {hint} @@ -83,9 +82,26 @@ const ListItem: React.FC = ({ label, hint, children, onClick, onSelec
    {children} - {isSelected ? ( - - ) : null} +
    + +
    ) : null} diff --git a/app/soapbox/components/load-more.tsx b/app/soapbox/components/load-more.tsx index e937965fd..878adda7c 100644 --- a/app/soapbox/components/load-more.tsx +++ b/app/soapbox/components/load-more.tsx @@ -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 = ({ onClick, disabled, visible = true }) => { +const LoadMore: React.FC = ({ onClick, disabled, visible = true, className }) => { if (!visible) { return null; } return ( - ); diff --git a/app/soapbox/components/media-gallery.tsx b/app/soapbox/components/media-gallery.tsx index 001ac0729..0c26b26f6 100644 --- a/app/soapbox/components/media-gallery.tsx +++ b/app/soapbox/components/media-gallery.tsx @@ -152,7 +152,14 @@ const Item: React.FC = ({ ); return ( -
    +
    1, + })} + key={attachment.id} + style={{ position, float, left, top, right, bottom, height, width: `${width}%` }} + > {attachmentIcon} @@ -245,7 +252,14 @@ const Item: React.FC = ({ } return ( -
    +
    1, + })} + key={attachment.id} + style={{ position, float, left, top, right, bottom, height, width: `${width}%` }} + > {last && total > ATTACHMENT_LIMIT && (
    +{total - ATTACHMENT_LIMIT + 1} @@ -260,7 +274,7 @@ const Item: React.FC = ({ ); }; -interface IMediaGallery { +export interface IMediaGallery { sensitive?: boolean media: ImmutableList height?: number @@ -270,13 +284,15 @@ interface IMediaGallery { visible?: boolean onToggleVisibility?: () => void displayMedia?: string - compact: boolean + compact?: boolean + className?: string } const MediaGallery: React.FC = (props) => { const { media, defaultWidth = 0, + className, onOpenMedia, cacheWidth, compact, @@ -546,7 +562,11 @@ const MediaGallery: React.FC = (props) => { }, [node.current]); return ( -
    +
    {children}
    ); diff --git a/app/soapbox/components/pending-items-row.tsx b/app/soapbox/components/pending-items-row.tsx new file mode 100644 index 000000000..4fbf236cd --- /dev/null +++ b/app/soapbox/components/pending-items-row.tsx @@ -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 = ({ to, count, size = 'md' }) => { + return ( + + + +
    + +
    + + + + +
    + + +
    + + ); +}; + +export { PendingItemsRow }; \ No newline at end of file diff --git a/app/soapbox/components/scrollable-list.tsx b/app/soapbox/components/scrollable-list.tsx index a56c294b3..78fb7f036 100644 --- a/app/soapbox/components/scrollable-list.tsx +++ b/app/soapbox/components/scrollable-list.tsx @@ -52,6 +52,8 @@ interface IScrollableList extends VirtuosoProps { 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 /** Callback when the list is scrolled to the top. */ @@ -87,6 +89,7 @@ const ScrollableList = React.forwardRef(({ children, isLoading, emptyMessage, + emptyMessageCard = true, showLoading, onRefresh, onScroll, @@ -158,13 +161,17 @@ const ScrollableList = React.forwardRef(({
    {alwaysPrepend && prepend} - - {isLoading ? ( - - ) : ( - emptyMessage - )} - + {isLoading ? ( + + ) : ( + <> + {emptyMessageCard ? ( + + {emptyMessage} + + ) : emptyMessage} + + )}
    ); }; diff --git a/app/soapbox/components/sidebar-menu.tsx b/app/soapbox/components/sidebar-menu.tsx index ffe3a0e98..aad2d7f6b 100644 --- a/app/soapbox/components/sidebar-menu.tsx +++ b/app/soapbox/components/sidebar-menu.tsx @@ -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 && ( { /> )} - {features.filters && ( + {(features.filters || features.filtersV2) && ( { 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 && ( } /> diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 8e8d9c202..2938e91e7 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -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 = ({ } }; + const handleDislikeClick: React.EventHandler = (e) => { + if (me) { + dispatch(toggleDislike(status)); + } else { + onOpenUnauthorizedModal('DISLIKE'); + } + }; + const handleBookmarkClick: React.EventHandler = (e) => { dispatch(toggleBookmark(status)); }; @@ -254,7 +265,7 @@ const StatusActionBar: React.FC = ({ 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 = ({ }; const handleReport: React.EventHandler = (e) => { - dispatch(initReport(status.account as Account, { status })); + dispatch(initReport(ReportableEntities.STATUS, status.account as Account, { status })); }; const handleConversationMuteClick: React.EventHandler = (e) => { @@ -538,7 +549,8 @@ const StatusActionBar: React.FC = ({ 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 = ({ '': 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 = ({ grow={space === 'expand'} onClick={e => e.stopPropagation()} > - + + + {(features.quotePosts && me) ? ( = ({ 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 = ({ ) : ( )} + {features.dislikes && ( + + )} + {canShare && ( text?: React.ReactNode } @@ -42,7 +44,7 @@ const StatusActionButton = React.forwardRef - + ); } else { diff --git a/app/soapbox/components/status-reaction-wrapper.tsx b/app/soapbox/components/status-reaction-wrapper.tsx index 224da7cda..206cd1fed 100644 --- a/app/soapbox/components/status-reaction-wrapper.tsx +++ b/app/soapbox/components/status-reaction-wrapper.tsx @@ -60,9 +60,9 @@ const StatusReactionWrapper: React.FC = ({ 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 = ({ statusId, chi }; const handleClick: React.EventHandler = 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 = ({ statusId, chi referenceElement={referenceElement} onReact={handleReact} visible={visible} + onClose={() => setVisible(false)} /> )} diff --git a/app/soapbox/components/status-reply-mentions.tsx b/app/soapbox/components/status-reply-mentions.tsx index 7bd44495c..61f2f2969 100644 --- a/app/soapbox/components/status-reply-mentions.tsx +++ b/app/soapbox/components/status-reply-mentions.tsx @@ -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 = ({ status, hoverable className='reply-mentions__account' onClick={(e) => e.stopPropagation()} > - @{account.username} + @{isPubkey(account.username) ? account.username.slice(0, 8) : account.username} ); diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 16358f9dd..3ec072394 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -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 = (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 = (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 = (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 = (props) => {
    - + : {status.filtered.join(', ')}. + {' '} +
    diff --git a/app/soapbox/components/ui/button/useButtonStyles.ts b/app/soapbox/components/ui/button/useButtonStyles.ts index 2cc06f6e7..740b06ba4 100644 --- a/app/soapbox/components/ui/button/useButtonStyles.ts +++ b/app/soapbox/components/ui/button/useButtonStyles.ts @@ -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, diff --git a/app/soapbox/components/ui/card/card.tsx b/app/soapbox/components/ui/card/card.tsx index 927b6944a..aedf3e132 100644 --- a/app/soapbox/components/ui/card/card.tsx +++ b/app/soapbox/components/ui/card/card.tsx @@ -64,7 +64,7 @@ const CardHeader: React.FC = ({ className, children, backHref, onBa const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick }; return ( - + {intl.formatMessage(messages.back)} diff --git a/app/soapbox/components/ui/carousel/carousel.tsx b/app/soapbox/components/ui/carousel/carousel.tsx new file mode 100644 index 000000000..441c1b1d1 --- /dev/null +++ b/app/soapbox/components/ui/carousel/carousel.tsx @@ -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 = (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(0); + const [currentPage, setCurrentPage] = useState(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 ( + +
    + +
    + +
    + + {renderChildren()} + +
    + +
    + +
    +
    + ); +}; + +export default Carousel; \ No newline at end of file diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index 317cf9841..d6cadec77 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -7,10 +7,10 @@ import { useSoapboxConfig } from 'soapbox/hooks'; import { Card, CardBody, CardHeader, CardTitle } from '../card/card'; -type IColumnHeader = Pick; +type IColumnHeader = Pick; /** Contains the column title with optional back button. */ -const ColumnHeader: React.FC = ({ label, backHref, className }) => { +const ColumnHeader: React.FC = ({ label, backHref, className, action }) => { const history = useHistory(); const handleBackClick = () => { @@ -29,6 +29,12 @@ const ColumnHeader: React.FC = ({ label, backHref, className }) = return ( + + {action && ( +
    + {action} +
    + )}
    ); }; @@ -48,11 +54,12 @@ export interface IColumn { ref?: React.Ref /** 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 = React.forwardRef((props, ref: React.ForwardedRef): 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 = React.forwardRef((props, ref: React.ForwardedR label={label} backHref={backHref} className={clsx({ 'px-4 pt-4 sm:p-0': transparent })} + action={action} /> )} diff --git a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx index 5c4fa8dd4..a4485e79f 100644 --- a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx +++ b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx @@ -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 = ({ emoji, className, onClick, tabInd return ( ); }; @@ -37,14 +38,13 @@ const EmojiButton: React.FC = ({ 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 = ({ 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(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({ 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 (
    {expanded ? ( - require('emoji-datasource/img/twitter/sheets/32.png')} - onClick={(emoji: any) => onReact(emoji.native)} + ) : ( { /** A single emoji image. */ const Emoji: React.FC = (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 = (props): JSX.Element | null => { {alt ); diff --git a/app/soapbox/components/ui/form-group/form-group.tsx b/app/soapbox/components/ui/form-group/form-group.tsx index e3c897a6f..7efb60ade 100644 --- a/app/soapbox/components/ui/form-group/form-group.tsx +++ b/app/soapbox/components/ui/form-group/form-group.tsx @@ -86,6 +86,12 @@ const FormGroup: React.FC = (props) => { )}
    + {hintText && ( +

    + {hintText} +

    + )} + {firstChild} {inputChildren.filter((_, i) => i !== 0)} @@ -97,12 +103,6 @@ const FormGroup: React.FC = (props) => { {errors.join(', ')}

    )} - - {hintText && ( -

    - {hintText} -

    - )}
    ); diff --git a/app/soapbox/components/ui/icon-button/icon-button.tsx b/app/soapbox/components/ui/icon-button/icon-button.tsx index 086b5a2c0..ad9d6a517 100644 --- a/app/soapbox/components/ui/icon-button/icon-button.tsx +++ b/app/soapbox/components/ui/icon-button/icon-button.tsx @@ -14,7 +14,7 @@ interface IIconButton extends React.ButtonHTMLAttributes { /** 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} diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index 964125cf0..d66c98195 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -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'; diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 2de6eb566..bb3f9957d 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -84,8 +84,10 @@ const Input = React.forwardRef( 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, diff --git a/app/soapbox/components/ui/popover/popover.tsx b/app/soapbox/components/ui/popover/popover.tsx new file mode 100644 index 000000000..7f909f8bc --- /dev/null +++ b/app/soapbox/components/ui/popover/popover.tsx @@ -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> + /** 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 = (props) => { + const { children, content, referenceElementClassName, interaction = 'hover', isFlush = false } = props; + + const [isOpen, setIsOpen] = useState(false); + + const arrowRef = useRef(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) && ( + +
    + {content} + + +
    +
    + )} + + ); +}; + +export default Popover; \ No newline at end of file diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 4795f11d3..dceaf9214 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -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', }; diff --git a/app/soapbox/components/ui/streamfield/streamfield.tsx b/app/soapbox/components/ui/streamfield/streamfield.tsx index 49658099a..5c436e70b 100644 --- a/app/soapbox/components/ui/streamfield/streamfield.tsx +++ b/app/soapbox/components/ui/streamfield/streamfield.tsx @@ -33,6 +33,8 @@ interface IStreamfield { onChange: (values: any[]) => void /** Input to render for each value. */ component: StreamfieldComponent + /** Minimum number of allowed inputs. */ + minItems?: number /** Maximum number of allowed inputs. */ maxItems?: number } @@ -47,6 +49,7 @@ const Streamfield: React.FC = ({ onChange, component: Component, maxItems = Infinity, + minItems = 0, }) => { const intl = useIntl(); @@ -67,10 +70,10 @@ const Streamfield: React.FC = ({ {(values.length > 0) && ( - {values.map((value, i) => ( + {values.map((value, i) => value?._destroy ? null : ( - {onRemoveItem && ( + {values.length > minItems && onRemoveItem && ( { >
    {count ? ( - + ) : null} diff --git a/app/soapbox/components/ui/textarea/textarea.tsx b/app/soapbox/components/ui/textarea/textarea.tsx index 03ddda81d..2b3f54897 100644 --- a/app/soapbox/components/ui/textarea/textarea.tsx +++ b/app/soapbox/components/ui/textarea/textarea.tsx @@ -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, 'maxLength' | 'onChange' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> { /** Put the cursor into the input on mount. */ @@ -28,6 +32,8 @@ interface ITextarea extends Pick) => { + const length = value?.length || 0; const [rows, setRows] = useState(autoGrow ? 1 : 4); const handleChange = (event: React.ChangeEvent) => { @@ -70,20 +79,35 @@ const Textarea = React.forwardRef(({ }; return ( -