diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9516ec2d6..97892e166 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: node:18 +image: node:20 variables: NODE_ENV: test diff --git a/.tool-versions b/.tool-versions index ab43e6ab2..6de89a83a 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs 18.14.0 +nodejs 20.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index ee32cbb92..56d312320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Hashtags: let users follow hashtags (Mastodon, Akkoma). - Posts: Support posts filtering on recent Mastodon versions - Reactions: Support custom emoji reactions - Compatbility: Support Mastodon v2 timeline filters. +- Compatbility: Preliminary support for Ditto backend. - 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. +- Posts: improved design of threads. - UI: unified design of "approve" and "reject" buttons in follow requests and waitlist. +- UI: added sticky column header. +- UI: add specific zones the user can drag-and-drop files. ### Fixed - Posts: fixed emojis being cut off in reactions modal. @@ -27,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 18n: fixed Chinese language being detected from the browser. - Conversations: fixed pagination (Mastodon). - Compatibility: fix version parsing for Friendica. +- UI: fixed various overflow issues related to long usernames. +- UI: fixed display of Markdown code blocks in the reply indicator. ## [3.2.0] - 2023-02-15 diff --git a/Dockerfile b/Dockerfile index bfb7c2e48..b02bf86e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18 as build +FROM node:20 as build WORKDIR /app COPY package.json . COPY yarn.lock . diff --git a/Dockerfile.dev b/Dockerfile.dev index 8d1655db0..1e6056945 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:18 +FROM node:20 RUN apt-get update &&\ apt-get install -y inotify-tools &&\ diff --git a/app/soapbox/actions/__tests__/account-notes.test.ts b/app/soapbox/actions/__tests__/account-notes.test.ts index 8b85eecc5..a00a9d877 100644 --- a/app/soapbox/actions/__tests__/account-notes.test.ts +++ b/app/soapbox/actions/__tests__/account-notes.test.ts @@ -1,10 +1,11 @@ import { Map as ImmutableMap } from 'immutable'; import { __stub } from 'soapbox/api'; +import { buildRelationship } from 'soapbox/jest/factory'; import { mockStore, rootState } from 'soapbox/jest/test-helpers'; import { ReducerRecord, EditRecord } from 'soapbox/reducers/account-notes'; -import { normalizeAccount, normalizeRelationship } from '../../normalizers'; +import { normalizeAccount } from '../../normalizers'; import { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes'; import type { Account } from 'soapbox/types/entities'; @@ -66,7 +67,7 @@ describe('initAccountNoteModal()', () => { beforeEach(() => { const state = rootState - .set('relationships', ImmutableMap({ '1': normalizeRelationship({ note: 'hello' }) })); + .set('relationships', ImmutableMap({ '1': buildRelationship({ note: 'hello' }) })); store = mockStore(state); }); diff --git a/app/soapbox/actions/__tests__/accounts.test.ts b/app/soapbox/actions/__tests__/accounts.test.ts index d9faa0213..c13f8ef90 100644 --- a/app/soapbox/actions/__tests__/accounts.test.ts +++ b/app/soapbox/actions/__tests__/accounts.test.ts @@ -1,10 +1,11 @@ import { Map as ImmutableMap } from 'immutable'; import { __stub } from 'soapbox/api'; +import { buildRelationship } from 'soapbox/jest/factory'; import { mockStore, rootState } from 'soapbox/jest/test-helpers'; import { ListRecord, ReducerRecord } from 'soapbox/reducers/user-lists'; -import { normalizeAccount, normalizeInstance, normalizeRelationship } from '../../normalizers'; +import { normalizeAccount, normalizeInstance } from '../../normalizers'; import { authorizeFollowRequest, blockAccount, @@ -1340,7 +1341,7 @@ describe('fetchRelationships()', () => { describe('without newAccountIds', () => { beforeEach(() => { const state = rootState - .set('relationships', ImmutableMap({ [id]: normalizeRelationship({}) })) + .set('relationships', ImmutableMap({ [id]: buildRelationship() })) .set('me', '123'); store = mockStore(state); }); diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index 06fe848e2..e4a10edd0 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -242,7 +242,8 @@ export const fetchOwnAccounts = () => return state.auth.users.forEach((user) => { const account = state.accounts.get(user.id); if (!account) { - dispatch(verifyCredentials(user.access_token, user.url)); + dispatch(verifyCredentials(user.access_token, user.url)) + .catch(() => console.warn(`Failed to load account: ${user.url}`)); } }); }; diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index 995f37c15..61303d15b 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -22,6 +22,7 @@ import { createStatus } from './statuses'; import type { AutoSuggestion } from 'soapbox/components/autosuggest-input'; import type { Emoji } from 'soapbox/features/emoji'; +import type { Group } from 'soapbox/schemas'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities'; import type { History } from 'soapbox/types/history'; @@ -168,6 +169,14 @@ const cancelQuoteCompose = () => ({ id: 'compose-modal', }); +const groupComposeModal = (group: Group) => + (dispatch: AppDispatch, getState: () => RootState) => { + const composeId = `group:${group.id}`; + + dispatch(groupCompose(composeId, group.id)); + dispatch(openModal('COMPOSE', { composeId })); + }; + const resetCompose = (composeId = 'compose-modal') => ({ type: COMPOSE_RESET, id: composeId, @@ -829,6 +838,7 @@ export { uploadComposeFail, undoUploadCompose, groupCompose, + groupComposeModal, setGroupTimelineVisible, clearComposeSuggestions, fetchComposeSuggestions, diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts index 8a6ad065e..ad760d916 100644 --- a/app/soapbox/actions/groups.ts +++ b/app/soapbox/actions/groups.ts @@ -1,21 +1,14 @@ -import { defineMessages } from 'react-intl'; - import { deleteEntities } from 'soapbox/entity-store/actions'; -import toast from 'soapbox/toast'; import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedGroups, importFetchedAccounts } from './importer'; -import { closeModal, openModal } from './modals'; -import { deleteFromTimelines } from './timelines'; import type { AxiosError } from 'axios'; import type { GroupRole } from 'soapbox/reducers/group-memberships'; import type { AppDispatch, RootState } from 'soapbox/store'; -import type { APIEntity, Group } from 'soapbox/types/entities'; - -const GROUP_EDITOR_SET = 'GROUP_EDITOR_SET'; +import type { APIEntity } from 'soapbox/types/entities'; const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST'; const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS'; @@ -41,10 +34,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_DELETE_STATUS_REQUEST = 'GROUP_DELETE_STATUS_REQUEST'; -const GROUP_DELETE_STATUS_SUCCESS = 'GROUP_DELETE_STATUS_SUCCESS'; -const GROUP_DELETE_STATUS_FAIL = 'GROUP_DELETE_STATUS_FAIL'; - const GROUP_KICK_REQUEST = 'GROUP_KICK_REQUEST'; const GROUP_KICK_SUCCESS = 'GROUP_KICK_SUCCESS'; const GROUP_KICK_FAIL = 'GROUP_KICK_FAIL'; @@ -97,100 +86,6 @@ const GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST = 'GROUP_MEMBERSHIP_REQUEST_REJECT const GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS'; const GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL = 'GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL'; -const GROUP_EDITOR_TITLE_CHANGE = 'GROUP_EDITOR_TITLE_CHANGE'; -const GROUP_EDITOR_DESCRIPTION_CHANGE = 'GROUP_EDITOR_DESCRIPTION_CHANGE'; -const GROUP_EDITOR_PRIVACY_CHANGE = 'GROUP_EDITOR_PRIVACY_CHANGE'; -const GROUP_EDITOR_MEDIA_CHANGE = 'GROUP_EDITOR_MEDIA_CHANGE'; - -const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET'; - -const messages = defineMessages({ - success: { id: 'manage_group.submit_success', defaultMessage: 'The group was created' }, - editSuccess: { id: 'manage_group.edit_success', defaultMessage: 'The group was edited' }, - joinSuccess: { id: 'group.join.success', defaultMessage: 'Joined the group' }, - joinRequestSuccess: { id: 'group.join.request_success', defaultMessage: 'Requested to join the group' }, - leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' }, - view: { id: 'toast.view', defaultMessage: 'View' }, -}); - -const editGroup = (group: Group) => (dispatch: AppDispatch) => { - dispatch({ - type: GROUP_EDITOR_SET, - group, - }); - dispatch(openModal('MANAGE_GROUP')); -}; - -const createGroup = (params: Record, shouldReset?: boolean) => - (dispatch: AppDispatch, getState: () => RootState) => { - dispatch(createGroupRequest()); - - return api(getState).post('/api/v1/groups', params, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }) - .then(({ data }) => { - dispatch(importFetchedGroups([data])); - dispatch(createGroupSuccess(data)); - toast.success(messages.success, { - actionLabel: messages.view, - actionLink: `/groups/${data.id}`, - }); - - if (shouldReset) { - dispatch(resetGroupEditor()); - } - - return data; - }).catch(err => dispatch(createGroupFail(err))); - }; - -const createGroupRequest = () => ({ - type: GROUP_CREATE_REQUEST, -}); - -const createGroupSuccess = (group: APIEntity) => ({ - type: GROUP_CREATE_SUCCESS, - group, -}); - -const createGroupFail = (error: AxiosError) => ({ - type: GROUP_CREATE_FAIL, - error, -}); - -const updateGroup = (id: string, params: Record, shouldReset?: boolean) => - (dispatch: AppDispatch, getState: () => RootState) => { - dispatch(updateGroupRequest()); - - return api(getState).put(`/api/v1/groups/${id}`, params) - .then(({ data }) => { - dispatch(importFetchedGroups([data])); - dispatch(updateGroupSuccess(data)); - toast.success(messages.editSuccess); - - if (shouldReset) { - dispatch(resetGroupEditor()); - } - dispatch(closeModal('MANAGE_GROUP')); - }).catch(err => dispatch(updateGroupFail(err))); - }; - -const updateGroupRequest = () => ({ - type: GROUP_UPDATE_REQUEST, -}); - -const updateGroupSuccess = (group: APIEntity) => ({ - type: GROUP_UPDATE_SUCCESS, - group, -}); - -const updateGroupFail = (error: AxiosError) => ({ - type: GROUP_UPDATE_FAIL, - error, -}); - const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(deleteEntities([id], 'Group')); @@ -306,36 +201,6 @@ const fetchGroupRelationshipsFail = (error: AxiosError) => ({ skipNotFound: true, }); -const groupDeleteStatus = (groupId: string, statusId: string) => - (dispatch: AppDispatch, getState: () => RootState) => { - dispatch(groupDeleteStatusRequest(groupId, statusId)); - - return api(getState).delete(`/api/v1/groups/${groupId}/statuses/${statusId}`) - .then(() => { - dispatch(deleteFromTimelines(statusId)); - dispatch(groupDeleteStatusSuccess(groupId, statusId)); - }).catch(err => dispatch(groupDeleteStatusFail(groupId, statusId, err))); - }; - -const groupDeleteStatusRequest = (groupId: string, statusId: string) => ({ - type: GROUP_DELETE_STATUS_REQUEST, - groupId, - statusId, -}); - -const groupDeleteStatusSuccess = (groupId: string, statusId: string) => ({ - type: GROUP_DELETE_STATUS_SUCCESS, - groupId, - statusId, -}); - -const groupDeleteStatusFail = (groupId: string, statusId: string, error: AxiosError) => ({ - type: GROUP_DELETE_STATUS_SUCCESS, - groupId, - statusId, - error, -}); - const groupKick = (groupId: string, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(groupKickRequest(groupId, accountId)); @@ -758,57 +623,7 @@ const rejectGroupMembershipRequestFail = (groupId: string, accountId: string, er error, }); -const changeGroupEditorTitle = (value: string) => ({ - type: GROUP_EDITOR_TITLE_CHANGE, - value, -}); - -const changeGroupEditorDescription = (value: string) => ({ - type: GROUP_EDITOR_DESCRIPTION_CHANGE, - value, -}); - -const changeGroupEditorPrivacy = (value: boolean) => ({ - type: GROUP_EDITOR_PRIVACY_CHANGE, - value, -}); - -const changeGroupEditorMedia = (mediaType: 'header' | 'avatar', file: File) => ({ - type: GROUP_EDITOR_MEDIA_CHANGE, - mediaType, - value: file, -}); - -const resetGroupEditor = () => ({ - type: GROUP_EDITOR_RESET, -}); - -const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => { - const groupId = getState().group_editor.groupId; - const displayName = getState().group_editor.displayName; - const note = getState().group_editor.note; - const avatar = getState().group_editor.avatar; - const header = getState().group_editor.header; - const visibility = getState().group_editor.locked ? 'members_only' : 'everyone'; // Truth Social - - const params: Record = { - display_name: displayName, - group_visibility: visibility, - note, - }; - - if (avatar) params.avatar = avatar; - if (header) params.header = header; - - if (groupId === null) { - return dispatch(createGroup(params, shouldReset)); - } else { - return dispatch(updateGroup(groupId, params, shouldReset)); - } -}; - export { - GROUP_EDITOR_SET, GROUP_CREATE_REQUEST, GROUP_CREATE_SUCCESS, GROUP_CREATE_FAIL, @@ -827,9 +642,6 @@ export { GROUP_RELATIONSHIPS_FETCH_REQUEST, GROUP_RELATIONSHIPS_FETCH_SUCCESS, GROUP_RELATIONSHIPS_FETCH_FAIL, - GROUP_DELETE_STATUS_REQUEST, - GROUP_DELETE_STATUS_SUCCESS, - GROUP_DELETE_STATUS_FAIL, GROUP_KICK_REQUEST, GROUP_KICK_SUCCESS, GROUP_KICK_FAIL, @@ -869,20 +681,6 @@ export { GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST, GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS, GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL, - GROUP_EDITOR_TITLE_CHANGE, - GROUP_EDITOR_DESCRIPTION_CHANGE, - GROUP_EDITOR_PRIVACY_CHANGE, - GROUP_EDITOR_MEDIA_CHANGE, - GROUP_EDITOR_RESET, - editGroup, - createGroup, - createGroupRequest, - createGroupSuccess, - createGroupFail, - updateGroup, - updateGroupRequest, - updateGroupSuccess, - updateGroupFail, deleteGroup, deleteGroupRequest, deleteGroupSuccess, @@ -899,10 +697,6 @@ export { fetchGroupRelationshipsRequest, fetchGroupRelationshipsSuccess, fetchGroupRelationshipsFail, - groupDeleteStatus, - groupDeleteStatusRequest, - groupDeleteStatusSuccess, - groupDeleteStatusFail, groupKick, groupKickRequest, groupKickSuccess, @@ -955,10 +749,4 @@ export { rejectGroupMembershipRequestRequest, rejectGroupMembershipRequestSuccess, rejectGroupMembershipRequestFail, - changeGroupEditorTitle, - changeGroupEditorDescription, - changeGroupEditorPrivacy, - changeGroupEditorMedia, - resetGroupEditor, - submitGroupEditor, }; diff --git a/app/soapbox/actions/importer/index.ts b/app/soapbox/actions/importer/index.ts index ec0ec3121..fc9ad63bd 100644 --- a/app/soapbox/actions/importer/index.ts +++ b/app/soapbox/actions/importer/index.ts @@ -74,7 +74,7 @@ const importFetchedGroup = (group: APIEntity) => importFetchedGroups([group]); const importFetchedGroups = (groups: APIEntity[]) => { - const entities = filteredArray(groupSchema).catch([]).parse(groups); + const entities = filteredArray(groupSchema).parse(groups); return importGroups(entities); }; diff --git a/app/soapbox/actions/moderation.tsx b/app/soapbox/actions/moderation.tsx index c236a2986..cd08fcd2f 100644 --- a/app/soapbox/actions/moderation.tsx +++ b/app/soapbox/actions/moderation.tsx @@ -142,7 +142,7 @@ const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = () dispatch(openModal('CONFIRM', { icon: require('@tabler/icons/trash.svg'), heading: intl.formatMessage(messages.deleteStatusHeading), - message: intl.formatMessage(messages.deleteStatusPrompt, { acct }), + message: intl.formatMessage(messages.deleteStatusPrompt, { acct: {acct} }), confirm: intl.formatMessage(messages.deleteStatusConfirm), onConfirm: () => { dispatch(deleteStatus(statusId)).then(() => { diff --git a/app/soapbox/actions/search.ts b/app/soapbox/actions/search.ts index 6d64b6534..a2f165ac0 100644 --- a/app/soapbox/actions/search.ts +++ b/app/soapbox/actions/search.ts @@ -1,7 +1,7 @@ import api from '../api'; import { fetchRelationships } from './accounts'; -import { importFetchedAccounts, importFetchedGroups, importFetchedStatuses } from './importer'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; import type { AxiosError } from 'axios'; import type { SearchFilter } from 'soapbox/reducers/search'; @@ -83,10 +83,6 @@ const submitSearch = (filter?: SearchFilter) => dispatch(importFetchedStatuses(response.data.statuses)); } - if (response.data.groups) { - dispatch(importFetchedGroups(response.data.groups)); - } - dispatch(fetchSearchSuccess(response.data, value, type)); dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id))); }).catch(error => { @@ -143,10 +139,6 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: ( dispatch(importFetchedStatuses(data.statuses)); } - if (data.groups) { - dispatch(importFetchedGroups(data.groups)); - } - dispatch(expandSearchSuccess(data, value, type)); dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id))); }).catch(error => { diff --git a/app/soapbox/actions/statuses.ts b/app/soapbox/actions/statuses.ts index b14108de2..ab6a855ee 100644 --- a/app/soapbox/actions/statuses.ts +++ b/app/soapbox/actions/statuses.ts @@ -5,6 +5,7 @@ import { shouldHaveCard } from 'soapbox/utils/status'; import api, { getNextLink } from '../api'; import { setComposeToStatus } from './compose'; +import { fetchGroupRelationships } from './groups'; import { importFetchedStatus, importFetchedStatuses } from './importer'; import { openModal } from './modals'; import { deleteFromTimelines } from './timelines'; @@ -124,6 +125,9 @@ const fetchStatus = (id: string) => { return api(getState).get(`/api/v1/statuses/${id}`).then(({ data: status }) => { dispatch(importFetchedStatus(status)); + if (status.group) { + dispatch(fetchGroupRelationships([status.group.id])); + } dispatch({ type: STATUS_FETCH_SUCCESS, status, skipLoading }); return status; }).catch(error => { diff --git a/app/soapbox/actions/tags.ts b/app/soapbox/actions/tags.ts new file mode 100644 index 000000000..75d8e00fa --- /dev/null +++ b/app/soapbox/actions/tags.ts @@ -0,0 +1,201 @@ +import api, { getLinks } from '../api'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST'; +const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS'; +const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL'; + +const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST'; +const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS'; +const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL'; + +const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST'; +const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS'; +const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL'; + +const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST'; +const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS'; +const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL'; + +const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST'; +const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS'; +const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL'; + +const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchHashtagRequest()); + + api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => { + dispatch(fetchHashtagSuccess(name, data)); + }).catch(err => { + dispatch(fetchHashtagFail(err)); + }); +}; + +const fetchHashtagRequest = () => ({ + type: HASHTAG_FETCH_REQUEST, +}); + +const fetchHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_FETCH_SUCCESS, + name, + tag, +}); + +const fetchHashtagFail = (error: AxiosError) => ({ + type: HASHTAG_FETCH_FAIL, + error, +}); + +const followHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(followHashtagRequest(name)); + + api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => { + dispatch(followHashtagSuccess(name, data)); + }).catch(err => { + dispatch(followHashtagFail(name, err)); + }); +}; + +const followHashtagRequest = (name: string) => ({ + type: HASHTAG_FOLLOW_REQUEST, + name, +}); + +const followHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_FOLLOW_SUCCESS, + name, + tag, +}); + +const followHashtagFail = (name: string, error: AxiosError) => ({ + type: HASHTAG_FOLLOW_FAIL, + name, + error, +}); + +const unfollowHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(unfollowHashtagRequest(name)); + + api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => { + dispatch(unfollowHashtagSuccess(name, data)); + }).catch(err => { + dispatch(unfollowHashtagFail(name, err)); + }); +}; + +const unfollowHashtagRequest = (name: string) => ({ + type: HASHTAG_UNFOLLOW_REQUEST, + name, +}); + +const unfollowHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_UNFOLLOW_SUCCESS, + name, + tag, +}); + +const unfollowHashtagFail = (name: string, error: AxiosError) => ({ + type: HASHTAG_UNFOLLOW_FAIL, + name, + error, +}); + +const fetchFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchFollowedHashtagsRequest()); + + api(getState).get('/api/v1/followed_tags').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null)); + }).catch(err => { + dispatch(fetchFollowedHashtagsFail(err)); + }); +}; + +const fetchFollowedHashtagsRequest = () => ({ + type: FOLLOWED_HASHTAGS_FETCH_REQUEST, +}); + +const fetchFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({ + type: FOLLOWED_HASHTAGS_FETCH_SUCCESS, + followed_tags, + next, +}); + +const fetchFollowedHashtagsFail = (error: AxiosError) => ({ + type: FOLLOWED_HASHTAGS_FETCH_FAIL, + error, +}); + +const expandFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().followed_tags.next; + + if (url === null) { + return; + } + + dispatch(expandFollowedHashtagsRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandFollowedHashtagsFail(error)); + }); +}; + +const expandFollowedHashtagsRequest = () => ({ + type: FOLLOWED_HASHTAGS_EXPAND_REQUEST, +}); + +const expandFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({ + type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS, + followed_tags, + next, +}); + +const expandFollowedHashtagsFail = (error: AxiosError) => ({ + type: FOLLOWED_HASHTAGS_EXPAND_FAIL, + error, +}); + + +export { + HASHTAG_FETCH_REQUEST, + HASHTAG_FETCH_SUCCESS, + HASHTAG_FETCH_FAIL, + HASHTAG_FOLLOW_REQUEST, + HASHTAG_FOLLOW_SUCCESS, + HASHTAG_FOLLOW_FAIL, + HASHTAG_UNFOLLOW_REQUEST, + HASHTAG_UNFOLLOW_SUCCESS, + HASHTAG_UNFOLLOW_FAIL, + FOLLOWED_HASHTAGS_FETCH_REQUEST, + FOLLOWED_HASHTAGS_FETCH_SUCCESS, + FOLLOWED_HASHTAGS_FETCH_FAIL, + FOLLOWED_HASHTAGS_EXPAND_REQUEST, + FOLLOWED_HASHTAGS_EXPAND_SUCCESS, + FOLLOWED_HASHTAGS_EXPAND_FAIL, + fetchHashtag, + fetchHashtagRequest, + fetchHashtagSuccess, + fetchHashtagFail, + followHashtag, + followHashtagRequest, + followHashtagSuccess, + followHashtagFail, + unfollowHashtag, + unfollowHashtagRequest, + unfollowHashtagSuccess, + unfollowHashtagFail, + fetchFollowedHashtags, + fetchFollowedHashtagsRequest, + fetchFollowedHashtagsSuccess, + fetchFollowedHashtagsFail, + expandFollowedHashtags, + expandFollowedHashtagsRequest, + expandFollowedHashtagsSuccess, + expandFollowedHashtagsFail, +}; diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts index 7ae023338..902b99f70 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -4,7 +4,7 @@ import { getSettings } from 'soapbox/actions/settings'; import { normalizeStatus } from 'soapbox/normalizers'; import { shouldFilter } from 'soapbox/utils/timelines'; -import api, { getLinks } from '../api'; +import api, { getNextLink, getPrevLink } from '../api'; import { importFetchedStatus, importFetchedStatuses } from './importer'; @@ -139,7 +139,7 @@ const parseTags = (tags: Record = {}, mode: 'any' | 'all' | 'none }; const replaceHomeTimeline = ( - accountId: string | null, + accountId: string | undefined, { maxId }: Record = {}, done?: () => void, ) => (dispatch: AppDispatch, _getState: () => RootState) => { @@ -162,7 +162,12 @@ const expandTimeline = (timelineId: string, path: string, params: Record 0) { + if ( + !params.max_id && + !params.pinned && + (timeline.items || ImmutableOrderedSet()).size > 0 && + !path.includes('max_id=') + ) { params.since_id = timeline.getIn(['items', 0]); } @@ -171,9 +176,16 @@ const expandTimeline = (timelineId: string, path: string, params: Record { - const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); - dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore)); + dispatch(expandTimelineSuccess( + timelineId, + response.data, + getNextLink(response), + getPrevLink(response), + response.status === 206, + isLoadingRecent, + isLoadingMore, + )); done(); }).catch(error => { dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); @@ -181,9 +193,26 @@ const expandTimeline = (timelineId: string, path: string, params: Record = {}, done = noOp) => { - const endpoint = accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home'; - const params: any = { max_id: maxId }; +interface ExpandHomeTimelineOpts { + accountId?: string + maxId?: string + url?: string +} + +interface HomeTimelineParams { + max_id?: string + exclude_replies?: boolean + with_muted?: boolean +} + +const expandHomeTimeline = ({ url, accountId, maxId }: ExpandHomeTimelineOpts = {}, done = noOp) => { + const endpoint = url || (accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home'); + const params: HomeTimelineParams = {}; + + if (!url && maxId) { + params.max_id = maxId; + } + if (accountId) { params.exclude_replies = true; params.with_muted = true; @@ -219,6 +248,9 @@ const expandListTimeline = (id: string, { maxId }: Record = {}, don const expandGroupTimeline = (id: string, { maxId }: Record = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done); +const expandGroupTimelineFromTag = (id: string, tagName: string, { maxId }: Record = {}, done = noOp) => + expandTimeline(`group:tags:${id}:${tagName}`, `/api/v1/timelines/group/${id}/tags/${tagName}`, { max_id: maxId }, done); + const expandGroupMediaTimeline = (id: string | number, { maxId }: Record = {}) => expandTimeline(`group:${id}:media`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: true, limit: 40, with_muted: true }); @@ -237,11 +269,20 @@ const expandTimelineRequest = (timeline: string, isLoadingMore: boolean) => ({ skipLoading: !isLoadingMore, }); -const expandTimelineSuccess = (timeline: string, statuses: APIEntity[], next: string | null, partial: boolean, isLoadingRecent: boolean, isLoadingMore: boolean) => ({ +const expandTimelineSuccess = ( + timeline: string, + statuses: APIEntity[], + next: string | undefined, + prev: string | undefined, + partial: boolean, + isLoadingRecent: boolean, + isLoadingMore: boolean, +) => ({ type: TIMELINE_EXPAND_SUCCESS, timeline, statuses, next, + prev, partial, isLoadingRecent, skipLoading: !isLoadingMore, @@ -312,6 +353,7 @@ export { expandAccountMediaTimeline, expandListTimeline, expandGroupTimeline, + expandGroupTimelineFromTag, expandGroupMediaTimeline, expandHashtagTimeline, expandTimelineRequest, diff --git a/app/soapbox/api/hooks/accounts/useAccount.ts b/app/soapbox/api/hooks/accounts/useAccount.ts new file mode 100644 index 000000000..2442ad642 --- /dev/null +++ b/app/soapbox/api/hooks/accounts/useAccount.ts @@ -0,0 +1,26 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntity } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks/useApi'; +import { type Account, accountSchema } from 'soapbox/schemas'; + + +import { useRelationships } from './useRelationships'; + +function useAccount(id: string) { + const api = useApi(); + + const { entity: account, ...result } = useEntity( + [Entities.ACCOUNTS, id], + () => api.get(`/api/v1/accounts/${id}`), + { schema: accountSchema }, + ); + const { relationships, isLoading } = useRelationships([account?.id as string]); + + return { + ...result, + isLoading: result.isLoading || isLoading, + account: account ? { ...account, relationship: relationships[0] || null } : undefined, + }; +} + +export { useAccount }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/accounts/useRelationships.ts b/app/soapbox/api/hooks/accounts/useRelationships.ts new file mode 100644 index 000000000..2103e2438 --- /dev/null +++ b/app/soapbox/api/hooks/accounts/useRelationships.ts @@ -0,0 +1,21 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks/useApi'; +import { type Relationship, relationshipSchema } from 'soapbox/schemas'; + +function useRelationships(ids: string[]) { + const api = useApi(); + + const { entities: relationships, ...result } = useEntities( + [Entities.RELATIONSHIPS], + () => api.get(`/api/v1/accounts/relationships?${ids.map(id => `id[]=${id}`).join('&')}`), + { schema: relationshipSchema, enabled: ids.filter(Boolean).length > 0 }, + ); + + return { + ...result, + relationships, + }; +} + +export { useRelationships }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts new file mode 100644 index 000000000..8afd06f1a --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts @@ -0,0 +1,41 @@ +import { __stub } from 'soapbox/api'; +import { buildGroup } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import { useGroup } from '../useGroup'; + +const group = buildGroup({ id: '1', display_name: 'soapbox' }); + +describe('useGroup hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/${group.id}`).reply(200, group); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(() => useGroup(group.id)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.group?.id).toBe(group.id); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/${group.id}`).networkError(); + }); + }); + + it('is has error state', async() => { + const { result } = renderHook(() => useGroup(group.id)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.group).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts new file mode 100644 index 000000000..2397b16ce --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts @@ -0,0 +1,41 @@ +import { __stub } from 'soapbox/api'; +import { buildGroup } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import { useGroupLookup } from '../useGroupLookup'; + +const group = buildGroup({ id: '1', slug: 'soapbox' }); + +describe('useGroupLookup hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).reply(200, group); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(() => useGroupLookup(group.slug)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.entity?.id).toBe(group.id); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).networkError(); + }); + }); + + it('is has error state', async() => { + const { result } = renderHook(() => useGroupLookup(group.slug)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.entity).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts new file mode 100644 index 000000000..a68b79eb1 --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts @@ -0,0 +1,44 @@ +import { __stub } from 'soapbox/api'; +import { buildStatus } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import { useGroupMedia } from '../useGroupMedia'; + +const status = buildStatus(); +const groupId = '1'; + +describe('useGroupMedia hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).reply(200, [status]); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(() => useGroupMedia(groupId)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.entities.length).toBe(1); + expect(result.current.entities[0].id).toBe(status.id); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).networkError(); + }); + }); + + it('is has error state', async() => { + const { result } = renderHook(() => useGroupMedia(groupId)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.entities.length).toBe(0); + expect(result.current.isError).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroupMembers.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroupMembers.test.ts new file mode 100644 index 000000000..6f2fb6eac --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroupMembers.test.ts @@ -0,0 +1,45 @@ +import { __stub } from 'soapbox/api'; +import { buildGroupMember } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; +import { GroupRoles } from 'soapbox/schemas/group-member'; + +import { useGroupMembers } from '../useGroupMembers'; + +const groupMember = buildGroupMember(); +const groupId = '1'; + +describe('useGroupMembers hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).reply(200, [groupMember]); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.groupMembers.length).toBe(1); + expect(result.current.groupMembers[0].id).toBe(groupMember.id); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).networkError(); + }); + }); + + it('is has error state', async() => { + const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.groupMembers.length).toBe(0); + expect(result.current.isError).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts new file mode 100644 index 000000000..739a1c0af --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts @@ -0,0 +1,47 @@ +import { __stub } from 'soapbox/api'; +import { buildGroup } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; +import { normalizeInstance } from 'soapbox/normalizers'; + +import { useGroups } from '../useGroups'; + +const group = buildGroup({ id: '1', display_name: 'soapbox' }); +const store = { + instance: normalizeInstance({ + version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)', + }), +}; + +describe('useGroups hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/groups').reply(200, [group]); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(useGroups, undefined, store); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.groups).toHaveLength(1); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/groups').networkError(); + }); + }); + + it('is has error state', async() => { + const { result } = renderHook(useGroups, undefined, store); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.groups).toHaveLength(0); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/useBlockGroupMember.ts b/app/soapbox/api/hooks/groups/useBlockGroupMember.ts similarity index 100% rename from app/soapbox/hooks/api/groups/useBlockGroupMember.ts rename to app/soapbox/api/hooks/groups/useBlockGroupMember.ts diff --git a/app/soapbox/api/hooks/groups/useCancelMembershipRequest.ts b/app/soapbox/api/hooks/groups/useCancelMembershipRequest.ts new file mode 100644 index 000000000..2c6007c51 --- /dev/null +++ b/app/soapbox/api/hooks/groups/useCancelMembershipRequest.ts @@ -0,0 +1,22 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useCreateEntity } from 'soapbox/entity-store/hooks'; +import { useApi, useOwnAccount } from 'soapbox/hooks'; + +import type { Group } from 'soapbox/schemas'; + +function useCancelMembershipRequest(group: Group) { + const api = useApi(); + const me = useOwnAccount(); + + const { createEntity, isSubmitting } = useCreateEntity( + [Entities.GROUP_RELATIONSHIPS], + () => api.post(`/api/v1/groups/${group.id}/membership_requests/${me?.id}/reject`), + ); + + return { + mutate: createEntity, + isSubmitting, + }; +} + +export { useCancelMembershipRequest }; diff --git a/app/soapbox/api/hooks/groups/useCreateGroup.ts b/app/soapbox/api/hooks/groups/useCreateGroup.ts new file mode 100644 index 000000000..302374946 --- /dev/null +++ b/app/soapbox/api/hooks/groups/useCreateGroup.ts @@ -0,0 +1,33 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useCreateEntity } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks/useApi'; +import { groupSchema } from 'soapbox/schemas'; + +interface CreateGroupParams { + display_name?: string + note?: string + avatar?: File + header?: File + group_visibility?: 'members_only' | 'everyone' + discoverable?: boolean + tags?: string[] +} + +function useCreateGroup() { + const api = useApi(); + + const { createEntity, ...rest } = useCreateEntity([Entities.GROUPS, 'search', ''], (params: CreateGroupParams) => { + return api.post('/api/v1/groups', params, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + }, { schema: groupSchema }); + + return { + createGroup: createEntity, + ...rest, + }; +} + +export { useCreateGroup, type CreateGroupParams }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/useDeleteGroup.ts b/app/soapbox/api/hooks/groups/useDeleteGroup.ts similarity index 73% rename from app/soapbox/hooks/api/groups/useDeleteGroup.ts rename to app/soapbox/api/hooks/groups/useDeleteGroup.ts index 277cfec46..d1b25cccd 100644 --- a/app/soapbox/hooks/api/groups/useDeleteGroup.ts +++ b/app/soapbox/api/hooks/groups/useDeleteGroup.ts @@ -4,14 +4,14 @@ import { useEntityActions } from 'soapbox/entity-store/hooks'; import type { Group } from 'soapbox/schemas'; function useDeleteGroup() { - const { deleteEntity, isLoading } = useEntityActions( + const { deleteEntity, isSubmitting } = useEntityActions( [Entities.GROUPS], { delete: '/api/v1/groups/:id' }, ); return { mutate: deleteEntity, - isLoading, + isSubmitting, }; } diff --git a/app/soapbox/api/hooks/groups/useDeleteGroupStatus.ts b/app/soapbox/api/hooks/groups/useDeleteGroupStatus.ts new file mode 100644 index 000000000..55a6f9459 --- /dev/null +++ b/app/soapbox/api/hooks/groups/useDeleteGroupStatus.ts @@ -0,0 +1,20 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useDeleteEntity } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; + +import type { Group } from 'soapbox/schemas'; + +function useDeleteGroupStatus(group: Group, statusId: string) { + const api = useApi(); + const { deleteEntity, isSubmitting } = useDeleteEntity( + Entities.STATUSES, + () => api.delete(`/api/v1/groups/${group.id}/statuses/${statusId}`), + ); + + return { + mutate: deleteEntity, + isSubmitting, + }; +} + +export { useDeleteGroupStatus }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/useDemoteGroupMember.ts b/app/soapbox/api/hooks/groups/useDemoteGroupMember.ts similarity index 100% rename from app/soapbox/hooks/api/groups/useDemoteGroupMember.ts rename to app/soapbox/api/hooks/groups/useDemoteGroupMember.ts diff --git a/app/soapbox/api/hooks/groups/useGroup.ts b/app/soapbox/api/hooks/groups/useGroup.ts new file mode 100644 index 000000000..b66c0fee7 --- /dev/null +++ b/app/soapbox/api/hooks/groups/useGroup.ts @@ -0,0 +1,24 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntity } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; +import { type Group, groupSchema } from 'soapbox/schemas'; + +import { useGroupRelationship } from './useGroupRelationship'; + +function useGroup(groupId: string, refetch = true) { + const api = useApi(); + + const { entity: group, ...result } = useEntity( + [Entities.GROUPS, groupId], + () => api.get(`/api/v1/groups/${groupId}`), + { schema: groupSchema, refetch }, + ); + const { entity: relationship } = useGroupRelationship(groupId); + + return { + ...result, + group: group ? { ...group, relationship: relationship || null } : undefined, + }; +} + +export { useGroup }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/useGroupLookup.ts b/app/soapbox/api/hooks/groups/useGroupLookup.ts new file mode 100644 index 000000000..6e41975e5 --- /dev/null +++ b/app/soapbox/api/hooks/groups/useGroupLookup.ts @@ -0,0 +1,26 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntityLookup } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks/useApi'; +import { groupSchema } from 'soapbox/schemas'; + +import { useGroupRelationship } from './useGroupRelationship'; + +function useGroupLookup(slug: string) { + const api = useApi(); + + const { entity: group, ...result } = useEntityLookup( + Entities.GROUPS, + (group) => group.slug === slug, + () => api.get(`/api/v1/groups/lookup?name=${slug}`), + { schema: groupSchema }, + ); + + const { entity: relationship } = useGroupRelationship(group?.id); + + return { + ...result, + entity: group ? { ...group, relationship: relationship || null } : undefined, + }; +} + +export { useGroupLookup }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/useGroupMedia.ts b/app/soapbox/api/hooks/groups/useGroupMedia.ts new file mode 100644 index 000000000..4db7fd179 --- /dev/null +++ b/app/soapbox/api/hooks/groups/useGroupMedia.ts @@ -0,0 +1,17 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks/useApi'; +import { normalizeStatus } from 'soapbox/normalizers'; +import { toSchema } from 'soapbox/utils/normalizers'; + +const statusSchema = toSchema(normalizeStatus); + +function useGroupMedia(groupId: string) { + const api = useApi(); + + return useEntities([Entities.STATUSES, 'groupMedia', groupId], () => { + return api.get(`/api/v1/timelines/group/${groupId}?only_media=true`); + }, { schema: statusSchema }); +} + +export { useGroupMedia }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/useGroupMembers.ts b/app/soapbox/api/hooks/groups/useGroupMembers.ts similarity index 71% rename from app/soapbox/hooks/api/useGroupMembers.ts rename to app/soapbox/api/hooks/groups/useGroupMembers.ts index 669f1c082..a9b03e7f2 100644 --- a/app/soapbox/hooks/api/useGroupMembers.ts +++ b/app/soapbox/api/hooks/groups/useGroupMembers.ts @@ -1,10 +1,11 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useEntities } from 'soapbox/entity-store/hooks'; import { GroupMember, groupMemberSchema } from 'soapbox/schemas'; +import { GroupRoles } from 'soapbox/schemas/group-member'; -import { useApi } from '../useApi'; +import { useApi } from '../../../hooks/useApi'; -function useGroupMembers(groupId: string, role: string) { +function useGroupMembers(groupId: string, role: GroupRoles) { const api = useApi(); const { entities, ...result } = useEntities( diff --git a/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts b/app/soapbox/api/hooks/groups/useGroupMembershipRequests.ts similarity index 54% rename from app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts rename to app/soapbox/api/hooks/groups/useGroupMembershipRequests.ts index 6fab87209..a6e068091 100644 --- a/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts +++ b/app/soapbox/api/hooks/groups/useGroupMembershipRequests.ts @@ -1,7 +1,10 @@ import { Entities } from 'soapbox/entity-store/entities'; -import { useEntities, useIncrementEntity } from 'soapbox/entity-store/hooks'; +import { useDismissEntity, useEntities } from 'soapbox/entity-store/hooks'; import { useApi } from 'soapbox/hooks/useApi'; import { accountSchema } from 'soapbox/schemas'; +import { GroupRoles } from 'soapbox/schemas/group-member'; + +import { useGroupRelationship } from './useGroupRelationship'; import type { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types'; @@ -9,19 +12,24 @@ function useGroupMembershipRequests(groupId: string) { const api = useApi(); const path: ExpandedEntitiesPath = [Entities.ACCOUNTS, 'membership_requests', groupId]; - const { entities, invalidate, ...rest } = useEntities( + const { entity: relationship } = useGroupRelationship(groupId); + + const { entities, invalidate, fetchEntities, ...rest } = useEntities( path, () => api.get(`/api/v1/groups/${groupId}/membership_requests`), - { schema: accountSchema }, + { + schema: accountSchema, + enabled: relationship?.role === GroupRoles.OWNER || relationship?.role === GroupRoles.ADMIN, + }, ); - const { incrementEntity: authorize } = useIncrementEntity(path, -1, async (accountId: string) => { + const { dismissEntity: authorize } = useDismissEntity(path, async (accountId: string) => { const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`); invalidate(); return response; }); - const { incrementEntity: reject } = useIncrementEntity(path, -1, async (accountId: string) => { + const { dismissEntity: reject } = useDismissEntity(path, async (accountId: string) => { const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`); invalidate(); return response; @@ -29,6 +37,7 @@ function useGroupMembershipRequests(groupId: string) { return { accounts: entities, + refetch: fetchEntities, authorize, reject, ...rest, diff --git a/app/soapbox/api/hooks/groups/useGroupRelationship.ts b/app/soapbox/api/hooks/groups/useGroupRelationship.ts new file mode 100644 index 000000000..21d8d3efd --- /dev/null +++ b/app/soapbox/api/hooks/groups/useGroupRelationship.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import { z } from 'zod'; + +import { fetchGroupRelationshipsSuccess } from 'soapbox/actions/groups'; +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntity } from 'soapbox/entity-store/hooks'; +import { useApi, useAppDispatch } from 'soapbox/hooks'; +import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas'; + +function useGroupRelationship(groupId: string | undefined) { + const api = useApi(); + const dispatch = useAppDispatch(); + + const { entity: groupRelationship, ...result } = useEntity( + [Entities.GROUP_RELATIONSHIPS, groupId as string], + () => api.get(`/api/v1/groups/relationships?id[]=${groupId}`), + { + enabled: !!groupId, + schema: z.array(groupRelationshipSchema).transform(arr => arr[0]), + }, + ); + + useEffect(() => { + if (groupRelationship?.id) { + dispatch(fetchGroupRelationshipsSuccess([groupRelationship])); + } + }, [groupRelationship?.id]); + + return { + entity: groupRelationship, + ...result, + }; +} + +export { useGroupRelationship }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/useGroupRelationships.ts b/app/soapbox/api/hooks/groups/useGroupRelationships.ts new file mode 100644 index 000000000..c4106adda --- /dev/null +++ b/app/soapbox/api/hooks/groups/useGroupRelationships.ts @@ -0,0 +1,27 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; +import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas'; + +function useGroupRelationships(groupIds: string[]) { + const api = useApi(); + const q = groupIds.map(id => `id[]=${id}`).join('&'); + + const { entities, ...result } = useEntities( + [Entities.GROUP_RELATIONSHIPS, ...groupIds], + () => api.get(`/api/v1/groups/relationships?${q}`), + { schema: groupRelationshipSchema, enabled: groupIds.length > 0 }, + ); + + const relationships = entities.reduce>((map, relationship) => { + map[relationship.id] = relationship; + return map; + }, {}); + + return { + ...result, + relationships, + }; +} + +export { useGroupRelationships }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/useGroupSearch.ts b/app/soapbox/api/hooks/groups/useGroupSearch.ts similarity index 83% rename from app/soapbox/hooks/api/groups/useGroupSearch.ts rename to app/soapbox/api/hooks/groups/useGroupSearch.ts index 17c10e90f..d1d8acf9c 100644 --- a/app/soapbox/hooks/api/groups/useGroupSearch.ts +++ b/app/soapbox/api/hooks/groups/useGroupSearch.ts @@ -1,11 +1,9 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useEntities } from 'soapbox/entity-store/hooks'; +import { useApi, useFeatures } from 'soapbox/hooks'; import { groupSchema } from 'soapbox/schemas'; -import { useApi } from '../../useApi'; -import { useFeatures } from '../../useFeatures'; - -import { useGroupRelationships } from './useGroups'; +import { useGroupRelationships } from './useGroupRelationships'; import type { Group } from 'soapbox/schemas'; diff --git a/app/soapbox/api/hooks/groups/useGroupTag.ts b/app/soapbox/api/hooks/groups/useGroupTag.ts new file mode 100644 index 000000000..d0e63d74d --- /dev/null +++ b/app/soapbox/api/hooks/groups/useGroupTag.ts @@ -0,0 +1,21 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntity } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; +import { type GroupTag, groupTagSchema } from 'soapbox/schemas'; + +function useGroupTag(tagId: string) { + const api = useApi(); + + const { entity: tag, ...result } = useEntity( + [Entities.GROUP_TAGS, tagId], + () => api.get(`/api/v1/tags/${tagId }`), + { schema: groupTagSchema }, + ); + + return { + ...result, + tag, + }; +} + +export { useGroupTag }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/useGroupTags.ts b/app/soapbox/api/hooks/groups/useGroupTags.ts new file mode 100644 index 000000000..b2c29aa3c --- /dev/null +++ b/app/soapbox/api/hooks/groups/useGroupTags.ts @@ -0,0 +1,23 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks/useApi'; +import { groupTagSchema } from 'soapbox/schemas'; + +import type { GroupTag } from 'soapbox/schemas'; + +function useGroupTags(groupId: string) { + const api = useApi(); + + const { entities, ...result } = useEntities( + [Entities.GROUP_TAGS, groupId], + () => api.get(`/api/v1/truth/trends/groups/${groupId}/tags`), + { schema: groupTagSchema }, + ); + + return { + ...result, + tags: entities, + }; +} + +export { useGroupTags }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/useGroupValidation.ts b/app/soapbox/api/hooks/groups/useGroupValidation.ts similarity index 100% rename from app/soapbox/hooks/api/groups/useGroupValidation.ts rename to app/soapbox/api/hooks/groups/useGroupValidation.ts diff --git a/app/soapbox/api/hooks/groups/useGroups.ts b/app/soapbox/api/hooks/groups/useGroups.ts new file mode 100644 index 000000000..13ca45713 --- /dev/null +++ b/app/soapbox/api/hooks/groups/useGroups.ts @@ -0,0 +1,31 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; +import { useFeatures } from 'soapbox/hooks/useFeatures'; +import { groupSchema, type Group } from 'soapbox/schemas/group'; + +import { useGroupRelationships } from './useGroupRelationships'; + +function useGroups(q: string = '') { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUPS, 'search', q], + () => api.get('/api/v1/groups', { params: { q } }), + { enabled: features.groups, schema: groupSchema }, + ); + const { relationships } = useGroupRelationships(entities.map(entity => entity.id)); + + const groups = entities.map((group) => ({ + ...group, + relationship: relationships[group.id] || null, + })); + + return { + ...result, + groups, + }; +} + +export { useGroups }; diff --git a/app/soapbox/api/hooks/groups/useGroupsFromTag.ts b/app/soapbox/api/hooks/groups/useGroupsFromTag.ts new file mode 100644 index 000000000..2c7e5a94f --- /dev/null +++ b/app/soapbox/api/hooks/groups/useGroupsFromTag.ts @@ -0,0 +1,35 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { useApi, useFeatures } from 'soapbox/hooks'; +import { groupSchema } from 'soapbox/schemas'; + +import { useGroupRelationships } from './useGroupRelationships'; + +import type { Group } from 'soapbox/schemas'; + +function useGroupsFromTag(tagId: string) { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUPS, 'tags', tagId], + () => api.get(`/api/v1/tags/${tagId}/groups`), + { + schema: groupSchema, + enabled: features.groupsDiscovery, + }, + ); + const { relationships } = useGroupRelationships(entities.map(entity => entity.id)); + + const groups = entities.map((group) => ({ + ...group, + relationship: relationships[group.id] || null, + })); + + return { + ...result, + groups, + }; +} + +export { useGroupsFromTag }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/useJoinGroup.ts b/app/soapbox/api/hooks/groups/useJoinGroup.ts similarity index 82% rename from app/soapbox/hooks/api/groups/useJoinGroup.ts rename to app/soapbox/api/hooks/groups/useJoinGroup.ts index 46cd1d5bd..0ea9293d9 100644 --- a/app/soapbox/hooks/api/groups/useJoinGroup.ts +++ b/app/soapbox/api/hooks/groups/useJoinGroup.ts @@ -9,7 +9,7 @@ import type { Group, GroupRelationship } from 'soapbox/schemas'; function useJoinGroup(group: Group) { const { invalidate } = useGroups(); - const { createEntity, isLoading } = useEntityActions( + const { createEntity, isSubmitting } = useEntityActions( [Entities.GROUP_RELATIONSHIPS, group.id], { post: `/api/v1/groups/${group.id}/join` }, { schema: groupRelationshipSchema }, @@ -17,7 +17,7 @@ function useJoinGroup(group: Group) { return { mutate: createEntity, - isLoading, + isSubmitting, invalidate, }; } diff --git a/app/soapbox/hooks/api/groups/useLeaveGroup.ts b/app/soapbox/api/hooks/groups/useLeaveGroup.ts similarity index 86% rename from app/soapbox/hooks/api/groups/useLeaveGroup.ts rename to app/soapbox/api/hooks/groups/useLeaveGroup.ts index af78c6d35..e1b11e737 100644 --- a/app/soapbox/hooks/api/groups/useLeaveGroup.ts +++ b/app/soapbox/api/hooks/groups/useLeaveGroup.ts @@ -9,7 +9,7 @@ import type { Group, GroupRelationship } from 'soapbox/schemas'; function useLeaveGroup(group: Group) { const { invalidate } = useGroups(); - const { createEntity, isLoading } = useEntityActions( + const { createEntity, isSubmitting } = useEntityActions( [Entities.GROUP_RELATIONSHIPS, group.id], { post: `/api/v1/groups/${group.id}/leave` }, { schema: groupRelationshipSchema }, @@ -17,7 +17,7 @@ function useLeaveGroup(group: Group) { return { mutate: createEntity, - isLoading, + isSubmitting, invalidate, }; } diff --git a/app/soapbox/hooks/api/usePopularGroups.ts b/app/soapbox/api/hooks/groups/usePopularGroups.ts similarity index 78% rename from app/soapbox/hooks/api/usePopularGroups.ts rename to app/soapbox/api/hooks/groups/usePopularGroups.ts index 385322500..b5959a335 100644 --- a/app/soapbox/hooks/api/usePopularGroups.ts +++ b/app/soapbox/api/hooks/groups/usePopularGroups.ts @@ -2,9 +2,10 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useEntities } from 'soapbox/entity-store/hooks'; import { Group, groupSchema } from 'soapbox/schemas'; -import { useGroupRelationships } from '../api/groups/useGroups'; -import { useApi } from '../useApi'; -import { useFeatures } from '../useFeatures'; +import { useApi } from '../../../hooks/useApi'; +import { useFeatures } from '../../../hooks/useFeatures'; + +import { useGroupRelationships } from './useGroupRelationships'; function usePopularGroups() { const api = useApi(); diff --git a/app/soapbox/api/hooks/groups/usePopularTags.ts b/app/soapbox/api/hooks/groups/usePopularTags.ts new file mode 100644 index 000000000..e0ec2c550 --- /dev/null +++ b/app/soapbox/api/hooks/groups/usePopularTags.ts @@ -0,0 +1,25 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { useApi, useFeatures } from 'soapbox/hooks'; +import { type GroupTag, groupTagSchema } from 'soapbox/schemas'; + +function usePopularTags() { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUP_TAGS], + () => api.get('/api/v1/groups/tags'), + { + schema: groupTagSchema, + enabled: features.groupsDiscovery, + }, + ); + + return { + ...result, + tags: entities, + }; +} + +export { usePopularTags }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/usePromoteGroupMember.ts b/app/soapbox/api/hooks/groups/usePromoteGroupMember.ts similarity index 100% rename from app/soapbox/hooks/api/groups/usePromoteGroupMember.ts rename to app/soapbox/api/hooks/groups/usePromoteGroupMember.ts diff --git a/app/soapbox/hooks/api/useSuggestedGroups.ts b/app/soapbox/api/hooks/groups/useSuggestedGroups.ts similarity index 75% rename from app/soapbox/hooks/api/useSuggestedGroups.ts rename to app/soapbox/api/hooks/groups/useSuggestedGroups.ts index 49f60c2b1..be9b5a78e 100644 --- a/app/soapbox/hooks/api/useSuggestedGroups.ts +++ b/app/soapbox/api/hooks/groups/useSuggestedGroups.ts @@ -1,10 +1,9 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useEntities } from 'soapbox/entity-store/hooks'; -import { Group, groupSchema } from 'soapbox/schemas'; +import { useApi, useFeatures } from 'soapbox/hooks'; +import { type Group, groupSchema } from 'soapbox/schemas'; -import { useGroupRelationships } from '../api/groups/useGroups'; -import { useApi } from '../useApi'; -import { useFeatures } from '../useFeatures'; +import { useGroupRelationships } from './useGroupRelationships'; function useSuggestedGroups() { const api = useApi(); diff --git a/app/soapbox/hooks/api/groups/useUpdateGroup.ts b/app/soapbox/api/hooks/groups/useUpdateGroup.ts similarity index 100% rename from app/soapbox/hooks/api/groups/useUpdateGroup.ts rename to app/soapbox/api/hooks/groups/useUpdateGroup.ts diff --git a/app/soapbox/api/hooks/groups/useUpdateGroupTag.ts b/app/soapbox/api/hooks/groups/useUpdateGroupTag.ts new file mode 100644 index 000000000..1c68c714d --- /dev/null +++ b/app/soapbox/api/hooks/groups/useUpdateGroupTag.ts @@ -0,0 +1,18 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntityActions } from 'soapbox/entity-store/hooks'; + +import type { GroupTag } from 'soapbox/schemas'; + +function useUpdateGroupTag(groupId: string, tagId: string) { + const { updateEntity, ...rest } = useEntityActions( + [Entities.GROUP_TAGS, groupId, tagId], + { patch: `/api/v1/groups/${groupId}/tags/${tagId}` }, + ); + + return { + updateGroupTag: updateEntity, + ...rest, + }; +} + +export { useUpdateGroupTag }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/index.ts b/app/soapbox/api/hooks/index.ts new file mode 100644 index 000000000..ade03f799 --- /dev/null +++ b/app/soapbox/api/hooks/index.ts @@ -0,0 +1,40 @@ + +/** + * Accounts + */ +export { useAccount } from './accounts/useAccount'; + +/** + * Groups + */ +export { useBlockGroupMember } from './groups/useBlockGroupMember'; +export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest'; +export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup'; +export { useDeleteGroup } from './groups/useDeleteGroup'; +export { useDemoteGroupMember } from './groups/useDemoteGroupMember'; +export { useGroup } from './groups/useGroup'; +export { useGroupLookup } from './groups/useGroupLookup'; +export { useGroupMedia } from './groups/useGroupMedia'; +export { useGroupMembers } from './groups/useGroupMembers'; +export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests'; +export { useGroupRelationship } from './groups/useGroupRelationship'; +export { useGroupRelationships } from './groups/useGroupRelationships'; +export { useGroupSearch } from './groups/useGroupSearch'; +export { useGroupTag } from './groups/useGroupTag'; +export { useGroupTags } from './groups/useGroupTags'; +export { useGroupValidation } from './groups/useGroupValidation'; +export { useGroups } from './groups/useGroups'; +export { useGroupsFromTag } from './groups/useGroupsFromTag'; +export { useJoinGroup } from './groups/useJoinGroup'; +export { useLeaveGroup } from './groups/useLeaveGroup'; +export { usePopularGroups } from './groups/usePopularGroups'; +export { usePopularTags } from './groups/usePopularTags'; +export { usePromoteGroupMember } from './groups/usePromoteGroupMember'; +export { useSuggestedGroups } from './groups/useSuggestedGroups'; +export { useUpdateGroup } from './groups/useUpdateGroup'; +export { useUpdateGroupTag } from './groups/useUpdateGroupTag'; + +/** + * Relationships + */ +export { useRelationships } from './accounts/useRelationships'; \ No newline at end of file diff --git a/app/soapbox/components/authorize-reject-buttons.tsx b/app/soapbox/components/authorize-reject-buttons.tsx index 9edd44189..5dfb37a31 100644 --- a/app/soapbox/components/authorize-reject-buttons.tsx +++ b/app/soapbox/components/authorize-reject-buttons.tsx @@ -14,6 +14,23 @@ interface IAuthorizeRejectButtons { const AuthorizeRejectButtons: React.FC = ({ onAuthorize, onReject, countdown }) => { const [state, setState] = useState<'authorizing' | 'rejecting' | 'authorized' | 'rejected' | 'pending'>('pending'); const timeout = useRef(); + const interval = useRef>(); + + const [progress, setProgress] = useState(0); + + const startProgressInterval = () => { + let startValue = 1; + interval.current = setInterval(() => { + startValue++; + const newValue = startValue * 3.6; // get to 360 (deg) + setProgress(newValue); + + if (newValue >= 360) { + clearInterval(interval.current as NodeJS.Timeout); + setProgress(0); + } + }, (countdown as number) / 100); + }; function handleAction( present: 'authorizing' | 'rejecting', @@ -21,6 +38,9 @@ const AuthorizeRejectButtons: React.FC = ({ onAuthorize action: () => Promise | unknown, ): void { if (state === present) { + if (interval.current) { + clearInterval(interval.current); + } if (timeout.current) { clearTimeout(timeout.current); } @@ -31,12 +51,13 @@ const AuthorizeRejectButtons: React.FC = ({ onAuthorize await action(); setState(past); } catch (e) { - console.error(e); + if (e) console.error(e); } }; if (typeof countdown === 'number') { setState(present); timeout.current = setTimeout(doAction, countdown); + startProgressInterval(); } else { doAction(); } @@ -46,11 +67,28 @@ const AuthorizeRejectButtons: React.FC = ({ onAuthorize const handleAuthorize = async () => handleAction('authorizing', 'authorized', onAuthorize); const handleReject = async () => handleAction('rejecting', 'rejected', onReject); + const renderStyle = (selectedState: typeof state) => { + if (state === 'authorizing' && selectedState === 'authorizing') { + return { + background: `conic-gradient(rgb(var(--color-primary-500)) ${progress}deg, rgb(var(--color-primary-500) / 0.1) 0deg)`, + }; + } else if (state === 'rejecting' && selectedState === 'rejecting') { + return { + background: `conic-gradient(rgb(var(--color-danger-600)) ${progress}deg, rgb(var(--color-danger-600) / 0.1) 0deg)`, + }; + } + + return {}; + }; + useEffect(() => { return () => { if (timeout.current) { clearTimeout(timeout.current); } + if (interval.current) { + clearInterval(interval.current); + } }; }, []); @@ -72,6 +110,7 @@ const AuthorizeRejectButtons: React.FC = ({ onAuthorize action={handleReject} isLoading={state === 'rejecting'} disabled={state === 'authorizing'} + style={renderStyle('rejecting')} /> = ({ onAuthorize action={handleAuthorize} isLoading={state === 'authorizing'} disabled={state === 'rejecting'} + style={renderStyle('authorizing')} /> ); @@ -105,33 +145,34 @@ interface IAuthorizeRejectButton { action(): void isLoading?: boolean disabled?: boolean + style: React.CSSProperties } -const AuthorizeRejectButton: React.FC = ({ theme, icon, action, isLoading, disabled }) => { +const AuthorizeRejectButton: React.FC = ({ theme, icon, action, isLoading, style, disabled }) => { return (
- - {(isLoading) && ( -
+ - )} +
); }; diff --git a/app/soapbox/components/birthday-panel.tsx b/app/soapbox/components/birthday-panel.tsx index 059b8678b..bcfe5d073 100644 --- a/app/soapbox/components/birthday-panel.tsx +++ b/app/soapbox/components/birthday-panel.tsx @@ -58,6 +58,7 @@ const BirthdayPanel = ({ limit }: IBirthdayPanel) => { key={accountId} // @ts-ignore: TS thinks `id` is passed to , but it isn't id={accountId} + withRelationship={false} /> ))} diff --git a/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx b/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx index 8b0ca7755..8a6c8f531 100644 --- a/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx +++ b/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx @@ -94,7 +94,7 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => { > {item.icon && } - {item.text} + {item.text} {item.count ? ( diff --git a/app/soapbox/components/group-card.tsx b/app/soapbox/components/group-card.tsx index 2aee2bc7f..84e771902 100644 --- a/app/soapbox/components/group-card.tsx +++ b/app/soapbox/components/group-card.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { defineMessages, useIntl } from 'react-intl'; +import GroupHeaderImage from 'soapbox/features/group/components/group-header-image'; 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'; @@ -10,17 +10,11 @@ import { HStack, Stack, Text } from './ui'; import type { Group as GroupEntity } from 'soapbox/types/entities'; -const messages = defineMessages({ - groupHeader: { id: 'group.header.alt', defaultMessage: 'Group header' }, -}); - interface IGroupCard { group: GroupEntity } const GroupCard: React.FC = ({ group }) => { - const intl = useIntl(); - return ( = ({ group }) => { > {/* Group Cover Image */} - {group.header && ( - {intl.formatMessage(messages.groupHeader)} - )} + {/* Group Avatar */} diff --git a/app/soapbox/components/groups/popover/group-popover.tsx b/app/soapbox/components/groups/popover/group-popover.tsx index 752deeb8a..245420af6 100644 --- a/app/soapbox/components/groups/popover/group-popover.tsx +++ b/app/soapbox/components/groups/popover/group-popover.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { Link } from 'react-router-dom'; +import { Link, matchPath, useHistory } 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'; @@ -26,6 +26,13 @@ const GroupPopover = (props: IGroupPopoverContainer) => { const { children, group, isEnabled } = props; const intl = useIntl(); + const history = useHistory(); + + const path = history.location.pathname; + const shouldHideAction = matchPath(path, { + path: ['/group/:groupSlug'], + exact: true, + }); if (!isEnabled) { return children; @@ -36,7 +43,7 @@ const GroupPopover = (props: IGroupPopoverContainer) => { interaction='click' referenceElementClassName='cursor-pointer' content={ - + { -
- - - -
+ {!shouldHideAction && ( +
+ + + +
+ )}
} isFlush diff --git a/app/soapbox/components/hoc/group-lookup-hoc.tsx b/app/soapbox/components/hoc/group-lookup-hoc.tsx new file mode 100644 index 000000000..07c2d475b --- /dev/null +++ b/app/soapbox/components/hoc/group-lookup-hoc.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +import { useGroupLookup } from 'soapbox/api/hooks'; +import ColumnLoading from 'soapbox/features/ui/components/column-loading'; + +import { Layout } from '../ui'; + +interface IGroupLookup { + params: { + groupSlug: string + } +} + +interface IMaybeGroupLookup { + params?: { + groupSlug?: string + groupId?: string + } +} + +function GroupLookupHoc(Component: React.ComponentType<{ params: { groupId: string } }>) { + const GroupLookup: React.FC = (props) => { + const { entity: group } = useGroupLookup(props.params.groupSlug); + + if (!group) return ( + <> + + + + + + + ); + + const newProps = { + ...props, + params: { + ...props.params, + id: group.id, + groupId: group.id, + }, + }; + + return ( + + ); + }; + + const MaybeGroupLookup: React.FC = (props) => { + const { params } = props; + + if (params?.groupId) { + return ; + } else { + return ; + } + }; + + return MaybeGroupLookup; +} + +export default GroupLookupHoc; \ No newline at end of file diff --git a/app/soapbox/components/hoc/with-hoc.tsx b/app/soapbox/components/hoc/with-hoc.tsx new file mode 100644 index 000000000..d5752a45f --- /dev/null +++ b/app/soapbox/components/hoc/with-hoc.tsx @@ -0,0 +1,11 @@ +type HOC = (Component: React.ComponentType

) => React.ComponentType +type AsyncComponent

= () => Promise<{ default: React.ComponentType

}> + +const withHoc = (asyncComponent: AsyncComponent

, hoc: HOC) => { + return async () => { + const { default: component } = await asyncComponent(); + return { default: hoc(component) }; + }; +}; + +export default withHoc; \ No newline at end of file diff --git a/app/soapbox/components/list.tsx b/app/soapbox/components/list.tsx index ee04b2d92..b56e0e6a7 100644 --- a/app/soapbox/components/list.tsx +++ b/app/soapbox/components/list.tsx @@ -56,14 +56,13 @@ const ListItem: React.FC = ({ label, hint, children, onClick, onSelec return (

- {label} + {label} {hint ? ( {hint} @@ -71,7 +70,7 @@ const ListItem: React.FC = ({ label, hint, children, onClick, onSelec
{onClick ? ( - + {children} diff --git a/app/soapbox/components/modal-root.tsx b/app/soapbox/components/modal-root.tsx index 84c1252c9..2358a951f 100644 --- a/app/soapbox/components/modal-root.tsx +++ b/app/soapbox/components/modal-root.tsx @@ -181,7 +181,9 @@ const ModalRoot: React.FC = ({ children, onCancel, onClose, type }) }; const getSiblings = () => { - return Array(...(ref.current!.parentElement!.childNodes as any as ChildNode[])).filter(node => node !== ref.current); + return Array(...(ref.current!.parentElement!.childNodes as any as ChildNode[])) + .filter(node => (node as HTMLDivElement).id !== 'toaster') + .filter(node => node !== ref.current); }; useEffect(() => { diff --git a/app/soapbox/components/polls/__tests__/poll-footer.test.tsx b/app/soapbox/components/polls/__tests__/poll-footer.test.tsx index 29c841a0a..a9e709399 100644 --- a/app/soapbox/components/polls/__tests__/poll-footer.test.tsx +++ b/app/soapbox/components/polls/__tests__/poll-footer.test.tsx @@ -4,14 +4,22 @@ import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; import { __stub } from 'soapbox/api'; -import { normalizePoll } from 'soapbox/normalizers/poll'; +import { mockStore, render, screen, rootState } from 'soapbox/jest/test-helpers'; +import { type Poll } from 'soapbox/schemas'; -import { mockStore, render, screen, rootState } from '../../../jest/test-helpers'; import PollFooter from '../poll-footer'; -let poll = normalizePoll({ - id: 1, - options: [{ title: 'Apples', votes_count: 0 }], +let poll: Poll = { + id: '1', + options: [{ + title: 'Apples', + votes_count: 0, + title_emojified: 'Apples', + }, { + title: 'Oranges', + votes_count: 0, + title_emojified: 'Oranges', + }], emojis: [], expired: false, expires_at: '2020-03-24T19:33:06.000Z', @@ -20,7 +28,7 @@ let poll = normalizePoll({ votes_count: 0, own_votes: null, voted: false, -}); +}; describe('', () => { describe('with "showResults" enabled', () => { @@ -62,10 +70,10 @@ describe('', () => { describe('when the Poll has not expired', () => { beforeEach(() => { - poll = normalizePoll({ - ...poll.toJS(), + poll = { + ...poll, expired: false, - }); + }; }); it('renders time remaining', () => { @@ -77,10 +85,10 @@ describe('', () => { describe('when the Poll has expired', () => { beforeEach(() => { - poll = normalizePoll({ - ...poll.toJS(), + poll = { + ...poll, expired: true, - }); + }; }); it('renders closed', () => { @@ -100,10 +108,10 @@ describe('', () => { describe('when the Poll is multiple', () => { beforeEach(() => { - poll = normalizePoll({ - ...poll.toJS(), + poll = { + ...poll, multiple: true, - }); + }; }); it('renders the Vote button', () => { @@ -115,10 +123,10 @@ describe('', () => { describe('when the Poll is not multiple', () => { beforeEach(() => { - poll = normalizePoll({ - ...poll.toJS(), + poll = { + ...poll, multiple: false, - }); + }; }); it('does not render the Vote button', () => { diff --git a/app/soapbox/components/polls/poll-footer.tsx b/app/soapbox/components/polls/poll-footer.tsx index c62cc9522..1994c1e76 100644 --- a/app/soapbox/components/polls/poll-footer.tsx +++ b/app/soapbox/components/polls/poll-footer.tsx @@ -40,21 +40,21 @@ const PollFooter: React.FC = ({ poll, showResults, selected }): JSX let votesCount = null; if (poll.voters_count !== null && poll.voters_count !== undefined) { - votesCount = ; + votesCount = ; } else { - votesCount = ; + votesCount = ; } return ( - {(!showResults && poll?.multiple) && ( + {(!showResults && poll.multiple) && ( )} - {poll.pleroma.get('non_anonymous') && ( + {poll.pleroma?.non_anonymous && ( <> diff --git a/app/soapbox/components/polls/poll-option.tsx b/app/soapbox/components/polls/poll-option.tsx index 792a3a066..b4c37e11d 100644 --- a/app/soapbox/components/polls/poll-option.tsx +++ b/app/soapbox/components/polls/poll-option.tsx @@ -112,10 +112,13 @@ const PollOption: React.FC = (props): JSX.Element | null => { const pollVotesCount = poll.voters_count || poll.votes_count; const percent = pollVotesCount === 0 ? 0 : (option.votes_count / pollVotesCount) * 100; - const leading = poll.options.filterNot(other => other.title === option.title).every(other => option.votes_count >= other.votes_count); const voted = poll.own_votes?.includes(index); const message = intl.formatMessage(messages.votes, { votes: option.votes_count }); + const leading = poll.options + .filter(other => other.title !== option.title) + .every(other => option.votes_count >= other.votes_count); + return (
{showResults ? ( diff --git a/app/soapbox/components/profile-hover-card.tsx b/app/soapbox/components/profile-hover-card.tsx index 2dd6a3fdd..d0c8a93d4 100644 --- a/app/soapbox/components/profile-hover-card.tsx +++ b/app/soapbox/components/profile-hover-card.tsx @@ -106,7 +106,7 @@ export const ProfileHoverCard: React.FC = ({ visible = true } onMouseEnter={handleMouseEnter(dispatch)} onMouseLeave={handleMouseLeave(dispatch)} > - + diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 2938e91e7..13e1a4a3c 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -7,18 +7,20 @@ import { blockAccount } from 'soapbox/actions/accounts'; 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, 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, ReportableEntities } from 'soapbox/actions/reports'; import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses'; +import { deleteFromTimelines } from 'soapbox/actions/timelines'; +import { useDeleteGroupStatus } from 'soapbox/api/hooks/groups/useDeleteGroupStatus'; import DropdownMenu from 'soapbox/components/dropdown-menu'; import StatusActionButton from 'soapbox/components/status-action-button'; import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper'; import { HStack } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks'; +import { GroupRoles } from 'soapbox/schemas/group-member'; import toast from 'soapbox/toast'; import { isLocal, isRemote } from 'soapbox/utils/accounts'; import copy from 'soapbox/utils/copy'; @@ -87,16 +89,7 @@ const messages = defineMessages({ blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' }, groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' }, - groupModKick: { id: 'status.group_mod_kick', defaultMessage: 'Kick @{name} from group' }, - groupModBlock: { id: 'status.group_mod_block', defaultMessage: 'Block @{name} from group' }, - deleteFromGroupHeading: { id: 'confirmations.delete_from_group.heading', defaultMessage: 'Delete from group' }, deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' }, - kickFromGroupHeading: { id: 'confirmations.kick_from_group.heading', defaultMessage: 'Kick group member' }, - kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' }, - kickFromGroupConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' }, - blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Block group member' }, - blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' }, - blockFromGroupConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' }, }); interface IStatusActionBar { @@ -121,6 +114,7 @@ const StatusActionBar: React.FC = ({ const features = useFeatures(); const settings = useSettings(); const soapboxConfig = useSoapboxConfig(); + const deleteGroupStatus = useDeleteGroupStatus(status?.group as Group, status.id); const { allowedEmoji } = soapboxConfig; @@ -258,8 +252,8 @@ const StatusActionBar: React.FC = ({ dispatch(openModal('CONFIRM', { icon: require('@tabler/icons/ban.svg'), - heading: , - message: @{account.get('acct')} }} />, + heading: , + message: @{account.acct} }} />, confirm: intl.formatMessage(messages.blockConfirm), onConfirm: () => dispatch(blockAccount(account.id)), secondary: intl.formatMessage(messages.blockAndReport), @@ -313,31 +307,15 @@ const StatusActionBar: React.FC = ({ dispatch(openModal('CONFIRM', { heading: intl.formatMessage(messages.deleteHeading), - message: intl.formatMessage(messages.deleteFromGroupMessage, { name: account.username }), + message: intl.formatMessage(messages.deleteFromGroupMessage, { name: {account.username} }), confirm: intl.formatMessage(messages.deleteConfirm), - onConfirm: () => dispatch(groupDeleteStatus((status.group as Group).id, status.id)), - })); - }; - - const handleKickFromGroup: React.EventHandler = () => { - const account = status.account as Account; - - dispatch(openModal('CONFIRM', { - heading: intl.formatMessage(messages.kickFromGroupHeading), - message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }), - confirm: intl.formatMessage(messages.kickFromGroupConfirm), - onConfirm: () => dispatch(groupKick((status.group as Group).id, account.id)), - })); - }; - - const handleBlockFromGroup: React.EventHandler = () => { - const account = status.account as Account; - - dispatch(openModal('CONFIRM', { - heading: intl.formatMessage(messages.blockFromGroupHeading), - message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }), - confirm: intl.formatMessage(messages.blockFromGroupConfirm), - onConfirm: () => dispatch(groupBlock((status.group as Group).id, account.id)), + onConfirm: () => { + deleteGroupStatus.mutate(status.id, { + onSuccess() { + dispatch(deleteFromTimelines(status.id)); + }, + }); + }, })); }; @@ -362,7 +340,7 @@ const StatusActionBar: React.FC = ({ menu.push({ text: intl.formatMessage(messages.copy), action: handleCopy, - icon: require('@tabler/icons/link.svg'), + icon: require('@tabler/icons/clipboard-copy.svg'), }); if (features.embeds && isLocal(account)) { @@ -466,7 +444,7 @@ const StatusActionBar: React.FC = ({ menu.push({ text: intl.formatMessage(messages.mute, { name: username }), action: handleMuteClick, - icon: require('@tabler/icons/circle-x.svg'), + icon: require('@tabler/icons/volume-3.svg'), }); menu.push({ text: intl.formatMessage(messages.block, { name: username }), @@ -480,23 +458,17 @@ const StatusActionBar: React.FC = ({ }); } - if (status.group && groupRelationship?.role && ['admin', 'moderator'].includes(groupRelationship.role)) { + if (status.group && + groupRelationship?.role && + [GroupRoles.OWNER].includes(groupRelationship.role) && + !ownAccount + ) { menu.push(null); menu.push({ text: intl.formatMessage(messages.groupModDelete), action: handleDeleteFromGroup, icon: require('@tabler/icons/trash.svg'), - }); - // TODO: figure out when an account is not in the group anymore - menu.push({ - text: intl.formatMessage(messages.groupModKick, { name: account.get('username') }), - action: handleKickFromGroup, - icon: require('@tabler/icons/user-minus.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.groupModBlock, { name: account.get('username') }), - action: handleBlockFromGroup, - icon: require('@tabler/icons/ban.svg'), + destructive: true, }); } @@ -536,7 +508,7 @@ const StatusActionBar: React.FC = ({ return menu; }; - const publicStatus = ['public', 'unlisted'].includes(status.visibility); + const publicStatus = ['public', 'unlisted', 'group'].includes(status.visibility); const replyCount = status.replies_count; const reblogCount = status.reblogs_count; @@ -609,7 +581,7 @@ const StatusActionBar: React.FC = ({ replyTitle = intl.formatMessage(messages.replyAll); } - const canShare = ('share' in navigator) && status.visibility === 'public'; + const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group'); return ( diff --git a/app/soapbox/components/status-action-button.tsx b/app/soapbox/components/status-action-button.tsx index 39795fc7e..47b3c11b8 100644 --- a/app/soapbox/components/status-action-button.tsx +++ b/app/soapbox/components/status-action-button.tsx @@ -53,7 +53,7 @@ const StatusActionButton = React.forwardRef = ({ onMoveDown={handleMoveDown} contextType={timelineId} showGroup={showGroup} + variant={divideType === 'border' ? 'slim' : 'rounded'} /> ); }; @@ -172,6 +173,7 @@ const StatusList: React.FC = ({ onMoveDown={handleMoveDown} contextType={timelineId} showGroup={showGroup} + variant={divideType === 'border' ? 'slim' : 'default'} /> )); }; @@ -245,7 +247,7 @@ const StatusList: React.FC = ({ isLoading={isLoading} showLoading={isLoading && statusIds.size === 0} onLoadMore={handleLoadOlder} - placeholderComponent={PlaceholderStatus} + placeholderComponent={() => } placeholderCount={20} ref={node} className={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', { diff --git a/app/soapbox/components/status-media.tsx b/app/soapbox/components/status-media.tsx index 0867e6ca2..192a4c169 100644 --- a/app/soapbox/components/status-media.tsx +++ b/app/soapbox/components/status-media.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { openModal } from 'soapbox/actions/modals'; import AttachmentThumbs from 'soapbox/components/attachment-thumbs'; +import { GroupLinkPreview } from 'soapbox/features/groups/components/group-link-preview'; import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder-card'; import Card from 'soapbox/features/status/components/card'; import Bundle from 'soapbox/features/ui/components/bundle'; @@ -153,6 +154,10 @@ const StatusMedia: React.FC = ({ ); } + } else if (status.spoiler_text.length === 0 && !status.quote && status.card?.group) { + media = ( + + ); } else if (status.spoiler_text.length === 0 && !status.quote && status.card) { media = ( = ({ status, hoverable e.stopPropagation()} > @{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 3ec072394..0c1a7be59 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -2,13 +2,12 @@ import clsx from 'clsx'; import React, { useEffect, useRef, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; -import { useHistory } from 'react-router-dom'; +import { Link, 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, 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'; import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container'; @@ -22,7 +21,8 @@ import StatusMedia from './status-media'; import StatusReplyMentions from './status-reply-mentions'; import SensitiveContentOverlay from './statuses/sensitive-content-overlay'; import StatusInfo from './statuses/status-info'; -import { Card, Stack, Text } from './ui'; +import Tombstone from './tombstone'; +import { Card, Icon, Stack, Text } from './ui'; import type { Account as AccountEntity, @@ -51,7 +51,7 @@ export interface IStatus { featured?: boolean hideActionBar?: boolean hoverable?: boolean - variant?: 'default' | 'rounded' + variant?: 'default' | 'rounded' | 'slim' showGroup?: boolean accountAction?: React.ReactElement } @@ -212,26 +212,67 @@ const Status: React.FC = (props) => { }; const renderStatusInfo = () => { - if (isReblog) { + if (isReblog && showGroup && group) { return ( } + icon={} + text={ + + + + + + ), + group: ( + + + + ), + }} + /> + } + /> + ); + } else if (isReblog) { + return ( + } text={ - - + + + + + ), }} /> @@ -242,11 +283,9 @@ const Status: React.FC = (props) => { return ( } + icon={} text={ - - - + } /> ); @@ -254,18 +293,23 @@ const Status: React.FC = (props) => { return ( } + icon={} text={ - - - ) }} - /> - + + + + + + + + ), + }} + /> } /> ); @@ -345,6 +389,17 @@ const Status: React.FC = (props) => { const isUnderReview = actualStatus.visibility === 'self'; const isSensitive = actualStatus.hidden; + const isSoftDeleted = status.tombstone?.reason === 'deleted'; + + if (isSoftDeleted) { + return ( + onMoveUp ? onMoveUp(id) : null} + onMoveDown={(id) => onMoveDown ? onMoveDown(id) : null} + /> + ); + } return ( diff --git a/app/soapbox/components/statuses/status-info.tsx b/app/soapbox/components/statuses/status-info.tsx index 322a911bc..50fad86b3 100644 --- a/app/soapbox/components/statuses/status-info.tsx +++ b/app/soapbox/components/statuses/status-info.tsx @@ -1,37 +1,44 @@ import React from 'react'; -import { Link } from 'react-router-dom'; + +import { HStack, Text } from '../ui'; interface IStatusInfo { avatarSize: number - to?: string icon: React.ReactNode text: React.ReactNode } const StatusInfo = (props: IStatusInfo) => { - const { avatarSize, to, icon, text } = props; + const { avatarSize, icon, text } = props; - const onClick = (event: React.MouseEvent) => { + const onClick = (event: React.MouseEvent) => { event.stopPropagation(); }; - const Container = to ? Link : 'div'; - const containerProps: any = to ? { onClick, to } : {}; - return ( - -
- {icon} -
+
+ {icon} +
- {text} -
+ + {text} + +
+
); }; diff --git a/app/soapbox/components/still-image.tsx b/app/soapbox/components/still-image.tsx index 8dce32f6f..cdebaf359 100644 --- a/app/soapbox/components/still-image.tsx +++ b/app/soapbox/components/still-image.tsx @@ -3,7 +3,7 @@ import React, { useRef } from 'react'; import { useSettings } from 'soapbox/hooks'; -interface IStillImage { +export interface IStillImage { /** Image alt text. */ alt?: string /** Extra class names for the outer
container. */ @@ -16,10 +16,12 @@ interface IStillImage { letterboxed?: boolean /** Whether to show the file extension in the corner. */ showExt?: boolean + /** Callback function if the image fails to load */ + onError?(): void } /** Renders images on a canvas, only playing GIFs if autoPlayGif is enabled. */ -const StillImage: React.FC = ({ alt, className, src, style, letterboxed = false, showExt = false }) => { +const StillImage: React.FC = ({ alt, className, src, style, letterboxed = false, showExt = false, onError }) => { const settings = useSettings(); const autoPlayGif = settings.get('autoPlayGif'); @@ -55,6 +57,7 @@ const StillImage: React.FC = ({ alt, className, src, style, letterb alt={alt} ref={img} onLoad={handleImageLoad} + onError={onError} className={clsx(baseClassName, { 'invisible group-hover:visible': hoverToPlay, })} diff --git a/app/soapbox/components/tombstone.tsx b/app/soapbox/components/tombstone.tsx index 6c6a2a6f9..b92fb7e70 100644 --- a/app/soapbox/components/tombstone.tsx +++ b/app/soapbox/components/tombstone.tsx @@ -19,10 +19,17 @@ const Tombstone: React.FC = ({ id, onMoveUp, onMoveDown }) => { return ( -
- - - +
+
+ + + +
); diff --git a/app/soapbox/components/ui/accordion/accordion.tsx b/app/soapbox/components/ui/accordion/accordion.tsx index f83a011be..842032242 100644 --- a/app/soapbox/components/ui/accordion/accordion.tsx +++ b/app/soapbox/components/ui/accordion/accordion.tsx @@ -21,13 +21,16 @@ interface IAccordion { menu?: Menu expanded?: boolean onToggle?: (value: boolean) => void + action?: () => void + actionIcon?: string + actionLabel?: string } /** * Accordion * An accordion is a vertically stacked group of collapsible sections. */ -const Accordion: React.FC = ({ headline, children, menu, expanded = false, onToggle = () => {} }) => { +const Accordion: React.FC = ({ headline, children, menu, expanded = false, onToggle = () => {}, action, actionIcon, actionLabel }) => { const intl = useIntl(); const handleToggle = (e: React.MouseEvent) => { @@ -35,6 +38,13 @@ const Accordion: React.FC = ({ headline, children, menu, expanded = e.preventDefault(); }; + const handleAction = (e: React.MouseEvent) => { + if (!action) return; + + action(); + e.preventDefault(); + }; + return (
+ )} { /** Width and height of the avatar in pixels. */ size?: number - /** Extra class names for the div surrounding the avatar image. */ - className?: string } /** Round profile avatar for accounts. */ const Avatar = (props: IAvatar) => { const { src, size = AVATAR_SIZE, className } = props; + const [isAvatarMissing, setIsAvatarMissing] = useState(false); + + const handleLoadFailure = () => setIsAvatarMissing(true); + const style: React.CSSProperties = React.useMemo(() => ({ width: size, height: size, }), [size]); + if (isAvatarMissing) { + return ( +
+ +
+ ); + } + return ( ); }; diff --git a/app/soapbox/components/ui/card/card.tsx b/app/soapbox/components/ui/card/card.tsx index aedf3e132..4b8d9799d 100644 --- a/app/soapbox/components/ui/card/card.tsx +++ b/app/soapbox/components/ui/card/card.tsx @@ -16,11 +16,13 @@ const messages = defineMessages({ back: { id: 'card.back.label', defaultMessage: 'Back' }, }); +export type CardSizes = keyof typeof sizes + interface ICard { /** The type of card. */ - variant?: 'default' | 'rounded' + variant?: 'default' | 'rounded' | 'slim' /** Card size preset. */ - size?: keyof typeof sizes + size?: CardSizes /** Extra classnames for the
element. */ className?: string /** Elements inside the card. */ @@ -33,8 +35,9 @@ const Card = React.forwardRef(({ children, variant = 'def ref={ref} {...filteredProps} className={clsx({ - 'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none overflow-hidden': variant === 'rounded', + 'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none': variant === 'rounded', [sizes[size]]: variant === 'rounded', + 'py-4': variant === 'slim', }, className)} > {children} @@ -72,7 +75,7 @@ const CardHeader: React.FC = ({ className, children, backHref, onBa }; return ( - + {renderBackButton()} {children} diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index d6cadec77..8d7c2da39 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -1,11 +1,12 @@ import clsx from 'clsx'; -import React from 'react'; +import throttle from 'lodash/throttle'; +import React, { useCallback, useEffect, useState } from 'react'; import { useHistory } from 'react-router-dom'; import Helmet from 'soapbox/components/helmet'; import { useSoapboxConfig } from 'soapbox/hooks'; -import { Card, CardBody, CardHeader, CardTitle } from '../card/card'; +import { Card, CardBody, CardHeader, CardTitle, type CardSizes } from '../card/card'; type IColumnHeader = Pick; @@ -50,17 +51,35 @@ export interface IColumn { withHeader?: boolean /** Extra class name for top
element. */ className?: string + /** Extra class name for the element. */ + bodyClassName?: string /** Ref forwarded to column. */ ref?: React.Ref /** Children to display in the column. */ children?: React.ReactNode + /** Action for the ColumnHeader, displayed at the end. */ action?: React.ReactNode + /** Column size, inherited from Card. */ + size?: CardSizes } /** 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, action } = props; + const { backHref, children, label, transparent = false, withHeader = true, className, bodyClassName, action, size } = props; const soapboxConfig = useSoapboxConfig(); + const [isScrolled, setIsScrolled] = useState(false); + + const handleScroll = useCallback(throttle(() => { + setIsScrolled(window.pageYOffset > 32); + }, 50), []); + + useEffect(() => { + window.addEventListener('scroll', handleScroll); + + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, []); return (
@@ -76,17 +95,23 @@ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedR )} - + {withHeader && ( )} - + {children} diff --git a/app/soapbox/components/ui/icon-button/icon-button.tsx b/app/soapbox/components/ui/icon-button/icon-button.tsx index ad9d6a517..1ece137df 100644 --- a/app/soapbox/components/ui/icon-button/icon-button.tsx +++ b/app/soapbox/components/ui/icon-button/icon-button.tsx @@ -11,24 +11,22 @@ interface IIconButton extends React.ButtonHTMLAttributes { src: string /** Text to display next ot the button. */ text?: string - /** Don't render a background behind the icon. */ - transparent?: boolean /** Predefined styles to display for the button. */ - theme?: 'seamless' | 'outlined' | 'secondary' + theme?: 'seamless' | 'outlined' | 'secondary' | 'transparent' /** Override the data-testid */ 'data-testid'?: string } /** A clickable icon. */ const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef): JSX.Element => { - const { src, className, iconClassName, text, transparent = false, theme = 'seamless', ...filteredProps } = props; + const { src, className, iconClassName, text, theme = 'seamless', ...filteredProps } = props; return (
); diff --git a/app/soapbox/components/ui/toast/toast.tsx b/app/soapbox/components/ui/toast/toast.tsx index 38bda317e..ce6847ced 100644 --- a/app/soapbox/components/ui/toast/toast.tsx +++ b/app/soapbox/components/ui/toast/toast.tsx @@ -8,6 +8,8 @@ import { ToastText, ToastType } from 'soapbox/toast'; import HStack from '../hstack/hstack'; import Icon from '../icon/icon'; +import Stack from '../stack/stack'; +import Text from '../text/text'; const renderText = (text: ToastText) => { if (typeof text === 'string') { @@ -24,13 +26,14 @@ interface IToast { action?(): void actionLink?: string actionLabel?: ToastText + summary?: string } /** * Customizable Toasts for in-app notifications. */ const Toast = (props: IToast) => { - const { t, message, type, action, actionLink, actionLabel } = props; + const { t, message, type, action, actionLink, actionLabel, summary } = props; const dismissToast = () => toast.dismiss(t.id); @@ -109,35 +112,46 @@ const Toast = (props: IToast) => { }) } > - - - -
- {renderIcon()} -
+ + + + +
+ {renderIcon()} +
-

- {renderText(message)} -

+ + {renderText(message)} + +
+ + {/* Action */} + {renderAction()}
- {/* Action */} - {renderAction()} + {/* Dismiss Button */} +
+ +
- {/* Dismiss Button */} -
- -
-
+ {summary ? ( + {summary} + ) : null} +
); }; diff --git a/app/soapbox/components/ui/toggle/toggle.tsx b/app/soapbox/components/ui/toggle/toggle.tsx index 34651e98d..0311da8ac 100644 --- a/app/soapbox/components/ui/toggle/toggle.tsx +++ b/app/soapbox/components/ui/toggle/toggle.tsx @@ -26,6 +26,7 @@ const Toggle: React.FC = ({ id, size = 'md', name, checked, onChange, r 'cursor-default': disabled, })} onClick={handleClick} + type='button' >
> /** Text to display in the tooltip. */ text: string - /** Element to display the tooltip around. */ - children: React.ReactNode } -const centered = (triggerRect: any, tooltipRect: any) => { - const triggerCenter = triggerRect.left + triggerRect.width / 2; - const left = triggerCenter - tooltipRect.width / 2; - const maxLeft = window.innerWidth - tooltipRect.width - 2; - return { - left: Math.min(Math.max(2, left), maxLeft) + window.scrollX, - top: triggerRect.bottom + 8 + window.scrollY, - }; -}; +/** + * Tooltip + */ +const Tooltip: React.FC = (props) => { + const { children, text } = props; -/** Hoverable tooltip element. */ -const Tooltip: React.FC = ({ - children, - text, -}) => { - // get the props from useTooltip - const [trigger, tooltip] = useTooltip(); + const [isOpen, setIsOpen] = useState(false); - // destructure off what we need to position the triangle - const { isVisible, triggerRect } = tooltip; + const arrowRef = useRef(null); + + const { x, y, strategy, refs, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + placement: 'top', + middleware: [ + offset(6), + arrow({ + element: arrowRef, + }), + ], + }); + + const hover = useHover(context); + const { isMounted, styles } = useTransitionStyles(context, { + initial: { + opacity: 0, + transform: 'scale(0.8)', + }, + duration: { + open: 200, + close: 200, + }, + }); + + const { getReferenceProps, getFloatingProps } = useInteractions([ + hover, + ]); return ( - - {React.cloneElement(children as any, trigger)} + <> + {React.cloneElement(children, { + ref: refs.setReference, + ...getReferenceProps(), + })} - {isVisible && ( - // The Triangle. We position it relative to the trigger, not the popup - // so that collisions don't have a triangle pointing off to nowhere. - // Using a Portal may seem a little extreme, but we can keep the - // positioning logic simpler here instead of needing to consider - // the popup's position relative to the trigger and collisions - + {(isMounted) && ( +
- + className='pointer-events-none z-[100] whitespace-nowrap rounded bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-100 shadow dark:bg-gray-100 dark:text-gray-900' + {...getFloatingProps()} + > + {text} + + +
+
)} - -
+ ); }; -export default Tooltip; +export default Tooltip; \ No newline at end of file diff --git a/app/soapbox/containers/group-container.tsx b/app/soapbox/containers/group-container.tsx deleted file mode 100644 index f1254b2ca..000000000 --- a/app/soapbox/containers/group-container.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React, { useCallback } from 'react'; - -import GroupCard from 'soapbox/components/group-card'; -import { useAppSelector } from 'soapbox/hooks'; -import { makeGetGroup } from 'soapbox/selectors'; - -interface IGroupContainer { - id: string -} - -const GroupContainer: React.FC = (props) => { - const { id, ...rest } = props; - - const getGroup = useCallback(makeGetGroup(), []); - const group = useAppSelector(state => getGroup(state, id)); - - if (group) { - return ; - } else { - return null; - } -}; - -export default GroupContainer; diff --git a/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index fb6ce9481..75134b00d 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -191,7 +191,14 @@ const SoapboxMount = () => { - + +
+ +
diff --git a/app/soapbox/entity-store/__tests__/reducer.test.ts b/app/soapbox/entity-store/__tests__/reducer.test.ts index 1cfc19697..3e6aa3510 100644 --- a/app/soapbox/entity-store/__tests__/reducer.test.ts +++ b/app/soapbox/entity-store/__tests__/reducer.test.ts @@ -110,7 +110,7 @@ test('import entities with override', () => { const now = new Date(); - const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', { + const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', 'end', { next: undefined, prev: undefined, totalCount: 2, diff --git a/app/soapbox/entity-store/actions.ts b/app/soapbox/entity-store/actions.ts index c3ba25559..bb96255c6 100644 --- a/app/soapbox/entity-store/actions.ts +++ b/app/soapbox/entity-store/actions.ts @@ -1,4 +1,4 @@ -import type { Entity, EntityListState } from './types'; +import type { Entity, EntityListState, ImportPosition } from './types'; const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const; const ENTITIES_DELETE = 'ENTITIES_DELETE' as const; @@ -10,12 +10,13 @@ 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) { +function importEntities(entities: Entity[], entityType: string, listKey?: string, pos?: ImportPosition) { return { type: ENTITIES_IMPORT, entityType, entities, listKey, + pos, }; } @@ -62,6 +63,7 @@ function entitiesFetchSuccess( entities: Entity[], entityType: string, listKey?: string, + pos?: ImportPosition, newState?: EntityListState, overwrite = false, ) { @@ -70,6 +72,7 @@ function entitiesFetchSuccess( entityType, entities, listKey, + pos, newState, overwrite, }; diff --git a/app/soapbox/entity-store/entities.ts b/app/soapbox/entity-store/entities.ts index 44f2db3c9..9878cbbf2 100644 --- a/app/soapbox/entity-store/entities.ts +++ b/app/soapbox/entity-store/entities.ts @@ -1,6 +1,9 @@ export enum Entities { ACCOUNTS = 'Accounts', GROUPS = 'Groups', - GROUP_RELATIONSHIPS = 'GroupRelationships', GROUP_MEMBERSHIPS = 'GroupMemberships', + GROUP_RELATIONSHIPS = 'GroupRelationships', + GROUP_TAGS = 'GroupTags', + RELATIONSHIPS = 'Relationships', + STATUSES = 'Statuses' } \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/index.ts b/app/soapbox/entity-store/hooks/index.ts index d113c505a..b95d2d1af 100644 --- a/app/soapbox/entity-store/hooks/index.ts +++ b/app/soapbox/entity-store/hooks/index.ts @@ -1,6 +1,7 @@ export { useEntities } from './useEntities'; export { useEntity } from './useEntity'; export { useEntityActions } from './useEntityActions'; +export { useEntityLookup } from './useEntityLookup'; export { useCreateEntity } from './useCreateEntity'; export { useDeleteEntity } from './useDeleteEntity'; export { useDismissEntity } from './useDismissEntity'; diff --git a/app/soapbox/entity-store/hooks/useCreateEntity.ts b/app/soapbox/entity-store/hooks/useCreateEntity.ts index ba9dd802b..24ce3af7d 100644 --- a/app/soapbox/entity-store/hooks/useCreateEntity.ts +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -1,3 +1,4 @@ +import { AxiosError } from 'axios'; import { z } from 'zod'; import { useAppDispatch, useLoading } from 'soapbox/hooks'; @@ -20,31 +21,35 @@ function useCreateEntity( ) { const dispatch = useAppDispatch(); - const [isLoading, setPromise] = useLoading(); + const [isSubmitting, setPromise] = useLoading(); const { entityType, listKey } = parseEntitiesPath(expandedPath); - async function createEntity(data: Data, callbacks: EntityCallbacks = {}): Promise { + async function createEntity(data: Data, callbacks: EntityCallbacks = {}): Promise { try { const result = await setPromise(entityFn(data)); const schema = opts.schema || z.custom(); const entity = schema.parse(result.data); // TODO: optimistic updating - dispatch(importEntities([entity], entityType, listKey)); + dispatch(importEntities([entity], entityType, listKey, 'start')); if (callbacks.onSuccess) { callbacks.onSuccess(entity); } } catch (error) { - if (callbacks.onError) { - callbacks.onError(error); + if (error instanceof AxiosError) { + if (callbacks.onError) { + callbacks.onError(error); + } + } else { + throw error; } } } return { createEntity, - isLoading, + isSubmitting, }; } diff --git a/app/soapbox/entity-store/hooks/useDeleteEntity.ts b/app/soapbox/entity-store/hooks/useDeleteEntity.ts index 767224af6..dac1d9a26 100644 --- a/app/soapbox/entity-store/hooks/useDeleteEntity.ts +++ b/app/soapbox/entity-store/hooks/useDeleteEntity.ts @@ -15,7 +15,7 @@ function useDeleteEntity( ) { const dispatch = useAppDispatch(); const getState = useGetState(); - const [isLoading, setPromise] = useLoading(); + const [isSubmitting, setPromise] = useLoading(); async function deleteEntity(entityId: string, callbacks: EntityCallbacks = {}): Promise { // Get the entity before deleting, so we can reverse the action if the API request fails. @@ -47,7 +47,7 @@ function useDeleteEntity( return { deleteEntity, - isLoading, + isSubmitting, }; } diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index f2e84c93e..cd413f487 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -54,7 +54,7 @@ function useEntities( const next = useListState(path, 'next'); const prev = useListState(path, 'prev'); - const fetchPage = async(req: EntityFn, overwrite = false): Promise => { + const fetchPage = async(req: EntityFn, pos: 'start' | 'end', overwrite = false): Promise => { // Get `isFetching` state from the store again to prevent race conditions. const isFetching = selectListState(getState(), path, 'fetching'); if (isFetching) return; @@ -65,11 +65,12 @@ function useEntities( const schema = opts.schema || z.custom(); const entities = filteredArray(schema).parse(response.data); const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']); + const totalCount = parsedCount.success ? parsedCount.data : undefined; - dispatch(entitiesFetchSuccess(entities, entityType, listKey, { + dispatch(entitiesFetchSuccess(entities, entityType, listKey, pos, { next: getNextLink(response), prev: getPrevLink(response), - totalCount: parsedCount.success ? parsedCount.data : undefined, + totalCount: Number(totalCount) >= entities.length ? totalCount : undefined, fetching: false, fetched: true, error: null, @@ -82,18 +83,18 @@ function useEntities( }; const fetchEntities = async(): Promise => { - await fetchPage(entityFn, true); + await fetchPage(entityFn, 'end', true); }; const fetchNextPage = async(): Promise => { if (next) { - await fetchPage(() => api.get(next)); + await fetchPage(() => api.get(next), 'end'); } }; const fetchPreviousPage = async(): Promise => { if (prev) { - await fetchPage(() => api.get(prev)); + await fetchPage(() => api.get(prev), 'start'); } }; @@ -112,7 +113,7 @@ function useEntities( if (isInvalid || isUnset || isStale) { fetchEntities(); } - }, [isEnabled]); + }, [isEnabled, ...path]); return { entities, diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index f30c9a18a..3d57c8ab0 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -14,6 +14,8 @@ interface UseEntityOpts { schema?: EntitySchema /** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */ refetch?: boolean + /** A flag to potentially disable sending requests to the API. */ + enabled?: boolean } function useEntity( @@ -21,7 +23,7 @@ function useEntity( entityFn: EntityFn, opts: UseEntityOpts = {}, ) { - const [isFetching, setPromise] = useLoading(); + const [isFetching, setPromise] = useLoading(true); const dispatch = useAppDispatch(); const [entityType, entityId] = path; @@ -31,6 +33,7 @@ function useEntity( const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined); + const isEnabled = opts.enabled ?? true; const isLoading = isFetching && !entity; const fetchEntity = async () => { @@ -44,10 +47,11 @@ function useEntity( }; useEffect(() => { + if (!isEnabled) return; if (!entity || opts.refetch) { fetchEntity(); } - }, []); + }, [isEnabled]); return { entity, @@ -59,4 +63,5 @@ function useEntity( export { useEntity, + type UseEntityOpts, }; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts index dab6f7f77..c7e2e431d 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -12,8 +12,9 @@ interface UseEntityActionsOpts { } interface EntityActionEndpoints { - post?: string delete?: string + patch?: string + post?: string } function useEntityActions( @@ -24,16 +25,20 @@ function useEntityActions( const api = useApi(); const { entityType, path } = parseEntitiesPath(expandedPath); - const { deleteEntity, isLoading: deleteLoading } = + const { deleteEntity, isSubmitting: deleteSubmitting } = useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId))); - const { createEntity, isLoading: createLoading } = + const { createEntity, isSubmitting: createSubmitting } = useCreateEntity(path, (data) => api.post(endpoints.post!, data), opts); + const { createEntity: updateEntity, isSubmitting: updateSubmitting } = + useCreateEntity(path, (data) => api.patch(endpoints.patch!, data), opts); + return { createEntity, deleteEntity, - isLoading: createLoading || deleteLoading, + updateEntity, + isSubmitting: createSubmitting || deleteSubmitting || updateSubmitting, }; } diff --git a/app/soapbox/entity-store/hooks/useEntityLookup.ts b/app/soapbox/entity-store/hooks/useEntityLookup.ts new file mode 100644 index 000000000..a49a659a4 --- /dev/null +++ b/app/soapbox/entity-store/hooks/useEntityLookup.ts @@ -0,0 +1,66 @@ +import { useEffect } from 'react'; +import { z } from 'zod'; + +import { useAppDispatch, useAppSelector, useLoading } from 'soapbox/hooks'; +import { type RootState } from 'soapbox/store'; + +import { importEntities } from '../actions'; +import { Entity } from '../types'; + +import { EntityFn } from './types'; +import { type UseEntityOpts } from './useEntity'; + +/** Entities will be filtered through this function until it returns true. */ +type LookupFn = (entity: TEntity) => boolean + +function useEntityLookup( + entityType: string, + lookupFn: LookupFn, + entityFn: EntityFn, + opts: UseEntityOpts = {}, +) { + const { schema = z.custom() } = opts; + + const dispatch = useAppDispatch(); + const [isFetching, setPromise] = useLoading(true); + + const entity = useAppSelector(state => findEntity(state, entityType, lookupFn)); + 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, + }; +} + +function findEntity( + state: RootState, + entityType: string, + lookupFn: LookupFn, +) { + const cache = state.entities[entityType]; + + if (cache) { + return (Object.values(cache.store) as TEntity[]).find(lookupFn); + } +} + +export { useEntityLookup }; \ No newline at end of file diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts index b71fb812f..ef7b604d9 100644 --- a/app/soapbox/entity-store/reducer.ts +++ b/app/soapbox/entity-store/reducer.ts @@ -14,7 +14,7 @@ import { import { createCache, createList, updateStore, updateList } from './utils'; import type { DeleteEntitiesOpts } from './actions'; -import type { Entity, EntityCache, EntityListState } from './types'; +import type { Entity, EntityCache, EntityListState, ImportPosition } from './types'; enableMapSet(); @@ -29,6 +29,7 @@ const importEntities = ( entityType: string, entities: Entity[], listKey?: string, + pos?: ImportPosition, newState?: EntityListState, overwrite = false, ): State => { @@ -43,7 +44,7 @@ const importEntities = ( list.ids = new Set(); } - list = updateList(list, entities); + list = updateList(list, entities, pos); if (newState) { list.state = newState; @@ -159,7 +160,7 @@ const invalidateEntityList = (state: State, entityType: string, listKey: string) function reducer(state: Readonly = {}, action: EntityAction): State { switch (action.type) { case ENTITIES_IMPORT: - return importEntities(state, action.entityType, action.entities, action.listKey); + return importEntities(state, action.entityType, action.entities, action.listKey, action.pos); case ENTITIES_DELETE: return deleteEntities(state, action.entityType, action.ids, action.opts); case ENTITIES_DISMISS: @@ -167,7 +168,7 @@ function reducer(state: Readonly = {}, action: EntityAction): State { 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); + return importEntities(state, action.entityType, action.entities, action.listKey, action.pos, action.newState, action.overwrite); case ENTITIES_FETCH_REQUEST: return setFetching(state, action.entityType, action.listKey, true); case ENTITIES_FETCH_FAIL: diff --git a/app/soapbox/entity-store/types.ts b/app/soapbox/entity-store/types.ts index 006b13ba2..5fff2f474 100644 --- a/app/soapbox/entity-store/types.ts +++ b/app/soapbox/entity-store/types.ts @@ -47,10 +47,14 @@ interface EntityCache { } } +/** Whether to import items at the start or end of the list. */ +type ImportPosition = 'start' | 'end' + export { Entity, EntityStore, EntityList, EntityListState, EntityCache, + ImportPosition, }; \ No newline at end of file diff --git a/app/soapbox/entity-store/utils.ts b/app/soapbox/entity-store/utils.ts index e108639c2..58d54465a 100644 --- a/app/soapbox/entity-store/utils.ts +++ b/app/soapbox/entity-store/utils.ts @@ -1,4 +1,4 @@ -import type { Entity, EntityStore, EntityList, EntityCache, EntityListState } from './types'; +import type { Entity, EntityStore, EntityList, EntityCache, EntityListState, ImportPosition } from './types'; /** Insert the entities into the store. */ const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => { @@ -9,9 +9,10 @@ const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => { }; /** Update the list with new entity IDs. */ -const updateList = (list: EntityList, entities: Entity[]): EntityList => { +const updateList = (list: EntityList, entities: Entity[], pos: ImportPosition = 'end'): EntityList => { const newIds = entities.map(entity => entity.id); - const ids = new Set([...Array.from(list.ids), ...newIds]); + const oldIds = Array.from(list.ids); + const ids = new Set(pos === 'start' ? [...newIds, ...oldIds] : [...oldIds, ...newIds]); if (typeof list.state.totalCount === 'number') { const sizeDiff = ids.size - list.ids.size; diff --git a/app/soapbox/features/account-gallery/index.tsx b/app/soapbox/features/account-gallery/index.tsx index 7082124c8..7cee5c569 100644 --- a/app/soapbox/features/account-gallery/index.tsx +++ b/app/soapbox/features/account-gallery/index.tsx @@ -148,7 +148,7 @@ const AccountGallery = () => { ))} {!isLoading && attachments.size === 0 && ( -
+
)} diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx index ac4fdd66e..fbdfc3181 100644 --- a/app/soapbox/features/account/components/header.tsx +++ b/app/soapbox/features/account/components/header.tsx @@ -105,7 +105,7 @@ const Header: React.FC = ({ account }) => { if (!account) { return ( -
+
@@ -130,7 +130,7 @@ const Header: React.FC = ({ account }) => { dispatch(openModal('CONFIRM', { icon: require('@tabler/icons/ban.svg'), heading: , - message: @{account.acct} }} />, + message: @{account.acct}
}} />, confirm: intl.formatMessage(messages.blockConfirm), onConfirm: () => dispatch(blockAccount(account.id)), secondary: intl.formatMessage(messages.blockAndReport), @@ -215,7 +215,7 @@ const Header: React.FC = ({ account }) => { const unfollowModal = getSettings(getState()).get('unfollowModal'); if (unfollowModal) { dispatch(openModal('CONFIRM', { - message: @{account.acct} }} />, + message: @{account.acct}
}} />, confirm: intl.formatMessage(messages.removeFromFollowersConfirm), onConfirm: () => dispatch(removeFromFollowers(account.id)), })); @@ -608,7 +608,7 @@ const Header: React.FC = ({ account }) => { const menu = makeMenu(); return ( -
+
{(account.moved && typeof account.moved === 'object') && ( )} diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx index 0895093b7..6af45db24 100644 --- a/app/soapbox/features/ads/components/ad.tsx +++ b/app/soapbox/features/ads/components/ad.tsx @@ -73,7 +73,7 @@ const Ad: React.FC = ({ ad }) => { return (
- + diff --git a/app/soapbox/features/ads/providers/index.ts b/app/soapbox/features/ads/providers/index.ts index 8ff7d5219..63067b81d 100644 --- a/app/soapbox/features/ads/providers/index.ts +++ b/app/soapbox/features/ads/providers/index.ts @@ -6,7 +6,6 @@ import type { Card } from 'soapbox/types/entities'; /** Map of available provider modules. */ const PROVIDERS: Record Promise> = { soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default, - rumble: async() => (await import(/* webpackChunkName: "features/ads/rumble" */'./rumble')).default, truth: async() => (await import(/* webpackChunkName: "features/ads/truth" */'./truth')).default, }; diff --git a/app/soapbox/features/ads/providers/rumble.ts b/app/soapbox/features/ads/providers/rumble.ts deleted file mode 100644 index 21dc6e7f3..000000000 --- a/app/soapbox/features/ads/providers/rumble.ts +++ /dev/null @@ -1,58 +0,0 @@ -import axios from 'axios'; - -import { getSettings } from 'soapbox/actions/settings'; -import { getSoapboxConfig } from 'soapbox/actions/soapbox'; -import { normalizeAd, normalizeCard } from 'soapbox/normalizers'; - -import type { AdProvider } from '.'; - -/** Rumble ad API entity. */ -interface RumbleAd { - type: number - impression: string - click: string - asset: string - expires: number -} - -/** Response from Rumble ad server. */ -interface RumbleApiResponse { - count: number - ads: RumbleAd[] -} - -/** Provides ads from Soapbox Config. */ -const RumbleAdProvider: AdProvider = { - getAds: async(getState) => { - const state = getState(); - const settings = getSettings(state); - const soapboxConfig = getSoapboxConfig(state); - const endpoint = soapboxConfig.extensions.getIn(['ads', 'endpoint']) as string | undefined; - - if (endpoint) { - try { - const { data } = await axios.get(endpoint, { - headers: { - 'Accept-Language': settings.get('locale', '*') as string, - }, - }); - - return data.ads.map(item => normalizeAd({ - impression: item.impression, - card: normalizeCard({ - type: item.type === 1 ? 'link' : 'rich', - image: item.asset, - url: item.click, - }), - expires_at: new Date(item.expires * 1000), - })); - } catch (e) { - // do nothing - } - } - - return []; - }, -}; - -export default RumbleAdProvider; diff --git a/app/soapbox/features/ads/providers/truth.ts b/app/soapbox/features/ads/providers/truth.ts index 9207db522..5582bd3cf 100644 --- a/app/soapbox/features/ads/providers/truth.ts +++ b/app/soapbox/features/ads/providers/truth.ts @@ -1,18 +1,19 @@ import axios from 'axios'; +import { z } from 'zod'; import { getSettings } from 'soapbox/actions/settings'; -import { normalizeCard } from 'soapbox/normalizers'; +import { cardSchema } from 'soapbox/schemas/card'; +import { filteredArray } from 'soapbox/schemas/utils'; import type { AdProvider } from '.'; -import type { Card } from 'soapbox/types/entities'; /** TruthSocial ad API entity. */ -interface TruthAd { - impression: string - card: Card - expires_at: string - reason: string -} +const truthAdSchema = z.object({ + impression: z.string(), + card: cardSchema, + expires_at: z.string(), + reason: z.string().catch(''), +}); /** Provides ads from the TruthSocial API. */ const TruthAdProvider: AdProvider = { @@ -21,16 +22,13 @@ const TruthAdProvider: AdProvider = { const settings = getSettings(state); try { - const { data } = await axios.get('/api/v2/truth/ads?device=desktop', { + const { data } = await axios.get('/api/v2/truth/ads?device=desktop', { headers: { - 'Accept-Language': settings.get('locale', '*') as string, + 'Accept-Language': z.string().catch('*').parse(settings.get('locale')), }, }); - return data.map(item => ({ - ...item, - card: normalizeCard(item.card), - })); + return filteredArray(truthAdSchema).parse(data); } catch (e) { // do nothing } diff --git a/app/soapbox/features/auth-login/components/login-form.tsx b/app/soapbox/features/auth-login/components/login-form.tsx index a91998671..d7871fc75 100644 --- a/app/soapbox/features/auth-login/components/login-form.tsx +++ b/app/soapbox/features/auth-login/components/login-form.tsx @@ -3,13 +3,18 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; import { Button, Form, FormActions, FormGroup, Input, Stack } from 'soapbox/components/ui'; +import { useFeatures } from 'soapbox/hooks'; import ConsumersList from './consumers-list'; const messages = defineMessages({ username: { id: 'login.fields.username_label', - defaultMessage: 'Email or username', + defaultMessage: 'E-mail or username', + }, + email: { + id: 'login.fields.email_label', + defaultMessage: 'E-mail address', }, password: { id: 'login.fields.password_placeholder', @@ -24,6 +29,10 @@ interface ILoginForm { const LoginForm: React.FC = ({ isLoading, handleSubmit }) => { const intl = useIntl(); + const features = useFeatures(); + + const usernameLabel = intl.formatMessage(features.logInWithUsername ? messages.username : messages.email); + const passwordLabel = intl.formatMessage(messages.password); return (
@@ -33,10 +42,10 @@ const LoginForm: React.FC = ({ isLoading, handleSubmit }) => {
- + = ({ isLoading, handleSubmit }) => { + = ({ isLoading, handleSubmit }) => { } > { const dispatch = useAppDispatch(); const intl = useIntl(); + const features = useFeatures(); const [isLoading, setIsLoading] = useState(false); const [success, setSuccess] = useState(false); @@ -43,7 +45,7 @@ const PasswordReset = () => {
- + ', () => { render( { - {(chatMessage.emoji_reactions?.size) ? ( + {(chatMessage.emoji_reactions?.length) ? (
({ id, shouldCondense, autoFocus, clickab const [composeFocused, setComposeFocused] = useState(false); - const formRef = useRef(null); + const formRef = useRef(null); const spoilerTextRef = useRef(null); const autosuggestTextareaRef = useRef(null); + const { isDraggedOver } = useDraggedFiles(formRef); + const handleChange: React.ChangeEventHandler = (e) => { dispatch(changeCompose(id, e.target.value)); }; @@ -236,7 +238,7 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab ), [features, id]); - const condensed = shouldCondense && !composeFocused && isEmpty() && !isUploading; + const condensed = shouldCondense && !isDraggedOver && !composeFocused && isEmpty() && !isUploading; const disabled = isSubmitting; const countedText = [spoilerText, countableText(text)].join(''); const disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia); @@ -319,7 +321,6 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab - ({ id, shouldCondense, autoFocus, clickab onSuggestionSelected={onSpoilerSuggestionSelected} ref={spoilerTextRef} /> + + } diff --git a/app/soapbox/features/compose/components/reply-indicator.tsx b/app/soapbox/features/compose/components/reply-indicator.tsx index a7b762778..fdc7c359b 100644 --- a/app/soapbox/features/compose/components/reply-indicator.tsx +++ b/app/soapbox/features/compose/components/reply-indicator.tsx @@ -2,7 +2,8 @@ import clsx from 'clsx'; import React from 'react'; import AttachmentThumbs from 'soapbox/components/attachment-thumbs'; -import { Stack, Text } from 'soapbox/components/ui'; +import Markup from 'soapbox/components/markup'; +import { Stack } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account-container'; import { isRtl } from 'soapbox/rtl'; @@ -45,8 +46,8 @@ const ReplyIndicator: React.FC = ({ className, status, hideActi hideActions={hideActions} /> - = ({ composeId }) => { } return ( - - + + diff --git a/app/soapbox/features/compose/components/search-results.tsx b/app/soapbox/features/compose/components/search-results.tsx index 1729def21..fe7a66d22 100644 --- a/app/soapbox/features/compose/components/search-results.tsx +++ b/app/soapbox/features/compose/components/search-results.tsx @@ -9,13 +9,11 @@ import IconButton from 'soapbox/components/icon-button'; import ScrollableList from 'soapbox/components/scrollable-list'; import { HStack, Tabs, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account-container'; -import GroupContainer from 'soapbox/containers/group-container'; import StatusContainer from 'soapbox/containers/status-container'; import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account'; -import PlaceholderGroupCard from 'soapbox/features/placeholder/components/placeholder-group-card'; import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status'; -import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; import type { VirtuosoHandle } from 'react-virtuoso'; @@ -24,7 +22,6 @@ import type { SearchFilter } from 'soapbox/reducers/search'; const messages = defineMessages({ accounts: { id: 'search_results.accounts', defaultMessage: 'People' }, statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' }, - groups: { id: 'search_results.groups', defaultMessage: 'Groups' }, hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' }, }); @@ -33,7 +30,6 @@ const SearchResults = () => { const intl = useIntl(); const dispatch = useAppDispatch(); - const features = useFeatures(); const value = useAppSelector((state) => state.search.submittedValue); const results = useAppSelector((state) => state.search.results); @@ -66,14 +62,6 @@ const SearchResults = () => { }, ); - if (features.groups) items.push( - { - text: intl.formatMessage(messages.groups), - action: () => selectFilter('groups'), - name: 'groups', - }, - ); - items.push( { text: intl.formatMessage(messages.hashtags), @@ -186,31 +174,6 @@ const SearchResults = () => { } } - if (selectedFilter === 'groups') { - hasMore = results.groupsHasMore; - loaded = results.groupsLoaded; - placeholderComponent = PlaceholderGroupCard; - - if (results.groups && results.groups.size > 0) { - searchResults = results.groups.map((groupId: string) => ( - - )); - resultsIds = results.groups; - } else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) { - searchResults = null; - } else if (loaded) { - noResultsMessage = ( -
- -
- ); - } - } - if (selectedFilter === 'hashtags') { hasMore = results.hashtagsHasMore; loaded = results.hashtagsLoaded; @@ -238,11 +201,11 @@ const SearchResults = () => { {filterByAccount ? ( - + {account} }} /> diff --git a/app/soapbox/features/compose/components/search.tsx b/app/soapbox/features/compose/components/search.tsx index 0e50fc0be..3a3bdcd6b 100644 --- a/app/soapbox/features/compose/components/search.tsx +++ b/app/soapbox/features/compose/components/search.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import debounce from 'lodash/debounce'; -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; @@ -135,6 +135,18 @@ const Search = (props: ISearch) => { componentProps.autoSelect = false; } + useEffect(() => { + return () => { + const newPath = history.location.pathname; + const shouldPersistSearch = !!newPath.match(/@.+\/posts\/\d+/g) + || !!newPath.match(/\/tags\/.+/g); + + if (!shouldPersistSearch) { + dispatch(changeSearch('')); + } + }; + }, []); + return (
diff --git a/app/soapbox/features/compose/containers/warning-container.tsx b/app/soapbox/features/compose/containers/warning-container.tsx index 6c2a81f87..fd073cda8 100644 --- a/app/soapbox/features/compose/containers/warning-container.tsx +++ b/app/soapbox/features/compose/containers/warning-container.tsx @@ -18,7 +18,7 @@ const WarningWrapper: React.FC = ({ composeId }) => { const me = useAppSelector((state) => state.me); const needsLockWarning = useAppSelector((state) => compose.privacy === 'private' && !state.accounts.get(me)!.locked); - const hashtagWarning = compose.privacy !== 'public' && APPROX_HASHTAG_RE.test(compose.text); + const hashtagWarning = (compose.privacy !== 'public' && compose.privacy !== 'group') && APPROX_HASHTAG_RE.test(compose.text); const directMessageWarning = compose.privacy === 'direct'; if (needsLockWarning) { diff --git a/app/soapbox/features/edit-profile/index.tsx b/app/soapbox/features/edit-profile/index.tsx index 8483a171e..f45d75afa 100644 --- a/app/soapbox/features/edit-profile/index.tsx +++ b/app/soapbox/features/edit-profile/index.tsx @@ -124,7 +124,7 @@ const accountToCredentials = (account: Account): AccountCredentials => { discoverable: account.discoverable, bot: account.bot, display_name: account.display_name, - note: account.source.get('note'), + note: account.source.get('note', ''), locked: account.locked, fields_attributes: [...account.source.get>('fields', ImmutableList()).toJS()], stranger_notifications: account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']) === true, diff --git a/app/soapbox/features/event/event-discussion.tsx b/app/soapbox/features/event/event-discussion.tsx index 54a539a8a..3c96c73b8 100644 --- a/app/soapbox/features/event/event-discussion.tsx +++ b/app/soapbox/features/event/event-discussion.tsx @@ -184,7 +184,7 @@ const EventDiscussion: React.FC = (props) => { ref={scroller} hasMore={!!next} onLoadMore={handleLoadMore} - placeholderComponent={() => } + placeholderComponent={() => } initialTopMostItemIndex={0} emptyMessage={} > diff --git a/app/soapbox/features/feed-filtering/feed-carousel.tsx b/app/soapbox/features/feed-filtering/feed-carousel.tsx index af6cb47bb..f437aad0c 100644 --- a/app/soapbox/features/feed-filtering/feed-carousel.tsx +++ b/app/soapbox/features/feed-filtering/feed-carousel.tsx @@ -30,7 +30,7 @@ const CarouselItem = React.forwardRef(( setLoading(true); if (isSelected) { - dispatch(replaceHomeTimeline(null, { maxId: null }, () => setLoading(false))); + dispatch(replaceHomeTimeline(undefined, { maxId: null }, () => setLoading(false))); if (onPinned) { onPinned(null); @@ -207,7 +207,7 @@ const FeedCarousel = () => { style={{ width: widthPerAvatar || 'auto' }} key={idx} > - +
)) ) : ( diff --git a/app/soapbox/features/filters/index.tsx b/app/soapbox/features/filters/index.tsx index 46eba410b..5aadf1795 100644 --- a/app/soapbox/features/filters/index.tsx +++ b/app/soapbox/features/filters/index.tsx @@ -70,8 +70,8 @@ const Filters = () => { emptyMessage={emptyMessage} itemClassName='pb-4 last:pb-0' > - {filters.map((filter, i) => ( -
+ {filters.map((filter) => ( +
diff --git a/app/soapbox/features/followed_tags/index.tsx b/app/soapbox/features/followed_tags/index.tsx new file mode 100644 index 000000000..6745f5fc0 --- /dev/null +++ b/app/soapbox/features/followed_tags/index.tsx @@ -0,0 +1,52 @@ +import debounce from 'lodash/debounce'; +import React, { useEffect } from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { fetchFollowedHashtags, expandFollowedHashtags } from 'soapbox/actions/tags'; +import Hashtag from 'soapbox/components/hashtag'; +import ScrollableList from 'soapbox/components/scrollable-list'; +import { Column } from 'soapbox/components/ui'; +import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + heading: { id: 'column.followed_tags', defaultMessage: 'Followed hashtags' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandFollowedHashtags()); +}, 300, { leading: true }); + +const FollowedTags = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(fetchFollowedHashtags()); + }, []); + + const tags = useAppSelector((state => state.followed_tags.items)); + const isLoading = useAppSelector((state => state.followed_tags.isLoading)); + const hasMore = useAppSelector((state => !!state.followed_tags.next)); + + const emptyMessage = ; + + return ( + + handleLoadMore(dispatch)} + placeholderComponent={PlaceholderHashtag} + placeholderCount={5} + itemClassName='pb-3' + > + {tags.map(tag => )} + + + ); +}; + +export default FollowedTags; diff --git a/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx b/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx index 6809ea009..a0df6affe 100644 --- a/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx +++ b/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { buildGroup, buildGroupRelationship } from 'soapbox/jest/factory'; import { render, screen } from 'soapbox/jest/test-helpers'; +import { GroupRoles } from 'soapbox/schemas/group-member'; import { Group } from 'soapbox/types/entities'; import GroupActionButton from '../group-action-button'; @@ -45,7 +46,7 @@ describe('', () => { beforeEach(() => { group = buildGroup({ relationship: buildGroupRelationship({ - member: null, + member: false, }), }); }); @@ -98,7 +99,7 @@ describe('', () => { relationship: buildGroupRelationship({ requested: false, member: true, - role: 'owner', + role: GroupRoles.OWNER, }), }); }); @@ -116,7 +117,7 @@ describe('', () => { relationship: buildGroupRelationship({ requested: false, member: true, - role: 'user', + role: GroupRoles.USER, }), }); }); diff --git a/app/soapbox/features/group/components/__tests__/group-header.test.tsx b/app/soapbox/features/group/components/__tests__/group-header.test.tsx new file mode 100644 index 000000000..03f171e14 --- /dev/null +++ b/app/soapbox/features/group/components/__tests__/group-header.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import { buildGroup } from 'soapbox/jest/factory'; +import { render, screen } from 'soapbox/jest/test-helpers'; +import { Group } from 'soapbox/types/entities'; + +import GroupHeader from '../group-header'; + +let group: Group; + +describe('', () => { + describe('without a group', () => { + it('should render the blankslate', () => { + render(); + expect(screen.getByTestId('group-header-missing')).toBeInTheDocument(); + }); + }); + + describe('when the Group has been deleted', () => { + it('only shows name, header, and avatar', () => { + group = buildGroup({ display_name: 'my group', deleted_at: new Date().toISOString() }); + render(); + + expect(screen.queryAllByTestId('group-header-missing')).toHaveLength(0); + expect(screen.queryAllByTestId('group-actions')).toHaveLength(0); + expect(screen.queryAllByTestId('group-meta')).toHaveLength(0); + expect(screen.getByTestId('group-header-image')).toBeInTheDocument(); + expect(screen.getByTestId('group-avatar')).toBeInTheDocument(); + expect(screen.getByTestId('group-name')).toBeInTheDocument(); + }); + }); + + describe('with a valid Group', () => { + it('only shows all fields', () => { + group = buildGroup({ display_name: 'my group', deleted_at: null }); + render(); + + expect(screen.queryAllByTestId('group-header-missing')).toHaveLength(0); + expect(screen.getByTestId('group-actions')).toBeInTheDocument(); + expect(screen.getByTestId('group-meta')).toBeInTheDocument(); + expect(screen.getByTestId('group-header-image')).toBeInTheDocument(); + expect(screen.getByTestId('group-avatar')).toBeInTheDocument(); + expect(screen.getByTestId('group-name')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/group/components/__tests__/group-member-list-item.test.tsx b/app/soapbox/features/group/components/__tests__/group-member-list-item.test.tsx new file mode 100644 index 000000000..abecc3287 --- /dev/null +++ b/app/soapbox/features/group/components/__tests__/group-member-list-item.test.tsx @@ -0,0 +1,320 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { __stub } from 'soapbox/api'; +import { buildGroup, buildGroupMember, buildGroupRelationship } from 'soapbox/jest/factory'; +import { render, screen, waitFor } from 'soapbox/jest/test-helpers'; +import { GroupRoles } from 'soapbox/schemas/group-member'; + +import GroupMemberListItem from '../group-member-list-item'; + +describe('', () => { + describe('account rendering', () => { + const accountId = '4'; + const groupMember = buildGroupMember({}, { + id: accountId, + display_name: 'tiger woods', + }); + + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account); + }); + }); + + it('should render the users avatar', async () => { + const group = buildGroup({ + relationship: buildGroupRelationship(), + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('group-member-list-item')).toHaveTextContent(groupMember.account.display_name); + }); + }); + }); + + describe('role badge', () => { + const accountId = '4'; + const group = buildGroup(); + + describe('when the user is an Owner', () => { + const groupMember = buildGroupMember({ role: GroupRoles.OWNER }, { + id: accountId, + display_name: 'tiger woods', + }); + + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account); + }); + }); + + it('should render the correct badge', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('role-badge')).toHaveTextContent('owner'); + }); + }); + }); + + describe('when the user is an Admin', () => { + const groupMember = buildGroupMember({ role: GroupRoles.ADMIN }, { + id: accountId, + display_name: 'tiger woods', + }); + + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account); + }); + }); + + it('should render the correct badge', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('role-badge')).toHaveTextContent('admin'); + }); + }); + }); + + describe('when the user is an User', () => { + const groupMember = buildGroupMember({ role: GroupRoles.USER }, { + id: accountId, + display_name: 'tiger woods', + }); + + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account); + }); + }); + + it('should render no correct badge', async () => { + render(); + + await waitFor(() => { + expect(screen.queryAllByTestId('role-badge')).toHaveLength(0); + }); + }); + }); + }); + + describe('as a Group owner', () => { + const group = buildGroup({ + relationship: buildGroupRelationship({ + role: GroupRoles.OWNER, + member: true, + }), + }); + + describe('when the user has role of "user"', () => { + const accountId = '4'; + const groupMember = buildGroupMember({}, { + id: accountId, + display_name: 'tiger woods', + username: 'tiger', + }); + + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account); + }); + }); + + describe('when "canPromoteToAdmin is true', () => { + it('should render dropdown with correct Owner actions', async () => { + const user = userEvent.setup(); + + render(); + + await waitFor(async() => { + await user.click(screen.getByTestId('icon-button')); + }); + + const dropdownMenu = screen.getByTestId('dropdown-menu'); + expect(dropdownMenu).toHaveTextContent('Assign admin role'); + expect(dropdownMenu).toHaveTextContent('Kick @tiger from group'); + expect(dropdownMenu).toHaveTextContent('Ban from group'); + }); + }); + + describe('when "canPromoteToAdmin is false', () => { + it('should prevent promoting user to Admin', async () => { + const user = userEvent.setup(); + + render(); + + await waitFor(async() => { + await user.click(screen.getByTestId('icon-button')); + await user.click(screen.getByTitle('Assign admin role')); + }); + + expect(screen.getByTestId('toast')).toHaveTextContent('Admin limit reached'); + }); + }); + }); + + describe('when the user has role of "admin"', () => { + const accountId = '4'; + const groupMember = buildGroupMember( + { + role: GroupRoles.ADMIN, + }, + { + id: accountId, + display_name: 'tiger woods', + username: 'tiger', + }, + ); + + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account); + }); + }); + + it('should render dropdown with correct Owner actions', async () => { + const user = userEvent.setup(); + + render(); + + await waitFor(async() => { + await user.click(screen.getByTestId('icon-button')); + }); + + const dropdownMenu = screen.getByTestId('dropdown-menu'); + expect(dropdownMenu).toHaveTextContent('Remove admin role'); + expect(dropdownMenu).toHaveTextContent('Kick @tiger from group'); + expect(dropdownMenu).toHaveTextContent('Ban from group'); + }); + }); + }); + + describe('as a Group admin', () => { + const group = buildGroup({ + relationship: buildGroupRelationship({ + role: GroupRoles.ADMIN, + member: true, + }), + }); + + describe('when the user has role of "user"', () => { + const accountId = '4'; + const groupMember = buildGroupMember({}, { + id: accountId, + display_name: 'tiger woods', + username: 'tiger', + }); + + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account); + }); + }); + + it('should render dropdown with correct Admin actions', async () => { + const user = userEvent.setup(); + + render(); + + await waitFor(async() => { + await user.click(screen.getByTestId('icon-button')); + }); + + const dropdownMenu = screen.getByTestId('dropdown-menu'); + expect(dropdownMenu).not.toHaveTextContent('Assign admin role'); + expect(dropdownMenu).toHaveTextContent('Kick @tiger from group'); + expect(dropdownMenu).toHaveTextContent('Ban from group'); + }); + }); + + describe('when the user has role of "admin"', () => { + const accountId = '4'; + const groupMember = buildGroupMember( + { + role: GroupRoles.ADMIN, + }, + { + id: accountId, + display_name: 'tiger woods', + username: 'tiger', + }, + ); + + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account); + }); + }); + + it('should not render the dropdown', async () => { + render(); + + await waitFor(async() => { + expect(screen.queryAllByTestId('icon-button')).toHaveLength(0); + }); + }); + }); + + describe('when the user has role of "owner"', () => { + const accountId = '4'; + const groupMember = buildGroupMember( + { + role: GroupRoles.OWNER, + }, + { + id: accountId, + display_name: 'tiger woods', + username: 'tiger', + }, + ); + + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account); + }); + }); + + it('should not render the dropdown', async () => { + render(); + + await waitFor(async() => { + expect(screen.queryAllByTestId('icon-button')).toHaveLength(0); + }); + }); + }); + }); + + describe('as a Group user', () => { + const group = buildGroup({ + relationship: buildGroupRelationship({ + role: GroupRoles.USER, + member: true, + }), + }); + const accountId = '4'; + const groupMember = buildGroupMember({}, { + id: accountId, + display_name: 'tiger woods', + username: 'tiger', + }); + + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account); + }); + }); + + it('should not render the dropdown', async () => { + render(); + + await waitFor(async() => { + expect(screen.queryAllByTestId('icon-button')).toHaveLength(0); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx b/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx index 4d7779799..e3171bb81 100644 --- a/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx +++ b/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx @@ -17,7 +17,7 @@ describe('', () => { requested: false, member: true, blocked_by: true, - role: 'user', + role: GroupRoles.USER, }), }); }); @@ -40,10 +40,11 @@ describe('', () => { }); }); - it('should render null', () => { + it('should render one option for leaving the group', () => { render(); - expect(screen.queryAllByTestId('dropdown-menu-button')).toHaveLength(0); + // Leave group option only + expect(screen.queryAllByTestId('dropdown-menu-button')).toHaveLength(1); }); }); diff --git a/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx b/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx new file mode 100644 index 000000000..f91853dc4 --- /dev/null +++ b/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx @@ -0,0 +1,124 @@ +import React from 'react'; + +import { buildGroup, buildGroupTag, buildGroupRelationship } from 'soapbox/jest/factory'; +import { render, screen } from 'soapbox/jest/test-helpers'; +import { GroupRoles } from 'soapbox/schemas/group-member'; + +import GroupTagListItem from '../group-tag-list-item'; + +describe('', () => { + describe('tag name', () => { + const name = 'hello'; + + it('should render the tag name', () => { + const group = buildGroup(); + const tag = buildGroupTag({ name }); + render(); + + expect(screen.getByTestId('group-tag-list-item')).toHaveTextContent(`#${name}`); + }); + + describe('when the tag is "visible"', () => { + const group = buildGroup(); + const tag = buildGroupTag({ name, visible: true }); + + it('renders the default name', () => { + render(); + expect(screen.getByTestId('group-tag-name')).toHaveClass('text-gray-900'); + }); + }); + + describe('when the tag is not "visible" and user is Owner', () => { + const group = buildGroup({ + relationship: buildGroupRelationship({ + role: GroupRoles.OWNER, + member: true, + }), + }); + const tag = buildGroupTag({ + name, + visible: false, + }); + + it('renders the subtle name', () => { + render(); + expect(screen.getByTestId('group-tag-name')).toHaveClass('text-gray-400'); + }); + }); + + describe('when the tag is not "visible" and user is Admin or User', () => { + const group = buildGroup({ + relationship: buildGroupRelationship({ + role: GroupRoles.ADMIN, + member: true, + }), + }); + const tag = buildGroupTag({ + name, + visible: false, + }); + + it('renders the subtle name', () => { + render(); + expect(screen.getByTestId('group-tag-name')).toHaveClass('text-gray-900'); + }); + }); + }); + + describe('pinning', () => { + describe('as an owner', () => { + const group = buildGroup({ + relationship: buildGroupRelationship({ + role: GroupRoles.OWNER, + member: true, + }), + }); + + describe('when the tag is visible', () => { + const tag = buildGroupTag({ visible: true }); + + it('renders the pin icon', () => { + render(); + expect(screen.getByTestId('pin-icon')).toBeInTheDocument(); + }); + }); + + describe('when the tag is not visible', () => { + const tag = buildGroupTag({ visible: false }); + + it('does not render the pin icon', () => { + render(); + expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0); + }); + }); + }); + + describe('as a non-owner', () => { + const group = buildGroup({ + relationship: buildGroupRelationship({ + role: GroupRoles.ADMIN, + member: true, + }), + }); + + describe('when the tag is pinned', () => { + const tag = buildGroupTag({ pinned: true, visible: true }); + + it('does render the pin icon', () => { + render(); + screen.debug(); + expect(screen.queryAllByTestId('pin-icon')).toHaveLength(1); + }); + }); + + describe('when the tag is not pinned', () => { + const tag = buildGroupTag({ pinned: false, visible: true }); + + it('does not render the pin icon', () => { + render(); + expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/group/components/group-action-button.tsx b/app/soapbox/features/group/components/group-action-button.tsx index c697bc4ee..f3b208574 100644 --- a/app/soapbox/features/group/components/group-action-button.tsx +++ b/app/soapbox/features/group/components/group-action-button.tsx @@ -1,16 +1,19 @@ import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { fetchGroupRelationshipsSuccess } from 'soapbox/actions/groups'; import { openModal } from 'soapbox/actions/modals'; +import { useCancelMembershipRequest, useJoinGroup, useLeaveGroup } from 'soapbox/api/hooks'; import { Button } from 'soapbox/components/ui'; -import { deleteEntities } from 'soapbox/entity-store/actions'; +import { importEntities } from 'soapbox/entity-store/actions'; import { Entities } from 'soapbox/entity-store/entities'; -import { useAppDispatch } from 'soapbox/hooks'; -import { useCancelMembershipRequest, useJoinGroup, useLeaveGroup } from 'soapbox/hooks/api'; +import { useAppDispatch, useOwnAccount } from 'soapbox/hooks'; +import { queryClient } from 'soapbox/queries/client'; +import { GroupKeys } from 'soapbox/queries/groups'; import { GroupRoles } from 'soapbox/schemas/group-member'; import toast from 'soapbox/toast'; -import type { Group } from 'soapbox/types/entities'; +import type { Group, GroupRelationship } from 'soapbox/types/entities'; interface IGroupActionButton { group: Group @@ -20,7 +23,7 @@ const messages = defineMessages({ confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' }, confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' }, confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' }, - joinRequestSuccess: { id: 'group.join.request_success', defaultMessage: 'Requested to join the group' }, + joinRequestSuccess: { id: 'group.join.request_success', defaultMessage: 'Request sent to group owner' }, joinSuccess: { id: 'group.join.success', defaultMessage: 'Group joined successfully!' }, leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' }, }); @@ -28,6 +31,7 @@ const messages = defineMessages({ const GroupActionButton = ({ group }: IGroupActionButton) => { const dispatch = useAppDispatch(); const intl = useIntl(); + const account = useOwnAccount(); const joinGroup = useJoinGroup(group); const leaveGroup = useLeaveGroup(group); @@ -36,11 +40,14 @@ const GroupActionButton = ({ group }: IGroupActionButton) => { const isRequested = group.relationship?.requested; const isNonMember = !group.relationship?.member && !isRequested; const isOwner = group.relationship?.role === GroupRoles.OWNER; + const isAdmin = group.relationship?.role === GroupRoles.ADMIN; const isBlocked = group.relationship?.blocked_by; const onJoinGroup = () => joinGroup.mutate({}, { - onSuccess() { + onSuccess(entity) { joinGroup.invalidate(); + dispatch(fetchGroupRelationshipsSuccess([entity])); + queryClient.invalidateQueries(GroupKeys.pendingGroups(account?.id as string)); toast.success( group.locked @@ -48,6 +55,12 @@ const GroupActionButton = ({ group }: IGroupActionButton) => { : intl.formatMessage(messages.joinSuccess), ); }, + onError(error) { + const message = (error.response?.data as any).error; + if (message) { + toast.error(message); + } + }, }); const onLeaveGroup = () => @@ -56,8 +69,9 @@ const GroupActionButton = ({ group }: IGroupActionButton) => { message: intl.formatMessage(messages.confirmationMessage), confirm: intl.formatMessage(messages.confirmationConfirm), onConfirm: () => leaveGroup.mutate(group.relationship?.id as string, { - onSuccess() { + onSuccess(entity) { leaveGroup.invalidate(); + dispatch(fetchGroupRelationshipsSuccess([entity])); toast.success(intl.formatMessage(messages.leaveSuccess)); }, }), @@ -65,7 +79,12 @@ const GroupActionButton = ({ group }: IGroupActionButton) => { const onCancelRequest = () => cancelRequest.mutate({}, { onSuccess() { - dispatch(deleteEntities([group.id], Entities.GROUP_RELATIONSHIPS)); + const entity = { + ...group.relationship as GroupRelationship, + requested: false, + }; + dispatch(importEntities([entity], Entities.GROUP_RELATIONSHIPS)); + queryClient.invalidateQueries(GroupKeys.pendingGroups(account?.id as string)); }, }); @@ -73,11 +92,11 @@ const GroupActionButton = ({ group }: IGroupActionButton) => { return null; } - if (isOwner) { + if (isOwner || isAdmin) { return ( @@ -89,7 +108,7 @@ const GroupActionButton = ({ group }: IGroupActionButton) => { @@ -114,7 +133,7 @@ const GroupActionButton = ({ group }: IGroupActionButton) => { diff --git a/app/soapbox/features/group/components/group-avatar-picker.tsx b/app/soapbox/features/group/components/group-avatar-picker.tsx new file mode 100644 index 000000000..b13dfe80e --- /dev/null +++ b/app/soapbox/features/group/components/group-avatar-picker.tsx @@ -0,0 +1,45 @@ +import clsx from 'clsx'; +import React from 'react'; + +import Icon from 'soapbox/components/icon'; +import { Avatar, HStack } from 'soapbox/components/ui'; + +interface IMediaInput { + src: string | undefined + accept: string + onChange: React.ChangeEventHandler + disabled?: boolean +} + +const AvatarPicker = React.forwardRef(({ src, onChange, accept, disabled }, ref) => { + return ( + + ); +}); + +export default AvatarPicker; \ No newline at end of file diff --git a/app/soapbox/features/group/components/group-header-image.tsx b/app/soapbox/features/group/components/group-header-image.tsx new file mode 100644 index 000000000..f40749536 --- /dev/null +++ b/app/soapbox/features/group/components/group-header-image.tsx @@ -0,0 +1,50 @@ +import clsx from 'clsx'; +import React, { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { Icon } from 'soapbox/components/ui'; + +import type { Group } from 'soapbox/schemas'; + +const messages = defineMessages({ + header: { id: 'group.header.alt', defaultMessage: 'Group header' }, +}); + +interface IGroupHeaderImage { + group?: Group | false | null + className?: string +} + +const GroupHeaderImage: React.FC = ({ className, group }) => { + const intl = useIntl(); + + const [isHeaderMissing, setIsHeaderMissing] = useState(false); + + if (!group || !group.header) { + return null; + } + + if (isHeaderMissing) { + return ( +
+ +
+ ); + } + + return ( + {intl.formatMessage(messages.header)} setIsHeaderMissing(true)} + /> + ); +}; + +export default GroupHeaderImage; diff --git a/app/soapbox/features/group/components/group-header-picker.tsx b/app/soapbox/features/group/components/group-header-picker.tsx new file mode 100644 index 000000000..d2457ac1e --- /dev/null +++ b/app/soapbox/features/group/components/group-header-picker.tsx @@ -0,0 +1,52 @@ +import clsx from 'clsx'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Icon from 'soapbox/components/icon'; +import { HStack, Text } from 'soapbox/components/ui'; + +interface IMediaInput { + src: string | undefined + accept: string + onChange: React.ChangeEventHandler + disabled?: boolean +} + +const HeaderPicker = React.forwardRef(({ src, onChange, accept, disabled }, ref) => { + return ( + + ); +}); + +export default HeaderPicker; \ No newline at end of file diff --git a/app/soapbox/features/group/components/group-header.tsx b/app/soapbox/features/group/components/group-header.tsx index 713c64c86..2491a7bb7 100644 --- a/app/soapbox/features/group/components/group-header.tsx +++ b/app/soapbox/features/group/components/group-header.tsx @@ -1,11 +1,11 @@ import { List as ImmutableList } from 'immutable'; -import React from 'react'; +import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { openModal } from 'soapbox/actions/modals'; import GroupAvatar from 'soapbox/components/groups/group-avatar'; import StillImage from 'soapbox/components/still-image'; -import { HStack, Stack, Text } from 'soapbox/components/ui'; +import { HStack, Icon, Stack, Text } from 'soapbox/components/ui'; import { useAppDispatch } from 'soapbox/hooks'; import { normalizeAttachment } from 'soapbox/normalizers'; import { isDefaultHeader } from 'soapbox/utils/accounts'; @@ -30,9 +30,11 @@ const GroupHeader: React.FC = ({ group }) => { const intl = useIntl(); const dispatch = useAppDispatch(); + const [isHeaderMissing, setIsHeaderMissing] = useState(false); + if (!group) { return ( -
+
@@ -50,6 +52,8 @@ const GroupHeader: React.FC = ({ group }) => { ); } + const isDeleted = !!group.deleted_at; + const onAvatarClick = () => { const avatar = normalizeAttachment({ type: 'image', @@ -88,28 +92,38 @@ const GroupHeader: React.FC = ({ group }) => { setIsHeaderMissing(true)} /> ); if (!isDefaultHeader(group.header)) { header = ( - + {header} ); } } - return header; + return ( +
+ {isHeaderMissing ? ( + + ) : header} +
+ ); }; return ( -
+
{renderHeader()} -
+ ); diff --git a/app/soapbox/features/group/components/group-member-list-item.tsx b/app/soapbox/features/group/components/group-member-list-item.tsx index 10ad4e0cb..f9b18735d 100644 --- a/app/soapbox/features/group/components/group-member-list-item.tsx +++ b/app/soapbox/features/group/components/group-member-list-item.tsx @@ -4,20 +4,25 @@ import { defineMessages, useIntl } from 'react-intl'; import { groupKick } from 'soapbox/actions/groups'; import { openModal } from 'soapbox/actions/modals'; +import { useAccount, useBlockGroupMember, useDemoteGroupMember, usePromoteGroupMember } from 'soapbox/api/hooks'; import Account from 'soapbox/components/account'; import DropdownMenu from 'soapbox/components/dropdown-menu/dropdown-menu'; import { HStack } from 'soapbox/components/ui'; import { deleteEntities } from 'soapbox/entity-store/actions'; import { Entities } from 'soapbox/entity-store/entities'; -import { useAccount, useAppDispatch, useFeatures } from 'soapbox/hooks'; -import { useBlockGroupMember, useDemoteGroupMember, usePromoteGroupMember } from 'soapbox/hooks/api'; +import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account'; +import { useAppDispatch, useFeatures } from 'soapbox/hooks'; import { GroupRoles } from 'soapbox/schemas/group-member'; import toast from 'soapbox/toast'; +import { MAX_ADMIN_COUNT } from '../group-members'; + import type { Menu as IMenu } from 'soapbox/components/dropdown-menu'; -import type { Account as AccountEntity, Group, GroupMember } from 'soapbox/types/entities'; +import type { Group, GroupMember } from 'soapbox/types/entities'; const messages = defineMessages({ + adminLimitTitle: { id: 'group.member.admin.limit.title', defaultMessage: 'Admin limit reached' }, + adminLimitSummary: { id: 'group.member.admin.limit.summary', defaultMessage: 'You can assign up to {count} admins for the group at this time.' }, blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Ban' }, blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Ban From Group' }, blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to ban @{name} from the group?' }, @@ -38,10 +43,11 @@ const messages = defineMessages({ interface IGroupMemberListItem { member: GroupMember group: Group + canPromoteToAdmin: boolean } const GroupMemberListItem = (props: IGroupMemberListItem) => { - const { member, group } = props; + const { canPromoteToAdmin, member, group } = props; const dispatch = useAppDispatch(); const features = useFeatures(); @@ -51,7 +57,7 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { const promoteGroupMember = usePromoteGroupMember(group, member); const demoteGroupMember = useDemoteGroupMember(group, member); - const account = useAccount(member.account.id) as AccountEntity; + const { account, isLoading } = useAccount(member.account.id); // Current user role const isCurrentUserOwner = group.relationship?.role === GroupRoles.OWNER; @@ -64,10 +70,10 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { const handleKickFromGroup = () => { dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }), + message: intl.formatMessage(messages.kickFromGroupMessage, { name: account?.username }), confirm: intl.formatMessage(messages.kickConfirm), - onConfirm: () => dispatch(groupKick(group.id, account.id)).then(() => - toast.success(intl.formatMessage(messages.kicked, { name: account.acct })), + onConfirm: () => dispatch(groupKick(group.id, account?.id as string)).then(() => + toast.success(intl.formatMessage(messages.kicked, { name: account?.acct })), ), })); }; @@ -75,13 +81,13 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { const handleBlockFromGroup = () => { dispatch(openModal('CONFIRM', { heading: intl.formatMessage(messages.blockFromGroupHeading), - message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }), + message: intl.formatMessage(messages.blockFromGroupMessage, { name: account?.username }), confirm: intl.formatMessage(messages.blockConfirm), onConfirm: () => { blockGroupMember({ account_ids: [member.account.id] }, { onSuccess() { dispatch(deleteEntities([member.id], Entities.GROUP_MEMBERSHIPS)); - toast.success(intl.formatMessage(messages.blocked, { name: account.acct })); + toast.success(intl.formatMessage(messages.blocked, { name: account?.acct })); }, }); }, @@ -89,16 +95,23 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { }; const handleAdminAssignment = () => { + if (!canPromoteToAdmin) { + toast.error(intl.formatMessage(messages.adminLimitTitle), { + summary: intl.formatMessage(messages.adminLimitSummary, { count: MAX_ADMIN_COUNT }), + }); + return; + } + dispatch(openModal('CONFIRM', { heading: intl.formatMessage(messages.promoteConfirm), - message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }), + message: intl.formatMessage(messages.promoteConfirmMessage, { name: account?.username }), confirm: intl.formatMessage(messages.promoteConfirm), confirmationTheme: 'primary', onConfirm: () => { - promoteGroupMember({ role: GroupRoles.ADMIN, account_ids: [account.id] }, { + promoteGroupMember({ role: GroupRoles.ADMIN, account_ids: [account?.id] }, { onSuccess() { toast.success( - intl.formatMessage(messages.promotedToAdmin, { name: account.acct }), + intl.formatMessage(messages.promotedToAdmin, { name: account?.acct }), ); }, }); @@ -107,9 +120,9 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { }; const handleUserAssignment = () => { - demoteGroupMember({ role: GroupRoles.USER, account_ids: [account.id] }, { + demoteGroupMember({ role: GroupRoles.USER, account_ids: [account?.id] }, { onSuccess() { - toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct })); + toast.success(intl.formatMessage(messages.demotedToUser, { name: account?.acct })); }, }); }; @@ -160,10 +173,18 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { } return items; - }, [group, account]); + }, [group, account?.id]); + + if (isLoading) { + return ; + } return ( - +
@@ -171,6 +192,7 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { {(isMemberOwner || isMemberAdmin) ? ( { const account = useOwnAccount(); const dispatch = useAppDispatch(); const intl = useIntl(); + const leaveGroup = useLeaveGroup(group); const isMember = group.relationship?.role === GroupRoles.USER; + const isAdmin = group.relationship?.role === GroupRoles.ADMIN; const isBlocked = group.relationship?.blocked_by; - const menu: Menu = useMemo(() => ([ - { - text: intl.formatMessage(messages.report), - icon: require('@tabler/icons/flag.svg'), - action: () => dispatch(initReport(ReportableEntities.GROUP, account as Account, { group })), - }, - ]), []); + const handleShare = () => { + navigator.share({ + text: group.display_name, + url: group.url, + }).catch((e) => { + if (e.name !== 'AbortError') console.error(e); + }); + }; - if (isBlocked || !isMember || menu.length === 0) { + const onLeaveGroup = () => + dispatch(openModal('CONFIRM', { + heading: intl.formatMessage(messages.confirmationHeading), + message: intl.formatMessage(messages.confirmationMessage), + confirm: intl.formatMessage(messages.confirmationConfirm), + onConfirm: () => leaveGroup.mutate(group.relationship?.id as string, { + onSuccess() { + leaveGroup.invalidate(); + toast.success(intl.formatMessage(messages.leaveSuccess)); + }, + }), + })); + + const menu: Menu = useMemo(() => { + const canShare = 'share' in navigator; + const items = []; + + if (isMember || isAdmin) { + items.push({ + text: intl.formatMessage(messages.report), + icon: require('@tabler/icons/flag.svg'), + action: () => dispatch(initReport(ReportableEntities.GROUP, account as Account, { group })), + }); + } + + if (canShare) { + items.push({ + text: intl.formatMessage(messages.share), + icon: require('@tabler/icons/share.svg'), + action: handleShare, + }); + } + + if (isAdmin) { + items.push({ + text: intl.formatMessage(messages.leave), + icon: require('@tabler/icons/logout.svg'), + action: onLeaveGroup, + }); + } + + return items; + }, [isMember, isAdmin]); + + if (isBlocked || menu.length === 0) { return null; } diff --git a/app/soapbox/features/group/components/group-tag-list-item.tsx b/app/soapbox/features/group/components/group-tag-list-item.tsx new file mode 100644 index 000000000..07660cf21 --- /dev/null +++ b/app/soapbox/features/group/components/group-tag-list-item.tsx @@ -0,0 +1,196 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; + +import { useUpdateGroupTag } from 'soapbox/api/hooks'; +import { HStack, Icon, IconButton, Stack, Text, Tooltip } from 'soapbox/components/ui'; +import { importEntities } from 'soapbox/entity-store/actions'; +import { Entities } from 'soapbox/entity-store/entities'; +import { useAppDispatch } from 'soapbox/hooks'; +import { GroupRoles } from 'soapbox/schemas/group-member'; +import toast from 'soapbox/toast'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; + +import type { Group, GroupTag } from 'soapbox/schemas'; + +const messages = defineMessages({ + hideTag: { id: 'group.tags.hide', defaultMessage: 'Hide topic' }, + showTag: { id: 'group.tags.show', defaultMessage: 'Show topic' }, + total: { id: 'group.tags.total', defaultMessage: 'Total Posts' }, + pinTag: { id: 'group.tags.pin', defaultMessage: 'Pin topic' }, + unpinTag: { id: 'group.tags.unpin', defaultMessage: 'Unpin topic' }, + pinSuccess: { id: 'group.tags.pin.success', defaultMessage: 'Pinned!' }, + unpinSuccess: { id: 'group.tags.unpin.success', defaultMessage: 'Unpinned!' }, + visibleSuccess: { id: 'group.tags.visible.success', defaultMessage: 'Topic marked as visible' }, + hiddenSuccess: { id: 'group.tags.hidden.success', defaultMessage: 'Topic marked as hidden' }, +}); + +interface IGroupMemberListItem { + tag: GroupTag + group: Group + isPinnable: boolean +} + +const GroupTagListItem = (props: IGroupMemberListItem) => { + const { group, tag, isPinnable } = props; + const dispatch = useAppDispatch(); + + const intl = useIntl(); + const { updateGroupTag } = useUpdateGroupTag(group.id, tag.id); + + const isOwner = group.relationship?.role === GroupRoles.OWNER; + + const toggleVisibility = () => { + const isHiding = tag.visible; + + updateGroupTag({ + group_tag_type: isHiding ? 'hidden' : 'normal', + }, { + onSuccess() { + const entity: GroupTag = { + ...tag, + visible: !tag.visible, + pinned: isHiding ? false : tag.pinned, // unpin if we're hiding + }; + dispatch(importEntities([entity], Entities.GROUP_TAGS)); + + toast.success( + entity.visible ? + intl.formatMessage(messages.visibleSuccess) : + intl.formatMessage(messages.hiddenSuccess), + ); + }, + }); + }; + + const togglePin = () => { + updateGroupTag({ + group_tag_type: tag.pinned ? 'normal' : 'pinned', + }, { + onSuccess() { + const entity = { + ...tag, + pinned: !tag.pinned, + }; + dispatch(importEntities([entity], Entities.GROUP_TAGS)); + + toast.success( + entity.pinned ? + intl.formatMessage(messages.pinSuccess) : + intl.formatMessage(messages.unpinSuccess), + ); + }, + }); + }; + + const renderPinIcon = () => { + if (!isOwner && tag.pinned) { + return ( + + ); + } + + if (!isOwner) { + return null; + } + + if (isPinnable) { + return ( + + + + ); + } + + if (!isPinnable && tag.pinned) { + return ( + + + + + ); + } + }; + + return ( + + + + + #{tag.name} + + + {intl.formatMessage(messages.total)}: + {' '} + + {shortNumberFormat(tag.uses)} + + + + + + + {tag.visible ? ( + renderPinIcon() + ) : null} + + {isOwner ? ( + + + + ) : null} + + + ); +}; + +export default GroupTagListItem; \ No newline at end of file diff --git a/app/soapbox/features/group/components/group-tags-field.tsx b/app/soapbox/features/group/components/group-tags-field.tsx new file mode 100644 index 000000000..f8092d5c0 --- /dev/null +++ b/app/soapbox/features/group/components/group-tags-field.tsx @@ -0,0 +1,59 @@ +import React, { useMemo } from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; + +import { Input, Streamfield } from 'soapbox/components/ui'; + +import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield'; + +const messages = defineMessages({ + hashtagPlaceholder: { id: 'manage_group.fields.hashtag_placeholder', defaultMessage: 'Add a topic' }, +}); + +interface IGroupTagsField { + tags: string[] + onChange(tags: string[]): void + onAddItem(): void + onRemoveItem(i: number): void + maxItems?: number +} + +const GroupTagsField: React.FC = ({ tags, onChange, onAddItem, onRemoveItem, maxItems = 3 }) => { + return ( + } + hint={} + component={HashtagField} + values={tags} + onChange={onChange} + onAddItem={onAddItem} + onRemoveItem={onRemoveItem} + maxItems={maxItems} + minItems={1} + /> + ); +}; + +const HashtagField: StreamfieldComponent = ({ value, onChange, autoFocus = false }) => { + const intl = useIntl(); + + const formattedValue = useMemo(() => { + return `#${value}`; + }, [value]); + + const handleChange: React.ChangeEventHandler = ({ target }) => { + onChange(target.value.replace('#', '')); + }; + + return ( + + ); +}; + +export default GroupTagsField; \ No newline at end of file diff --git a/app/soapbox/features/group/edit-group.tsx b/app/soapbox/features/group/edit-group.tsx index d385fb580..82f4841b4 100644 --- a/app/soapbox/features/group/edit-group.tsx +++ b/app/soapbox/features/group/edit-group.tsx @@ -1,120 +1,51 @@ -import clsx from 'clsx'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import Icon from 'soapbox/components/icon'; -import { Avatar, Button, Column, Form, FormActions, FormGroup, HStack, Input, Spinner, Text, Textarea } from 'soapbox/components/ui'; +import { useGroup, useGroupTags, useUpdateGroup } from 'soapbox/api/hooks'; +import { Button, Column, Form, FormActions, FormGroup, Icon, Input, Spinner, Textarea } from 'soapbox/components/ui'; import { useAppSelector, useInstance } from 'soapbox/hooks'; -import { useGroup, useUpdateGroup } from 'soapbox/hooks/api'; import { useImageField, useTextField } from 'soapbox/hooks/forms'; +import toast from 'soapbox/toast'; import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts'; +import AvatarPicker from './components/group-avatar-picker'; +import HeaderPicker from './components/group-header-picker'; +import GroupTagsField from './components/group-tags-field'; + import type { List as ImmutableList } from 'immutable'; const nonDefaultAvatar = (url: string | undefined) => url && isDefaultAvatar(url) ? undefined : url; const nonDefaultHeader = (url: string | undefined) => url && isDefaultHeader(url) ? undefined : url; -interface IMediaInput { - src: string | undefined - accept: string - onChange: React.ChangeEventHandler - disabled: boolean -} - const messages = defineMessages({ heading: { id: 'navigation_bar.edit_group', defaultMessage: 'Edit Group' }, groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' }, groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' }, -}); - -const HeaderPicker = React.forwardRef(({ src, onChange, accept, disabled }, ref) => { - return ( - - ); -}); - -const AvatarPicker = React.forwardRef(({ src, onChange, accept, disabled }, ref) => { - return ( - - ); + groupSaved: { id: 'group.update.success', defaultMessage: 'Group successfully saved' }, }); interface IEditGroup { params: { - id: string + groupId: string } } -const EditGroup: React.FC = ({ params: { id: groupId } }) => { +const EditGroup: React.FC = ({ params: { groupId } }) => { const intl = useIntl(); const instance = useInstance(); const { group, isLoading } = useGroup(groupId); const { updateGroup } = useUpdateGroup(groupId); + const { invalidate } = useGroupTags(groupId); const [isSubmitting, setIsSubmitting] = useState(false); + const [tags, setTags] = useState(['']); const avatar = useImageField({ maxPixels: 400 * 400, preview: nonDefaultAvatar(group?.avatar) }); const header = useImageField({ maxPixels: 1920 * 1080, preview: nonDefaultHeader(group?.header) }); const displayName = useTextField(group?.display_name); - const note = useTextField(group?.note); + const note = useTextField(group?.note_plain); const maxName = Number(instance.configuration.getIn(['groups', 'max_characters_name'])); const maxNote = Number(instance.configuration.getIn(['groups', 'max_characters_description'])); @@ -131,11 +62,40 @@ const EditGroup: React.FC = ({ params: { id: groupId } }) => { note: note.value, avatar: avatar.file, header: header.file, + tags, + }, { + onSuccess() { + invalidate(); + toast.success(intl.formatMessage(messages.groupSaved)); + }, + onError(error) { + const message = (error.response?.data as any)?.error; + + if (error.response?.status === 422 && typeof message !== 'undefined') { + toast.error(message); + } + }, }); setIsSubmitting(false); } + const handleAddTag = () => { + setTags([...tags, '']); + }; + + const handleRemoveTag = (i: number) => { + const newTags = [...tags]; + newTags.splice(i, 1); + setTags(newTags); + }; + + useEffect(() => { + if (group) { + setTags(group.tags.map((t) => t.name)); + } + }, [group?.id]); + if (isLoading) { return ; } @@ -171,6 +131,15 @@ const EditGroup: React.FC = ({ params: { id: groupId } }) => { /> +
+ +
+ + + +
+); + +export { LayoutButtons as default, GroupLayout }; \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/popular-groups.tsx b/app/soapbox/features/groups/components/discover/popular-groups.tsx index 8dcf607e7..83426f553 100644 --- a/app/soapbox/features/groups/components/discover/popular-groups.tsx +++ b/app/soapbox/features/groups/components/discover/popular-groups.tsx @@ -1,10 +1,10 @@ import React, { useState } from 'react'; import { FormattedMessage } from 'react-intl'; +import { usePopularGroups } from 'soapbox/api/hooks'; import Link from 'soapbox/components/link'; import { Carousel, HStack, Stack, Text } from 'soapbox/components/ui'; import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover'; -import { usePopularGroups } from 'soapbox/hooks/api/usePopularGroups'; import GroupGridItem from './group-grid-item'; @@ -15,7 +15,7 @@ const PopularGroups = () => { const [groupCover, setGroupCover] = useState(null); return ( - + { {isFetching ? ( new Array(4).fill(0).map((_, idx) => (
diff --git a/app/soapbox/features/groups/components/discover/popular-tags.tsx b/app/soapbox/features/groups/components/discover/popular-tags.tsx new file mode 100644 index 000000000..75ff36628 --- /dev/null +++ b/app/soapbox/features/groups/components/discover/popular-tags.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { usePopularTags } from 'soapbox/api/hooks'; +import Link from 'soapbox/components/link'; +import { HStack, Stack, Text } from 'soapbox/components/ui'; + +import TagListItem from './tag-list-item'; + +const PopularTags = () => { + const { tags, isFetched, isError } = usePopularTags(); + const isEmpty = (isFetched && tags.length === 0) || isError; + + return ( + + + + + + + + + + + + + + {isEmpty ? ( + + + + ) : ( + + {tags.slice(0, 10).map((tag) => ( + + ))} + + )} + + ); +}; + +export default PopularTags; \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/search/__tests__/blankslate.test.tsx b/app/soapbox/features/groups/components/discover/search/__tests__/blankslate.test.tsx new file mode 100644 index 000000000..6b1166477 --- /dev/null +++ b/app/soapbox/features/groups/components/discover/search/__tests__/blankslate.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { render, screen } from 'soapbox/jest/test-helpers'; + +import Blankslate from '../blankslate'; + + +describe('', () => { + describe('with string props', () => { + it('should render correctly', () => { + render(); + + expect(screen.getByTestId('no-results')).toHaveTextContent('Title'); + expect(screen.getByTestId('no-results')).toHaveTextContent('Subtitle'); + }); + }); + + describe('with node props', () => { + it('should render correctly', () => { + render( + Title} + subtitle={Subtitle} + />); + + expect(screen.getByTestId('no-results')).toHaveTextContent('Title'); + expect(screen.getByTestId('no-results')).toHaveTextContent('Subtitle'); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/search/__tests__/results.test.tsx b/app/soapbox/features/groups/components/discover/search/__tests__/results.test.tsx new file mode 100644 index 000000000..66523ec5d --- /dev/null +++ b/app/soapbox/features/groups/components/discover/search/__tests__/results.test.tsx @@ -0,0 +1,67 @@ +import userEvent from '@testing-library/user-event'; +import { Map as ImmutableMap } from 'immutable'; +import React from 'react'; +import { VirtuosoGridMockContext, VirtuosoMockContext } from 'react-virtuoso'; + +import { buildGroup } from 'soapbox/jest/factory'; +import { render, screen, waitFor } from 'soapbox/jest/test-helpers'; +import { normalizeAccount } from 'soapbox/normalizers'; + +import Results from '../results'; + +const userId = '1'; +const store = { + me: userId, + accounts: ImmutableMap({ + [userId]: normalizeAccount({ + id: userId, + acct: 'justin-username', + display_name: 'Justin L', + avatar: 'test.jpg', + chats_onboarded: false, + }), + }), +}; + +const renderApp = (children: React.ReactNode) => ( + render( + + + {children} + + , + undefined, + store, + ) +); + +const groupSearchResult = { + groups: [buildGroup()], + hasNextPage: false, + isFetching: false, + fetchNextPage: jest.fn(), +} as any; + +describe('', () => { + describe('with a list layout', () => { + it('should render the GroupListItem components', async () => { + renderApp(); + await waitFor(() => { + expect(screen.getByTestId('group-list-item')).toBeInTheDocument(); + }); + }); + }); + + describe('with a grid layout', () => { + it('should render the GroupGridItem components', async () => { + const user = userEvent.setup(); + renderApp(); + + await user.click(screen.getByTestId('layout-grid-action')); + + await waitFor(() => { + expect(screen.getByTestId('group-grid-item')).toBeInTheDocument(); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/search/recent-searches.tsx b/app/soapbox/features/groups/components/discover/search/recent-searches.tsx index 1fe5e1d2f..44f134b4c 100644 --- a/app/soapbox/features/groups/components/discover/search/recent-searches.tsx +++ b/app/soapbox/features/groups/components/discover/search/recent-searches.tsx @@ -62,7 +62,7 @@ export default (props: Props) => {
diff --git a/app/soapbox/features/groups/components/discover/search/results.tsx b/app/soapbox/features/groups/components/discover/search/results.tsx index 14e1e5a67..3ae6b2179 100644 --- a/app/soapbox/features/groups/components/discover/search/results.tsx +++ b/app/soapbox/features/groups/components/discover/search/results.tsx @@ -3,22 +3,19 @@ import React, { useCallback, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso'; -import { HStack, Icon, Stack, Text } from 'soapbox/components/ui'; -import { useGroupSearch } from 'soapbox/hooks/api'; -import { Group } from 'soapbox/types/entities'; +import { useGroupSearch } from 'soapbox/api/hooks'; +import { HStack, Stack, Text } from 'soapbox/components/ui'; import GroupGridItem from '../group-grid-item'; import GroupListItem from '../group-list-item'; +import LayoutButtons, { GroupLayout } from '../layout-buttons'; + +import type { Group } from 'soapbox/types/entities'; interface Props { groupSearchResult: ReturnType } -enum Layout { - LIST = 'LIST', - GRID = 'GRID' -} - const GridList: Components['List'] = React.forwardRef((props, ref) => { const { context, ...rest } = props; return
; @@ -27,7 +24,7 @@ const GridList: Components['List'] = React.forwardRef((props, ref) => { export default (props: Props) => { const { groupSearchResult } = props; - const [layout, setLayout] = useState(Layout.LIST); + const [layout, setLayout] = useState(GroupLayout.LIST); const { groups, hasNextPage, isFetching, fetchNextPage } = groupSearchResult; @@ -49,10 +46,8 @@ export default (props: Props) => {
), []); - const renderGroupGrid = useCallback((group: Group, index: number) => ( -
- -
+ const renderGroupGrid = useCallback((group: Group) => ( + ), []); return ( @@ -65,32 +60,13 @@ export default (props: Props) => { /> - - - - - + setLayout(selectedLayout)} + />
- {layout === Layout.LIST ? ( + {layout === GroupLayout.LIST ? ( { renderGroupGrid(group, index)} + itemContent={(_index, group) => renderGroupGrid(group)} components={{ Item: (props) => ( -
+
), List: GridList, }} diff --git a/app/soapbox/features/groups/components/discover/search/search.tsx b/app/soapbox/features/groups/components/discover/search/search.tsx index 4e3308353..0e2d7f00e 100644 --- a/app/soapbox/features/groups/components/discover/search/search.tsx +++ b/app/soapbox/features/groups/components/discover/search/search.tsx @@ -1,10 +1,10 @@ import React, { useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; +import { useGroupSearch } from 'soapbox/api/hooks'; import { Stack } from 'soapbox/components/ui'; import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search'; import { useDebounce, useOwnAccount } from 'soapbox/hooks'; -import { useGroupSearch } from 'soapbox/hooks/api'; import { saveGroupSearch } from 'soapbox/utils/groups'; import Blankslate from './blankslate'; @@ -26,7 +26,7 @@ export default (props: Props) => { const debouncedValueToSave = debounce(searchValue as string, 1000); const groupSearchResult = useGroupSearch(debouncedValue); - const { groups, isFetching, isFetched, isError } = groupSearchResult; + const { groups, isLoading, isFetched, isError } = groupSearchResult; const hasSearchResults = isFetched && groups.length > 0; const hasNoSearchResults = isFetched && groups.length === 0; @@ -37,7 +37,7 @@ export default (props: Props) => { } }, [debouncedValueToSave]); - if (isFetching) { + if (isLoading) { return ( diff --git a/app/soapbox/features/groups/components/discover/suggested-groups.tsx b/app/soapbox/features/groups/components/discover/suggested-groups.tsx index 1e6c2514e..5d73cc3f0 100644 --- a/app/soapbox/features/groups/components/discover/suggested-groups.tsx +++ b/app/soapbox/features/groups/components/discover/suggested-groups.tsx @@ -1,10 +1,10 @@ import React, { useState } from 'react'; import { FormattedMessage } from 'react-intl'; +import { useSuggestedGroups } from 'soapbox/api/hooks'; import Link from 'soapbox/components/link'; import { Carousel, HStack, Stack, Text } from 'soapbox/components/ui'; import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover'; -import { useSuggestedGroups } from 'soapbox/hooks/api/useSuggestedGroups'; import GroupGridItem from './group-grid-item'; @@ -15,7 +15,7 @@ const SuggestedGroups = () => { const [groupCover, setGroupCover] = useState(null); return ( - + { + const { tag } = props; + + return ( + + + + #{tag.name} + + + + + :{' '} + {tag.groups} + + + + ); +}; + +export default TagListItem; \ No newline at end of file diff --git a/app/soapbox/features/groups/components/group-link-preview.tsx b/app/soapbox/features/groups/components/group-link-preview.tsx new file mode 100644 index 000000000..98ca03076 --- /dev/null +++ b/app/soapbox/features/groups/components/group-link-preview.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useHistory } from 'react-router-dom'; + +import { Avatar, Button, CardTitle, Stack } from 'soapbox/components/ui'; +import { type Card as StatusCard } from 'soapbox/types/entities'; + +interface IGroupLinkPreview { + card: StatusCard +} + +const GroupLinkPreview: React.FC = ({ card }) => { + const history = useHistory(); + + const { group } = card; + if (!group) return null; + + const navigateToGroup = () => history.push(`/group/${group.slug}`); + + return ( + +
+ + + + + } /> + + + + + ); +}; + +export { GroupLinkPreview }; \ No newline at end of file diff --git a/app/soapbox/features/groups/discover.tsx b/app/soapbox/features/groups/discover.tsx index 4e0c0c70a..47273d2ed 100644 --- a/app/soapbox/features/groups/discover.tsx +++ b/app/soapbox/features/groups/discover.tsx @@ -4,6 +4,7 @@ import { defineMessages, useIntl } from 'react-intl'; import { HStack, Icon, IconButton, Input, Stack } from 'soapbox/components/ui'; import PopularGroups from './components/discover/popular-groups'; +import PopularTags from './components/discover/popular-tags'; import Search from './components/discover/search/search'; import SuggestedGroups from './components/discover/suggested-groups'; import TabBar, { TabItems } from './components/tab-bar'; @@ -38,6 +39,7 @@ const Discover: React.FC = () => { src={require('@tabler/icons/arrow-left.svg')} iconClassName='mr-2 h-5 w-5 fill-current text-gray-600' onClick={cancelSearch} + data-testid='group-search-icon' /> ) : null} @@ -71,6 +73,7 @@ const Discover: React.FC = () => { <> + )} diff --git a/app/soapbox/features/groups/index.tsx b/app/soapbox/features/groups/index.tsx index 7c392d28d..7b1c51c55 100644 --- a/app/soapbox/features/groups/index.tsx +++ b/app/soapbox/features/groups/index.tsx @@ -3,11 +3,11 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; import { openModal } from 'soapbox/actions/modals'; +import { useGroups } from 'soapbox/api/hooks'; import GroupCard from 'soapbox/components/group-card'; import ScrollableList from 'soapbox/components/scrollable-list'; import { Button, Input, Stack, Text } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector, useDebounce, useFeatures } from 'soapbox/hooks'; -import { useGroups } from 'soapbox/hooks/api'; import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions'; import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card'; @@ -30,12 +30,16 @@ const Groups: React.FC = () => { const [searchValue, setSearchValue] = useState(''); const debouncedValue = debounce(searchValue, 300); - const { groups, isLoading } = useGroups(debouncedValue); + const { groups, isLoading, hasNextPage, fetchNextPage } = useGroups(debouncedValue); - const createGroup = () => { - dispatch(openModal('MANAGE_GROUP')); + const handleLoadMore = () => { + if (hasNextPage) { + fetchNextPage(); + } }; + const createGroup = () => dispatch(openModal('CREATE_GROUP')); + const renderBlankslate = () => ( @@ -104,9 +108,11 @@ const Groups: React.FC = () => { showLoading={isLoading && groups.length === 0} placeholderComponent={PlaceholderGroupCard} placeholderCount={3} + onLoadMore={handleLoadMore} + hasMore={hasNextPage} > {groups.map((group) => ( - + ))} diff --git a/app/soapbox/features/groups/pending-requests.tsx b/app/soapbox/features/groups/pending-requests.tsx index cc9ceed1d..1233ff3a7 100644 --- a/app/soapbox/features/groups/pending-requests.tsx +++ b/app/soapbox/features/groups/pending-requests.tsx @@ -57,7 +57,7 @@ export default () => { showLoading={isLoading && groups.length === 0} > {groups.map((group) => ( - + ))} diff --git a/app/soapbox/features/groups/popular.tsx b/app/soapbox/features/groups/popular.tsx index 61d030173..2f417dd8f 100644 --- a/app/soapbox/features/groups/popular.tsx +++ b/app/soapbox/features/groups/popular.tsx @@ -3,11 +3,12 @@ import React, { useCallback, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso'; -import { Column, HStack, Icon } from 'soapbox/components/ui'; -import { usePopularGroups } from 'soapbox/hooks/api/usePopularGroups'; +import { usePopularGroups } from 'soapbox/api/hooks'; +import { Column } from 'soapbox/components/ui'; import GroupGridItem from './components/discover/group-grid-item'; import GroupListItem from './components/discover/group-list-item'; +import LayoutButtons, { GroupLayout } from './components/discover/layout-buttons'; import type { Group } from 'soapbox/schemas'; @@ -15,21 +16,15 @@ const messages = defineMessages({ label: { id: 'groups.popular.label', defaultMessage: 'Popular Groups' }, }); -enum Layout { - LIST = 'LIST', - GRID = 'GRID' -} - const GridList: Components['List'] = React.forwardRef((props, ref) => { const { context, ...rest } = props; return
; }); - const Popular: React.FC = () => { const intl = useIntl(); - const [layout, setLayout] = useState(Layout.LIST); + const [layout, setLayout] = useState(GroupLayout.LIST); const { groups, hasNextPage, fetchNextPage } = usePopularGroups(); @@ -51,42 +46,21 @@ const Popular: React.FC = () => {
), []); - const renderGroupGrid = useCallback((group: Group, index: number) => ( -
- -
+ const renderGroupGrid = useCallback((group: Group) => ( + ), []); return ( - - - - + setLayout(selectedLayout)} + /> } > - {layout === Layout.LIST ? ( + {layout === GroupLayout.LIST ? ( { renderGroupGrid(group, index)} + itemContent={(_index, group) => renderGroupGrid(group)} components={{ Item: (props) => ( -
+
), List: GridList, }} diff --git a/app/soapbox/features/groups/suggested.tsx b/app/soapbox/features/groups/suggested.tsx index 8c17fc0af..89833a9a8 100644 --- a/app/soapbox/features/groups/suggested.tsx +++ b/app/soapbox/features/groups/suggested.tsx @@ -3,11 +3,12 @@ import React, { useCallback, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso'; -import { Column, HStack, Icon } from 'soapbox/components/ui'; -import { useSuggestedGroups } from 'soapbox/hooks/api/useSuggestedGroups'; +import { useSuggestedGroups } from 'soapbox/api/hooks'; +import { Column } from 'soapbox/components/ui'; import GroupGridItem from './components/discover/group-grid-item'; import GroupListItem from './components/discover/group-list-item'; +import LayoutButtons, { GroupLayout } from './components/discover/layout-buttons'; import type { Group } from 'soapbox/schemas'; @@ -15,21 +16,15 @@ const messages = defineMessages({ label: { id: 'groups.popular.label', defaultMessage: 'Suggested Groups' }, }); -enum Layout { - LIST = 'LIST', - GRID = 'GRID' -} - const GridList: Components['List'] = React.forwardRef((props, ref) => { const { context, ...rest } = props; return
; }); - const Suggested: React.FC = () => { const intl = useIntl(); - const [layout, setLayout] = useState(Layout.LIST); + const [layout, setLayout] = useState(GroupLayout.LIST); const { groups, hasNextPage, fetchNextPage } = useSuggestedGroups(); @@ -51,42 +46,21 @@ const Suggested: React.FC = () => {
), []); - const renderGroupGrid = useCallback((group: Group, index: number) => ( -
- -
+ const renderGroupGrid = useCallback((group: Group) => ( + ), []); return ( - - - - + setLayout(selectedLayout)} + /> } > - {layout === Layout.LIST ? ( + {layout === GroupLayout.LIST ? ( { renderGroupGrid(group, index)} + itemContent={(_index, group) => renderGroupGrid(group)} components={{ Item: (props) => ( -
+
), List: GridList, }} diff --git a/app/soapbox/features/groups/tag.tsx b/app/soapbox/features/groups/tag.tsx new file mode 100644 index 000000000..ccc54bbb3 --- /dev/null +++ b/app/soapbox/features/groups/tag.tsx @@ -0,0 +1,115 @@ +import clsx from 'clsx'; +import React, { useCallback, useState } from 'react'; +import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso'; + +import { useGroupTag, useGroupsFromTag } from 'soapbox/api/hooks'; +import { Column, HStack, Icon } from 'soapbox/components/ui'; + +import GroupGridItem from './components/discover/group-grid-item'; +import GroupListItem from './components/discover/group-list-item'; + +import type { Group } from 'soapbox/schemas'; + +enum Layout { + LIST = 'LIST', + GRID = 'GRID' +} + +const GridList: Components['List'] = React.forwardRef((props, ref) => { + const { context, ...rest } = props; + return
; +}); + +interface ITag { + params: { id: string } +} + +const Tag: React.FC = (props) => { + const tagId = props.params.id; + + const [layout, setLayout] = useState(Layout.LIST); + + const { tag, isLoading } = useGroupTag(tagId); + const { groups, hasNextPage, fetchNextPage } = useGroupsFromTag(tagId); + + const handleLoadMore = () => { + if (hasNextPage) { + fetchNextPage(); + } + }; + + const renderGroupList = useCallback((group: Group, index: number) => ( +
+ +
+ ), []); + + const renderGroupGrid = useCallback((group: Group) => ( + + ), []); + + if (isLoading || !tag) { + return null; + } + + return ( + + + + + + } + > + {layout === Layout.LIST ? ( + renderGroupList(group, index)} + endReached={handleLoadMore} + /> + ) : ( + renderGroupGrid(group)} + components={{ + Item: (props) => ( +
+ ), + List: GridList, + }} + endReached={handleLoadMore} + /> + )} + + ); +}; + +export default Tag; diff --git a/app/soapbox/features/groups/tags.tsx b/app/soapbox/features/groups/tags.tsx new file mode 100644 index 000000000..aa37a514b --- /dev/null +++ b/app/soapbox/features/groups/tags.tsx @@ -0,0 +1,62 @@ +import clsx from 'clsx'; +import React from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import { Virtuoso } from 'react-virtuoso'; + +import { usePopularTags } from 'soapbox/api/hooks'; +import { Column, Text } from 'soapbox/components/ui'; + +import TagListItem from './components/discover/tag-list-item'; + +import type { GroupTag } from 'soapbox/schemas'; + +const messages = defineMessages({ + title: { id: 'groups.tags.title', defaultMessage: 'Browse Topics' }, +}); + +const Tags: React.FC = () => { + const intl = useIntl(); + + const { tags, isFetched, isError, hasNextPage, fetchNextPage } = usePopularTags(); + const isEmpty = (isFetched && tags.length === 0) || isError; + + const handleLoadMore = () => { + if (hasNextPage) { + fetchNextPage(); + } + }; + + const renderItem = (index: number, tag: GroupTag) => ( +
+ +
+ ); + + return ( + + {isEmpty ? ( + + + + ) : ( + + )} + + ); +}; + +export default Tags; diff --git a/app/soapbox/features/hashtag-timeline/index.tsx b/app/soapbox/features/hashtag-timeline/index.tsx index 2133e3e3a..e448bef8a 100644 --- a/app/soapbox/features/hashtag-timeline/index.tsx +++ b/app/soapbox/features/hashtag-timeline/index.tsx @@ -1,11 +1,13 @@ import React, { useEffect, useRef } from 'react'; -import { useIntl, defineMessages } from 'react-intl'; +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import { connectHashtagStream } from 'soapbox/actions/streaming'; +import { fetchHashtag, followHashtag, unfollowHashtag } from 'soapbox/actions/tags'; import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines'; -import { Column } from 'soapbox/components/ui'; +import List, { ListItem } from 'soapbox/components/list'; +import { Column, Toggle } from 'soapbox/components/ui'; import Timeline from 'soapbox/features/ui/components/timeline'; -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import type { Tag as TagEntity } from 'soapbox/types/entities'; @@ -32,9 +34,11 @@ export const HashtagTimeline: React.FC = ({ params }) => { const intl = useIntl(); const id = params?.id || ''; const tags = params?.tags || { any: [], all: [], none: [] }; - + + const features = useFeatures(); const dispatch = useAppDispatch(); const disconnects = useRef<(() => void)[]>([]); + const tag = useAppSelector((state) => state.tags.get(id)); // Mastodon supports displaying results from multiple hashtags. // https://github.com/mastodon/mastodon/issues/6359 @@ -88,9 +92,18 @@ export const HashtagTimeline: React.FC = ({ params }) => { dispatch(expandHashtagTimeline(id, { maxId, tags })); }; + const handleFollow = () => { + if (tag?.following) { + dispatch(unfollowHashtag(id)); + } else { + dispatch(followHashtag(id)); + } + }; + useEffect(() => { subscribe(); dispatch(expandHashtagTimeline(id, { tags })); + dispatch(fetchHashtag(id)); return () => { unsubscribe(); @@ -105,7 +118,19 @@ export const HashtagTimeline: React.FC = ({ params }) => { }, [id]); return ( - + + {features.followHashtags && ( + + } + > + + + + )} = ({ params }) => { ); }; -export default HashtagTimeline; \ No newline at end of file +export default HashtagTimeline; diff --git a/app/soapbox/features/home-timeline/index.tsx b/app/soapbox/features/home-timeline/index.tsx index aaaec2bb3..611fcf7ce 100644 --- a/app/soapbox/features/home-timeline/index.tsx +++ b/app/soapbox/features/home-timeline/index.tsx @@ -27,9 +27,10 @@ const HomeTimeline: React.FC = () => { const isPartial = useAppSelector(state => state.timelines.get('home')?.isPartial === true); const currentAccountId = useAppSelector(state => state.timelines.get('home')?.feedAccountId as string | undefined); const currentAccountRelationship = useAppSelector(state => currentAccountId ? state.relationships.get(currentAccountId) : null); + const next = useAppSelector(state => state.timelines.get('home')?.next); const handleLoadMore = (maxId: string) => { - dispatch(expandHomeTimeline({ maxId, accountId: currentAccountId })); + dispatch(expandHomeTimeline({ url: next, maxId, accountId: currentAccountId })); }; // Mastodon generates the feed in Redis, and can return a partial timeline @@ -52,7 +53,7 @@ const HomeTimeline: React.FC = () => { }; const handleRefresh = () => { - return dispatch(expandHomeTimeline({ maxId: null, accountId: currentAccountId })); + return dispatch(expandHomeTimeline({ accountId: currentAccountId })); }; useEffect(() => { diff --git a/app/soapbox/features/notifications/components/notification.tsx b/app/soapbox/features/notifications/components/notification.tsx index 6812cdeb8..7cf41bb4e 100644 --- a/app/soapbox/features/notifications/components/notification.tsx +++ b/app/soapbox/features/notifications/components/notification.tsx @@ -43,7 +43,9 @@ const icons: Record = { follow_request: require('@tabler/icons/user-plus.svg'), mention: require('@tabler/icons/at.svg'), favourite: require('@tabler/icons/heart.svg'), + group_favourite: require('@tabler/icons/heart.svg'), reblog: require('@tabler/icons/repeat.svg'), + group_reblog: require('@tabler/icons/repeat.svg'), status: require('@tabler/icons/bell-ringing.svg'), poll: require('@tabler/icons/chart-bar.svg'), move: require('@tabler/icons/briefcase.svg'), @@ -78,10 +80,18 @@ const messages: Record = defineMessages({ id: 'notification.favourite', defaultMessage: '{name} liked your post', }, + group_favourite: { + id: 'notification.group_favourite', + defaultMessage: '{name} liked your group post', + }, reblog: { id: 'notification.reblog', defaultMessage: '{name} reposted your post', }, + group_reblog: { + id: 'notification.group_reblog', + defaultMessage: '{name} reposted your group post', + }, status: { id: 'notification.status', defaultMessage: '{name} just posted', @@ -314,8 +324,10 @@ const Notification: React.FC = (props) => { /> ) : null; case 'favourite': + case 'group_favourite': case 'mention': case 'reblog': + case 'group_reblog': case 'status': case 'poll': case 'update': @@ -331,6 +343,7 @@ const Notification: React.FC = (props) => { onMoveUp={handleMoveUp} avatarSize={avatarSize} contextType='notifications' + showGroup={false} /> ) : null; default: diff --git a/app/soapbox/features/placeholder/components/placeholder-account.tsx b/app/soapbox/features/placeholder/components/placeholder-account.tsx index 28f04adb4..c887903fd 100644 --- a/app/soapbox/features/placeholder/components/placeholder-account.tsx +++ b/app/soapbox/features/placeholder/components/placeholder-account.tsx @@ -18,4 +18,4 @@ const PlaceholderAccount: React.FC = () => ( ); -export default PlaceholderAccount; +export default React.memo(PlaceholderAccount); diff --git a/app/soapbox/features/placeholder/components/placeholder-avatar.tsx b/app/soapbox/features/placeholder/components/placeholder-avatar.tsx index 6eb479f13..058904001 100644 --- a/app/soapbox/features/placeholder/components/placeholder-avatar.tsx +++ b/app/soapbox/features/placeholder/components/placeholder-avatar.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import React from 'react'; import { Stack } from 'soapbox/components/ui'; @@ -5,10 +6,11 @@ import { Stack } from 'soapbox/components/ui'; interface IPlaceholderAvatar { size: number withText?: boolean + className?: string } /** Fake avatar to display while data is loading. */ -const PlaceholderAvatar: React.FC = ({ size, withText = false }) => { +const PlaceholderAvatar: React.FC = ({ size, withText = false, className }) => { const style = React.useMemo(() => { if (!size) { return {}; @@ -21,7 +23,10 @@ const PlaceholderAvatar: React.FC = ({ size, withText = fals }, [size]); return ( - +
= ({ minLength, ); }; -export default PlaceholderDisplayName; +export default React.memo(PlaceholderDisplayName); diff --git a/app/soapbox/features/placeholder/components/placeholder-status.tsx b/app/soapbox/features/placeholder/components/placeholder-status.tsx index 37da3fa45..739c96c5c 100644 --- a/app/soapbox/features/placeholder/components/placeholder-status.tsx +++ b/app/soapbox/features/placeholder/components/placeholder-status.tsx @@ -8,15 +8,16 @@ import PlaceholderDisplayName from './placeholder-display-name'; import PlaceholderStatusContent from './placeholder-status-content'; interface IPlaceholderStatus { - thread?: boolean + variant?: 'rounded' | 'slim' | 'default' } /** Fake status to display while data is loading. */ -const PlaceholderStatus: React.FC = ({ thread = false }) => ( +const PlaceholderStatus: React.FC = ({ variant }) => (
diff --git a/app/soapbox/features/public-layout/components/header.tsx b/app/soapbox/features/public-layout/components/header.tsx index 64e273e26..11853871a 100644 --- a/app/soapbox/features/public-layout/components/header.tsx +++ b/app/soapbox/features/public-layout/components/header.tsx @@ -7,7 +7,7 @@ import { fetchInstance } from 'soapbox/actions/instance'; import { openModal } from 'soapbox/actions/modals'; import SiteLogo from 'soapbox/components/site-logo'; import { Button, Form, HStack, IconButton, Input, Tooltip } from 'soapbox/components/ui'; -import { useSoapboxConfig, useOwnAccount, useAppDispatch, useRegistrationStatus } from 'soapbox/hooks'; +import { useSoapboxConfig, useOwnAccount, useAppDispatch, useRegistrationStatus, useFeatures } from 'soapbox/hooks'; import Sonar from './sonar'; @@ -18,7 +18,8 @@ const messages = defineMessages({ home: { id: 'header.home.label', defaultMessage: 'Home' }, login: { id: 'header.login.label', defaultMessage: 'Log in' }, register: { id: 'header.register.label', defaultMessage: 'Register' }, - username: { id: 'header.login.username.placeholder', defaultMessage: 'Email or username' }, + username: { id: 'header.login.username.placeholder', defaultMessage: 'E-mail or username' }, + email: { id: 'header.login.email.placeholder', defaultMessage: 'E-mail address' }, password: { id: 'header.login.password.label', defaultMessage: 'Password' }, forgotPassword: { id: 'header.login.forgot_password', defaultMessage: 'Forgot password?' }, }); @@ -26,6 +27,7 @@ const messages = defineMessages({ const Header = () => { const dispatch = useAppDispatch(); const intl = useIntl(); + const features = useFeatures(); const account = useOwnAccount(); const soapboxConfig = useSoapboxConfig(); @@ -123,7 +125,7 @@ const Header = () => { value={username} onChange={(event) => setUsername(event.target.value.trim())} type='text' - placeholder={intl.formatMessage(messages.username)} + placeholder={intl.formatMessage(features.logInWithUsername ? messages.username : messages.email)} className='max-w-[200px]' autoCorrect='off' autoCapitalize='off' @@ -147,7 +149,6 @@ const Header = () => { src={require('@tabler/icons/help.svg')} className='cursor-pointer bg-transparent text-gray-700 hover:text-gray-800 dark:text-gray-600 dark:hover:text-gray-500' iconClassName='h-5 w-5' - transparent /> diff --git a/app/soapbox/features/public-timeline/index.tsx b/app/soapbox/features/public-timeline/index.tsx index b62c5495c..8f96e432d 100644 --- a/app/soapbox/features/public-timeline/index.tsx +++ b/app/soapbox/features/public-timeline/index.tsx @@ -30,10 +30,6 @@ const CommunityTimeline = () => { const explanationBoxExpanded = settings.get('explanationBox'); const showExplanationBox = settings.get('showExplanationBox'); - const explanationBoxMenu = () => { - return [{ text: intl.formatMessage(messages.dismiss), action: dismissExplanationBox }]; - }; - const dismissExplanationBox = () => { dispatch(changeSetting(['showExplanationBox'], false)); }; @@ -66,7 +62,9 @@ const CommunityTimeline = () => { {showExplanationBox &&
} - menu={explanationBoxMenu()} + action={dismissExplanationBox} + actionIcon={require('@tabler/icons/x.svg')} + actionLabel={intl.formatMessage(messages.dismiss)} expanded={explanationBoxExpanded} onToggle={toggleExplanationBox} > diff --git a/app/soapbox/features/settings/index.tsx b/app/soapbox/features/settings/index.tsx index 77bb22ef8..6ebc8eacf 100644 --- a/app/soapbox/features/settings/index.tsx +++ b/app/soapbox/features/settings/index.tsx @@ -74,7 +74,7 @@ const Settings = () => { - {displayName} + {displayName} diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index 4fc0f0eaf..15f2e86e8 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -1,21 +1,22 @@ import React, { useEffect, useRef, useState } from 'react'; import { FormattedDate, FormattedMessage, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; import Account from 'soapbox/components/account'; -import Icon from 'soapbox/components/icon'; import StatusContent from 'soapbox/components/status-content'; import StatusMedia from 'soapbox/components/status-media'; import StatusReplyMentions from 'soapbox/components/status-reply-mentions'; import SensitiveContentOverlay from 'soapbox/components/statuses/sensitive-content-overlay'; +import StatusInfo from 'soapbox/components/statuses/status-info'; import TranslateButton from 'soapbox/components/translate-button'; -import { HStack, Stack, Text } from 'soapbox/components/ui'; +import { HStack, Icon, Stack, Text } from 'soapbox/components/ui'; import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container'; import { getActualStatus } from 'soapbox/utils/status'; import StatusInteractionBar from './status-interaction-bar'; import type { List as ImmutableList } from 'immutable'; -import type { Attachment as AttachmentEntity, Status as StatusEntity } from 'soapbox/types/entities'; +import type { Attachment as AttachmentEntity, Group, Status as StatusEntity } from 'soapbox/types/entities'; interface IDetailedStatus { status: StatusEntity @@ -50,6 +51,41 @@ const DetailedStatus: React.FC = ({ onOpenCompareHistoryModal(status); }; + const renderStatusInfo = () => { + if (status.group) { + return ( +
+ + } + text={ + + + + + + + + ), + }} + /> + } + /> +
+ ); + } + }; + const actualStatus = getActualStatus(status); if (!actualStatus) return null; const { account } = actualStatus; @@ -75,19 +111,20 @@ const DetailedStatus: React.FC = ({ } if (actualStatus.visibility === 'direct') { - statusTypeIcon = ; + statusTypeIcon = ; } else if (actualStatus.visibility === 'private') { - statusTypeIcon = ; + statusTypeIcon = ; } return (
+ {renderStatusInfo()} +
= ({ count, onClick, chi } > - + {shortNumberFormat(count)} diff --git a/app/soapbox/features/status/components/thread-status.tsx b/app/soapbox/features/status/components/thread-status.tsx index 73c42e7e3..442bae0e3 100644 --- a/app/soapbox/features/status/components/thread-status.tsx +++ b/app/soapbox/features/status/components/thread-status.tsx @@ -31,9 +31,8 @@ const ThreadStatus: React.FC = (props): JSX.Element => { return ( ); diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index b51333883..6b41148c4 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -50,7 +50,7 @@ import type { } from 'soapbox/types/entities'; const messages = defineMessages({ - title: { id: 'status.title', defaultMessage: '@{username}\'s Post' }, + title: { id: 'status.title', defaultMessage: 'Post Details' }, titleDirect: { id: 'status.title_direct', defaultMessage: 'Direct message' }, deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' }, @@ -118,6 +118,7 @@ type DisplayMedia = 'default' | 'hide_all' | 'show_all'; type RouteParams = { statusId: string groupId?: string + groupSlug?: string }; interface IThread { @@ -403,7 +404,7 @@ const Thread: React.FC = (props) => { useEffect(() => { scroller.current?.scrollToIndex({ index: ancestorsIds.size, - offset: -80, + offset: -146, }); setImmediate(() => statusRef.current?.querySelector('.detailed-actualStatus')?.focus()); @@ -442,7 +443,9 @@ const Thread: React.FC = (props) => { ); } else if (!status) { return ( - + + + ); } @@ -462,9 +465,6 @@ const Thread: React.FC = (props) => { react: handleHotkeyReact, }; - const username = String(status.getIn(['account', 'acct'])); - const titleMessage = status.visibility === 'direct' ? messages.titleDirect : messages.title; - const focusedStatus = (
@@ -488,7 +488,7 @@ const Thread: React.FC = (props) => { {!isUnderReview ? ( <> -
+
= (props) => {
{hasDescendants && ( -
+
)}
); @@ -519,21 +519,28 @@ const Thread: React.FC = (props) => { children.push(...renderChildren(descendantsIds).toArray()); } - if (status.group && typeof status.group === 'object' && !props.params.groupId) { - return ; + if (status.group && typeof status.group === 'object') { + if (status.group.slug && !props.params.groupSlug) { + return ; + } } + const titleMessage = () => { + if (status.visibility === 'direct') return messages.titleDirect; + return messages.title; + }; + return ( - + - +
} + placeholderComponent={() => } initialTopMostItemIndex={ancestorsIds.size} > {children} diff --git a/app/soapbox/features/test-timeline/index.tsx b/app/soapbox/features/test-timeline/index.tsx index 51ab1491e..136d0d324 100644 --- a/app/soapbox/features/test-timeline/index.tsx +++ b/app/soapbox/features/test-timeline/index.tsx @@ -35,7 +35,7 @@ const TestTimeline: React.FC = () => { React.useEffect(() => { dispatch(importFetchedStatuses(MOCK_STATUSES)); - dispatch(expandTimelineSuccess(timelineId, MOCK_STATUSES, null, false, false, false)); + dispatch(expandTimelineSuccess(timelineId, MOCK_STATUSES, undefined, undefined, false, false, false)); }, []); return ( diff --git a/app/soapbox/features/ui/components/__tests__/compose-button.test.tsx b/app/soapbox/features/ui/components/__tests__/compose-button.test.tsx index c98b7aa50..11d9bdea1 100644 --- a/app/soapbox/features/ui/components/__tests__/compose-button.test.tsx +++ b/app/soapbox/features/ui/components/__tests__/compose-button.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; import '@testing-library/jest-dom'; +import { MemoryRouter } from 'react-router-dom'; import { MODAL_OPEN } from 'soapbox/actions/modals'; import { mockStore, rootState } from 'soapbox/jest/test-helpers'; @@ -14,7 +15,9 @@ const renderComposeButton = () => { render( - + + + , ); diff --git a/app/soapbox/features/ui/components/__tests__/subscribe-button.test.tsx b/app/soapbox/features/ui/components/__tests__/subscribe-button.test.tsx index d0ec92f96..5edc9636b 100644 --- a/app/soapbox/features/ui/components/__tests__/subscribe-button.test.tsx +++ b/app/soapbox/features/ui/components/__tests__/subscribe-button.test.tsx @@ -1,8 +1,9 @@ -// import { Map as ImmutableMap } from 'immutable'; import React from 'react'; -import { render, screen } from '../../../../jest/test-helpers'; -import { normalizeAccount, normalizeRelationship } from '../../../../normalizers'; +import { buildRelationship } from 'soapbox/jest/factory'; +import { render, screen } from 'soapbox/jest/test-helpers'; +import { normalizeAccount } from 'soapbox/normalizers'; + import SubscribeButton from '../subscription-button'; import type { ReducerAccount } from 'soapbox/reducers/accounts'; @@ -19,162 +20,10 @@ describe('', () => { describe('with "accountNotifies" disabled', () => { it('renders nothing', () => { - const account = normalizeAccount({ ...justin, relationship: normalizeRelationship({ following: true }) }) as ReducerAccount; + const account = normalizeAccount({ ...justin, relationship: buildRelationship({ following: true }) }) as ReducerAccount; render(, undefined, store); expect(screen.queryAllByTestId('icon-button')).toHaveLength(0); }); }); - - // describe('with "accountNotifies" enabled', () => { - // beforeEach(() => { - // store = { - // ...store, - // instance: normalizeInstance({ - // version: '3.4.1 (compatible; TruthSocial 1.0.0)', - // software: 'TRUTHSOCIAL', - // pleroma: ImmutableMap({}), - // }), - // }; - // }); - - // describe('when the relationship is requested', () => { - // beforeEach(() => { - // account = normalizeAccount({ ...account, relationship: normalizeRelationship({ requested: true }) }); - - // store = { - // ...store, - // accounts: ImmutableMap({ - // '1': account, - // }), - // }; - // }); - - // it('renders the button', () => { - // render(, null, store); - // expect(screen.getByTestId('icon-button')).toBeInTheDocument(); - // }); - - // describe('when the user "isSubscribed"', () => { - // beforeEach(() => { - // account = normalizeAccount({ - // ...account, - // relationship: normalizeRelationship({ requested: true, notifying: true }), - // }); - - // store = { - // ...store, - // accounts: ImmutableMap({ - // '1': account, - // }), - // }; - // }); - - // it('renders the unsubscribe button', () => { - // render(, null, store); - // expect(screen.getByTestId('icon-button').title).toEqual(`Unsubscribe to notifications from @${account.acct}`); - // }); - // }); - - // describe('when the user is not "isSubscribed"', () => { - // beforeEach(() => { - // account = normalizeAccount({ - // ...account, - // relationship: normalizeRelationship({ requested: true, notifying: false }), - // }); - - // store = { - // ...store, - // accounts: ImmutableMap({ - // '1': account, - // }), - // }; - // }); - - // it('renders the unsubscribe button', () => { - // render(, null, store); - // expect(screen.getByTestId('icon-button').title).toEqual(`Subscribe to notifications from @${account.acct}`); - // }); - // }); - // }); - - // describe('when the user is not following the account', () => { - // beforeEach(() => { - // account = normalizeAccount({ ...account, relationship: normalizeRelationship({ following: false }) }); - - // store = { - // ...store, - // accounts: ImmutableMap({ - // '1': account, - // }), - // }; - // }); - - // it('renders nothing', () => { - // render(, null, store); - // expect(screen.queryAllByTestId('icon-button')).toHaveLength(0); - // }); - // }); - - // describe('when the user is following the account', () => { - // beforeEach(() => { - // account = normalizeAccount({ ...account, relationship: normalizeRelationship({ following: true }) }); - - // store = { - // ...store, - // accounts: ImmutableMap({ - // '1': account, - // }), - // }; - // }); - - // it('renders the button', () => { - // render(, null, store); - // expect(screen.getByTestId('icon-button')).toBeInTheDocument(); - // }); - - // describe('when the user "isSubscribed"', () => { - // beforeEach(() => { - // account = normalizeAccount({ - // ...account, - // relationship: normalizeRelationship({ requested: true, notifying: true }), - // }); - - // store = { - // ...store, - // accounts: ImmutableMap({ - // '1': account, - // }), - // }; - // }); - - // it('renders the unsubscribe button', () => { - // render(, null, store); - // expect(screen.getByTestId('icon-button').title).toEqual(`Unsubscribe to notifications from @${account.acct}`); - // }); - // }); - - // describe('when the user is not "isSubscribed"', () => { - // beforeEach(() => { - // account = normalizeAccount({ - // ...account, - // relationship: normalizeRelationship({ requested: true, notifying: false }), - // }); - - // store = { - // ...store, - // accounts: ImmutableMap({ - // '1': account, - // }), - // }; - // }); - - // it('renders the unsubscribe button', () => { - // render(, null, store); - // expect(screen.getByTestId('icon-button').title).toEqual(`Subscribe to notifications from @${account.acct}`); - // }); - // }); - // }); - // }); - }); diff --git a/app/soapbox/features/ui/components/compose-button.tsx b/app/soapbox/features/ui/components/compose-button.tsx index 155a7194e..7686f9c55 100644 --- a/app/soapbox/features/ui/components/compose-button.tsx +++ b/app/soapbox/features/ui/components/compose-button.tsx @@ -1,18 +1,34 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; +import { useLocation, useRouteMatch } from 'react-router-dom'; +import { groupComposeModal } from 'soapbox/actions/compose'; import { openModal } from 'soapbox/actions/modals'; -import { Button } from 'soapbox/components/ui'; +import { useGroupLookup } from 'soapbox/api/hooks'; +import { Avatar, Button, HStack } from 'soapbox/components/ui'; import { useAppDispatch } from 'soapbox/hooks'; const ComposeButton = () => { + const location = useLocation(); + const isOnGroupPage = location.pathname.startsWith('/group/'); + const match = useRouteMatch<{ groupSlug: string }>('/group/:groupSlug'); + const { entity: group } = useGroupLookup(match?.params.groupSlug || ''); + const isGroupMember = !!group?.relationship?.member; + + if (isOnGroupPage && isGroupMember) { + return ; + } + + return ; +}; + +const HomeComposeButton = () => { const dispatch = useAppDispatch(); const onOpenCompose = () => dispatch(openModal('COMPOSE')); return ( + ); +}; + export default ComposeButton; diff --git a/app/soapbox/features/ui/components/floating-action-button.tsx b/app/soapbox/features/ui/components/floating-action-button.tsx index 8800cf444..ea6bd64ee 100644 --- a/app/soapbox/features/ui/components/floating-action-button.tsx +++ b/app/soapbox/features/ui/components/floating-action-button.tsx @@ -1,20 +1,30 @@ import clsx from 'clsx'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import { useLocation, useRouteMatch } from 'react-router-dom'; +import { groupComposeModal } from 'soapbox/actions/compose'; import { openModal } from 'soapbox/actions/modals'; -import { Icon } from 'soapbox/components/ui'; +import { useGroupLookup } from 'soapbox/api/hooks'; +import { Avatar, HStack, Icon } from 'soapbox/components/ui'; import { useAppDispatch } from 'soapbox/hooks'; const messages = defineMessages({ publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }, }); -interface IFloatingActionButton { -} - /** FloatingActionButton (aka FAB), a composer button that floats in the corner on mobile. */ -const FloatingActionButton: React.FC = () => { +const FloatingActionButton: React.FC = () => { + const location = useLocation(); + + if (location.pathname.startsWith('/group/')) { + return ; + } + + return ; +}; + +const HomeFAB: React.FC = () => { const intl = useIntl(); const dispatch = useAppDispatch(); @@ -39,4 +49,37 @@ const FloatingActionButton: React.FC = () => { ); }; +const GroupFAB: React.FC = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const match = useRouteMatch<{ groupSlug: string }>('/group/:groupSlug'); + const { entity: group } = useGroupLookup(match?.params.groupSlug || ''); + + if (!group) return null; + + const handleOpenComposeModal = () => { + dispatch(groupComposeModal(group)); + }; + + return ( + + ); +}; + export default FloatingActionButton; diff --git a/app/soapbox/features/ui/components/group-media-panel.tsx b/app/soapbox/features/ui/components/group-media-panel.tsx index 85432d74c..9f52af5a2 100644 --- a/app/soapbox/features/ui/components/group-media-panel.tsx +++ b/app/soapbox/features/ui/components/group-media-panel.tsx @@ -21,6 +21,9 @@ const GroupMediaPanel: React.FC = ({ group }) => { const [loading, setLoading] = useState(true); + const isMember = !!group?.relationship?.member; + const isPrivate = group?.locked; + const attachments: ImmutableList = useAppSelector((state) => group ? getGroupGallery(state, group?.id) : ImmutableList()); const handleOpenMedia = (attachment: Attachment): void => { @@ -37,13 +40,13 @@ const GroupMediaPanel: React.FC = ({ group }) => { useEffect(() => { setLoading(true); - if (group) { + if (group && !group.deleted_at && (isMember || !isPrivate)) { dispatch(expandGroupMediaTimeline(group.id)) // @ts-ignore .then(() => setLoading(false)) .catch(() => {}); } - }, [group?.id]); + }, [group?.id, isMember, isPrivate]); const renderAttachments = () => { const nineAttachments = attachments.slice(0, 9); @@ -69,6 +72,10 @@ const GroupMediaPanel: React.FC = ({ group }) => { } }; + if ((isPrivate && !isMember) || group?.deleted_at) { + return null; + } + return ( }> {group && ( diff --git a/app/soapbox/features/ui/components/modal-root.tsx b/app/soapbox/features/ui/components/modal-root.tsx index 55e53c268..08fd4c88c 100644 --- a/app/soapbox/features/ui/components/modal-root.tsx +++ b/app/soapbox/features/ui/components/modal-root.tsx @@ -26,7 +26,7 @@ import { LandingPageModal, ListAdder, ListEditor, - ManageGroupModal, + CreateGroupModal, MediaModal, MentionsModal, MissingDescriptionModal, @@ -59,6 +59,7 @@ const MODAL_COMPONENTS = { 'COMPOSE': ComposeModal, 'COMPOSE_EVENT': ComposeEventModal, 'CONFIRM': ConfirmationModal, + 'CREATE_GROUP': CreateGroupModal, 'CRYPTO_DONATE': CryptoDonateModal, 'DISLIKES': DislikesModal, 'EDIT_ANNOUNCEMENT': EditAnnouncementModal, @@ -73,7 +74,6 @@ const MODAL_COMPONENTS = { 'LANDING_PAGE': LandingPageModal, 'LIST_ADDER': ListAdder, 'LIST_EDITOR': ListEditor, - 'MANAGE_GROUP': ManageGroupModal, 'MEDIA': MediaModal, 'MENTIONS': MentionsModal, 'MISSING_DESCRIPTION': MissingDescriptionModal, diff --git a/app/soapbox/features/ui/components/modals/compose-event-modal/compose-event-modal.tsx b/app/soapbox/features/ui/components/modals/compose-event-modal/compose-event-modal.tsx index 2ecbbda4f..6a23268d5 100644 --- a/app/soapbox/features/ui/components/modals/compose-event-modal/compose-event-modal.tsx +++ b/app/soapbox/features/ui/components/modals/compose-event-modal/compose-event-modal.tsx @@ -184,7 +184,7 @@ const ComposeEventModal: React.FC = ({ onClose }) => { {location.description} - {[location.street, location.locality, location.country].filter(val => val.trim()).join(' · ')} + {[location.street, location.locality, location.country].filter(val => val?.trim()).join(' · ')} onChangeLocation(null)} /> diff --git a/app/soapbox/features/ui/components/modals/compose-modal.tsx b/app/soapbox/features/ui/components/modals/compose-modal.tsx index b007ae1ba..2bbdb4a2e 100644 --- a/app/soapbox/features/ui/components/modals/compose-modal.tsx +++ b/app/soapbox/features/ui/components/modals/compose-modal.tsx @@ -1,11 +1,13 @@ -import React from 'react'; +import clsx from 'clsx'; +import React, { useRef } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { cancelReplyCompose } from 'soapbox/actions/compose'; +import { cancelReplyCompose, setGroupTimelineVisible, uploadCompose } from 'soapbox/actions/compose'; import { openModal, closeModal } from 'soapbox/actions/modals'; +import { useGroup } from 'soapbox/api/hooks'; import { checkComposeContent } from 'soapbox/components/modal-root'; -import { Modal } from 'soapbox/components/ui'; -import { useAppDispatch, useCompose } from 'soapbox/hooks'; +import { HStack, Modal, Text, Toggle } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector, useCompose, useDraggedFiles } from 'soapbox/hooks'; import ComposeForm from '../../../compose/components/compose-form'; @@ -17,15 +19,20 @@ const messages = defineMessages({ interface IComposeModal { onClose: (type?: string) => void + composeId?: string } -const ComposeModal: React.FC = ({ onClose }) => { +const ComposeModal: React.FC = ({ onClose, composeId = 'compose-modal' }) => { const intl = useIntl(); const dispatch = useAppDispatch(); + const node = useRef(null); + const compose = useCompose(composeId); - const compose = useCompose('compose-modal'); + const { id: statusId, privacy, in_reply_to: inReplyTo, quote, group_id: groupId } = compose!; - const { id: statusId, privacy, in_reply_to: inReplyTo, quote } = compose!; + const { isDragging, isDraggedOver } = useDraggedFiles(node, (files) => { + dispatch(uploadCompose(composeId, files, intl)); + }); const onClickClose = () => { if (checkComposeContent(compose)) { @@ -53,6 +60,10 @@ const ComposeModal: React.FC = ({ onClose }) => { return ; } else if (privacy === 'direct') { return ; + } else if (inReplyTo && groupId) { + return ; + } else if (groupId) { + return ; } else if (inReplyTo) { return ; } else if (quote) { @@ -64,12 +75,57 @@ const ComposeModal: React.FC = ({ onClose }) => { return ( - + } + /> ); }; +interface IComposeFormGroupToggle { + composeId: string + groupId: string | null +} + +const ComposeFormGroupToggle: React.FC = ({ composeId, groupId }) => { + const dispatch = useAppDispatch(); + const { group } = useGroup(groupId || '', false); + + const groupTimelineVisible = useAppSelector((state) => !!state.compose.get(composeId)?.group_timeline_visible); + + const handleToggleChange = () => { + dispatch(setGroupTimelineVisible(composeId, !groupTimelineVisible)); + }; + + const labelId = `group-timeline-visible+${composeId}`; + + if (!group) return null; + if (group.locked) return null; + + return ( + + + + + ); +}; + export default ComposeModal; diff --git a/app/soapbox/features/ui/components/modals/manage-group-modal/create-group-modal.tsx b/app/soapbox/features/ui/components/modals/manage-group-modal/create-group-modal.tsx new file mode 100644 index 000000000..d87126c74 --- /dev/null +++ b/app/soapbox/features/ui/components/modals/manage-group-modal/create-group-modal.tsx @@ -0,0 +1,130 @@ +import { AxiosError } from 'axios'; +import React, { useMemo, useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { useCreateGroup, useGroupValidation, type CreateGroupParams } from 'soapbox/api/hooks'; +import { Modal, Stack } from 'soapbox/components/ui'; +import { useDebounce } from 'soapbox/hooks'; +import { type Group } from 'soapbox/schemas'; +import toast from 'soapbox/toast'; + +import ConfirmationStep from './steps/confirmation-step'; +import DetailsStep from './steps/details-step'; +import PrivacyStep from './steps/privacy-step'; + +const messages = defineMessages({ + next: { id: 'manage_group.next', defaultMessage: 'Next' }, + create: { id: 'manage_group.create', defaultMessage: 'Create Group' }, + done: { id: 'manage_group.done', defaultMessage: 'Done' }, +}); + +enum Steps { + ONE = 'ONE', + TWO = 'TWO', + THREE = 'THREE', +} + +interface ICreateGroupModal { + onClose: (type?: string) => void +} + +const CreateGroupModal: React.FC = ({ onClose }) => { + const intl = useIntl(); + const debounce = useDebounce; + + const [group, setGroup] = useState(null); + const [params, setParams] = useState({ + group_visibility: 'everyone', + }); + const [currentStep, setCurrentStep] = useState(Steps.ONE); + + const { createGroup, isSubmitting } = useCreateGroup(); + + const debouncedName = debounce(params.display_name || '', 300); + const { data: { isValid } } = useGroupValidation(debouncedName); + + const handleClose = () => { + onClose('MANAGE_GROUP'); + }; + + const confirmationText = useMemo(() => { + switch (currentStep) { + case Steps.THREE: + return intl.formatMessage(messages.done); + case Steps.TWO: + return intl.formatMessage(messages.create); + default: + return intl.formatMessage(messages.next); + } + }, [currentStep]); + + const handleNextStep = () => { + switch (currentStep) { + case Steps.ONE: + setCurrentStep(Steps.TWO); + break; + case Steps.TWO: + createGroup(params, { + onSuccess(group) { + setCurrentStep(Steps.THREE); + setGroup(group); + }, + onError(error) { + if (error instanceof AxiosError) { + const msg = error.response?.data.error; + if (typeof msg === 'string') { + toast.error(msg); + } + } + }, + }); + break; + case Steps.THREE: + handleClose(); + break; + default: + break; + } + }; + + const renderStep = () => { + switch (currentStep) { + case Steps.ONE: + return ; + case Steps.TWO: + return ; + case Steps.THREE: + return ; + } + }; + + const renderModalTitle = () => { + switch (currentStep) { + case Steps.ONE: + return ; + default: + if (params.group_visibility === 'everyone') { + return ; + } else { + return ; + } + } + }; + + return ( + + + {renderStep()} + + + ); +}; + +export default CreateGroupModal; diff --git a/app/soapbox/features/ui/components/modals/manage-group-modal/manage-group-modal.tsx b/app/soapbox/features/ui/components/modals/manage-group-modal/manage-group-modal.tsx deleted file mode 100644 index fcc7c14da..000000000 --- a/app/soapbox/features/ui/components/modals/manage-group-modal/manage-group-modal.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; - -import { submitGroupEditor } from 'soapbox/actions/groups'; -import { Modal, Stack } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector, useDebounce } from 'soapbox/hooks'; -import { useGroupValidation } from 'soapbox/hooks/api'; - -import ConfirmationStep from './steps/confirmation-step'; -import DetailsStep from './steps/details-step'; -import PrivacyStep from './steps/privacy-step'; - -const messages = defineMessages({ - next: { id: 'manage_group.next', defaultMessage: 'Next' }, - create: { id: 'manage_group.create', defaultMessage: 'Create' }, - update: { id: 'manage_group.update', defaultMessage: 'Update' }, - done: { id: 'manage_group.done', defaultMessage: 'Done' }, -}); - -enum Steps { - ONE = 'ONE', - TWO = 'TWO', - THREE = 'THREE', -} - -const manageGroupSteps = { - ONE: PrivacyStep, - TWO: DetailsStep, - THREE: ConfirmationStep, -}; - -interface IManageGroupModal { - onClose: (type?: string) => void -} - -const ManageGroupModal: React.FC = ({ onClose }) => { - const intl = useIntl(); - const debounce = useDebounce; - const dispatch = useAppDispatch(); - - const id = useAppSelector((state) => state.group_editor.groupId); - const [group, setGroup] = useState(null); - - const isSubmitting = useAppSelector((state) => state.group_editor.isSubmitting); - - const [currentStep, setCurrentStep] = useState(id ? Steps.TWO : Steps.ONE); - - const name = useAppSelector((state) => state.group_editor.displayName); - const debouncedName = debounce(name, 300); - - const { data: { isValid } } = useGroupValidation(debouncedName); - - const handleClose = () => { - onClose('MANAGE_GROUP'); - }; - - const handleSubmit = () => { - return dispatch(submitGroupEditor(true)); - }; - - const confirmationText = useMemo(() => { - switch (currentStep) { - case Steps.THREE: - return intl.formatMessage(messages.done); - case Steps.TWO: - return intl.formatMessage(id ? messages.update : messages.create); - default: - return intl.formatMessage(messages.next); - } - }, [currentStep]); - - const handleNextStep = () => { - switch (currentStep) { - case Steps.ONE: - setCurrentStep(Steps.TWO); - break; - case Steps.TWO: - handleSubmit() - .then((group) => { - setCurrentStep(Steps.THREE); - setGroup(group); - }) - .catch(() => {}); - break; - case Steps.THREE: - handleClose(); - break; - default: - break; - } - }; - - const StepToRender = manageGroupSteps[currentStep]; - - return ( - - : } - confirmationAction={handleNextStep} - confirmationText={confirmationText} - confirmationDisabled={isSubmitting || (currentStep === Steps.TWO && !isValid)} - confirmationFullWidth - onClose={handleClose} - > - - {/* @ts-ignore */} - - - - ); -}; - -export default ManageGroupModal; diff --git a/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx b/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx index 7db6d9c20..785da5da1 100644 --- a/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx +++ b/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx @@ -1,30 +1,42 @@ import React from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { Avatar, Divider, HStack, Stack, Text, Button } from 'soapbox/components/ui'; +import toast from 'soapbox/toast'; +import copy from 'soapbox/utils/copy'; import type { Group } from 'soapbox/schemas'; interface IConfirmationStep { - group: Group + group: Group | null } +const messages = defineMessages({ + copied: { id: 'copy.success', defaultMessage: 'Copied to clipboard!' }, +}); + const ConfirmationStep: React.FC = ({ group }) => { + const intl = useIntl(); + const handleCopyLink = () => { - if (navigator.clipboard) { - navigator.clipboard.writeText(group.uri); - } + copy(group?.url as string, () => { + toast.success(intl.formatMessage(messages.copied)); + }); }; const handleShare = () => { navigator.share({ - text: group.display_name, - url: group.uri, + text: group?.display_name, + url: group?.uri, }).catch((e) => { if (e.name !== 'AbortError') console.error(e); }); }; + if (!group) { + return null; + } + return ( @@ -42,7 +54,11 @@ const ConfirmationStep: React.FC = ({ group }) => { {group.display_name} - {group.note} + diff --git a/app/soapbox/features/ui/components/modals/manage-group-modal/steps/details-step.tsx b/app/soapbox/features/ui/components/modals/manage-group-modal/steps/details-step.tsx index 59b59b2ec..02dbf3279 100644 --- a/app/soapbox/features/ui/components/modals/manage-group-modal/steps/details-step.tsx +++ b/app/soapbox/features/ui/components/modals/manage-group-modal/steps/details-step.tsx @@ -1,162 +1,99 @@ -import clsx from 'clsx'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { - changeGroupEditorTitle, - changeGroupEditorDescription, - changeGroupEditorMedia, -} from 'soapbox/actions/groups'; -import { Avatar, Form, FormGroup, HStack, Icon, Input, Text, Textarea } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector, useDebounce, useInstance } from 'soapbox/hooks'; -import { useGroupValidation } from 'soapbox/hooks/api'; -import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts'; +import { CreateGroupParams, useGroupValidation } from 'soapbox/api/hooks'; +import { Form, FormGroup, Input, Textarea } from 'soapbox/components/ui'; +import AvatarPicker from 'soapbox/features/group/components/group-avatar-picker'; +import HeaderPicker from 'soapbox/features/group/components/group-header-picker'; +import GroupTagsField from 'soapbox/features/group/components/group-tags-field'; +import { useAppSelector, useDebounce, useInstance } from 'soapbox/hooks'; +import { usePreview } from 'soapbox/hooks/forms'; import resizeImage from 'soapbox/utils/resize-image'; import type { List as ImmutableList } from 'immutable'; -interface IMediaInput { - src: string | null - accept: string - onChange: React.ChangeEventHandler - disabled: boolean -} - const messages = defineMessages({ groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' }, groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' }, + hashtagPlaceholder: { id: 'manage_group.fields.hashtag_placeholder', defaultMessage: 'Add a topic' }, }); -const HeaderPicker: React.FC = ({ src, onChange, accept, disabled }) => { - return ( - - ); -}; - -const AvatarPicker: React.FC = ({ src, onChange, accept, disabled }) => { - return ( - - ); -}; - -const DetailsStep = () => { +const DetailsStep: React.FC = ({ params, onChange }) => { const intl = useIntl(); const debounce = useDebounce; - const dispatch = useAppDispatch(); const instance = useInstance(); - const groupId = useAppSelector((state) => state.group_editor.groupId); - const isUploading = useAppSelector((state) => state.group_editor.isUploading); - const name = useAppSelector((state) => state.group_editor.displayName); - const description = useAppSelector((state) => state.group_editor.note); - - const debouncedName = debounce(name, 300); + const { + display_name: displayName = '', + note = '', + tags = [''], + } = params; + const debouncedName = debounce(displayName, 300); const { data: { isValid, message: errorMessage } } = useGroupValidation(debouncedName); - const [avatarSrc, setAvatarSrc] = useState(null); - const [headerSrc, setHeaderSrc] = useState(null); + const avatarSrc = usePreview(params.avatar); + const headerSrc = usePreview(params.header); const attachmentTypes = useAppSelector( state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList, )?.filter(type => type.startsWith('image/')).toArray().join(','); - const onChangeName: React.ChangeEventHandler = ({ target }) => { - dispatch(changeGroupEditorTitle(target.value)); + const handleTextChange = (property: keyof CreateGroupParams): React.ChangeEventHandler => { + return (e) => { + onChange({ + ...params, + [property]: e.target.value, + }); + }; }; - const onChangeDescription: React.ChangeEventHandler = ({ target }) => { - dispatch(changeGroupEditorDescription(target.value)); + const handleImageChange = (property: keyof CreateGroupParams, maxPixels?: number): React.ChangeEventHandler => { + return async ({ target: { files } }) => { + const file = files ? files[0] : undefined; + if (file) { + const resized = await resizeImage(file, maxPixels); + onChange({ + ...params, + [property]: resized, + }); + } + }; }; - const handleFileChange: React.ChangeEventHandler = e => { - const rawFile = e.target.files?.item(0); - - if (!rawFile) return; - - if (e.target.name === 'avatar') { - resizeImage(rawFile, 400 * 400).then(file => { - dispatch(changeGroupEditorMedia('avatar', file)); - setAvatarSrc(URL.createObjectURL(file)); - }).catch(console.error); - } else { - resizeImage(rawFile, 1920 * 1080).then(file => { - dispatch(changeGroupEditorMedia('header', file)); - setHeaderSrc(URL.createObjectURL(file)); - }).catch(console.error); - } - }; - - useEffect(() => { - if (!groupId) return; - - dispatch((_, getState) => { - const group = getState().groups.items.get(groupId); - if (!group) return; - if (group.avatar && !isDefaultAvatar(group.avatar)) setAvatarSrc(group.avatar); - if (group.header && !isDefaultHeader(group.header)) setHeaderSrc(group.header); + const handleTagsChange = (tags: string[]) => { + onChange({ + ...params, + tags, }); - }, [groupId]); + }; + + const handleAddTag = () => { + onChange({ + ...params, + tags: [...tags, ''], + }); + }; + + const handleRemoveTag = (i: number) => { + const newTags = [...tags]; + newTags.splice(i, 1); + onChange({ + ...params, + tags: newTags, + }); + }; return (
- - + +
{ @@ -179,11 +116,20 @@ const DetailsStep = () => {