From 28a69ad88b37125ae5eaf356ac0e1af5d6f99ec3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 15:30:13 -0500 Subject: [PATCH 01/44] Ensure group_visibility param is passed when creating group --- app/soapbox/actions/groups.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts index d78e7f5d8..8a6ad065e 100644 --- a/app/soapbox/actions/groups.ts +++ b/app/soapbox/actions/groups.ts @@ -789,9 +789,11 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get const note = getState().group_editor.note; const avatar = getState().group_editor.avatar; const header = getState().group_editor.header; + const visibility = getState().group_editor.locked ? 'members_only' : 'everyone'; // Truth Social const params: Record = { display_name: displayName, + group_visibility: visibility, note, }; From d08178f5fcdde0cf178101218544ebe23020d1ce Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 15:54:06 -0500 Subject: [PATCH 02/44] Groups: use entity store for pending requests --- app/soapbox/entity-store/entities.ts | 1 + .../features/group/group-membership-requests.tsx | 4 +++- .../hooks/api/groups/useGroupMembershipRequests.ts | 13 +++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts diff --git a/app/soapbox/entity-store/entities.ts b/app/soapbox/entity-store/entities.ts index 30220eed6..44f2db3c9 100644 --- a/app/soapbox/entity-store/entities.ts +++ b/app/soapbox/entity-store/entities.ts @@ -1,4 +1,5 @@ export enum Entities { + ACCOUNTS = 'Accounts', GROUPS = 'Groups', GROUP_RELATIONSHIPS = 'GroupRelationships', GROUP_MEMBERSHIPS = 'GroupMemberships', diff --git a/app/soapbox/features/group/group-membership-requests.tsx b/app/soapbox/features/group/group-membership-requests.tsx index fd33f3947..dc0704054 100644 --- a/app/soapbox/features/group/group-membership-requests.tsx +++ b/app/soapbox/features/group/group-membership-requests.tsx @@ -6,6 +6,7 @@ import Account from 'soapbox/components/account'; import ScrollableList from 'soapbox/components/scrollable-list'; import { Button, Column, HStack, Spinner } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector, useGroup } from 'soapbox/hooks'; +import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests'; import { makeGetAccount } from 'soapbox/selectors'; import toast from 'soapbox/toast'; @@ -80,7 +81,8 @@ const GroupMembershipRequests: React.FC = ({ params }) const id = params?.id; const { group } = useGroup(id); - const accountIds = useAppSelector((state) => state.user_lists.membership_requests.get(id)?.items); + const { entities } = useGroupMembershipRequests(id); + const accountIds = entities.map(e => e.id); useEffect(() => { dispatch(fetchGroupMembershipRequests(id)); diff --git a/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts b/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts new file mode 100644 index 000000000..78793e0d0 --- /dev/null +++ b/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts @@ -0,0 +1,13 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { accountSchema } from 'soapbox/schemas'; + +function useGroupMembershipRequests(groupId: string) { + return useEntities( + [Entities.ACCOUNTS, 'membership_requests', groupId], + `/api/v1/groups/${groupId}/membership_requests`, + { schema: accountSchema }, + ); +} + +export { useGroupMembershipRequests }; \ No newline at end of file From 3c06ba734b341c611a279800bd524ccdab6f2d56 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 16:03:41 -0500 Subject: [PATCH 03/44] Display pending counter in group member list --- app/soapbox/features/group/group-members.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/soapbox/features/group/group-members.tsx b/app/soapbox/features/group/group-members.tsx index 9fa1d135f..755786a33 100644 --- a/app/soapbox/features/group/group-members.tsx +++ b/app/soapbox/features/group/group-members.tsx @@ -1,8 +1,9 @@ import React, { useMemo } from 'react'; import ScrollableList from 'soapbox/components/scrollable-list'; +import { useGroup } from 'soapbox/hooks'; +import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests'; import { useGroupMembers } from 'soapbox/hooks/api/useGroupMembers'; -import { useGroup } from 'soapbox/queries/groups'; import { GroupRoles } from 'soapbox/schemas/group-member'; import PlaceholderAccount from '../placeholder/components/placeholder-account'; @@ -22,8 +23,9 @@ const GroupMembers: React.FC = (props) => { const { groupMembers: owners, isFetching: isFetchingOwners } = useGroupMembers(groupId, GroupRoles.OWNER); const { groupMembers: admins, isFetching: isFetchingAdmins } = useGroupMembers(groupId, GroupRoles.ADMIN); const { groupMembers: users, isFetching: isFetchingUsers, fetchNextPage, hasNextPage } = useGroupMembers(groupId, GroupRoles.USER); + const { entities: pending, isFetching: isFetchingPending } = useGroupMembershipRequests(groupId); - const isLoading = isFetchingGroup || isFetchingOwners || isFetchingAdmins || isFetchingUsers; + const isLoading = isFetchingGroup || isFetchingOwners || isFetchingAdmins || isFetchingUsers || isFetchingPending; const members = useMemo(() => [ ...owners, @@ -44,6 +46,9 @@ const GroupMembers: React.FC = (props) => { className='divide-y divide-solid divide-gray-300' itemClassName='py-3 last:pb-0' > + {(pending.length > 0) && ( +
{pending.length} pending members
+ )} {members.map((member) => ( Date: Mon, 20 Mar 2023 16:09:19 -0500 Subject: [PATCH 04/44] Abstract PendingItemsRow into its own component --- app/soapbox/components/pending-items-row.tsx | 44 +++++++++++++++++++ .../groups/components/pending-groups-row.tsx | 35 +++------------ 2 files changed, 51 insertions(+), 28 deletions(-) create mode 100644 app/soapbox/components/pending-items-row.tsx diff --git a/app/soapbox/components/pending-items-row.tsx b/app/soapbox/components/pending-items-row.tsx new file mode 100644 index 000000000..04ff99d04 --- /dev/null +++ b/app/soapbox/components/pending-items-row.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; + +import { HStack, Icon, Text } from 'soapbox/components/ui'; + +interface IPendingItemsRow { + /** Path to navigate the user when clicked. */ + to: string + /** Number of pending items. */ + count: number +} + +const PendingItemsRow: React.FC = ({ to, count }) => { + return ( + + + +
+ +
+ + + + +
+ + +
+ + ); +}; + +export { PendingItemsRow }; \ No newline at end of file diff --git a/app/soapbox/features/groups/components/pending-groups-row.tsx b/app/soapbox/features/groups/components/pending-groups-row.tsx index c574b30aa..b57b4691c 100644 --- a/app/soapbox/features/groups/components/pending-groups-row.tsx +++ b/app/soapbox/features/groups/components/pending-groups-row.tsx @@ -1,8 +1,7 @@ import React from 'react'; -import { FormattedMessage } from 'react-intl'; -import { Link } from 'react-router-dom'; -import { Divider, HStack, Icon, Text } from 'soapbox/components/ui'; +import { PendingItemsRow } from 'soapbox/components/pending-items-row'; +import { Divider } from 'soapbox/components/ui'; import { useFeatures } from 'soapbox/hooks'; import { usePendingGroups } from 'soapbox/queries/groups'; @@ -17,31 +16,11 @@ export default () => { return ( <> - - - -
- -
- - - - -
- - -
- + From 143a9eda4400d3711be581a06fa9899e6da1a2dc Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 16:26:40 -0500 Subject: [PATCH 05/44] Use PendingItemsRow for pending members, pass a prop to control its size --- app/soapbox/components/pending-items-row.tsx | 16 +++++++++++++--- app/soapbox/features/group/group-members.tsx | 3 ++- .../groups/components/pending-groups-row.tsx | 1 + 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/soapbox/components/pending-items-row.tsx b/app/soapbox/components/pending-items-row.tsx index 04ff99d04..0081d33e5 100644 --- a/app/soapbox/components/pending-items-row.tsx +++ b/app/soapbox/components/pending-items-row.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import React from 'react'; import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router-dom'; @@ -9,17 +10,26 @@ interface IPendingItemsRow { to: string /** Number of pending items. */ count: number + /** Size of the icon. */ + size?: 'md' | 'lg' } -const PendingItemsRow: React.FC = ({ to, count }) => { +const PendingItemsRow: React.FC = ({ to, count, size = 'md' }) => { return ( -
+
diff --git a/app/soapbox/features/group/group-members.tsx b/app/soapbox/features/group/group-members.tsx index 755786a33..b1ab415f4 100644 --- a/app/soapbox/features/group/group-members.tsx +++ b/app/soapbox/features/group/group-members.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react'; +import { PendingItemsRow } from 'soapbox/components/pending-items-row'; import ScrollableList from 'soapbox/components/scrollable-list'; import { useGroup } from 'soapbox/hooks'; import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests'; @@ -47,7 +48,7 @@ const GroupMembers: React.FC = (props) => { itemClassName='py-3 last:pb-0' > {(pending.length > 0) && ( -
{pending.length} pending members
+ )} {members.map((member) => ( { data-testid='pending-groups-row' to='/groups/pending-requests' count={groups.length} + size='lg' /> From ca9a41f102807863e0a74ce2878b03aacc36b825 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 16:41:12 -0500 Subject: [PATCH 06/44] Use EntityStore for pending group requests --- .../group/group-membership-requests.tsx | 35 +++++++------------ app/soapbox/pages/group-page.tsx | 6 ---- 2 files changed, 13 insertions(+), 28 deletions(-) diff --git a/app/soapbox/features/group/group-membership-requests.tsx b/app/soapbox/features/group/group-membership-requests.tsx index dc0704054..e769a1680 100644 --- a/app/soapbox/features/group/group-membership-requests.tsx +++ b/app/soapbox/features/group/group-membership-requests.tsx @@ -1,17 +1,18 @@ -import React, { useCallback, useEffect } from 'react'; +import React from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; -import { authorizeGroupMembershipRequest, fetchGroupMembershipRequests, rejectGroupMembershipRequest } from 'soapbox/actions/groups'; +import { authorizeGroupMembershipRequest, rejectGroupMembershipRequest } from 'soapbox/actions/groups'; import Account from 'soapbox/components/account'; import ScrollableList from 'soapbox/components/scrollable-list'; import { Button, Column, HStack, Spinner } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector, useGroup } from 'soapbox/hooks'; +import { useAppDispatch, useGroup } from 'soapbox/hooks'; import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests'; -import { makeGetAccount } from 'soapbox/selectors'; import toast from 'soapbox/toast'; import ColumnForbidden from '../ui/components/column-forbidden'; +import type { Account as AccountEntity } from 'soapbox/schemas'; + type RouteParams = { id: string }; const messages = defineMessages({ @@ -23,27 +24,23 @@ const messages = defineMessages({ }); interface IMembershipRequest { - accountId: string + account: AccountEntity groupId: string } -const MembershipRequest: React.FC = ({ accountId, groupId }) => { +const MembershipRequest: React.FC = ({ account, groupId }) => { const intl = useIntl(); const dispatch = useAppDispatch(); - const getAccount = useCallback(makeGetAccount(), []); - - const account = useAppSelector((state) => getAccount(state, accountId)); - if (!account) return null; const handleAuthorize = () => - dispatch(authorizeGroupMembershipRequest(groupId, accountId)).then(() => { + dispatch(authorizeGroupMembershipRequest(groupId, account.id)).then(() => { toast.success(intl.formatMessage(messages.authorized, { name: account.acct })); }); const handleReject = () => - dispatch(rejectGroupMembershipRequest(groupId, accountId)).then(() => { + dispatch(rejectGroupMembershipRequest(groupId, account.id)).then(() => { toast.success(intl.formatMessage(messages.rejected, { name: account.acct })); }); @@ -76,19 +73,13 @@ interface IGroupMembershipRequests { const GroupMembershipRequests: React.FC = ({ params }) => { const intl = useIntl(); - const dispatch = useAppDispatch(); const id = params?.id; const { group } = useGroup(id); - const { entities } = useGroupMembershipRequests(id); - const accountIds = entities.map(e => e.id); + const { entities: accounts, isLoading } = useGroupMembershipRequests(id); - useEffect(() => { - dispatch(fetchGroupMembershipRequests(id)); - }, [id]); - - if (!group || !group.relationship || !accountIds) { + if (!group || !group.relationship || isLoading) { return ( @@ -108,8 +99,8 @@ const GroupMembershipRequests: React.FC = ({ params }) scrollKey='group_membership_requests' emptyMessage={emptyMessage} > - {accountIds.map((accountId) => - , + {accounts.map((account) => + , )} diff --git a/app/soapbox/pages/group-page.tsx b/app/soapbox/pages/group-page.tsx index c182068f2..bc7144f10 100644 --- a/app/soapbox/pages/group-page.tsx +++ b/app/soapbox/pages/group-page.tsx @@ -68,12 +68,6 @@ const GroupPage: React.FC = ({ params, children }) => { const isBlocked = group?.relationship?.blocked_by; const isPrivate = group?.locked; - // if ((group as any) === false) { - // return ( - // - // ); - // } - const items = [ { text: intl.formatMessage(messages.all), From 1d9ed41fec970e70d8fa386fbf7488561312149a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 17:45:52 -0500 Subject: [PATCH 07/44] Add AuthorizeRejectButtons component --- .../components/authorize-reject-buttons.tsx | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 app/soapbox/components/authorize-reject-buttons.tsx diff --git a/app/soapbox/components/authorize-reject-buttons.tsx b/app/soapbox/components/authorize-reject-buttons.tsx new file mode 100644 index 000000000..0a092fd78 --- /dev/null +++ b/app/soapbox/components/authorize-reject-buttons.tsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { Button, HStack } from 'soapbox/components/ui'; + +const messages = defineMessages({ + authorize: { id: 'authorize', defaultMessage: 'Accept' }, + authorized: { id: 'authorize.success', defaultMessage: 'Accepted' }, + reject: { id: 'reject', defaultMessage: 'Reject' }, + rejected: { id: 'reject.success', defaultMessage: 'Rejected' }, +}); + +interface IAuthorizeRejectButtons { + id: string + onAuthorize(id: string): Promise + onReject(id: string): Promise +} + +/** Buttons to approve or reject a pending item, usually an account. */ +const AuthorizeRejectButtons: React.FC = ({ id, onAuthorize, onReject }) => { + const intl = useIntl(); + const [state, setState] = useState<'authorized' | 'rejected' | 'pending'>('pending'); + + function handleAuthorize() { + onAuthorize(id) + .then(() => setState('authorized')) + .catch(console.error); + } + + function handleReject() { + onReject(id) + .then(() => setState('rejected')) + .catch(console.error); + } + + switch (state) { + case 'pending': + return ( + +
From d4e9fddd025f03ba021f42a85ae1cf912c573438 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 19:24:06 -0500 Subject: [PATCH 14/44] Update usage of AuthorizeRejectButtons in group membership requests --- app/soapbox/features/group/group-membership-requests.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/soapbox/features/group/group-membership-requests.tsx b/app/soapbox/features/group/group-membership-requests.tsx index 32f36f9bb..9c30bc8cf 100644 --- a/app/soapbox/features/group/group-membership-requests.tsx +++ b/app/soapbox/features/group/group-membership-requests.tsx @@ -36,13 +36,13 @@ const MembershipRequest: React.FC = ({ account, onAuthorize, if (!account) return null; - function handleAuthorize(accountId: string) { - return onAuthorize(accountId) + function handleAuthorize() { + return onAuthorize(account.id) .catch(() => toast.error(intl.formatMessage(messages.authorizeFail, { name: account.username }))); } - function handleReject(accountId: string) { - return onReject(accountId) + function handleReject() { + return onReject(account.id) .catch(() => toast.error(intl.formatMessage(messages.rejectFail, { name: account.username }))); } @@ -53,7 +53,6 @@ const MembershipRequest: React.FC = ({ account, onAuthorize, From 5774516ea0802e15f77c0c56de2624b12505dc35 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 19:32:24 -0500 Subject: [PATCH 15/44] Reorganize GroupMembershipRequests a little --- .../group/group-membership-requests.tsx | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/app/soapbox/features/group/group-membership-requests.tsx b/app/soapbox/features/group/group-membership-requests.tsx index 9c30bc8cf..eab20c1ef 100644 --- a/app/soapbox/features/group/group-membership-requests.tsx +++ b/app/soapbox/features/group/group-membership-requests.tsx @@ -17,34 +17,21 @@ type RouteParams = { id: string }; const messages = defineMessages({ heading: { id: 'column.group_pending_requests', defaultMessage: 'Pending requests' }, - authorize: { id: 'group.group_mod_authorize', defaultMessage: 'Accept' }, - authorized: { id: 'group.group_mod_authorize.success', defaultMessage: 'Accepted @{name} to group' }, authorizeFail: { id: 'group.group_mod_authorize.fail', defaultMessage: 'Failed to approve @{name}' }, - reject: { id: 'group.group_mod_reject', defaultMessage: 'Reject' }, - rejected: { id: 'group.group_mod_reject.success', defaultMessage: 'Rejected @{name} from group' }, rejectFail: { id: 'group.group_mod_reject.fail', defaultMessage: 'Failed to reject @{name}' }, }); interface IMembershipRequest { account: AccountEntity - onAuthorize(accountId: string): Promise - onReject(accountId: string): Promise + onAuthorize(account: AccountEntity): Promise + onReject(account: AccountEntity): Promise } const MembershipRequest: React.FC = ({ account, onAuthorize, onReject }) => { - const intl = useIntl(); - if (!account) return null; - function handleAuthorize() { - return onAuthorize(account.id) - .catch(() => toast.error(intl.formatMessage(messages.authorizeFail, { name: account.username }))); - } - - function handleReject() { - return onReject(account.id) - .catch(() => toast.error(intl.formatMessage(messages.rejectFail, { name: account.username }))); - } + const handleAuthorize = () => onAuthorize(account); + const handleReject = () => onReject(account); return ( @@ -65,9 +52,8 @@ interface IGroupMembershipRequests { } const GroupMembershipRequests: React.FC = ({ params }) => { - const intl = useIntl(); - const id = params?.id; + const intl = useIntl(); const { group } = useGroup(id); const { accounts, isLoading, authorize, reject } = useGroupMembershipRequests(id); @@ -81,11 +67,24 @@ const GroupMembershipRequests: React.FC = ({ params }) } if (!group.relationship.role || !['owner', 'admin', 'moderator'].includes(group.relationship.role)) { - return (); + return ; } - const handleAuthorize = (accountId: string) => authorize(accountId); - const handleReject = (accountId: string) => reject(accountId); + async function handleAuthorize(account: AccountEntity) { + try { + await authorize(account.id); + } catch (_e) { + toast.error(intl.formatMessage(messages.authorizeFail, { name: account.username })); + } + } + + async function handleReject(account: AccountEntity) { + try { + await reject(account.id); + } catch (_e) { + toast.error(intl.formatMessage(messages.rejectFail, { name: account.username })); + } + } return ( From 4f5866d43fdfb2bff5d670552e70d4dddfe60924 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 19:33:29 -0500 Subject: [PATCH 16/44] CHANGELOG: authorize/reject buttons --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e04be802..8a4937018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Posts: truncate Nostr pubkeys in reply mentions. - Posts: upgraded emoji picker component. +- UI: unified design of "approve" and "reject" buttons in follow requests and waitlist. ### Fixed - Posts: fixed emojis being cut off in reactions modal. From 9ca384dcd7b92a218330324ff6844e0800de5697 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 19:37:46 -0500 Subject: [PATCH 17/44] Card: fix back button not having a rounded border --- app/soapbox/components/ui/card/card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/components/ui/card/card.tsx b/app/soapbox/components/ui/card/card.tsx index 927b6944a..aedf3e132 100644 --- a/app/soapbox/components/ui/card/card.tsx +++ b/app/soapbox/components/ui/card/card.tsx @@ -64,7 +64,7 @@ const CardHeader: React.FC = ({ className, children, backHref, onBa const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick }; return ( - + {intl.formatMessage(messages.back)} From 3a12b316d9d177c177d45f5cae08b7216f931e84 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 20:02:58 -0500 Subject: [PATCH 18/44] GroupPage: add pending members counter --- app/soapbox/pages/group-page.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/soapbox/pages/group-page.tsx b/app/soapbox/pages/group-page.tsx index bc7144f10..954df3a00 100644 --- a/app/soapbox/pages/group-page.tsx +++ b/app/soapbox/pages/group-page.tsx @@ -12,6 +12,7 @@ import { SignUpPanel, } from 'soapbox/features/ui/util/async-components'; import { useGroup, useOwnAccount } from 'soapbox/hooks'; +import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests'; import { Group } from 'soapbox/schemas'; import { Tabs } from '../components/ui'; @@ -63,6 +64,7 @@ const GroupPage: React.FC = ({ params, children }) => { const id = params?.id || ''; const { group } = useGroup(id); + const { accounts: pending } = useGroupMembershipRequests(id); const isMember = !!group?.relationship?.member; const isBlocked = group?.relationship?.blocked_by; @@ -78,6 +80,7 @@ const GroupPage: React.FC = ({ params, children }) => { text: intl.formatMessage(messages.members), to: `/groups/${group?.id}/members`, name: '/groups/:id/members', + count: pending.length, }, ]; From b87af6a71c6297e26bee4cab8eb1348f818dbacb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 20:11:21 -0500 Subject: [PATCH 19/44] AuthorizeRejectButtons: fix styles, make less fragile --- app/soapbox/components/authorize-reject-buttons.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/soapbox/components/authorize-reject-buttons.tsx b/app/soapbox/components/authorize-reject-buttons.tsx index 690f6a04f..268363721 100644 --- a/app/soapbox/components/authorize-reject-buttons.tsx +++ b/app/soapbox/components/authorize-reject-buttons.tsx @@ -37,15 +37,15 @@ const AuthorizeRejectButtons: React.FC = ({ onAuthorize From 2196d9e3e56c7221fa30a99fdfd5f3215f788da9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 21:23:10 -0500 Subject: [PATCH 20/44] yarn i18n --- app/soapbox/locales/en.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 0c0b8aff0..4f738e9f4 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -192,6 +192,7 @@ "auth.invalid_credentials": "Wrong username or password", "auth.logged_out": "Logged out.", "auth_layout.register": "Create an account", + "authorize.success": "Authorized", "backups.actions.create": "Create backup", "backups.empty_message": "No backups found. {action}", "backups.empty_message.action": "Create one now?", @@ -767,16 +768,14 @@ "getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).", "group.cancel_request": "Cancel Request", "group.demote.user.success": "@{name} is now a member", - "group.group_mod_authorize": "Accept", - "group.group_mod_authorize.success": "Accepted @{name} to group", + "group.group_mod_authorize.fail": "Failed to approve @{name}", "group.group_mod_block": "Ban from group", "group.group_mod_block.success": "@{name} is banned", "group.group_mod_demote": "Remove {role} role", "group.group_mod_kick": "Kick @{name} from group", "group.group_mod_kick.success": "Kicked @{name} from group", "group.group_mod_promote_mod": "Assign {role} role", - "group.group_mod_reject": "Reject", - "group.group_mod_reject.success": "Rejected @{name} from group", + "group.group_mod_reject.fail": "Failed to reject @{name}", "group.group_mod_unblock": "Unblock", "group.group_mod_unblock.success": "Unblocked @{name} from group", "group.header.alt": "Group header", @@ -1201,6 +1200,7 @@ "registrations.unprocessable_entity": "This username has already been taken.", "registrations.username.hint": "May only contain A-Z, 0-9, and underscores", "registrations.username.label": "Your username", + "reject.success": "Rejected", "relative_time.days": "{number}d", "relative_time.hours": "{number}h", "relative_time.just_now": "now", From a8be701ea05ba6f2dca033a52023819802c4c668 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 21:31:07 -0500 Subject: [PATCH 21/44] Fix PendingGroupsRow test --- app/soapbox/components/pending-items-row.tsx | 2 +- .../groups/components/__tests__/pending-group-rows.test.tsx | 6 +++--- .../features/groups/components/pending-groups-row.tsx | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/soapbox/components/pending-items-row.tsx b/app/soapbox/components/pending-items-row.tsx index 0081d33e5..4fbf236cd 100644 --- a/app/soapbox/components/pending-items-row.tsx +++ b/app/soapbox/components/pending-items-row.tsx @@ -16,7 +16,7 @@ interface IPendingItemsRow { const PendingItemsRow: React.FC = ({ to, count, size = 'md' }) => { return ( - +
', () => { it('should not render', () => { renderApp(store); - expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(0); + expect(screen.queryAllByTestId('pending-items-row')).toHaveLength(0); }); }); @@ -69,7 +69,7 @@ describe('', () => { it('should not render', () => { renderApp(store); - expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(0); + expect(screen.queryAllByTestId('pending-items-row')).toHaveLength(0); }); }); @@ -95,7 +95,7 @@ describe('', () => { renderApp(store); await waitFor(() => { - expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(1); + expect(screen.queryAllByTestId('pending-items-row')).toHaveLength(1); }); }); }); diff --git a/app/soapbox/features/groups/components/pending-groups-row.tsx b/app/soapbox/features/groups/components/pending-groups-row.tsx index 8f5bfde4b..4d2760760 100644 --- a/app/soapbox/features/groups/components/pending-groups-row.tsx +++ b/app/soapbox/features/groups/components/pending-groups-row.tsx @@ -17,7 +17,6 @@ export default () => { return ( <> Date: Tue, 21 Mar 2023 11:56:48 -0500 Subject: [PATCH 22/44] "Authorized" --> "Approved" --- app/soapbox/components/authorize-reject-buttons.tsx | 2 +- app/soapbox/locales/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/components/authorize-reject-buttons.tsx b/app/soapbox/components/authorize-reject-buttons.tsx index 268363721..66d8d1d4a 100644 --- a/app/soapbox/components/authorize-reject-buttons.tsx +++ b/app/soapbox/components/authorize-reject-buttons.tsx @@ -54,7 +54,7 @@ const AuthorizeRejectButtons: React.FC = ({ onAuthorize return (
- +
); diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 4f738e9f4..8d3ab3b17 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -192,7 +192,7 @@ "auth.invalid_credentials": "Wrong username or password", "auth.logged_out": "Logged out.", "auth_layout.register": "Create an account", - "authorize.success": "Authorized", + "authorize.success": "Approved", "backups.actions.create": "Create backup", "backups.empty_message": "No backups found. {action}", "backups.empty_message.action": "Create one now?", From 1954848c653e4b345644029dee2c4bd5c8cfb3c8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 13:23:45 -0500 Subject: [PATCH 23/44] Tabs: vertically center the counter --- app/soapbox/components/ui/tabs/tabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/components/ui/tabs/tabs.tsx b/app/soapbox/components/ui/tabs/tabs.tsx index 75c80a363..d94ecb7a8 100644 --- a/app/soapbox/components/ui/tabs/tabs.tsx +++ b/app/soapbox/components/ui/tabs/tabs.tsx @@ -156,7 +156,7 @@ const Tabs = ({ items, activeItem }: ITabs) => { >
{count ? ( - + ) : null} From ee1b1b4397b76491c04a1309bada6ca9b7fb3337 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 13:47:18 -0500 Subject: [PATCH 24/44] GroupMembers: fix pending row borders and dark mode --- app/soapbox/features/group/group-members.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/soapbox/features/group/group-members.tsx b/app/soapbox/features/group/group-members.tsx index b20a37c71..34dbb04c3 100644 --- a/app/soapbox/features/group/group-members.tsx +++ b/app/soapbox/features/group/group-members.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import React, { useMemo } from 'react'; import { PendingItemsRow } from 'soapbox/components/pending-items-row'; @@ -44,12 +45,14 @@ const GroupMembers: React.FC = (props) => { showLoading={!group || isLoading && members.length === 0} placeholderComponent={PlaceholderAccount} placeholderCount={3} - className='divide-y divide-solid divide-gray-300' + className='divide-y divide-solid divide-gray-200 dark:divide-gray-800' itemClassName='py-3 last:pb-0' - > - {(pending.length > 0) && ( - + prepend={(pending.length > 0) && ( +
+ +
)} + > {members.map((member) => ( Date: Wed, 22 Mar 2023 14:05:24 -0500 Subject: [PATCH 25/44] Add separate useDeleteEntity hook accepting a callback --- .../entity-store/hooks/useDeleteEntity.ts | 37 ++++++++++++++++ .../entity-store/hooks/useEntityActions.ts | 42 +++++-------------- 2 files changed, 48 insertions(+), 31 deletions(-) create mode 100644 app/soapbox/entity-store/hooks/useDeleteEntity.ts diff --git a/app/soapbox/entity-store/hooks/useDeleteEntity.ts b/app/soapbox/entity-store/hooks/useDeleteEntity.ts new file mode 100644 index 000000000..006912a9d --- /dev/null +++ b/app/soapbox/entity-store/hooks/useDeleteEntity.ts @@ -0,0 +1,37 @@ +import { useAppDispatch, useGetState } from 'soapbox/hooks'; + +import { deleteEntities, importEntities } from '../actions'; + +interface DeleteEntityResult { + result: T +} + +type DeleteFn = (entityId: string) => Promise | T; + +function useDeleteEntity(entityType: string, deleteFn: DeleteFn) { + const dispatch = useAppDispatch(); + const getState = useGetState(); + + return async function deleteEntity(entityId: string): Promise> { + // Get the entity before deleting, so we can reverse the action if the API request fails. + const entity = getState().entities[entityType]?.store[entityId]; + + // Optimistically delete the entity from the _store_ but keep the lists in tact. + dispatch(deleteEntities([entityId], entityType, { preserveLists: true })); + + try { + const result = await deleteFn(entityId); + // Success - finish deleting entity from the state. + dispatch(deleteEntities([entityId], entityType)); + return { result }; + } catch (e) { + if (entity) { + // If the API failed, reimport the entity. + dispatch(importEntities([entity], entityType)); + } + throw e; + } + }; +} + +export { useDeleteEntity }; \ 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 eede5bcb3..8f286633a 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -1,8 +1,10 @@ import { z } from 'zod'; -import { useApi, useAppDispatch, useGetState } from 'soapbox/hooks'; +import { useApi, useAppDispatch } from 'soapbox/hooks'; -import { deleteEntities, importEntities } from '../actions'; +import { importEntities } from '../actions'; + +import { useDeleteEntity } from './useDeleteEntity'; import type { Entity } from '../types'; import type { EntitySchema } from './types'; @@ -19,10 +21,6 @@ interface CreateEntityResult { entity: TEntity } -interface DeleteEntityResult { - response: AxiosResponse -} - interface EntityActionEndpoints { post?: string delete?: string @@ -37,10 +35,15 @@ function useEntityActions( endpoints: EntityActionEndpoints, opts: UseEntityActionsOpts = {}, ) { + const [entityType, listKey] = path; + const api = useApi(); const dispatch = useAppDispatch(); - const getState = useGetState(); - const [entityType, listKey] = path; + + const deleteEntity = useDeleteEntity(entityType, (entityId) => { + if (!endpoints.delete) return Promise.reject(endpoints); + return api.delete(endpoints.delete.replace(':id', entityId)); + }); function createEntity(params: P, callbacks: EntityCallbacks = {}): Promise> { if (!endpoints.post) return Promise.reject(endpoints); @@ -63,29 +66,6 @@ function useEntityActions( }); } - function deleteEntity(entityId: string): Promise { - if (!endpoints.delete) return Promise.reject(endpoints); - // Get the entity before deleting, so we can reverse the action if the API request fails. - const entity = getState().entities[entityType]?.store[entityId]; - // Optimistically delete the entity from the _store_ but keep the lists in tact. - dispatch(deleteEntities([entityId], entityType, { preserveLists: true })); - - return api.delete(endpoints.delete.replaceAll(':id', entityId)).then((response) => { - // Success - finish deleting entity from the state. - dispatch(deleteEntities([entityId], entityType)); - - return { - response, - }; - }).catch((e) => { - if (entity) { - // If the API failed, reimport the entity. - dispatch(importEntities([entity], entityType)); - } - throw e; - }); - } - return { createEntity: createEntity, deleteEntity: endpoints.delete ? deleteEntity : undefined, From 3d72e6305f7efe365f46c52407c99faf8aaad888 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 14:34:10 -0500 Subject: [PATCH 26/44] EntityStory: add dismissEntities action for deleting ids from a list --- .../entity-store/__tests__/reducer.test.ts | 28 ++++++++++++++++++- app/soapbox/entity-store/actions.ts | 13 +++++++++ app/soapbox/entity-store/reducer.ts | 23 +++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/app/soapbox/entity-store/__tests__/reducer.test.ts b/app/soapbox/entity-store/__tests__/reducer.test.ts index df0ec8e57..7d4e6db9c 100644 --- a/app/soapbox/entity-store/__tests__/reducer.test.ts +++ b/app/soapbox/entity-store/__tests__/reducer.test.ts @@ -1,4 +1,10 @@ -import { deleteEntities, entitiesFetchFail, entitiesFetchRequest, importEntities } from '../actions'; +import { + deleteEntities, + dismissEntities, + entitiesFetchFail, + entitiesFetchRequest, + importEntities, +} from '../actions'; import reducer, { State } from '../reducer'; import { createListState } from '../utils'; @@ -97,4 +103,24 @@ test('deleting items', () => { expect(result.TestEntity!.store).toMatchObject({ '2': { id: '2' } }); expect([...result.TestEntity!.lists['']!.ids]).toEqual(['2']); +}); + +test('dismiss items', () => { + const state: State = { + TestEntity: { + store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } }, + lists: { + 'yolo': { + ids: new Set(['1', '2', '3']), + state: createListState(), + }, + }, + }, + }; + + const action = dismissEntities(['3', '1'], 'TestEntity', 'yolo'); + const result = reducer(state, action); + + expect(result.TestEntity!.store).toMatchObject(state.TestEntity!.store); + expect([...result.TestEntity!.lists.yolo!.ids]).toEqual(['2']); }); \ No newline at end of file diff --git a/app/soapbox/entity-store/actions.ts b/app/soapbox/entity-store/actions.ts index 4d9355cd8..5a05100c8 100644 --- a/app/soapbox/entity-store/actions.ts +++ b/app/soapbox/entity-store/actions.ts @@ -2,6 +2,7 @@ import type { Entity, EntityListState } from './types'; const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const; const ENTITIES_DELETE = 'ENTITIES_DELETE' as const; +const ENTITIES_DISMISS = 'ENTITIES_DISMISS' as const; const ENTITIES_FETCH_REQUEST = 'ENTITIES_FETCH_REQUEST' as const; const ENTITIES_FETCH_SUCCESS = 'ENTITIES_FETCH_SUCCESS' as const; const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const; @@ -29,6 +30,15 @@ function deleteEntities(ids: Iterable, entityType: string, opts: DeleteE }; } +function dismissEntities(ids: Iterable, entityType: string, listKey: string) { + return { + type: ENTITIES_DISMISS, + ids, + entityType, + listKey, + }; +} + function entitiesFetchRequest(entityType: string, listKey?: string) { return { type: ENTITIES_FETCH_REQUEST, @@ -60,6 +70,7 @@ function entitiesFetchFail(entityType: string, listKey: string | undefined, erro type EntityAction = ReturnType | ReturnType + | ReturnType | ReturnType | ReturnType | ReturnType; @@ -67,11 +78,13 @@ type EntityAction = export { ENTITIES_IMPORT, ENTITIES_DELETE, + ENTITIES_DISMISS, ENTITIES_FETCH_REQUEST, ENTITIES_FETCH_SUCCESS, ENTITIES_FETCH_FAIL, importEntities, deleteEntities, + dismissEntities, entitiesFetchRequest, entitiesFetchSuccess, entitiesFetchFail, diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts index 891e42f4c..448de33ab 100644 --- a/app/soapbox/entity-store/reducer.ts +++ b/app/soapbox/entity-store/reducer.ts @@ -3,6 +3,7 @@ import produce, { enableMapSet } from 'immer'; import { ENTITIES_IMPORT, ENTITIES_DELETE, + ENTITIES_DISMISS, ENTITIES_FETCH_REQUEST, ENTITIES_FETCH_SUCCESS, ENTITIES_FETCH_FAIL, @@ -68,6 +69,26 @@ const deleteEntities = ( }); }; +const dismissEntities = ( + state: State, + entityType: string, + ids: Iterable, + listKey: string, +) => { + return produce(state, draft => { + const cache = draft[entityType] ?? createCache(); + const list = cache.lists[listKey]; + + if (list) { + for (const id of ids) { + list.ids.delete(id); + } + + draft[entityType] = cache; + } + }); +}; + const setFetching = ( state: State, entityType: string, @@ -96,6 +117,8 @@ function reducer(state: Readonly = {}, action: EntityAction): State { return importEntities(state, action.entityType, action.entities, action.listKey); case ENTITIES_DELETE: return deleteEntities(state, action.entityType, action.ids, action.opts); + case ENTITIES_DISMISS: + return dismissEntities(state, action.entityType, action.ids, action.listKey); case ENTITIES_FETCH_SUCCESS: return importEntities(state, action.entityType, action.entities, action.listKey, action.newState); case ENTITIES_FETCH_REQUEST: From b76559f24a3dd124330ec864857f2ac7c1d5cc30 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 14:40:18 -0500 Subject: [PATCH 27/44] Add useDismissEntity hook, update useDeleteEntity to match --- .../entity-store/hooks/useDeleteEntity.ts | 13 +++++----- .../entity-store/hooks/useDismissEntity.ts | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 app/soapbox/entity-store/hooks/useDismissEntity.ts diff --git a/app/soapbox/entity-store/hooks/useDeleteEntity.ts b/app/soapbox/entity-store/hooks/useDeleteEntity.ts index 006912a9d..c13482dab 100644 --- a/app/soapbox/entity-store/hooks/useDeleteEntity.ts +++ b/app/soapbox/entity-store/hooks/useDeleteEntity.ts @@ -2,17 +2,18 @@ import { useAppDispatch, useGetState } from 'soapbox/hooks'; import { deleteEntities, importEntities } from '../actions'; -interface DeleteEntityResult { - result: T -} - type DeleteFn = (entityId: string) => Promise | T; +/** + * Optimistically deletes an entity from the store. + * This hook should be used to globally delete an entity from all lists. + * To remove an entity from a single list, see `useDismissEntity`. + */ function useDeleteEntity(entityType: string, deleteFn: DeleteFn) { const dispatch = useAppDispatch(); const getState = useGetState(); - return async function deleteEntity(entityId: string): Promise> { + return async function deleteEntity(entityId: string): Promise { // Get the entity before deleting, so we can reverse the action if the API request fails. const entity = getState().entities[entityType]?.store[entityId]; @@ -23,7 +24,7 @@ function useDeleteEntity(entityType: string, deleteFn: DeleteFn) const result = await deleteFn(entityId); // Success - finish deleting entity from the state. dispatch(deleteEntities([entityId], entityType)); - return { result }; + return result; } catch (e) { if (entity) { // If the API failed, reimport the entity. diff --git a/app/soapbox/entity-store/hooks/useDismissEntity.ts b/app/soapbox/entity-store/hooks/useDismissEntity.ts new file mode 100644 index 000000000..65eb8599f --- /dev/null +++ b/app/soapbox/entity-store/hooks/useDismissEntity.ts @@ -0,0 +1,24 @@ +import { useAppDispatch } from 'soapbox/hooks'; + +import { dismissEntities } from '../actions'; + +type EntityPath = [entityType: string, listKey: string] +type DismissFn = (entityId: string) => Promise | T; + +/** + * Removes an entity from a specific list. + * To remove an entity globally from all lists, see `useDeleteEntity`. + */ +function useDismissEntity(path: EntityPath, dismissFn: DismissFn) { + const [entityType, listKey] = path; + const dispatch = useAppDispatch(); + + // TODO: optimistic dismissing + return async function dismissEntity(entityId: string): Promise { + const result = await dismissFn(entityId); + dispatch(dismissEntities([entityId], entityType, listKey)); + return result; + }; +} + +export { useDismissEntity }; \ No newline at end of file From b1270251679c41cb41488bd1d9ea6f7ca5a47ce2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 15:31:58 -0500 Subject: [PATCH 28/44] Move useCreateEntity into its own hook as well, because why not --- .../entity-store/hooks/useCreateEntity.ts | 74 +++++++++++++++++++ .../entity-store/hooks/useEntityActions.ts | 49 +++--------- 2 files changed, 83 insertions(+), 40 deletions(-) create mode 100644 app/soapbox/entity-store/hooks/useCreateEntity.ts diff --git a/app/soapbox/entity-store/hooks/useCreateEntity.ts b/app/soapbox/entity-store/hooks/useCreateEntity.ts new file mode 100644 index 000000000..72873504b --- /dev/null +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -0,0 +1,74 @@ +import { z } from 'zod'; + +import { useAppDispatch } from 'soapbox/hooks'; + +import { importEntities } from '../actions'; + +import type { Entity } from '../types'; +import type { EntitySchema } from './types'; + +type EntityPath = [entityType: string, listKey?: string] +type CreateFn = (params: Params) => Promise | Result; + +interface UseCreateEntityOpts { + schema?: EntitySchema +} + +type CreateEntityResult = + { + success: true + result: Result + entity: TEntity + } | { + success: false + error: Error + } + +interface EntityCallbacks { + onSuccess?(entity: TEntity): void + onError?(error: Error): void +} + +function useCreateEntity( + path: EntityPath, + createFn: CreateFn, + opts: UseCreateEntityOpts = {}, +) { + const [entityType, listKey] = path; + const dispatch = useAppDispatch(); + + return async function createEntity( + params: Params, + callbacks: EntityCallbacks = {}, + ): Promise> { + try { + const result = await createFn(params); + const schema = opts.schema || z.custom(); + const entity = schema.parse(result); + + // TODO: optimistic updating + dispatch(importEntities([entity], entityType, listKey)); + + if (callbacks.onSuccess) { + callbacks.onSuccess(entity); + } + + return { + success: true, + result, + entity, + }; + } catch (error) { + if (callbacks.onError) { + callbacks.onError(error); + } + + return { + success: false, + error, + }; + } + }; +} + +export { useCreateEntity }; \ 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 8f286633a..8a259c0e0 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -1,14 +1,10 @@ -import { z } from 'zod'; - -import { useApi, useAppDispatch } from 'soapbox/hooks'; - -import { importEntities } from '../actions'; +import { useApi } from 'soapbox/hooks'; +import { useCreateEntity } from './useCreateEntity'; import { useDeleteEntity } from './useDeleteEntity'; import type { Entity } from '../types'; import type { EntitySchema } from './types'; -import type { AxiosResponse } from 'axios'; type EntityPath = [entityType: string, listKey?: string] @@ -16,59 +12,32 @@ interface UseEntityActionsOpts { schema?: EntitySchema } -interface CreateEntityResult { - response: AxiosResponse - entity: TEntity -} - interface EntityActionEndpoints { post?: string delete?: string } -interface EntityCallbacks { - onSuccess?(entity: TEntity): void -} - -function useEntityActions( +function useEntityActions( path: EntityPath, endpoints: EntityActionEndpoints, opts: UseEntityActionsOpts = {}, ) { - const [entityType, listKey] = path; - const api = useApi(); - const dispatch = useAppDispatch(); + const [entityType] = path; const deleteEntity = useDeleteEntity(entityType, (entityId) => { if (!endpoints.delete) return Promise.reject(endpoints); return api.delete(endpoints.delete.replace(':id', entityId)); }); - function createEntity(params: P, callbacks: EntityCallbacks = {}): Promise> { + const createEntity = useCreateEntity(path, (params: Params) => { if (!endpoints.post) return Promise.reject(endpoints); - - return api.post(endpoints.post, params).then((response) => { - const schema = opts.schema || z.custom(); - const entity = schema.parse(response.data); - - // TODO: optimistic updating - dispatch(importEntities([entity], entityType, listKey)); - - if (callbacks.onSuccess) { - callbacks.onSuccess(entity); - } - - return { - response, - entity, - }; - }); - } + return api.post(endpoints.post, params); + }, opts); return { - createEntity: createEntity, - deleteEntity: endpoints.delete ? deleteEntity : undefined, + createEntity, + deleteEntity, }; } From d2fd9e03876b55d40c0c8a856a2395cb0bfcaedd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 15:32:56 -0500 Subject: [PATCH 29/44] Export new entity hooks --- app/soapbox/entity-store/hooks/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/soapbox/entity-store/hooks/index.ts b/app/soapbox/entity-store/hooks/index.ts index af27c8f3e..09fe0c960 100644 --- a/app/soapbox/entity-store/hooks/index.ts +++ b/app/soapbox/entity-store/hooks/index.ts @@ -1,3 +1,6 @@ export { useEntities } from './useEntities'; export { useEntity } from './useEntity'; -export { useEntityActions } from './useEntityActions'; \ No newline at end of file +export { useEntityActions } from './useEntityActions'; +export { useCreateEntity } from './useCreateEntity'; +export { useDeleteEntity } from './useDeleteEntity'; +export { useDismissEntity } from './useDismissEntity'; \ No newline at end of file From 8f67d2c76fd1efce4d2b8ac9a60b967b24313151 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 16:06:10 -0500 Subject: [PATCH 30/44] EntityStore: consolidate types, fix type of "path" --- app/soapbox/entity-store/hooks/types.ts | 28 ++++++++++++++- .../entity-store/hooks/useCreateEntity.ts | 11 +++--- .../entity-store/hooks/useDismissEntity.ts | 9 +++-- app/soapbox/entity-store/hooks/useEntities.ts | 34 +++++++------------ app/soapbox/entity-store/hooks/useEntity.ts | 4 +-- .../entity-store/hooks/useEntityActions.ts | 8 ++--- app/soapbox/entity-store/hooks/utils.ts | 9 +++++ app/soapbox/hooks/useGroups.ts | 2 +- 8 files changed, 68 insertions(+), 37 deletions(-) create mode 100644 app/soapbox/entity-store/hooks/utils.ts diff --git a/app/soapbox/entity-store/hooks/types.ts b/app/soapbox/entity-store/hooks/types.ts index 89992c12d..7ce99fd82 100644 --- a/app/soapbox/entity-store/hooks/types.ts +++ b/app/soapbox/entity-store/hooks/types.ts @@ -3,4 +3,30 @@ import type z from 'zod'; type EntitySchema = z.ZodType; -export type { EntitySchema }; \ No newline at end of file +/** + * Tells us where to find/store the entity in the cache. + * This value is accepted in hooks, but needs to be parsed into an `EntitiesPath` + * before being passed to the store. + */ +type ExpandedEntitiesPath = [ + /** Name of the entity type for use in the global cache, eg `'Notification'`. */ + entityType: string, + /** + * Name of a particular index of this entity type. + * Multiple params get combined into one string with a `:` separator. + */ + ...listKeys: string[], +] + +/** Used to look up an entity in a list. */ +type EntitiesPath = [entityType: string, listKey: string] + +/** Used to look up a single entity by its ID. */ +type EntityPath = [entityType: string, entityId: string] + +export type { + EntitySchema, + ExpandedEntitiesPath, + EntitiesPath, + EntityPath, +}; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useCreateEntity.ts b/app/soapbox/entity-store/hooks/useCreateEntity.ts index 72873504b..719bed971 100644 --- a/app/soapbox/entity-store/hooks/useCreateEntity.ts +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -4,10 +4,11 @@ import { useAppDispatch } from 'soapbox/hooks'; import { importEntities } from '../actions'; -import type { Entity } from '../types'; -import type { EntitySchema } from './types'; +import { parseEntitiesPath } from './utils'; + +import type { Entity } from '../types'; +import type { EntitySchema, ExpandedEntitiesPath } from './types'; -type EntityPath = [entityType: string, listKey?: string] type CreateFn = (params: Params) => Promise | Result; interface UseCreateEntityOpts { @@ -30,11 +31,13 @@ interface EntityCallbacks { } function useCreateEntity( - path: EntityPath, + expandedPath: ExpandedEntitiesPath, createFn: CreateFn, opts: UseCreateEntityOpts = {}, ) { + const path = parseEntitiesPath(expandedPath); const [entityType, listKey] = path; + const dispatch = useAppDispatch(); return async function createEntity( diff --git a/app/soapbox/entity-store/hooks/useDismissEntity.ts b/app/soapbox/entity-store/hooks/useDismissEntity.ts index 65eb8599f..c887e9490 100644 --- a/app/soapbox/entity-store/hooks/useDismissEntity.ts +++ b/app/soapbox/entity-store/hooks/useDismissEntity.ts @@ -2,15 +2,20 @@ import { useAppDispatch } from 'soapbox/hooks'; import { dismissEntities } from '../actions'; -type EntityPath = [entityType: string, listKey: string] +import { parseEntitiesPath } from './utils'; + +import type { ExpandedEntitiesPath } from './types'; + type DismissFn = (entityId: string) => Promise | T; /** * Removes an entity from a specific list. * To remove an entity globally from all lists, see `useDeleteEntity`. */ -function useDismissEntity(path: EntityPath, dismissFn: DismissFn) { +function useDismissEntity(expandedPath: ExpandedEntitiesPath, dismissFn: DismissFn) { + const path = parseEntitiesPath(expandedPath); const [entityType, listKey] = path; + const dispatch = useAppDispatch(); // TODO: optimistic dismissing diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index 6945ccd8d..dec6aefd4 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -7,21 +7,11 @@ import { filteredArray } from 'soapbox/schemas/utils'; import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '../actions'; -import type { Entity, EntityListState } from '../types'; -import type { EntitySchema } from './types'; -import type { RootState } from 'soapbox/store'; +import { parseEntitiesPath } from './utils'; -/** Tells us where to find/store the entity in the cache. */ -type EntityPath = [ - /** Name of the entity type for use in the global cache, eg `'Notification'`. */ - entityType: string, - /** - * Name of a particular index of this entity type. - * Multiple params get combined into one string with a `:` separator. - * You can use empty-string (`''`) if you don't need separate lists. - */ - ...listKeys: string[], -] +import type { Entity, EntityListState } from '../types'; +import type { EntitiesPath, EntitySchema, ExpandedEntitiesPath } from './types'; +import type { RootState } from 'soapbox/store'; /** Additional options for the hook. */ interface UseEntitiesOpts { @@ -39,7 +29,7 @@ interface UseEntitiesOpts { /** A hook for fetching and displaying API entities. */ function useEntities( /** Tells us where to find/store the entity in the cache. */ - path: EntityPath, + expandedPath: ExpandedEntitiesPath, /** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */ endpoint: string | undefined, /** Additional options for the hook. */ @@ -49,8 +39,8 @@ function useEntities( const dispatch = useAppDispatch(); const getState = useGetState(); - const [entityType, ...listKeys] = path; - const listKey = listKeys.join(':'); + const path = parseEntitiesPath(expandedPath); + const [entityType, listKey] = path; const entities = useAppSelector(state => selectEntities(state, path)); @@ -128,10 +118,10 @@ function useEntities( } /** Get cache at path from Redux. */ -const selectCache = (state: RootState, path: EntityPath) => state.entities[path[0]]; +const selectCache = (state: RootState, path: EntitiesPath) => state.entities[path[0]]; /** Get list at path from Redux. */ -const selectList = (state: RootState, path: EntityPath) => { +const selectList = (state: RootState, path: EntitiesPath) => { const [, ...listKeys] = path; const listKey = listKeys.join(':'); @@ -139,18 +129,18 @@ const selectList = (state: RootState, path: EntityPath) => { }; /** Select a particular item from a list state. */ -function selectListState(state: RootState, path: EntityPath, key: K) { +function selectListState(state: RootState, path: EntitiesPath, key: K) { const listState = selectList(state, path)?.state; return listState ? listState[key] : undefined; } /** Hook to get a particular item from a list state. */ -function useListState(path: EntityPath, key: K) { +function useListState(path: EntitiesPath, key: K) { return useAppSelector(state => selectListState(state, path, key)); } /** Get list of entities from Redux. */ -function selectEntities(state: RootState, path: EntityPath): readonly TEntity[] { +function selectEntities(state: RootState, path: EntitiesPath): readonly TEntity[] { const cache = selectCache(state, path); const list = selectList(state, path); diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index 1dad1ff1e..aa7b40b5d 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -6,9 +6,7 @@ import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { importEntities } from '../actions'; import type { Entity } from '../types'; -import type { EntitySchema } from './types'; - -type EntityPath = [entityType: string, entityId: string] +import type { EntitySchema, EntityPath } from './types'; /** Additional options for the hook. */ interface UseEntityOpts { diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts index 8a259c0e0..96def69ab 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -2,11 +2,10 @@ import { useApi } from 'soapbox/hooks'; import { useCreateEntity } from './useCreateEntity'; import { useDeleteEntity } from './useDeleteEntity'; +import { parseEntitiesPath } from './utils'; import type { Entity } from '../types'; -import type { EntitySchema } from './types'; - -type EntityPath = [entityType: string, listKey?: string] +import type { EntitySchema, ExpandedEntitiesPath } from './types'; interface UseEntityActionsOpts { schema?: EntitySchema @@ -18,11 +17,12 @@ interface EntityActionEndpoints { } function useEntityActions( - path: EntityPath, + expandedPath: ExpandedEntitiesPath, endpoints: EntityActionEndpoints, opts: UseEntityActionsOpts = {}, ) { const api = useApi(); + const path = parseEntitiesPath(expandedPath); const [entityType] = path; const deleteEntity = useDeleteEntity(entityType, (entityId) => { diff --git a/app/soapbox/entity-store/hooks/utils.ts b/app/soapbox/entity-store/hooks/utils.ts new file mode 100644 index 000000000..69568b25a --- /dev/null +++ b/app/soapbox/entity-store/hooks/utils.ts @@ -0,0 +1,9 @@ +import type { EntitiesPath, ExpandedEntitiesPath } from './types'; + +function parseEntitiesPath(expandedPath: ExpandedEntitiesPath): EntitiesPath { + const [entityType, ...listKeys] = expandedPath; + const listKey = (listKeys || []).join(':'); + return [entityType, listKey]; +} + +export { parseEntitiesPath }; \ No newline at end of file diff --git a/app/soapbox/hooks/useGroups.ts b/app/soapbox/hooks/useGroups.ts index 865896e24..d77b49865 100644 --- a/app/soapbox/hooks/useGroups.ts +++ b/app/soapbox/hooks/useGroups.ts @@ -7,7 +7,7 @@ import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/grou function useGroups() { const { entities, ...result } = useEntities( - [Entities.GROUPS, ''], + [Entities.GROUPS], '/api/v1/groups', { schema: groupSchema }, ); From 61fb434a54ee17c8bcd6b7b57d50db9adf7f767d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 16:12:05 -0500 Subject: [PATCH 31/44] Improve API of parseEntitiesPath --- app/soapbox/entity-store/hooks/useCreateEntity.ts | 3 +-- app/soapbox/entity-store/hooks/useDismissEntity.ts | 3 +-- app/soapbox/entity-store/hooks/useEntities.ts | 4 +--- app/soapbox/entity-store/hooks/useEntityActions.ts | 3 +-- app/soapbox/entity-store/hooks/utils.ts | 10 ++++++++-- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/app/soapbox/entity-store/hooks/useCreateEntity.ts b/app/soapbox/entity-store/hooks/useCreateEntity.ts index 719bed971..373434e73 100644 --- a/app/soapbox/entity-store/hooks/useCreateEntity.ts +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -35,8 +35,7 @@ function useCreateEntity, opts: UseCreateEntityOpts = {}, ) { - const path = parseEntitiesPath(expandedPath); - const [entityType, listKey] = path; + const { entityType, listKey } = parseEntitiesPath(expandedPath); const dispatch = useAppDispatch(); diff --git a/app/soapbox/entity-store/hooks/useDismissEntity.ts b/app/soapbox/entity-store/hooks/useDismissEntity.ts index c887e9490..1ba5f4a60 100644 --- a/app/soapbox/entity-store/hooks/useDismissEntity.ts +++ b/app/soapbox/entity-store/hooks/useDismissEntity.ts @@ -13,8 +13,7 @@ type DismissFn = (entityId: string) => Promise | T; * To remove an entity globally from all lists, see `useDeleteEntity`. */ function useDismissEntity(expandedPath: ExpandedEntitiesPath, dismissFn: DismissFn) { - const path = parseEntitiesPath(expandedPath); - const [entityType, listKey] = path; + const { entityType, listKey } = parseEntitiesPath(expandedPath); const dispatch = useAppDispatch(); diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index dec6aefd4..309accf1b 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -39,9 +39,7 @@ function useEntities( const dispatch = useAppDispatch(); const getState = useGetState(); - const path = parseEntitiesPath(expandedPath); - const [entityType, listKey] = path; - + const { entityType, listKey, path } = parseEntitiesPath(expandedPath); const entities = useAppSelector(state => selectEntities(state, path)); const isEnabled = opts.enabled ?? true; diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts index 96def69ab..9406b3809 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -22,8 +22,7 @@ function useEntityActions( opts: UseEntityActionsOpts = {}, ) { const api = useApi(); - const path = parseEntitiesPath(expandedPath); - const [entityType] = path; + const { entityType, path } = parseEntitiesPath(expandedPath); const deleteEntity = useDeleteEntity(entityType, (entityId) => { if (!endpoints.delete) return Promise.reject(endpoints); diff --git a/app/soapbox/entity-store/hooks/utils.ts b/app/soapbox/entity-store/hooks/utils.ts index 69568b25a..d137ca1fb 100644 --- a/app/soapbox/entity-store/hooks/utils.ts +++ b/app/soapbox/entity-store/hooks/utils.ts @@ -1,9 +1,15 @@ import type { EntitiesPath, ExpandedEntitiesPath } from './types'; -function parseEntitiesPath(expandedPath: ExpandedEntitiesPath): EntitiesPath { +function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) { const [entityType, ...listKeys] = expandedPath; const listKey = (listKeys || []).join(':'); - return [entityType, listKey]; + const path: EntitiesPath = [entityType, listKey]; + + return { + entityType, + listKey, + path, + }; } export { parseEntitiesPath }; \ No newline at end of file From b47cdb368f2497e4c431890e77096f4a67ea5e80 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 16:31:49 -0500 Subject: [PATCH 32/44] useGroupMembershipRequests: use useDismissEntity hooks --- .../api/groups/useGroupMembershipRequests.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts b/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts index d437f61ab..0f6b3818b 100644 --- a/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts +++ b/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts @@ -1,21 +1,24 @@ import { Entities } from 'soapbox/entity-store/entities'; -import { useEntities } 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 type { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types'; + function useGroupMembershipRequests(groupId: string) { const api = useApi(); + const path: ExpandedEntitiesPath = [Entities.ACCOUNTS, 'membership_requests', groupId]; - function authorize(accountId: string) { + const authorize = useDismissEntity(path, (accountId) => { return api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`); - } + }); - function reject(accountId: string) { + const reject = useDismissEntity(path, (accountId) => { return api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`); - } + }); const { entities, ...rest } = useEntities( - [Entities.ACCOUNTS, 'membership_requests', groupId], + path, `/api/v1/groups/${groupId}/membership_requests`, { schema: accountSchema }, ); From 1eed61c3862aa3e9744da6b65960420efc3b3fbb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 16:42:08 -0500 Subject: [PATCH 33/44] GroupMembershipRequests: don't clear dismissed entries until new content is fetched --- .../features/group/group-membership-requests.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/soapbox/features/group/group-membership-requests.tsx b/app/soapbox/features/group/group-membership-requests.tsx index eab20c1ef..3dc52e154 100644 --- a/app/soapbox/features/group/group-membership-requests.tsx +++ b/app/soapbox/features/group/group-membership-requests.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import Account from 'soapbox/components/account'; @@ -56,7 +56,16 @@ const GroupMembershipRequests: React.FC = ({ params }) const intl = useIntl(); const { group } = useGroup(id); - const { accounts, isLoading, authorize, reject } = useGroupMembershipRequests(id); + + const { + accounts: entities, + isLoading, + authorize, + reject, + isFetching, + } = useGroupMembershipRequests(id); + + const accounts = useMemo(() => entities, [isFetching]); if (!group || !group.relationship || isLoading) { return ( From a256665aadb393ca09e9238e6124212b71565ffb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 17:39:58 -0500 Subject: [PATCH 34/44] EntityStore: add support for X-Total-Count from the API --- .../entity-store/__tests__/reducer.test.ts | 15 ++++++++++----- app/soapbox/entity-store/hooks/useEntities.ts | 4 ++++ app/soapbox/entity-store/reducer.ts | 6 +++++- app/soapbox/entity-store/types.ts | 2 ++ app/soapbox/entity-store/utils.ts | 8 +++++++- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/app/soapbox/entity-store/__tests__/reducer.test.ts b/app/soapbox/entity-store/__tests__/reducer.test.ts index 7d4e6db9c..4b5a9752d 100644 --- a/app/soapbox/entity-store/__tests__/reducer.test.ts +++ b/app/soapbox/entity-store/__tests__/reducer.test.ts @@ -42,7 +42,8 @@ test('import entities into a list', () => { const cache = result.TestEntity as EntityCache; expect(cache.store['2']!.msg).toBe('benis'); - expect(cache.lists.thingies?.ids.size).toBe(3); + expect(cache.lists.thingies!.ids.size).toBe(3); + expect(cache.lists.thingies!.state.totalCount).toBe(3); // Now try adding an additional item. const entities2: TestEntity[] = [ @@ -54,7 +55,8 @@ test('import entities into a list', () => { const cache2 = result2.TestEntity as EntityCache; expect(cache2.store['4']!.msg).toBe('hehe'); - expect(cache2.lists.thingies?.ids.size).toBe(4); + expect(cache2.lists.thingies!.ids.size).toBe(4); + expect(cache2.lists.thingies!.state.totalCount).toBe(4); // Finally, update an item. const entities3: TestEntity[] = [ @@ -66,7 +68,8 @@ test('import entities into a list', () => { const cache3 = result3.TestEntity as EntityCache; expect(cache3.store['2']!.msg).toBe('yolofam'); - expect(cache3.lists.thingies?.ids.size).toBe(4); // unchanged + expect(cache3.lists.thingies!.ids.size).toBe(4); // unchanged + expect(cache3.lists.thingies!.state.totalCount).toBe(4); }); test('fetching updates the list state', () => { @@ -92,7 +95,7 @@ test('deleting items', () => { lists: { '': { ids: new Set(['1', '2', '3']), - state: createListState(), + state: { ...createListState(), totalCount: 3 }, }, }, }, @@ -103,6 +106,7 @@ test('deleting items', () => { expect(result.TestEntity!.store).toMatchObject({ '2': { id: '2' } }); expect([...result.TestEntity!.lists['']!.ids]).toEqual(['2']); + expect(result.TestEntity!.lists['']!.state.totalCount).toBe(1); }); test('dismiss items', () => { @@ -112,7 +116,7 @@ test('dismiss items', () => { lists: { 'yolo': { ids: new Set(['1', '2', '3']), - state: createListState(), + state: { ...createListState(), totalCount: 3 }, }, }, }, @@ -123,4 +127,5 @@ test('dismiss items', () => { expect(result.TestEntity!.store).toMatchObject(state.TestEntity!.store); expect([...result.TestEntity!.lists.yolo!.ids]).toEqual(['2']); + expect(result.TestEntity!.lists.yolo!.state.totalCount).toBe(1); }); \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index 309accf1b..0f345d675 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -47,6 +47,7 @@ function useEntities( const lastFetchedAt = useListState(path, 'lastFetchedAt'); const isFetched = useListState(path, 'fetched'); const isError = !!useListState(path, 'error'); + const totalCount = useListState(path, 'totalCount'); const next = useListState(path, 'next'); const prev = useListState(path, 'prev'); @@ -61,10 +62,12 @@ function useEntities( const response = await api.get(url); const schema = opts.schema || z.custom(); const entities = filteredArray(schema).parse(response.data); + const numItems = (selectList(getState(), path)?.ids.size || 0) + entities.length; dispatch(entitiesFetchSuccess(entities, entityType, listKey, { next: getNextLink(response), prev: getPrevLink(response), + totalCount: Number(response.headers['x-total-count'] ?? numItems) || 0, fetching: false, fetched: true, error: null, @@ -108,6 +111,7 @@ function useEntities( fetchPreviousPage, hasNextPage: !!next, hasPreviousPage: !!prev, + totalCount, isError, isFetched, isFetching, diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts index 448de33ab..4f9c1e4d2 100644 --- a/app/soapbox/entity-store/reducer.ts +++ b/app/soapbox/entity-store/reducer.ts @@ -60,7 +60,10 @@ const deleteEntities = ( if (!opts?.preserveLists) { for (const list of Object.values(cache.lists)) { - list?.ids.delete(id); + if (list) { + list.ids.delete(id); + list.state.totalCount--; + } } } } @@ -82,6 +85,7 @@ const dismissEntities = ( if (list) { for (const id of ids) { list.ids.delete(id); + list.state.totalCount--; } draft[entityType] = cache; diff --git a/app/soapbox/entity-store/types.ts b/app/soapbox/entity-store/types.ts index 0e34b62a5..09e6c0174 100644 --- a/app/soapbox/entity-store/types.ts +++ b/app/soapbox/entity-store/types.ts @@ -23,6 +23,8 @@ interface EntityListState { next: string | undefined /** Previous URL for pagination, if any. */ prev: string | undefined + /** Total number of items according to the API. */ + totalCount: number /** Error returned from the API, if any. */ error: any /** Whether data has already been fetched */ diff --git a/app/soapbox/entity-store/utils.ts b/app/soapbox/entity-store/utils.ts index 9d56ceb42..0040ae674 100644 --- a/app/soapbox/entity-store/utils.ts +++ b/app/soapbox/entity-store/utils.ts @@ -11,9 +11,14 @@ const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => { /** Update the list with new entity IDs. */ const updateList = (list: EntityList, entities: Entity[]): EntityList => { const newIds = entities.map(entity => entity.id); + const ids = new Set([...Array.from(list.ids), ...newIds]); + + const sizeDiff = ids.size - list.ids.size; + list.state.totalCount += sizeDiff; + return { ...list, - ids: new Set([...Array.from(list.ids), ...newIds]), + ids, }; }; @@ -33,6 +38,7 @@ const createList = (): EntityList => ({ const createListState = (): EntityListState => ({ next: undefined, prev: undefined, + totalCount: 0, error: null, fetched: false, fetching: false, From e2510489c5e3138391ebcdca093b23f0d0b214b2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 18:17:28 -0500 Subject: [PATCH 35/44] EntityStore: support query invalidation --- app/soapbox/entity-store/actions.ts | 14 +++++++++++++- app/soapbox/entity-store/hooks/useEntities.ts | 16 ++++++++++++++-- app/soapbox/entity-store/reducer.ts | 11 +++++++++++ app/soapbox/entity-store/types.ts | 2 ++ app/soapbox/entity-store/utils.ts | 1 + 5 files changed, 41 insertions(+), 3 deletions(-) diff --git a/app/soapbox/entity-store/actions.ts b/app/soapbox/entity-store/actions.ts index 5a05100c8..30ae75535 100644 --- a/app/soapbox/entity-store/actions.ts +++ b/app/soapbox/entity-store/actions.ts @@ -6,6 +6,7 @@ const ENTITIES_DISMISS = 'ENTITIES_DISMISS' as const; const ENTITIES_FETCH_REQUEST = 'ENTITIES_FETCH_REQUEST' as const; const ENTITIES_FETCH_SUCCESS = 'ENTITIES_FETCH_SUCCESS' as const; const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const; +const ENTITIES_INVALIDATE_LIST = 'ENTITIES_INVALIDATE_LIST' as const; /** Action to import entities into the cache. */ function importEntities(entities: Entity[], entityType: string, listKey?: string) { @@ -66,6 +67,14 @@ function entitiesFetchFail(entityType: string, listKey: string | undefined, erro }; } +function invalidateEntityList(entityType: string, listKey: string) { + return { + type: ENTITIES_INVALIDATE_LIST, + entityType, + listKey, + }; +} + /** Any action pertaining to entities. */ type EntityAction = ReturnType @@ -73,7 +82,8 @@ type EntityAction = | ReturnType | ReturnType | ReturnType - | ReturnType; + | ReturnType + | ReturnType; export { ENTITIES_IMPORT, @@ -82,12 +92,14 @@ export { ENTITIES_FETCH_REQUEST, ENTITIES_FETCH_SUCCESS, ENTITIES_FETCH_FAIL, + ENTITIES_INVALIDATE_LIST, importEntities, deleteEntities, dismissEntities, entitiesFetchRequest, entitiesFetchSuccess, entitiesFetchFail, + invalidateEntityList, EntityAction, }; diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index 0f345d675..9ba7ad4f3 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -5,7 +5,7 @@ import { getNextLink, getPrevLink } from 'soapbox/api'; import { useApi, useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks'; import { filteredArray } from 'soapbox/schemas/utils'; -import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '../actions'; +import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions'; import { parseEntitiesPath } from './utils'; @@ -48,6 +48,7 @@ function useEntities( const isFetched = useListState(path, 'fetched'); const isError = !!useListState(path, 'error'); const totalCount = useListState(path, 'totalCount'); + const isInvalid = useListState(path, 'invalid'); const next = useListState(path, 'next'); const prev = useListState(path, 'prev'); @@ -72,6 +73,7 @@ function useEntities( fetched: true, error: null, lastFetchedAt: new Date(), + invalid: false, })); } catch (error) { dispatch(entitiesFetchFail(entityType, listKey, error)); @@ -96,10 +98,19 @@ function useEntities( } }; + const invalidate = () => { + dispatch(invalidateEntityList(entityType, listKey)); + }; + const staleTime = opts.staleTime ?? 60000; useEffect(() => { - if (isEnabled && !isFetching && (!lastFetchedAt || lastFetchedAt.getTime() + staleTime <= Date.now())) { + if (!isEnabled) return; + if (isFetching) return; + const isUnset = !lastFetchedAt; + const isStale = lastFetchedAt ? Date.now() >= lastFetchedAt.getTime() + staleTime : false; + + if (isInvalid || isUnset || isStale) { fetchEntities(); } }, [endpoint, isEnabled]); @@ -116,6 +127,7 @@ function useEntities( isFetched, isFetching, isLoading: isFetching && entities.length === 0, + invalidate, }; } diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts index 4f9c1e4d2..7654257cc 100644 --- a/app/soapbox/entity-store/reducer.ts +++ b/app/soapbox/entity-store/reducer.ts @@ -8,6 +8,7 @@ import { ENTITIES_FETCH_SUCCESS, ENTITIES_FETCH_FAIL, EntityAction, + ENTITIES_INVALIDATE_LIST, } from './actions'; import { createCache, createList, updateStore, updateList } from './utils'; @@ -114,6 +115,14 @@ const setFetching = ( }); }; +const invalidateEntityList = (state: State, entityType: string, listKey: string) => { + return produce(state, draft => { + const cache = draft[entityType] ?? createCache(); + const list = cache.lists[listKey] ?? createList(); + list.state.invalid = true; + }); +}; + /** Stores various entity data and lists in a one reducer. */ function reducer(state: Readonly = {}, action: EntityAction): State { switch (action.type) { @@ -129,6 +138,8 @@ function reducer(state: Readonly = {}, action: EntityAction): State { return setFetching(state, action.entityType, action.listKey, true); case ENTITIES_FETCH_FAIL: return setFetching(state, action.entityType, action.listKey, false, action.error); + case ENTITIES_INVALIDATE_LIST: + return invalidateEntityList(state, action.entityType, action.listKey); default: return state; } diff --git a/app/soapbox/entity-store/types.ts b/app/soapbox/entity-store/types.ts index 09e6c0174..67f37180d 100644 --- a/app/soapbox/entity-store/types.ts +++ b/app/soapbox/entity-store/types.ts @@ -33,6 +33,8 @@ interface EntityListState { fetching: boolean /** Date of the last API fetch for this list. */ lastFetchedAt: Date | undefined + /** Whether the entities should be refetched on the next component mount. */ + invalid: boolean } /** Cache data pertaining to a paritcular entity type.. */ diff --git a/app/soapbox/entity-store/utils.ts b/app/soapbox/entity-store/utils.ts index 0040ae674..cd023cc9c 100644 --- a/app/soapbox/entity-store/utils.ts +++ b/app/soapbox/entity-store/utils.ts @@ -43,6 +43,7 @@ const createListState = (): EntityListState => ({ fetched: false, fetching: false, lastFetchedAt: undefined, + invalid: false, }); export { From cb8363d179ff6221f98030fd34daef4df7876209 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 18:47:10 -0500 Subject: [PATCH 36/44] EntityStore: make fetching the first page override the old list --- .../entity-store/__tests__/reducer.test.ts | 41 ++++++++++++++++++- app/soapbox/entity-store/actions.ts | 9 +++- app/soapbox/entity-store/hooks/useEntities.ts | 6 +-- app/soapbox/entity-store/reducer.ts | 12 +++++- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/app/soapbox/entity-store/__tests__/reducer.test.ts b/app/soapbox/entity-store/__tests__/reducer.test.ts index 4b5a9752d..f43150b68 100644 --- a/app/soapbox/entity-store/__tests__/reducer.test.ts +++ b/app/soapbox/entity-store/__tests__/reducer.test.ts @@ -3,6 +3,7 @@ import { dismissEntities, entitiesFetchFail, entitiesFetchRequest, + entitiesFetchSuccess, importEntities, } from '../actions'; import reducer, { State } from '../reducer'; @@ -88,6 +89,44 @@ test('failure adds the error to the state', () => { expect(result.TestEntity!.lists.thingies!.state.error).toBe(error); }); +test('import entities with override', () => { + const state: State = { + TestEntity: { + store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } }, + lists: { + thingies: { + ids: new Set(['1', '2', '3']), + state: { ...createListState(), totalCount: 3 }, + }, + }, + }, + }; + + const entities: TestEntity[] = [ + { id: '4', msg: 'yolo' }, + { id: '5', msg: 'benis' }, + ]; + + const now = new Date(); + + const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', { + next: undefined, + prev: undefined, + totalCount: 2, + error: null, + fetched: true, + fetching: false, + lastFetchedAt: now, + invalid: false, + }, true); + + const result = reducer(state, action); + const cache = result.TestEntity as EntityCache; + + expect([...cache.lists.thingies!.ids]).toEqual(['4', '5']); + expect(cache.lists.thingies!.state.lastFetchedAt).toBe(now); // Also check that newState worked +}); + test('deleting items', () => { const state: State = { TestEntity: { @@ -114,7 +153,7 @@ test('dismiss items', () => { TestEntity: { store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } }, lists: { - 'yolo': { + yolo: { ids: new Set(['1', '2', '3']), state: { ...createListState(), totalCount: 3 }, }, diff --git a/app/soapbox/entity-store/actions.ts b/app/soapbox/entity-store/actions.ts index 30ae75535..8f4783c94 100644 --- a/app/soapbox/entity-store/actions.ts +++ b/app/soapbox/entity-store/actions.ts @@ -48,13 +48,20 @@ function entitiesFetchRequest(entityType: string, listKey?: string) { }; } -function entitiesFetchSuccess(entities: Entity[], entityType: string, listKey?: string, newState?: EntityListState) { +function entitiesFetchSuccess( + entities: Entity[], + entityType: string, + listKey?: string, + newState?: EntityListState, + overwrite = false, +) { return { type: ENTITIES_FETCH_SUCCESS, entityType, entities, listKey, newState, + overwrite, }; } diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index 9ba7ad4f3..996ee716f 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -53,7 +53,7 @@ function useEntities( const next = useListState(path, 'next'); const prev = useListState(path, 'prev'); - const fetchPage = async(url: string): Promise => { + const fetchPage = async(url: string, overwrite = false): Promise => { // Get `isFetching` state from the store again to prevent race conditions. const isFetching = selectListState(getState(), path, 'fetching'); if (isFetching) return; @@ -74,7 +74,7 @@ function useEntities( error: null, lastFetchedAt: new Date(), invalid: false, - })); + }, overwrite)); } catch (error) { dispatch(entitiesFetchFail(entityType, listKey, error)); } @@ -82,7 +82,7 @@ function useEntities( const fetchEntities = async(): Promise => { if (endpoint) { - await fetchPage(endpoint); + await fetchPage(endpoint, true); } }; diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts index 7654257cc..082204e88 100644 --- a/app/soapbox/entity-store/reducer.ts +++ b/app/soapbox/entity-store/reducer.ts @@ -29,17 +29,25 @@ const importEntities = ( entities: Entity[], listKey?: string, newState?: EntityListState, + overwrite = false, ): State => { return produce(state, draft => { const cache = draft[entityType] ?? createCache(); cache.store = updateStore(cache.store, entities); if (typeof listKey === 'string') { - let list = { ...(cache.lists[listKey] ?? createList()) }; + let list = cache.lists[listKey] ?? createList(); + + if (overwrite) { + list.ids = new Set(); + } + list = updateList(list, entities); + if (newState) { list.state = newState; } + cache.lists[listKey] = list; } @@ -133,7 +141,7 @@ function reducer(state: Readonly = {}, action: EntityAction): State { case ENTITIES_DISMISS: return dismissEntities(state, action.entityType, action.ids, action.listKey); case ENTITIES_FETCH_SUCCESS: - return importEntities(state, action.entityType, action.entities, action.listKey, action.newState); + return importEntities(state, action.entityType, action.entities, action.listKey, action.newState, action.overwrite); case ENTITIES_FETCH_REQUEST: return setFetching(state, action.entityType, action.listKey, true); case ENTITIES_FETCH_FAIL: From f016ac1e6d1e0efc0491822d62e55c63f7afb3b4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 18:48:24 -0500 Subject: [PATCH 37/44] GroupMembershipRequests: invalidate query upon authorize/reject --- .../group/group-membership-requests.tsx | 14 +++-------- .../api/groups/useGroupMembershipRequests.ts | 24 +++++++++++-------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/app/soapbox/features/group/group-membership-requests.tsx b/app/soapbox/features/group/group-membership-requests.tsx index 3dc52e154..d98517af8 100644 --- a/app/soapbox/features/group/group-membership-requests.tsx +++ b/app/soapbox/features/group/group-membership-requests.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import Account from 'soapbox/components/account'; @@ -57,15 +57,7 @@ const GroupMembershipRequests: React.FC = ({ params }) const { group } = useGroup(id); - const { - accounts: entities, - isLoading, - authorize, - reject, - isFetching, - } = useGroupMembershipRequests(id); - - const accounts = useMemo(() => entities, [isFetching]); + const { accounts, authorize, reject, isLoading } = useGroupMembershipRequests(id); if (!group || !group.relationship || isLoading) { return ( @@ -96,7 +88,7 @@ const GroupMembershipRequests: React.FC = ({ params }) } return ( - + } diff --git a/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts b/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts index 0f6b3818b..cafea3601 100644 --- a/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts +++ b/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts @@ -1,5 +1,5 @@ import { Entities } from 'soapbox/entity-store/entities'; -import { useDismissEntity, useEntities } from 'soapbox/entity-store/hooks'; +import { useEntities } from 'soapbox/entity-store/hooks'; import { useApi } from 'soapbox/hooks/useApi'; import { accountSchema } from 'soapbox/schemas'; @@ -9,20 +9,24 @@ function useGroupMembershipRequests(groupId: string) { const api = useApi(); const path: ExpandedEntitiesPath = [Entities.ACCOUNTS, 'membership_requests', groupId]; - const authorize = useDismissEntity(path, (accountId) => { - return api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`); - }); - - const reject = useDismissEntity(path, (accountId) => { - return api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`); - }); - - const { entities, ...rest } = useEntities( + const { entities, invalidate, ...rest } = useEntities( path, `/api/v1/groups/${groupId}/membership_requests`, { schema: accountSchema }, ); + function authorize(accountId: string) { + return api + .post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`) + .then(invalidate); + } + + function reject(accountId: string) { + return api + .post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`) + .then(invalidate); + } + return { accounts: entities, authorize, From c4d0dd568ed01c5298730ac39fa6f55ca30c8042 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 19:05:57 -0500 Subject: [PATCH 38/44] EntityStore: let totalCount be undefined, don't try to set it from the local count --- app/soapbox/entity-store/hooks/useEntities.ts | 5 +++-- app/soapbox/entity-store/reducer.ts | 10 ++++++++-- app/soapbox/entity-store/types.ts | 2 +- app/soapbox/entity-store/utils.ts | 6 ++++-- app/soapbox/utils/numbers.tsx | 4 ++++ 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index 996ee716f..6c8511e65 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -4,6 +4,7 @@ import z from 'zod'; import { getNextLink, getPrevLink } from 'soapbox/api'; import { useApi, useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks'; import { filteredArray } from 'soapbox/schemas/utils'; +import { realNumberSchema } from 'soapbox/utils/numbers'; import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions'; @@ -63,12 +64,12 @@ function useEntities( const response = await api.get(url); const schema = opts.schema || z.custom(); const entities = filteredArray(schema).parse(response.data); - const numItems = (selectList(getState(), path)?.ids.size || 0) + entities.length; + const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']); dispatch(entitiesFetchSuccess(entities, entityType, listKey, { next: getNextLink(response), prev: getPrevLink(response), - totalCount: Number(response.headers['x-total-count'] ?? numItems) || 0, + totalCount: parsedCount.success ? parsedCount.data : undefined, fetching: false, fetched: true, error: null, diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts index 082204e88..7559b66a7 100644 --- a/app/soapbox/entity-store/reducer.ts +++ b/app/soapbox/entity-store/reducer.ts @@ -71,7 +71,10 @@ const deleteEntities = ( for (const list of Object.values(cache.lists)) { if (list) { list.ids.delete(id); - list.state.totalCount--; + + if (typeof list.state.totalCount === 'number') { + list.state.totalCount--; + } } } } @@ -94,7 +97,10 @@ const dismissEntities = ( if (list) { for (const id of ids) { list.ids.delete(id); - list.state.totalCount--; + + if (typeof list.state.totalCount === 'number') { + list.state.totalCount--; + } } draft[entityType] = cache; diff --git a/app/soapbox/entity-store/types.ts b/app/soapbox/entity-store/types.ts index 67f37180d..006b13ba2 100644 --- a/app/soapbox/entity-store/types.ts +++ b/app/soapbox/entity-store/types.ts @@ -24,7 +24,7 @@ interface EntityListState { /** Previous URL for pagination, if any. */ prev: string | undefined /** Total number of items according to the API. */ - totalCount: number + totalCount: number | undefined /** Error returned from the API, if any. */ error: any /** Whether data has already been fetched */ diff --git a/app/soapbox/entity-store/utils.ts b/app/soapbox/entity-store/utils.ts index cd023cc9c..e108639c2 100644 --- a/app/soapbox/entity-store/utils.ts +++ b/app/soapbox/entity-store/utils.ts @@ -13,8 +13,10 @@ const updateList = (list: EntityList, entities: Entity[]): EntityList => { const newIds = entities.map(entity => entity.id); const ids = new Set([...Array.from(list.ids), ...newIds]); - const sizeDiff = ids.size - list.ids.size; - list.state.totalCount += sizeDiff; + if (typeof list.state.totalCount === 'number') { + const sizeDiff = ids.size - list.ids.size; + list.state.totalCount += sizeDiff; + } return { ...list, diff --git a/app/soapbox/utils/numbers.tsx b/app/soapbox/utils/numbers.tsx index c4c6aaf75..004d25603 100644 --- a/app/soapbox/utils/numbers.tsx +++ b/app/soapbox/utils/numbers.tsx @@ -1,9 +1,13 @@ import React from 'react'; import { FormattedNumber } from 'react-intl'; +import { z } from 'zod'; /** Check if a value is REALLY a number. */ export const isNumber = (value: unknown): value is number => typeof value === 'number' && !isNaN(value); +/** The input is a number and is not NaN. */ +export const realNumberSchema = z.coerce.number().refine(n => !isNaN(n)); + export const secondsToDays = (seconds: number) => Math.floor(seconds / (3600 * 24)); const roundDown = (num: number) => { From c5b1f23bdaa1b24e5377fece5f5e7478b52aa52f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 19:16:19 -0500 Subject: [PATCH 39/44] GroupMembers: use X-Total-Count if available --- app/soapbox/features/group/group-members.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/soapbox/features/group/group-members.tsx b/app/soapbox/features/group/group-members.tsx index 34dbb04c3..0ef02ce9c 100644 --- a/app/soapbox/features/group/group-members.tsx +++ b/app/soapbox/features/group/group-members.tsx @@ -25,7 +25,7 @@ const GroupMembers: React.FC = (props) => { const { groupMembers: owners, isFetching: isFetchingOwners } = useGroupMembers(groupId, GroupRoles.OWNER); const { groupMembers: admins, isFetching: isFetchingAdmins } = useGroupMembers(groupId, GroupRoles.ADMIN); const { groupMembers: users, isFetching: isFetchingUsers, fetchNextPage, hasNextPage } = useGroupMembers(groupId, GroupRoles.USER); - const { accounts: pending, isFetching: isFetchingPending } = useGroupMembershipRequests(groupId); + const { accounts: pending, isFetching: isFetchingPending, totalCount: pendingTotalCount } = useGroupMembershipRequests(groupId); const isLoading = isFetchingGroup || isFetchingOwners || isFetchingAdmins || isFetchingUsers || isFetchingPending; @@ -35,6 +35,9 @@ const GroupMembers: React.FC = (props) => { ...users, ], [owners, admins, users]); + // If the API gives us `X-Total-Count`, use it. Otherwise fallback to the number in the store. + const pendingCount = typeof pendingTotalCount === 'number' ? pendingTotalCount : pending.length; + return ( <> = (props) => { placeholderCount={3} className='divide-y divide-solid divide-gray-200 dark:divide-gray-800' itemClassName='py-3 last:pb-0' - prepend={(pending.length > 0) && ( + prepend={(pendingCount > 0) && (
- +
)} > From 402daec9c3caf4bfb21e0ebb6c935772a0f24381 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 19:45:02 -0500 Subject: [PATCH 40/44] Add useIncrementEntity hook --- .../entity-store/__tests__/reducer.test.ts | 39 +++++++++++++++++++ app/soapbox/entity-store/actions.ts | 13 +++++++ app/soapbox/entity-store/hooks/index.ts | 3 +- .../entity-store/hooks/useIncrementEntity.ts | 33 ++++++++++++++++ app/soapbox/entity-store/reducer.ts | 20 ++++++++++ .../api/groups/useGroupMembershipRequests.ts | 10 ++--- 6 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 app/soapbox/entity-store/hooks/useIncrementEntity.ts diff --git a/app/soapbox/entity-store/__tests__/reducer.test.ts b/app/soapbox/entity-store/__tests__/reducer.test.ts index f43150b68..1cfc19697 100644 --- a/app/soapbox/entity-store/__tests__/reducer.test.ts +++ b/app/soapbox/entity-store/__tests__/reducer.test.ts @@ -5,6 +5,7 @@ import { entitiesFetchRequest, entitiesFetchSuccess, importEntities, + incrementEntities, } from '../actions'; import reducer, { State } from '../reducer'; import { createListState } from '../utils'; @@ -167,4 +168,42 @@ test('dismiss items', () => { expect(result.TestEntity!.store).toMatchObject(state.TestEntity!.store); expect([...result.TestEntity!.lists.yolo!.ids]).toEqual(['2']); expect(result.TestEntity!.lists.yolo!.state.totalCount).toBe(1); +}); + +test('increment items', () => { + const state: State = { + TestEntity: { + store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } }, + lists: { + thingies: { + ids: new Set(['1', '2', '3']), + state: { ...createListState(), totalCount: 3 }, + }, + }, + }, + }; + + const action = incrementEntities('TestEntity', 'thingies', 1); + const result = reducer(state, action); + + expect(result.TestEntity!.lists.thingies!.state.totalCount).toBe(4); +}); + +test('decrement items', () => { + const state: State = { + TestEntity: { + store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } }, + lists: { + thingies: { + ids: new Set(['1', '2', '3']), + state: { ...createListState(), totalCount: 3 }, + }, + }, + }, + }; + + const action = incrementEntities('TestEntity', 'thingies', -1); + const result = reducer(state, action); + + expect(result.TestEntity!.lists.thingies!.state.totalCount).toBe(2); }); \ No newline at end of file diff --git a/app/soapbox/entity-store/actions.ts b/app/soapbox/entity-store/actions.ts index 8f4783c94..c3ba25559 100644 --- a/app/soapbox/entity-store/actions.ts +++ b/app/soapbox/entity-store/actions.ts @@ -3,6 +3,7 @@ import type { Entity, EntityListState } from './types'; const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const; const ENTITIES_DELETE = 'ENTITIES_DELETE' as const; const ENTITIES_DISMISS = 'ENTITIES_DISMISS' as const; +const ENTITIES_INCREMENT = 'ENTITIES_INCREMENT' as const; const ENTITIES_FETCH_REQUEST = 'ENTITIES_FETCH_REQUEST' as const; const ENTITIES_FETCH_SUCCESS = 'ENTITIES_FETCH_SUCCESS' as const; const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const; @@ -40,6 +41,15 @@ function dismissEntities(ids: Iterable, entityType: string, listKey: str }; } +function incrementEntities(entityType: string, listKey: string, diff: number) { + return { + type: ENTITIES_INCREMENT, + entityType, + listKey, + diff, + }; +} + function entitiesFetchRequest(entityType: string, listKey?: string) { return { type: ENTITIES_FETCH_REQUEST, @@ -87,6 +97,7 @@ type EntityAction = ReturnType | ReturnType | ReturnType + | ReturnType | ReturnType | ReturnType | ReturnType @@ -96,6 +107,7 @@ export { ENTITIES_IMPORT, ENTITIES_DELETE, ENTITIES_DISMISS, + ENTITIES_INCREMENT, ENTITIES_FETCH_REQUEST, ENTITIES_FETCH_SUCCESS, ENTITIES_FETCH_FAIL, @@ -103,6 +115,7 @@ export { importEntities, deleteEntities, dismissEntities, + incrementEntities, entitiesFetchRequest, entitiesFetchSuccess, entitiesFetchFail, diff --git a/app/soapbox/entity-store/hooks/index.ts b/app/soapbox/entity-store/hooks/index.ts index 09fe0c960..d113c505a 100644 --- a/app/soapbox/entity-store/hooks/index.ts +++ b/app/soapbox/entity-store/hooks/index.ts @@ -3,4 +3,5 @@ export { useEntity } from './useEntity'; export { useEntityActions } from './useEntityActions'; export { useCreateEntity } from './useCreateEntity'; export { useDeleteEntity } from './useDeleteEntity'; -export { useDismissEntity } from './useDismissEntity'; \ No newline at end of file +export { useDismissEntity } from './useDismissEntity'; +export { useIncrementEntity } from './useIncrementEntity'; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useIncrementEntity.ts b/app/soapbox/entity-store/hooks/useIncrementEntity.ts new file mode 100644 index 000000000..5f87fdea4 --- /dev/null +++ b/app/soapbox/entity-store/hooks/useIncrementEntity.ts @@ -0,0 +1,33 @@ +import { useAppDispatch } from 'soapbox/hooks'; + +import { incrementEntities } from '../actions'; + +import { parseEntitiesPath } from './utils'; + +import type { ExpandedEntitiesPath } from './types'; + +type IncrementFn = (entityId: string) => Promise | T; + +/** + * Increases (or decreases) the `totalCount` in the entity list by the specified amount. + * This only works if the API returns an `X-Total-Count` header and your components read it. + */ +function useIncrementEntity( + expandedPath: ExpandedEntitiesPath, + diff: number, + incrementFn: IncrementFn, +) { + const { entityType, listKey } = parseEntitiesPath(expandedPath); + const dispatch = useAppDispatch(); + + return async function incrementEntity(entityId: string): Promise { + try { + await incrementFn(entityId); + dispatch(incrementEntities(entityType, listKey, diff)); + } catch (e) { + dispatch(incrementEntities(entityType, listKey, diff * -1)); + } + }; +} + +export { useIncrementEntity }; \ No newline at end of file diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts index 7559b66a7..b71fb812f 100644 --- a/app/soapbox/entity-store/reducer.ts +++ b/app/soapbox/entity-store/reducer.ts @@ -9,6 +9,7 @@ import { ENTITIES_FETCH_FAIL, EntityAction, ENTITIES_INVALIDATE_LIST, + ENTITIES_INCREMENT, } from './actions'; import { createCache, createList, updateStore, updateList } from './utils'; @@ -108,6 +109,23 @@ const dismissEntities = ( }); }; +const incrementEntities = ( + state: State, + entityType: string, + listKey: string, + diff: number, +) => { + return produce(state, draft => { + const cache = draft[entityType] ?? createCache(); + const list = cache.lists[listKey]; + + if (typeof list?.state?.totalCount === 'number') { + list.state.totalCount += diff; + draft[entityType] = cache; + } + }); +}; + const setFetching = ( state: State, entityType: string, @@ -146,6 +164,8 @@ function reducer(state: Readonly = {}, action: EntityAction): State { return deleteEntities(state, action.entityType, action.ids, action.opts); case ENTITIES_DISMISS: return dismissEntities(state, action.entityType, action.ids, action.listKey); + case ENTITIES_INCREMENT: + return incrementEntities(state, action.entityType, action.listKey, action.diff); case ENTITIES_FETCH_SUCCESS: return importEntities(state, action.entityType, action.entities, action.listKey, action.newState, action.overwrite); case ENTITIES_FETCH_REQUEST: diff --git a/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts b/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts index cafea3601..560aef329 100644 --- a/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts +++ b/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts @@ -1,5 +1,5 @@ import { Entities } from 'soapbox/entity-store/entities'; -import { useEntities } from 'soapbox/entity-store/hooks'; +import { useEntities, useIncrementEntity } from 'soapbox/entity-store/hooks'; import { useApi } from 'soapbox/hooks/useApi'; import { accountSchema } from 'soapbox/schemas'; @@ -15,17 +15,17 @@ function useGroupMembershipRequests(groupId: string) { { schema: accountSchema }, ); - function authorize(accountId: string) { + const authorize = useIncrementEntity(path, -1, (accountId: string) => { return api .post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`) .then(invalidate); - } + }); - function reject(accountId: string) { + const reject = useIncrementEntity(path, -1, (accountId: string) => { return api .post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`) .then(invalidate); - } + }); return { accounts: entities, From 6929975aaa5afee926d6afa3788cdc5b830caa68 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 19:58:40 -0500 Subject: [PATCH 41/44] useIncrementEntity: fix optimistic counter --- app/soapbox/entity-store/hooks/useIncrementEntity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/entity-store/hooks/useIncrementEntity.ts b/app/soapbox/entity-store/hooks/useIncrementEntity.ts index 5f87fdea4..c0cbd133d 100644 --- a/app/soapbox/entity-store/hooks/useIncrementEntity.ts +++ b/app/soapbox/entity-store/hooks/useIncrementEntity.ts @@ -21,9 +21,9 @@ function useIncrementEntity( const dispatch = useAppDispatch(); return async function incrementEntity(entityId: string): Promise { + dispatch(incrementEntities(entityType, listKey, diff)); try { await incrementFn(entityId); - dispatch(incrementEntities(entityType, listKey, diff)); } catch (e) { dispatch(incrementEntities(entityType, listKey, diff * -1)); } From 2674c060ad5e053155053d9b11a758dc72251426 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 20:26:53 -0500 Subject: [PATCH 42/44] GroupMembers: showLoading if pending members are being fetched --- app/soapbox/features/group/group-members.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/features/group/group-members.tsx b/app/soapbox/features/group/group-members.tsx index 0ef02ce9c..f84ccd0aa 100644 --- a/app/soapbox/features/group/group-members.tsx +++ b/app/soapbox/features/group/group-members.tsx @@ -44,8 +44,8 @@ const GroupMembers: React.FC = (props) => { scrollKey='group-members' hasMore={hasNextPage} onLoadMore={fetchNextPage} - isLoading={isLoading || !group} - showLoading={!group || isLoading && members.length === 0} + isLoading={!group || isLoading} + showLoading={!group || isFetchingPending || isLoading && members.length === 0} placeholderComponent={PlaceholderAccount} placeholderCount={3} className='divide-y divide-solid divide-gray-200 dark:divide-gray-800' From 75b0262f9a68904caa9d6890d0b9079c13d64f63 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Mar 2023 21:24:53 -0500 Subject: [PATCH 43/44] Move pendingCount logic to useEntities --- app/soapbox/entity-store/hooks/useEntities.ts | 2 ++ app/soapbox/features/group/group-members.tsx | 5 +---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index 6c8511e65..8e679709a 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -129,6 +129,8 @@ function useEntities( isFetching, isLoading: isFetching && entities.length === 0, invalidate, + /** The `X-Total-Count` from the API if available, or the length of items in the store. */ + count: typeof totalCount === 'number' ? totalCount : entities.length, }; } diff --git a/app/soapbox/features/group/group-members.tsx b/app/soapbox/features/group/group-members.tsx index f84ccd0aa..39fbd940f 100644 --- a/app/soapbox/features/group/group-members.tsx +++ b/app/soapbox/features/group/group-members.tsx @@ -25,7 +25,7 @@ const GroupMembers: React.FC = (props) => { const { groupMembers: owners, isFetching: isFetchingOwners } = useGroupMembers(groupId, GroupRoles.OWNER); const { groupMembers: admins, isFetching: isFetchingAdmins } = useGroupMembers(groupId, GroupRoles.ADMIN); const { groupMembers: users, isFetching: isFetchingUsers, fetchNextPage, hasNextPage } = useGroupMembers(groupId, GroupRoles.USER); - const { accounts: pending, isFetching: isFetchingPending, totalCount: pendingTotalCount } = useGroupMembershipRequests(groupId); + const { isFetching: isFetchingPending, count: pendingCount } = useGroupMembershipRequests(groupId); const isLoading = isFetchingGroup || isFetchingOwners || isFetchingAdmins || isFetchingUsers || isFetchingPending; @@ -35,9 +35,6 @@ const GroupMembers: React.FC = (props) => { ...users, ], [owners, admins, users]); - // If the API gives us `X-Total-Count`, use it. Otherwise fallback to the number in the store. - const pendingCount = typeof pendingTotalCount === 'number' ? pendingTotalCount : pending.length; - return ( <> Date: Thu, 23 Mar 2023 10:45:49 -0500 Subject: [PATCH 44/44] useDeleteEntity: support onSuccess callback --- .../entity-store/hooks/useDeleteEntity.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/soapbox/entity-store/hooks/useDeleteEntity.ts b/app/soapbox/entity-store/hooks/useDeleteEntity.ts index c13482dab..363dcf8a9 100644 --- a/app/soapbox/entity-store/hooks/useDeleteEntity.ts +++ b/app/soapbox/entity-store/hooks/useDeleteEntity.ts @@ -4,16 +4,23 @@ import { deleteEntities, importEntities } from '../actions'; type DeleteFn = (entityId: string) => Promise | T; +interface EntityCallbacks { + onSuccess?(): void +} + /** * Optimistically deletes an entity from the store. * This hook should be used to globally delete an entity from all lists. * To remove an entity from a single list, see `useDismissEntity`. */ -function useDeleteEntity(entityType: string, deleteFn: DeleteFn) { +function useDeleteEntity( + entityType: string, + deleteFn: DeleteFn, +) { const dispatch = useAppDispatch(); const getState = useGetState(); - return async function deleteEntity(entityId: string): Promise { + return async function deleteEntity(entityId: string, callbacks: EntityCallbacks = {}): Promise { // Get the entity before deleting, so we can reverse the action if the API request fails. const entity = getState().entities[entityType]?.store[entityId]; @@ -24,6 +31,11 @@ function useDeleteEntity(entityType: string, deleteFn: DeleteFn) const result = await deleteFn(entityId); // Success - finish deleting entity from the state. dispatch(deleteEntities([entityId], entityType)); + + if (callbacks.onSuccess) { + callbacks.onSuccess(); + } + return result; } catch (e) { if (entity) {