From 28a69ad88b37125ae5eaf356ac0e1af5d6f99ec3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Mar 2023 15:30:13 -0500 Subject: [PATCH 01/59] 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 d78e7f5d8c..8a6ad065ed 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/59] 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 30220eed63..44f2db3c96 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 fd33f39475..dc07040546 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 0000000000..78793e0d08 --- /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/59] 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 9fa1d135f7..755786a339 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/59] 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 0000000000..04ff99d04c --- /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 c574b30aa7..b57b4691c1 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/59] 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 04ff99d04c..0081d33e5c 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 755786a339..b1ab415f4c 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/59] 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 dc07040546..e769a16808 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 c182068f2a..bc7144f105 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/59] 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 0000000000..0a092fd78d --- /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/59] 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 32f36f9bb8..9c30bc8cf4 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/59] 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 9c30bc8cf4..eab20c1efe 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/59] CHANGELOG: authorize/reject buttons --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e04be8028..8a4937018c 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/59] 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 927b6944a3..aedf3e132d 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/59] 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 bc7144f105..954df3a004 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/59] 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 690f6a04f6..268363721f 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/59] 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 0c0b8aff0f..4f738e9f4d 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/59] 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 0081d33e5c..4fbf236cd6 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 8f5bfde4b3..4d2760760b 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/59] "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 268363721f..66d8d1d4ae 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 4f738e9f4d..8d3ab3b177 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/59] 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 75c80a3636..d94ecb7a85 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/59] 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 b20a37c716..34dbb04c3b 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/59] 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 0000000000..006912a9dc --- /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 eede5bcb3d..8f286633a7 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/59] 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 df0ec8e57b..7d4e6db9c5 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 4d9355cd87..5a05100c87 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 891e42f4cb..448de33ab4 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/59] 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 006912a9dc..c13482dab5 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 0000000000..65eb8599f9 --- /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/59] 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 0000000000..72873504bd --- /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 8f286633a7..8a259c0e0b 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/59] 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 af27c8f3e8..09fe0c9604 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/59] 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 89992c12d5..7ce99fd823 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 72873504bd..719bed971f 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 65eb8599f9..c887e94900 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 6945ccd8db..dec6aefd43 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 1dad1ff1e3..aa7b40b5df 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 8a259c0e0b..96def69ab1 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 0000000000..69568b25a2 --- /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 865896e24f..d77b49865a 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/59] 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 719bed971f..373434e73c 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 c887e94900..1ba5f4a60f 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 dec6aefd43..309accf1bf 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 96def69ab1..9406b38094 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 69568b25a2..d137ca1fb6 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/59] 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 d437f61ab5..0f6b3818b4 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/59] 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 eab20c1efe..3dc52e1546 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/59] 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 7d4e6db9c5..4b5a9752de 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 309accf1bf..0f345d675c 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 448de33ab4..4f9c1e4d2c 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 0e34b62a54..09e6c0174a 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 9d56ceb42a..0040ae6746 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/59] 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 5a05100c87..30ae75535c 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 0f345d675c..9ba7ad4f39 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 4f9c1e4d2c..7654257cc0 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 09e6c0174a..67f37180d6 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 0040ae6746..cd023cc9ca 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/59] 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 4b5a9752de..f43150b685 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 30ae75535c..8f4783c940 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 9ba7ad4f39..996ee716f5 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 7654257cc0..082204e886 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/59] 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 3dc52e1546..d98517af84 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 0f6b3818b4..cafea3601a 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/59] 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 996ee716f5..6c8511e65e 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 082204e886..7559b66a7b 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 67f37180d6..006b13ba2c 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 cd023cc9ca..e108639c2d 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 c4c6aaf758..004d256032 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/59] 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 34dbb04c3b..0ef02ce9c3 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/59] 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 f43150b685..1cfc196970 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 8f4783c940..c3ba255596 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 09fe0c9604..d113c505a3 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 0000000000..5f87fdea49 --- /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 7559b66a7b..b71fb812f4 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 cafea3601a..560aef3290 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/59] 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 5f87fdea49..c0cbd133d9 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/59] 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 0ef02ce9c3..f84ccd0aaf 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/59] 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 6c8511e65e..8e679709a1 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 f84ccd0aaf..39fbd940f1 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/59] 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 c13482dab5..363dcf8a95 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) { From ad3f8acbe5c43cab3134520245bee0f59ee4b27f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Mar 2023 14:14:53 -0500 Subject: [PATCH 45/59] EntityStore: allow passing an EntityRequest object to useEntities --- app/soapbox/api/index.ts | 1 + app/soapbox/entity-store/hooks/types.ts | 8 ++++++++ app/soapbox/entity-store/hooks/useEntities.ts | 16 +++++++--------- app/soapbox/entity-store/hooks/utils.ts | 16 ++++++++++++++-- app/soapbox/hooks/useGroups.ts | 5 ++--- 5 files changed, 32 insertions(+), 14 deletions(-) diff --git a/app/soapbox/api/index.ts b/app/soapbox/api/index.ts index fc19e7c419..520c3b7ad9 100644 --- a/app/soapbox/api/index.ts +++ b/app/soapbox/api/index.ts @@ -15,6 +15,7 @@ import { getAccessToken, getAppToken, isURL, parseBaseURL } from 'soapbox/utils/ import type MockAdapter from 'axios-mock-adapter'; + /** Parse Link headers, mostly for pagination. @see {@link https://www.npmjs.com/package/http-link-header} diff --git a/app/soapbox/entity-store/hooks/types.ts b/app/soapbox/entity-store/hooks/types.ts index 7ce99fd823..8bd788d938 100644 --- a/app/soapbox/entity-store/hooks/types.ts +++ b/app/soapbox/entity-store/hooks/types.ts @@ -1,4 +1,5 @@ import type { Entity } from '../types'; +import type { AxiosRequestConfig } from 'axios'; import type z from 'zod'; type EntitySchema = z.ZodType; @@ -24,9 +25,16 @@ type EntitiesPath = [entityType: string, listKey: string] /** Used to look up a single entity by its ID. */ type EntityPath = [entityType: string, entityId: string] +/** + * Passed into hooks to make requests. + * Can be a URL for GET requests, or a request object. + */ +type EntityRequest = string | URL | AxiosRequestConfig; + export type { EntitySchema, ExpandedEntitiesPath, EntitiesPath, EntityPath, + EntityRequest, }; \ 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 8e679709a1..b424ed1f65 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -8,10 +8,10 @@ import { realNumberSchema } from 'soapbox/utils/numbers'; import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions'; -import { parseEntitiesPath } from './utils'; +import { parseEntitiesPath, toAxiosRequest } from './utils'; import type { Entity, EntityListState } from '../types'; -import type { EntitiesPath, EntitySchema, ExpandedEntitiesPath } from './types'; +import type { EntitiesPath, EntityRequest, EntitySchema, ExpandedEntitiesPath } from './types'; import type { RootState } from 'soapbox/store'; /** Additional options for the hook. */ @@ -32,7 +32,7 @@ function useEntities( /** Tells us where to find/store the entity in the cache. */ expandedPath: ExpandedEntitiesPath, /** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */ - endpoint: string | undefined, + request: EntityRequest, /** Additional options for the hook. */ opts: UseEntitiesOpts = {}, ) { @@ -54,14 +54,14 @@ function useEntities( const next = useListState(path, 'next'); const prev = useListState(path, 'prev'); - const fetchPage = async(url: string, overwrite = false): Promise => { + const fetchPage = async(req: EntityRequest, overwrite = false): Promise => { // Get `isFetching` state from the store again to prevent race conditions. const isFetching = selectListState(getState(), path, 'fetching'); if (isFetching) return; dispatch(entitiesFetchRequest(entityType, listKey)); try { - const response = await api.get(url); + const response = await api.request(toAxiosRequest(req)); const schema = opts.schema || z.custom(); const entities = filteredArray(schema).parse(response.data); const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']); @@ -82,9 +82,7 @@ function useEntities( }; const fetchEntities = async(): Promise => { - if (endpoint) { - await fetchPage(endpoint, true); - } + await fetchPage(request, true); }; const fetchNextPage = async(): Promise => { @@ -114,7 +112,7 @@ function useEntities( if (isInvalid || isUnset || isStale) { fetchEntities(); } - }, [endpoint, isEnabled]); + }, [request, isEnabled]); return { entities, diff --git a/app/soapbox/entity-store/hooks/utils.ts b/app/soapbox/entity-store/hooks/utils.ts index d137ca1fb6..741a5cf135 100644 --- a/app/soapbox/entity-store/hooks/utils.ts +++ b/app/soapbox/entity-store/hooks/utils.ts @@ -1,4 +1,5 @@ -import type { EntitiesPath, ExpandedEntitiesPath } from './types'; +import type { EntitiesPath, EntityRequest, ExpandedEntitiesPath } from './types'; +import type { AxiosRequestConfig } from 'axios'; function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) { const [entityType, ...listKeys] = expandedPath; @@ -12,4 +13,15 @@ function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) { }; } -export { parseEntitiesPath }; \ No newline at end of file +function toAxiosRequest(req: EntityRequest): AxiosRequestConfig { + if (typeof req === 'string' || req instanceof URL) { + return { + method: 'get', + url: req.toString(), + }; + } + + return req; +} + +export { parseEntitiesPath, toAxiosRequest }; \ No newline at end of file diff --git a/app/soapbox/hooks/useGroups.ts b/app/soapbox/hooks/useGroups.ts index d143b5f99d..832d852a50 100644 --- a/app/soapbox/hooks/useGroups.ts +++ b/app/soapbox/hooks/useGroups.ts @@ -52,11 +52,10 @@ function useGroupRelationship(groupId: string) { function useGroupRelationships(groupIds: string[]) { const q = groupIds.map(id => `id[]=${id}`).join('&'); - const endpoint = groupIds.length ? `/api/v1/groups/relationships?${q}` : undefined; const { entities, ...result } = useEntities( [Entities.GROUP_RELATIONSHIPS, ...groupIds], - endpoint, - { schema: groupRelationshipSchema }, + `/api/v1/groups/relationships?${q}`, + { schema: groupRelationshipSchema, enabled: groupIds.length > 0 }, ); const relationships = entities.reduce>((map, relationship) => { From 50f65bc7c97db6e127553c1811edaa66d7c9296e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Mar 2023 14:52:38 -0500 Subject: [PATCH 46/59] useCreateEntity: pass an EntityRequest, refactor --- .../entity-store/hooks/useCreateEntity.ts | 50 ++++++------------- .../entity-store/hooks/useEntityActions.ts | 13 ++--- 2 files changed, 23 insertions(+), 40 deletions(-) diff --git a/app/soapbox/entity-store/hooks/useCreateEntity.ts b/app/soapbox/entity-store/hooks/useCreateEntity.ts index 373434e73c..432bea8fa5 100644 --- a/app/soapbox/entity-store/hooks/useCreateEntity.ts +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -1,52 +1,45 @@ import { z } from 'zod'; -import { useAppDispatch } from 'soapbox/hooks'; +import { useApi, useAppDispatch } from 'soapbox/hooks'; import { importEntities } from '../actions'; -import { parseEntitiesPath } from './utils'; +import { parseEntitiesPath, toAxiosRequest } from './utils'; import type { Entity } from '../types'; -import type { EntitySchema, ExpandedEntitiesPath } from './types'; - -type CreateFn = (params: Params) => Promise | Result; +import type { EntityRequest, EntitySchema, ExpandedEntitiesPath } from './types'; 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( +function useCreateEntity( expandedPath: ExpandedEntitiesPath, - createFn: CreateFn, + request: EntityRequest, opts: UseCreateEntityOpts = {}, ) { - const { entityType, listKey } = parseEntitiesPath(expandedPath); - + const api = useApi(); const dispatch = useAppDispatch(); + const { entityType, listKey } = parseEntitiesPath(expandedPath); + return async function createEntity( - params: Params, + data: Data, callbacks: EntityCallbacks = {}, - ): Promise> { + ): Promise { try { - const result = await createFn(params); + const result = await api.request({ + ...toAxiosRequest(request), + data, + }); + const schema = opts.schema || z.custom(); - const entity = schema.parse(result); + const entity = schema.parse(result.data); // TODO: optimistic updating dispatch(importEntities([entity], entityType, listKey)); @@ -54,21 +47,10 @@ function useCreateEntity( +function useEntityActions( expandedPath: ExpandedEntitiesPath, endpoints: EntityActionEndpoints, opts: UseEntityActionsOpts = {}, @@ -34,11 +34,12 @@ function useEntityActions( .finally(() => setIsLoading(false)); }); - const createEntity = useCreateEntity(path, (params: Params) => { - if (!endpoints.post) return Promise.reject(endpoints); - return api.post(endpoints.post, params) - .finally(() => setIsLoading(false)); - }, opts); + const create = useCreateEntity(path, { method: 'post', url: endpoints.post }, opts); + + const createEntity: typeof create = async (...args) => { + await create(...args); + setIsLoading(false); + }; return { createEntity, From 1c5a6d8b412c999bbcc47e5fe95f1531c23a5ec7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Mar 2023 15:05:34 -0500 Subject: [PATCH 47/59] useDeleteEntity: refactor with EntityRequest --- .../entity-store/hooks/useCreateEntity.ts | 4 +-- .../entity-store/hooks/useDeleteEntity.ts | 30 ++++++++++++------- .../entity-store/hooks/useEntityActions.ts | 16 ++++------ 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/app/soapbox/entity-store/hooks/useCreateEntity.ts b/app/soapbox/entity-store/hooks/useCreateEntity.ts index 432bea8fa5..7df1220b19 100644 --- a/app/soapbox/entity-store/hooks/useCreateEntity.ts +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -13,7 +13,7 @@ interface UseCreateEntityOpts { schema?: EntitySchema } -interface EntityCallbacks { +interface CreateEntityCallbacks { onSuccess?(entity: TEntity): void onError?(error: Error): void } @@ -30,7 +30,7 @@ function useCreateEntity( return async function createEntity( data: Data, - callbacks: EntityCallbacks = {}, + callbacks: CreateEntityCallbacks = {}, ): Promise { try { const result = await api.request({ diff --git a/app/soapbox/entity-store/hooks/useDeleteEntity.ts b/app/soapbox/entity-store/hooks/useDeleteEntity.ts index 363dcf8a95..790a6dba8e 100644 --- a/app/soapbox/entity-store/hooks/useDeleteEntity.ts +++ b/app/soapbox/entity-store/hooks/useDeleteEntity.ts @@ -1,11 +1,14 @@ -import { useAppDispatch, useGetState } from 'soapbox/hooks'; +import { useApi, useAppDispatch, useGetState } from 'soapbox/hooks'; import { deleteEntities, importEntities } from '../actions'; -type DeleteFn = (entityId: string) => Promise | T; +import { toAxiosRequest } from './utils'; -interface EntityCallbacks { +import type { EntityRequest } from './types'; + +interface DeleteEntityCallbacks { onSuccess?(): void + onError?(): void } /** @@ -13,14 +16,15 @@ interface EntityCallbacks { * 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( +function useDeleteEntity( entityType: string, - deleteFn: DeleteFn, + request: EntityRequest, ) { + const api = useApi(); const dispatch = useAppDispatch(); const getState = useGetState(); - return async function deleteEntity(entityId: string, callbacks: EntityCallbacks = {}): Promise { + return async function deleteEntity(entityId: string, callbacks: DeleteEntityCallbacks = {}): Promise { // Get the entity before deleting, so we can reverse the action if the API request fails. const entity = getState().entities[entityType]?.store[entityId]; @@ -28,21 +32,27 @@ function useDeleteEntity( dispatch(deleteEntities([entityId], entityType, { preserveLists: true })); try { - const result = await deleteFn(entityId); + // HACK: replace occurrences of `:id` in the URL. Maybe there's a better way? + const axiosReq = toAxiosRequest(request); + axiosReq.url?.replaceAll(':id', entityId); + + await api.request(axiosReq); + // Success - finish deleting entity from the state. dispatch(deleteEntities([entityId], entityType)); if (callbacks.onSuccess) { callbacks.onSuccess(); } - - return result; } catch (e) { if (entity) { // If the API failed, reimport the entity. dispatch(importEntities([entity], entityType)); } - throw e; + + if (callbacks.onError) { + callbacks.onError(); + } } }; } diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts index 509e3c95ea..63ede41d5e 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -1,7 +1,5 @@ import { useState } from 'react'; -import { useApi } from 'soapbox/hooks'; - import { useCreateEntity } from './useCreateEntity'; import { useDeleteEntity } from './useDeleteEntity'; import { parseEntitiesPath } from './utils'; @@ -23,17 +21,10 @@ function useEntityActions( endpoints: EntityActionEndpoints, opts: UseEntityActionsOpts = {}, ) { - const api = useApi(); const { entityType, path } = parseEntitiesPath(expandedPath); - const [isLoading, setIsLoading] = useState(false); - const deleteEntity = useDeleteEntity(entityType, (entityId) => { - if (!endpoints.delete) return Promise.reject(endpoints); - return api.delete(endpoints.delete.replace(':id', entityId)) - .finally(() => setIsLoading(false)); - }); - + const _delete = useDeleteEntity(entityType, { method: 'delete', url: endpoints.delete }); const create = useCreateEntity(path, { method: 'post', url: endpoints.post }, opts); const createEntity: typeof create = async (...args) => { @@ -41,6 +32,11 @@ function useEntityActions( setIsLoading(false); }; + const deleteEntity: typeof _delete = async (...args) => { + await _delete(...args); + setIsLoading(false); + }; + return { createEntity, deleteEntity, From b4c3248791f2521c7ef4c782e2357a5f2c0d8948 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Mar 2023 15:09:00 -0500 Subject: [PATCH 48/59] useEntityActions: fix isLoading --- app/soapbox/entity-store/hooks/useEntityActions.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts index 63ede41d5e..fa90765b0d 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -28,11 +28,13 @@ function useEntityActions( const create = useCreateEntity(path, { method: 'post', url: endpoints.post }, opts); const createEntity: typeof create = async (...args) => { + setIsLoading(true); await create(...args); setIsLoading(false); }; const deleteEntity: typeof _delete = async (...args) => { + setIsLoading(true); await _delete(...args); setIsLoading(false); }; From 1b569b6c826d00cdbb25e54bfea982533fa78f9a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Mar 2023 15:15:04 -0500 Subject: [PATCH 49/59] useEntity: accept an EntityRequest object --- app/soapbox/entity-store/hooks/useEntity.ts | 23 +++++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index aa7b40b5df..cb71b86a9e 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -5,8 +5,10 @@ import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { importEntities } from '../actions'; +import { toAxiosRequest } from './utils'; + import type { Entity } from '../types'; -import type { EntitySchema, EntityPath } from './types'; +import type { EntitySchema, EntityPath, EntityRequest } from './types'; /** Additional options for the hook. */ interface UseEntityOpts { @@ -18,7 +20,7 @@ interface UseEntityOpts { function useEntity( path: EntityPath, - endpoint: string, + request: EntityRequest, opts: UseEntityOpts = {}, ) { const api = useApi(); @@ -34,15 +36,18 @@ function useEntity( const [isFetching, setIsFetching] = useState(false); const isLoading = isFetching && !entity; - const fetchEntity = () => { + const fetchEntity = async () => { setIsFetching(true); - api.get(endpoint).then(({ data }) => { - const entity = schema.parse(data); + + try { + const response = await api.request(toAxiosRequest(request)); + const entity = schema.parse(response.data); dispatch(importEntities([entity], entityType)); - setIsFetching(false); - }).catch(() => { - setIsFetching(false); - }); + } catch (e) { + // do nothing + } + + setIsFetching(false); }; useEffect(() => { From ac9718e6ed7b34811a56925bd27d6ce705ab6e57 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Mar 2023 15:30:45 -0500 Subject: [PATCH 50/59] Return isLoading from useCreateEntity and useDeleteEntity --- .../entity-store/hooks/useCreateEntity.ts | 16 ++++++++++---- .../entity-store/hooks/useDeleteEntity.ts | 14 +++++++++++- .../entity-store/hooks/useEntityActions.ts | 22 +++++-------------- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/app/soapbox/entity-store/hooks/useCreateEntity.ts b/app/soapbox/entity-store/hooks/useCreateEntity.ts index 7df1220b19..8c4aac94a7 100644 --- a/app/soapbox/entity-store/hooks/useCreateEntity.ts +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { z } from 'zod'; import { useApi, useAppDispatch } from 'soapbox/hooks'; @@ -25,13 +26,13 @@ function useCreateEntity( ) { const api = useApi(); const dispatch = useAppDispatch(); + const [isLoading, setIsLoading] = useState(false); const { entityType, listKey } = parseEntitiesPath(expandedPath); - return async function createEntity( - data: Data, - callbacks: CreateEntityCallbacks = {}, - ): Promise { + async function createEntity(data: Data, callbacks: CreateEntityCallbacks = {}): Promise { + setIsLoading(true); + try { const result = await api.request({ ...toAxiosRequest(request), @@ -52,6 +53,13 @@ function useCreateEntity( callbacks.onError(error); } } + + setIsLoading(false); + } + + return { + createEntity, + isLoading, }; } diff --git a/app/soapbox/entity-store/hooks/useDeleteEntity.ts b/app/soapbox/entity-store/hooks/useDeleteEntity.ts index 790a6dba8e..e7220e7469 100644 --- a/app/soapbox/entity-store/hooks/useDeleteEntity.ts +++ b/app/soapbox/entity-store/hooks/useDeleteEntity.ts @@ -1,3 +1,5 @@ +import { useState } from 'react'; + import { useApi, useAppDispatch, useGetState } from 'soapbox/hooks'; import { deleteEntities, importEntities } from '../actions'; @@ -23,8 +25,11 @@ function useDeleteEntity( const api = useApi(); const dispatch = useAppDispatch(); const getState = useGetState(); + const [isLoading, setIsLoading] = useState(false); + + async function deleteEntity(entityId: string, callbacks: DeleteEntityCallbacks = {}): Promise { + setIsLoading(true); - return async function deleteEntity(entityId: string, callbacks: DeleteEntityCallbacks = {}): Promise { // Get the entity before deleting, so we can reverse the action if the API request fails. const entity = getState().entities[entityType]?.store[entityId]; @@ -54,6 +59,13 @@ function useDeleteEntity( callbacks.onError(); } } + + setIsLoading(false); + } + + return { + deleteEntity, + isLoading, }; } diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts index fa90765b0d..f3dcd4db1e 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -1,5 +1,3 @@ -import { useState } from 'react'; - import { useCreateEntity } from './useCreateEntity'; import { useDeleteEntity } from './useDeleteEntity'; import { parseEntitiesPath } from './utils'; @@ -22,27 +20,17 @@ function useEntityActions( opts: UseEntityActionsOpts = {}, ) { const { entityType, path } = parseEntitiesPath(expandedPath); - const [isLoading, setIsLoading] = useState(false); - const _delete = useDeleteEntity(entityType, { method: 'delete', url: endpoints.delete }); - const create = useCreateEntity(path, { method: 'post', url: endpoints.post }, opts); + const { deleteEntity, isLoading: deleteLoading } = + useDeleteEntity(entityType, { method: 'delete', url: endpoints.delete }); - const createEntity: typeof create = async (...args) => { - setIsLoading(true); - await create(...args); - setIsLoading(false); - }; - - const deleteEntity: typeof _delete = async (...args) => { - setIsLoading(true); - await _delete(...args); - setIsLoading(false); - }; + const { createEntity, isLoading: createLoading } = + useCreateEntity(path, { method: 'post', url: endpoints.post }, opts); return { createEntity, deleteEntity, - isLoading, + isLoading: createLoading || deleteLoading, }; } From 72483317421dbace5786397578c0e5511e6e485c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Mar 2023 16:04:42 -0500 Subject: [PATCH 51/59] Add useEntityRequest hook --- .../entity-store/hooks/useEntityRequest.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 app/soapbox/entity-store/hooks/useEntityRequest.ts diff --git a/app/soapbox/entity-store/hooks/useEntityRequest.ts b/app/soapbox/entity-store/hooks/useEntityRequest.ts new file mode 100644 index 0000000000..7d2697e61f --- /dev/null +++ b/app/soapbox/entity-store/hooks/useEntityRequest.ts @@ -0,0 +1,30 @@ +import { useState } from 'react'; + +import { useApi } from 'soapbox/hooks'; + +import { EntityRequest } from './types'; +import { toAxiosRequest } from './utils'; + +function useEntityRequest() { + const api = useApi(); + const [isLoading, setIsLoading] = useState(false); + + async function request(entityRequest: EntityRequest) { + setIsLoading(true); + try { + const response = await api.request(toAxiosRequest(entityRequest)); + setIsLoading(false); + return response; + } catch (e) { + setIsLoading(false); + throw e; + } + } + + return { + request, + isLoading, + }; +} + +export { useEntityRequest }; \ No newline at end of file From 45c12e9b65f73b98353b49e7ad5371a051fd72d1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Mar 2023 16:09:04 -0500 Subject: [PATCH 52/59] Make EntityCallbacks a generic function --- app/soapbox/entity-store/hooks/types.ts | 7 +++++++ app/soapbox/entity-store/hooks/useCreateEntity.ts | 9 ++------- app/soapbox/entity-store/hooks/useDeleteEntity.ts | 13 ++++--------- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/app/soapbox/entity-store/hooks/types.ts b/app/soapbox/entity-store/hooks/types.ts index 8bd788d938..e9df8c18b7 100644 --- a/app/soapbox/entity-store/hooks/types.ts +++ b/app/soapbox/entity-store/hooks/types.ts @@ -25,6 +25,12 @@ type EntitiesPath = [entityType: string, listKey: string] /** Used to look up a single entity by its ID. */ type EntityPath = [entityType: string, entityId: string] +/** Callback functions for entity actions. */ +interface EntityCallbacks { + onSuccess?(value: Value): void + onError?(error: Error): void +} + /** * Passed into hooks to make requests. * Can be a URL for GET requests, or a request object. @@ -36,5 +42,6 @@ export type { ExpandedEntitiesPath, EntitiesPath, EntityPath, + EntityCallbacks, EntityRequest, }; \ 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 8c4aac94a7..87625f9429 100644 --- a/app/soapbox/entity-store/hooks/useCreateEntity.ts +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -8,17 +8,12 @@ import { importEntities } from '../actions'; import { parseEntitiesPath, toAxiosRequest } from './utils'; import type { Entity } from '../types'; -import type { EntityRequest, EntitySchema, ExpandedEntitiesPath } from './types'; +import type { EntityCallbacks, EntityRequest, EntitySchema, ExpandedEntitiesPath } from './types'; interface UseCreateEntityOpts { schema?: EntitySchema } -interface CreateEntityCallbacks { - onSuccess?(entity: TEntity): void - onError?(error: Error): void -} - function useCreateEntity( expandedPath: ExpandedEntitiesPath, request: EntityRequest, @@ -30,7 +25,7 @@ function useCreateEntity( const { entityType, listKey } = parseEntitiesPath(expandedPath); - async function createEntity(data: Data, callbacks: CreateEntityCallbacks = {}): Promise { + async function createEntity(data: Data, callbacks: EntityCallbacks = {}): Promise { setIsLoading(true); try { diff --git a/app/soapbox/entity-store/hooks/useDeleteEntity.ts b/app/soapbox/entity-store/hooks/useDeleteEntity.ts index e7220e7469..718ca5e11f 100644 --- a/app/soapbox/entity-store/hooks/useDeleteEntity.ts +++ b/app/soapbox/entity-store/hooks/useDeleteEntity.ts @@ -6,12 +6,7 @@ import { deleteEntities, importEntities } from '../actions'; import { toAxiosRequest } from './utils'; -import type { EntityRequest } from './types'; - -interface DeleteEntityCallbacks { - onSuccess?(): void - onError?(): void -} +import type { EntityCallbacks, EntityRequest } from './types'; /** * Optimistically deletes an entity from the store. @@ -27,7 +22,7 @@ function useDeleteEntity( const getState = useGetState(); const [isLoading, setIsLoading] = useState(false); - async function deleteEntity(entityId: string, callbacks: DeleteEntityCallbacks = {}): Promise { + async function deleteEntity(entityId: string, callbacks: EntityCallbacks = {}): Promise { setIsLoading(true); // Get the entity before deleting, so we can reverse the action if the API request fails. @@ -47,7 +42,7 @@ function useDeleteEntity( dispatch(deleteEntities([entityId], entityType)); if (callbacks.onSuccess) { - callbacks.onSuccess(); + callbacks.onSuccess(entityId); } } catch (e) { if (entity) { @@ -56,7 +51,7 @@ function useDeleteEntity( } if (callbacks.onError) { - callbacks.onError(); + callbacks.onError(e); } } From aa7e2f6965517c3556f2dae64480ef12847a1b28 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Mar 2023 16:22:15 -0500 Subject: [PATCH 53/59] Refactor hooks with useEntityRequest --- .../entity-store/hooks/useCreateEntity.ts | 17 ++++++----------- .../entity-store/hooks/useDeleteEntity.ts | 18 ++++++------------ app/soapbox/entity-store/hooks/useEntities.ts | 15 ++++++++------- app/soapbox/entity-store/hooks/useEntity.ts | 17 ++++++----------- 4 files changed, 26 insertions(+), 41 deletions(-) diff --git a/app/soapbox/entity-store/hooks/useCreateEntity.ts b/app/soapbox/entity-store/hooks/useCreateEntity.ts index 87625f9429..658cb180da 100644 --- a/app/soapbox/entity-store/hooks/useCreateEntity.ts +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -1,10 +1,10 @@ -import { useState } from 'react'; import { z } from 'zod'; -import { useApi, useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch } from 'soapbox/hooks'; import { importEntities } from '../actions'; +import { useEntityRequest } from './useEntityRequest'; import { parseEntitiesPath, toAxiosRequest } from './utils'; import type { Entity } from '../types'; @@ -16,21 +16,18 @@ interface UseCreateEntityOpts { function useCreateEntity( expandedPath: ExpandedEntitiesPath, - request: EntityRequest, + entityRequest: EntityRequest, opts: UseCreateEntityOpts = {}, ) { - const api = useApi(); const dispatch = useAppDispatch(); - const [isLoading, setIsLoading] = useState(false); + const { request, isLoading } = useEntityRequest(); const { entityType, listKey } = parseEntitiesPath(expandedPath); async function createEntity(data: Data, callbacks: EntityCallbacks = {}): Promise { - setIsLoading(true); - try { - const result = await api.request({ - ...toAxiosRequest(request), + const result = await request({ + ...toAxiosRequest(entityRequest), data, }); @@ -48,8 +45,6 @@ function useCreateEntity( callbacks.onError(error); } } - - setIsLoading(false); } return { diff --git a/app/soapbox/entity-store/hooks/useDeleteEntity.ts b/app/soapbox/entity-store/hooks/useDeleteEntity.ts index 718ca5e11f..a8c671cc10 100644 --- a/app/soapbox/entity-store/hooks/useDeleteEntity.ts +++ b/app/soapbox/entity-store/hooks/useDeleteEntity.ts @@ -1,9 +1,8 @@ -import { useState } from 'react'; - -import { useApi, useAppDispatch, useGetState } from 'soapbox/hooks'; +import { useAppDispatch, useGetState } from 'soapbox/hooks'; import { deleteEntities, importEntities } from '../actions'; +import { useEntityRequest } from './useEntityRequest'; import { toAxiosRequest } from './utils'; import type { EntityCallbacks, EntityRequest } from './types'; @@ -15,16 +14,13 @@ import type { EntityCallbacks, EntityRequest } from './types'; */ function useDeleteEntity( entityType: string, - request: EntityRequest, + entityRequest: EntityRequest, ) { - const api = useApi(); const dispatch = useAppDispatch(); const getState = useGetState(); - const [isLoading, setIsLoading] = useState(false); + const { request, isLoading } = useEntityRequest(); async function deleteEntity(entityId: string, callbacks: EntityCallbacks = {}): Promise { - setIsLoading(true); - // Get the entity before deleting, so we can reverse the action if the API request fails. const entity = getState().entities[entityType]?.store[entityId]; @@ -33,10 +29,10 @@ function useDeleteEntity( try { // HACK: replace occurrences of `:id` in the URL. Maybe there's a better way? - const axiosReq = toAxiosRequest(request); + const axiosReq = toAxiosRequest(entityRequest); axiosReq.url?.replaceAll(':id', entityId); - await api.request(axiosReq); + await request(axiosReq); // Success - finish deleting entity from the state. dispatch(deleteEntities([entityId], entityType)); @@ -54,8 +50,6 @@ function useDeleteEntity( callbacks.onError(e); } } - - setIsLoading(false); } return { diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index b424ed1f65..c20d75f435 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -2,13 +2,14 @@ import { useEffect } from 'react'; import z from 'zod'; import { getNextLink, getPrevLink } from 'soapbox/api'; -import { useApi, useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks'; import { filteredArray } from 'soapbox/schemas/utils'; import { realNumberSchema } from 'soapbox/utils/numbers'; import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions'; -import { parseEntitiesPath, toAxiosRequest } from './utils'; +import { useEntityRequest } from './useEntityRequest'; +import { parseEntitiesPath } from './utils'; import type { Entity, EntityListState } from '../types'; import type { EntitiesPath, EntityRequest, EntitySchema, ExpandedEntitiesPath } from './types'; @@ -32,11 +33,11 @@ function useEntities( /** Tells us where to find/store the entity in the cache. */ expandedPath: ExpandedEntitiesPath, /** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */ - request: EntityRequest, + entityRequest: EntityRequest, /** Additional options for the hook. */ opts: UseEntitiesOpts = {}, ) { - const api = useApi(); + const { request } = useEntityRequest(); const dispatch = useAppDispatch(); const getState = useGetState(); @@ -61,7 +62,7 @@ function useEntities( dispatch(entitiesFetchRequest(entityType, listKey)); try { - const response = await api.request(toAxiosRequest(req)); + const response = await request(req); const schema = opts.schema || z.custom(); const entities = filteredArray(schema).parse(response.data); const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']); @@ -82,7 +83,7 @@ function useEntities( }; const fetchEntities = async(): Promise => { - await fetchPage(request, true); + await fetchPage(entityRequest, true); }; const fetchNextPage = async(): Promise => { @@ -112,7 +113,7 @@ function useEntities( if (isInvalid || isUnset || isStale) { fetchEntities(); } - }, [request, isEnabled]); + }, [entityRequest, isEnabled]); return { entities, diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index cb71b86a9e..1b091c6557 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -1,11 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import z from 'zod'; -import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { importEntities } from '../actions'; -import { toAxiosRequest } from './utils'; +import { useEntityRequest } from './useEntityRequest'; import type { Entity } from '../types'; import type { EntitySchema, EntityPath, EntityRequest } from './types'; @@ -20,10 +20,10 @@ interface UseEntityOpts { function useEntity( path: EntityPath, - request: EntityRequest, + entityRequest: EntityRequest, opts: UseEntityOpts = {}, ) { - const api = useApi(); + const { request, isLoading: isFetching } = useEntityRequest(); const dispatch = useAppDispatch(); const [entityType, entityId] = path; @@ -33,21 +33,16 @@ function useEntity( const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined); - const [isFetching, setIsFetching] = useState(false); const isLoading = isFetching && !entity; const fetchEntity = async () => { - setIsFetching(true); - try { - const response = await api.request(toAxiosRequest(request)); + const response = await request(entityRequest); const entity = schema.parse(response.data); dispatch(importEntities([entity], entityType)); } catch (e) { // do nothing } - - setIsFetching(false); }; useEffect(() => { From 9d12173b874b8ffcbf595f465a594fdcf46fd830 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Mar 2023 18:42:34 -0500 Subject: [PATCH 54/59] Add useLoading hook --- .../entity-store/hooks/useEntityRequest.ts | 19 +++++-------------- app/soapbox/hooks/index.ts | 1 + app/soapbox/hooks/useLoading.ts | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 14 deletions(-) create mode 100644 app/soapbox/hooks/useLoading.ts diff --git a/app/soapbox/entity-store/hooks/useEntityRequest.ts b/app/soapbox/entity-store/hooks/useEntityRequest.ts index 7d2697e61f..678b8197c5 100644 --- a/app/soapbox/entity-store/hooks/useEntityRequest.ts +++ b/app/soapbox/entity-store/hooks/useEntityRequest.ts @@ -1,24 +1,15 @@ -import { useState } from 'react'; - -import { useApi } from 'soapbox/hooks'; +import { useApi, useLoading } from 'soapbox/hooks'; import { EntityRequest } from './types'; import { toAxiosRequest } from './utils'; function useEntityRequest() { const api = useApi(); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setPromise] = useLoading(); - async function request(entityRequest: EntityRequest) { - setIsLoading(true); - try { - const response = await api.request(toAxiosRequest(entityRequest)); - setIsLoading(false); - return response; - } catch (e) { - setIsLoading(false); - throw e; - } + function request(entityRequest: EntityRequest) { + const req = api.request(toAxiosRequest(entityRequest)); + return setPromise(req); } return { diff --git a/app/soapbox/hooks/index.ts b/app/soapbox/hooks/index.ts index 9cfd0a5e17..3f66a81472 100644 --- a/app/soapbox/hooks/index.ts +++ b/app/soapbox/hooks/index.ts @@ -11,6 +11,7 @@ export { useGroupsPath } from './useGroupsPath'; export { useDimensions } from './useDimensions'; export { useFeatures } from './useFeatures'; export { useInstance } from './useInstance'; +export { useLoading } from './useLoading'; export { useLocale } from './useLocale'; export { useOnScreen } from './useOnScreen'; export { useOwnAccount } from './useOwnAccount'; diff --git a/app/soapbox/hooks/useLoading.ts b/app/soapbox/hooks/useLoading.ts new file mode 100644 index 0000000000..51f15e5219 --- /dev/null +++ b/app/soapbox/hooks/useLoading.ts @@ -0,0 +1,19 @@ +import { useState } from 'react'; + +function useLoading() { + const [isLoading, setIsLoading] = useState(false); + + function setPromise(promise: Promise) { + setIsLoading(true); + + promise + .then(() => setIsLoading(false)) + .catch(() => setIsLoading(false)); + + return promise; + } + + return [isLoading, setPromise] as const; +} + +export { useLoading }; \ No newline at end of file From a530ec0202d832c8ed52b2a8a07e9a37cb1fb0e7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Mar 2023 19:22:26 -0500 Subject: [PATCH 55/59] EntityStore: switch all hooks to use a callback function --- app/soapbox/entity-store/hooks/types.ts | 8 +++---- .../entity-store/hooks/useCreateEntity.ts | 19 ++++++---------- .../entity-store/hooks/useDeleteEntity.ts | 17 +++++--------- .../entity-store/hooks/useDismissEntity.ts | 22 +++++++++++-------- app/soapbox/entity-store/hooks/useEntities.ts | 21 +++++++++--------- app/soapbox/entity-store/hooks/useEntity.ts | 12 +++++----- .../entity-store/hooks/useEntityActions.ts | 7 ++++-- .../entity-store/hooks/useEntityRequest.ts | 21 ------------------ .../entity-store/hooks/useIncrementEntity.ts | 22 +++++++++++-------- app/soapbox/entity-store/hooks/utils.ts | 15 ++----------- .../api/groups/useGroupMembershipRequests.ts | 18 +++++++-------- app/soapbox/hooks/api/useGroupMembers.ts | 6 ++++- app/soapbox/hooks/api/usePopularGroups.ts | 4 +++- app/soapbox/hooks/api/useSuggestedGroups.ts | 4 +++- app/soapbox/hooks/useGroups.ts | 15 +++++++++---- 15 files changed, 95 insertions(+), 116 deletions(-) delete mode 100644 app/soapbox/entity-store/hooks/useEntityRequest.ts diff --git a/app/soapbox/entity-store/hooks/types.ts b/app/soapbox/entity-store/hooks/types.ts index e9df8c18b7..95ba8b0162 100644 --- a/app/soapbox/entity-store/hooks/types.ts +++ b/app/soapbox/entity-store/hooks/types.ts @@ -1,5 +1,5 @@ import type { Entity } from '../types'; -import type { AxiosRequestConfig } from 'axios'; +import type { AxiosResponse } from 'axios'; import type z from 'zod'; type EntitySchema = z.ZodType; @@ -33,9 +33,9 @@ interface EntityCallbacks { /** * Passed into hooks to make requests. - * Can be a URL for GET requests, or a request object. + * Must return an Axios response. */ -type EntityRequest = string | URL | AxiosRequestConfig; +type EntityFn = (value: T) => Promise export type { EntitySchema, @@ -43,5 +43,5 @@ export type { EntitiesPath, EntityPath, EntityCallbacks, - EntityRequest, + EntityFn, }; \ 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 658cb180da..ba9dd802b8 100644 --- a/app/soapbox/entity-store/hooks/useCreateEntity.ts +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -1,36 +1,31 @@ import { z } from 'zod'; -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useLoading } from 'soapbox/hooks'; import { importEntities } from '../actions'; -import { useEntityRequest } from './useEntityRequest'; -import { parseEntitiesPath, toAxiosRequest } from './utils'; +import { parseEntitiesPath } from './utils'; import type { Entity } from '../types'; -import type { EntityCallbacks, EntityRequest, EntitySchema, ExpandedEntitiesPath } from './types'; +import type { EntityCallbacks, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types'; interface UseCreateEntityOpts { schema?: EntitySchema } -function useCreateEntity( +function useCreateEntity( expandedPath: ExpandedEntitiesPath, - entityRequest: EntityRequest, + entityFn: EntityFn, opts: UseCreateEntityOpts = {}, ) { const dispatch = useAppDispatch(); - const { request, isLoading } = useEntityRequest(); + const [isLoading, setPromise] = useLoading(); const { entityType, listKey } = parseEntitiesPath(expandedPath); async function createEntity(data: Data, callbacks: EntityCallbacks = {}): Promise { try { - const result = await request({ - ...toAxiosRequest(entityRequest), - data, - }); - + const result = await setPromise(entityFn(data)); const schema = opts.schema || z.custom(); const entity = schema.parse(result.data); diff --git a/app/soapbox/entity-store/hooks/useDeleteEntity.ts b/app/soapbox/entity-store/hooks/useDeleteEntity.ts index a8c671cc10..767224af60 100644 --- a/app/soapbox/entity-store/hooks/useDeleteEntity.ts +++ b/app/soapbox/entity-store/hooks/useDeleteEntity.ts @@ -1,11 +1,8 @@ -import { useAppDispatch, useGetState } from 'soapbox/hooks'; +import { useAppDispatch, useGetState, useLoading } from 'soapbox/hooks'; import { deleteEntities, importEntities } from '../actions'; -import { useEntityRequest } from './useEntityRequest'; -import { toAxiosRequest } from './utils'; - -import type { EntityCallbacks, EntityRequest } from './types'; +import type { EntityCallbacks, EntityFn } from './types'; /** * Optimistically deletes an entity from the store. @@ -14,11 +11,11 @@ import type { EntityCallbacks, EntityRequest } from './types'; */ function useDeleteEntity( entityType: string, - entityRequest: EntityRequest, + entityFn: EntityFn, ) { const dispatch = useAppDispatch(); const getState = useGetState(); - const { request, isLoading } = useEntityRequest(); + const [isLoading, setPromise] = useLoading(); async function deleteEntity(entityId: string, callbacks: EntityCallbacks = {}): Promise { // Get the entity before deleting, so we can reverse the action if the API request fails. @@ -28,11 +25,7 @@ function useDeleteEntity( dispatch(deleteEntities([entityId], entityType, { preserveLists: true })); try { - // HACK: replace occurrences of `:id` in the URL. Maybe there's a better way? - const axiosReq = toAxiosRequest(entityRequest); - axiosReq.url?.replaceAll(':id', entityId); - - await request(axiosReq); + await setPromise(entityFn(entityId)); // Success - finish deleting entity from the state. dispatch(deleteEntities([entityId], entityType)); diff --git a/app/soapbox/entity-store/hooks/useDismissEntity.ts b/app/soapbox/entity-store/hooks/useDismissEntity.ts index 1ba5f4a60f..b09e35951d 100644 --- a/app/soapbox/entity-store/hooks/useDismissEntity.ts +++ b/app/soapbox/entity-store/hooks/useDismissEntity.ts @@ -1,27 +1,31 @@ -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useLoading } from 'soapbox/hooks'; import { dismissEntities } from '../actions'; import { parseEntitiesPath } from './utils'; -import type { ExpandedEntitiesPath } from './types'; - -type DismissFn = (entityId: string) => Promise | T; +import type { EntityFn, ExpandedEntitiesPath } from './types'; /** * Removes an entity from a specific list. * To remove an entity globally from all lists, see `useDeleteEntity`. */ -function useDismissEntity(expandedPath: ExpandedEntitiesPath, dismissFn: DismissFn) { - const { entityType, listKey } = parseEntitiesPath(expandedPath); - +function useDismissEntity(expandedPath: ExpandedEntitiesPath, entityFn: EntityFn) { const dispatch = useAppDispatch(); + const [isLoading, setPromise] = useLoading(); + const { entityType, listKey } = parseEntitiesPath(expandedPath); + // TODO: optimistic dismissing - return async function dismissEntity(entityId: string): Promise { - const result = await dismissFn(entityId); + async function dismissEntity(entityId: string) { + const result = await setPromise(entityFn(entityId)); dispatch(dismissEntities([entityId], entityType, listKey)); return result; + } + + return { + dismissEntity, + isLoading, }; } diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index c20d75f435..f2e84c93e5 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -2,17 +2,16 @@ import { useEffect } from 'react'; import z from 'zod'; import { getNextLink, getPrevLink } from 'soapbox/api'; -import { useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks'; +import { useApi, useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks'; import { filteredArray } from 'soapbox/schemas/utils'; import { realNumberSchema } from 'soapbox/utils/numbers'; import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions'; -import { useEntityRequest } from './useEntityRequest'; import { parseEntitiesPath } from './utils'; import type { Entity, EntityListState } from '../types'; -import type { EntitiesPath, EntityRequest, EntitySchema, ExpandedEntitiesPath } from './types'; +import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types'; import type { RootState } from 'soapbox/store'; /** Additional options for the hook. */ @@ -33,11 +32,11 @@ function useEntities( /** Tells us where to find/store the entity in the cache. */ expandedPath: ExpandedEntitiesPath, /** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */ - entityRequest: EntityRequest, + entityFn: EntityFn, /** Additional options for the hook. */ opts: UseEntitiesOpts = {}, ) { - const { request } = useEntityRequest(); + const api = useApi(); const dispatch = useAppDispatch(); const getState = useGetState(); @@ -55,14 +54,14 @@ function useEntities( const next = useListState(path, 'next'); const prev = useListState(path, 'prev'); - const fetchPage = async(req: EntityRequest, overwrite = false): Promise => { + const fetchPage = async(req: EntityFn, overwrite = false): Promise => { // Get `isFetching` state from the store again to prevent race conditions. const isFetching = selectListState(getState(), path, 'fetching'); if (isFetching) return; dispatch(entitiesFetchRequest(entityType, listKey)); try { - const response = await request(req); + const response = await req(); const schema = opts.schema || z.custom(); const entities = filteredArray(schema).parse(response.data); const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']); @@ -83,18 +82,18 @@ function useEntities( }; const fetchEntities = async(): Promise => { - await fetchPage(entityRequest, true); + await fetchPage(entityFn, true); }; const fetchNextPage = async(): Promise => { if (next) { - await fetchPage(next); + await fetchPage(() => api.get(next)); } }; const fetchPreviousPage = async(): Promise => { if (prev) { - await fetchPage(prev); + await fetchPage(() => api.get(prev)); } }; @@ -113,7 +112,7 @@ function useEntities( if (isInvalid || isUnset || isStale) { fetchEntities(); } - }, [entityRequest, isEnabled]); + }, [isEnabled]); return { entities, diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index 1b091c6557..f30c9a18a6 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -1,14 +1,12 @@ import { useEffect } from 'react'; import z from 'zod'; -import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useLoading } from 'soapbox/hooks'; import { importEntities } from '../actions'; -import { useEntityRequest } from './useEntityRequest'; - import type { Entity } from '../types'; -import type { EntitySchema, EntityPath, EntityRequest } from './types'; +import type { EntitySchema, EntityPath, EntityFn } from './types'; /** Additional options for the hook. */ interface UseEntityOpts { @@ -20,10 +18,10 @@ interface UseEntityOpts { function useEntity( path: EntityPath, - entityRequest: EntityRequest, + entityFn: EntityFn, opts: UseEntityOpts = {}, ) { - const { request, isLoading: isFetching } = useEntityRequest(); + const [isFetching, setPromise] = useLoading(); const dispatch = useAppDispatch(); const [entityType, entityId] = path; @@ -37,7 +35,7 @@ function useEntity( const fetchEntity = async () => { try { - const response = await request(entityRequest); + const response = await setPromise(entityFn()); const entity = schema.parse(response.data); dispatch(importEntities([entity], entityType)); } catch (e) { diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts index f3dcd4db1e..dab6f7f77b 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -1,3 +1,5 @@ +import { useApi } from 'soapbox/hooks'; + import { useCreateEntity } from './useCreateEntity'; import { useDeleteEntity } from './useDeleteEntity'; import { parseEntitiesPath } from './utils'; @@ -19,13 +21,14 @@ function useEntityActions( endpoints: EntityActionEndpoints, opts: UseEntityActionsOpts = {}, ) { + const api = useApi(); const { entityType, path } = parseEntitiesPath(expandedPath); const { deleteEntity, isLoading: deleteLoading } = - useDeleteEntity(entityType, { method: 'delete', url: endpoints.delete }); + useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId))); const { createEntity, isLoading: createLoading } = - useCreateEntity(path, { method: 'post', url: endpoints.post }, opts); + useCreateEntity(path, (data) => api.post(endpoints.post!, data), opts); return { createEntity, diff --git a/app/soapbox/entity-store/hooks/useEntityRequest.ts b/app/soapbox/entity-store/hooks/useEntityRequest.ts deleted file mode 100644 index 678b8197c5..0000000000 --- a/app/soapbox/entity-store/hooks/useEntityRequest.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useApi, useLoading } from 'soapbox/hooks'; - -import { EntityRequest } from './types'; -import { toAxiosRequest } from './utils'; - -function useEntityRequest() { - const api = useApi(); - const [isLoading, setPromise] = useLoading(); - - function request(entityRequest: EntityRequest) { - const req = api.request(toAxiosRequest(entityRequest)); - return setPromise(req); - } - - return { - request, - isLoading, - }; -} - -export { useEntityRequest }; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useIncrementEntity.ts b/app/soapbox/entity-store/hooks/useIncrementEntity.ts index c0cbd133d9..2b09cc445c 100644 --- a/app/soapbox/entity-store/hooks/useIncrementEntity.ts +++ b/app/soapbox/entity-store/hooks/useIncrementEntity.ts @@ -1,32 +1,36 @@ -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useLoading } from 'soapbox/hooks'; import { incrementEntities } from '../actions'; import { parseEntitiesPath } from './utils'; -import type { ExpandedEntitiesPath } from './types'; - -type IncrementFn = (entityId: string) => Promise | T; +import type { EntityFn, ExpandedEntitiesPath } from './types'; /** * Increases (or decreases) the `totalCount` in the entity list by the specified amount. * This only works if the API returns an `X-Total-Count` header and your components read it. */ -function useIncrementEntity( +function useIncrementEntity( expandedPath: ExpandedEntitiesPath, diff: number, - incrementFn: IncrementFn, + entityFn: EntityFn, ) { - const { entityType, listKey } = parseEntitiesPath(expandedPath); const dispatch = useAppDispatch(); + const [isLoading, setPromise] = useLoading(); + const { entityType, listKey } = parseEntitiesPath(expandedPath); - return async function incrementEntity(entityId: string): Promise { + async function incrementEntity(entityId: string): Promise { dispatch(incrementEntities(entityType, listKey, diff)); try { - await incrementFn(entityId); + await setPromise(entityFn(entityId)); } catch (e) { dispatch(incrementEntities(entityType, listKey, diff * -1)); } + } + + return { + incrementEntity, + isLoading, }; } diff --git a/app/soapbox/entity-store/hooks/utils.ts b/app/soapbox/entity-store/hooks/utils.ts index 741a5cf135..8b9269a2e9 100644 --- a/app/soapbox/entity-store/hooks/utils.ts +++ b/app/soapbox/entity-store/hooks/utils.ts @@ -1,5 +1,4 @@ -import type { EntitiesPath, EntityRequest, ExpandedEntitiesPath } from './types'; -import type { AxiosRequestConfig } from 'axios'; +import type { EntitiesPath, ExpandedEntitiesPath } from './types'; function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) { const [entityType, ...listKeys] = expandedPath; @@ -13,15 +12,5 @@ function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) { }; } -function toAxiosRequest(req: EntityRequest): AxiosRequestConfig { - if (typeof req === 'string' || req instanceof URL) { - return { - method: 'get', - url: req.toString(), - }; - } - return req; -} - -export { parseEntitiesPath, toAxiosRequest }; \ No newline at end of file +export { parseEntitiesPath }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts b/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts index 560aef3290..6fab87209a 100644 --- a/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts +++ b/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts @@ -11,20 +11,20 @@ function useGroupMembershipRequests(groupId: string) { const { entities, invalidate, ...rest } = useEntities( path, - `/api/v1/groups/${groupId}/membership_requests`, + () => api.get(`/api/v1/groups/${groupId}/membership_requests`), { schema: accountSchema }, ); - const authorize = useIncrementEntity(path, -1, (accountId: string) => { - return api - .post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`) - .then(invalidate); + const { incrementEntity: authorize } = useIncrementEntity(path, -1, async (accountId: string) => { + const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`); + invalidate(); + return response; }); - const reject = useIncrementEntity(path, -1, (accountId: string) => { - return api - .post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`) - .then(invalidate); + const { incrementEntity: reject } = useIncrementEntity(path, -1, async (accountId: string) => { + const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`); + invalidate(); + return response; }); return { diff --git a/app/soapbox/hooks/api/useGroupMembers.ts b/app/soapbox/hooks/api/useGroupMembers.ts index 8948660d67..669f1c0827 100644 --- a/app/soapbox/hooks/api/useGroupMembers.ts +++ b/app/soapbox/hooks/api/useGroupMembers.ts @@ -2,10 +2,14 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useEntities } from 'soapbox/entity-store/hooks'; import { GroupMember, groupMemberSchema } from 'soapbox/schemas'; +import { useApi } from '../useApi'; + function useGroupMembers(groupId: string, role: string) { + const api = useApi(); + const { entities, ...result } = useEntities( [Entities.GROUP_MEMBERSHIPS, groupId, role], - `/api/v1/groups/${groupId}/memberships?role=${role}`, + () => api.get(`/api/v1/groups/${groupId}/memberships?role=${role}`), { schema: groupMemberSchema }, ); diff --git a/app/soapbox/hooks/api/usePopularGroups.ts b/app/soapbox/hooks/api/usePopularGroups.ts index 88ae48c9dc..97d3751742 100644 --- a/app/soapbox/hooks/api/usePopularGroups.ts +++ b/app/soapbox/hooks/api/usePopularGroups.ts @@ -2,15 +2,17 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useEntities } from 'soapbox/entity-store/hooks'; import { Group, groupSchema } from 'soapbox/schemas'; +import { useApi } from '../useApi'; import { useFeatures } from '../useFeatures'; import { useGroupRelationships } from '../useGroups'; function usePopularGroups() { + const api = useApi(); const features = useFeatures(); const { entities, ...result } = useEntities( [Entities.GROUPS, 'popular'], - '/api/mock/groups', // '/api/v1/truth/trends/groups' + () => api.get('/api/mock/groups'), // '/api/v1/truth/trends/groups' { schema: groupSchema, enabled: features.groupsDiscovery, diff --git a/app/soapbox/hooks/api/useSuggestedGroups.ts b/app/soapbox/hooks/api/useSuggestedGroups.ts index c1b85805ca..9d5e20ace4 100644 --- a/app/soapbox/hooks/api/useSuggestedGroups.ts +++ b/app/soapbox/hooks/api/useSuggestedGroups.ts @@ -2,15 +2,17 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useEntities } from 'soapbox/entity-store/hooks'; import { Group, groupSchema } from 'soapbox/schemas'; +import { useApi } from '../useApi'; import { useFeatures } from '../useFeatures'; import { useGroupRelationships } from '../useGroups'; function useSuggestedGroups() { + const api = useApi(); const features = useFeatures(); const { entities, ...result } = useEntities( [Entities.GROUPS, 'suggested'], - '/api/mock/groups', // '/api/v1/truth/suggestions/groups' + () => api.get('/api/mock/groups'), // '/api/v1/truth/suggestions/groups' { schema: groupSchema, enabled: features.groupsDiscovery, diff --git a/app/soapbox/hooks/useGroups.ts b/app/soapbox/hooks/useGroups.ts index 832d852a50..9bd0e99ca4 100644 --- a/app/soapbox/hooks/useGroups.ts +++ b/app/soapbox/hooks/useGroups.ts @@ -2,17 +2,19 @@ import { z } from 'zod'; import { Entities } from 'soapbox/entity-store/entities'; import { useEntities, useEntity } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; import { groupSchema, Group } from 'soapbox/schemas/group'; import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship'; import { useFeatures } from './useFeatures'; function useGroups() { + const api = useApi(); const features = useFeatures(); const { entities, ...result } = useEntities( [Entities.GROUPS], - '/api/v1/groups', + () => api.get('/api/v1/groups'), { enabled: features.groups, schema: groupSchema }, ); const { relationships } = useGroupRelationships(entities.map(entity => entity.id)); @@ -29,9 +31,11 @@ function useGroups() { } function useGroup(groupId: string, refetch = true) { + const api = useApi(); + const { entity: group, ...result } = useEntity( [Entities.GROUPS, groupId], - `/api/v1/groups/${groupId}`, + () => api.get(`/api/v1/groups/${groupId}`), { schema: groupSchema, refetch }, ); const { entity: relationship } = useGroupRelationship(groupId); @@ -43,18 +47,21 @@ function useGroup(groupId: string, refetch = true) { } function useGroupRelationship(groupId: string) { + const api = useApi(); + return useEntity( [Entities.GROUP_RELATIONSHIPS, groupId], - `/api/v1/groups/relationships?id[]=${groupId}`, + () => api.get(`/api/v1/groups/relationships?id[]=${groupId}`), { schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) }, ); } function useGroupRelationships(groupIds: string[]) { + const api = useApi(); const q = groupIds.map(id => `id[]=${id}`).join('&'); const { entities, ...result } = useEntities( [Entities.GROUP_RELATIONSHIPS, ...groupIds], - `/api/v1/groups/relationships?${q}`, + () => api.get(`/api/v1/groups/relationships?${q}`), { schema: groupRelationshipSchema, enabled: groupIds.length > 0 }, ); From 818b10efc3b3b3bf0aac35e43954ee795232f2a8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Mar 2023 19:24:20 -0500 Subject: [PATCH 56/59] Remove stray whitespace --- app/soapbox/api/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/soapbox/api/index.ts b/app/soapbox/api/index.ts index 520c3b7ad9..fc19e7c419 100644 --- a/app/soapbox/api/index.ts +++ b/app/soapbox/api/index.ts @@ -15,7 +15,6 @@ import { getAccessToken, getAppToken, isURL, parseBaseURL } from 'soapbox/utils/ import type MockAdapter from 'axios-mock-adapter'; - /** Parse Link headers, mostly for pagination. @see {@link https://www.npmjs.com/package/http-link-header} From 01359ca5920031a7814979882bb888d526297137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 25 Mar 2023 08:25:50 +0100 Subject: [PATCH 57/59] Update features.ts for Friendica MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/utils/features.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index c07fe5b60f..d488bcf9bd 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -91,6 +91,8 @@ const getInstanceFeatures = (instance: Instance) => { const features = instance.pleroma.getIn(['metadata', 'features'], ImmutableList()) as ImmutableList; const federation = instance.pleroma.getIn(['metadata', 'federation'], ImmutableMap()) as ImmutableMap; + console.log(v); + return { /** * Can view and manage ActivityPub aliases through the API. @@ -150,6 +152,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see POST /api/v1/accounts/:id/follow */ accountNotifies: any([ + v.software === FRIENDICA, v.software === MASTODON && gte(v.compatVersion, '3.3.0'), v.software === PLEROMA && gte(v.version, '2.4.50'), v.software === TRUTHSOCIAL, @@ -359,6 +362,7 @@ const getInstanceFeatures = (instance: Instance) => { ]), editStatuses: any([ + v.software === FRIENDICA, v.software === MASTODON && gte(v.version, '3.5.0'), features.includes('editing'), ]), @@ -432,6 +436,7 @@ const getInstanceFeatures = (instance: Instance) => { /** Whether the accounts who favourited or emoji-reacted to a status can be viewed through the API. */ exposableReactions: any([ + v.software === FRIENDICA, v.software === MASTODON, v.software === TAKAHE && gte(v.version, '0.6.1'), v.software === TRUTHSOCIAL, @@ -775,6 +780,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see {@link https://docs.joinmastodon.org/methods/scheduled_statuses/} */ scheduledStatuses: any([ + v.software === FRIENDICA, v.software === MASTODON && gte(v.version, '2.7.0'), v.software === PLEROMA, ]), @@ -785,6 +791,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see POST /api/v2/search */ searchFromAccount: any([ + v.software === FRIENDICA, v.software === MASTODON && gte(v.version, '2.8.0'), v.software === PLEROMA && gte(v.version, '1.0.0'), ]), @@ -853,7 +860,10 @@ const getInstanceFeatures = (instance: Instance) => { * Trending statuses. * @see GET /api/v1/trends/statuses */ - trendingStatuses: v.software === MASTODON && gte(v.compatVersion, '3.5.0'), + trendingStatuses: any([ + v.software === FRIENDICA, + v.software === MASTODON && gte(v.compatVersion, '3.5.0'), + ]), /** * Truth Social trending statuses API. @@ -866,6 +876,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see GET /api/v1/trends */ trends: any([ + v.software === FRIENDICA, v.software === MASTODON && gte(v.compatVersion, '3.0.0'), v.software === TRUTHSOCIAL, ]), @@ -886,7 +897,10 @@ const getInstanceFeatures = (instance: Instance) => { * Whether the backend allows adding users you don't follow to lists. * @see POST /api/v1/lists/:id/accounts */ - unrestrictedLists: v.software === PLEROMA, + unrestrictedLists: any([ + v.software === FRIENDICA, + v.software === PLEROMA, + ]), }; }; From 52172c923fe499f281f544cdfe0588c962630e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 25 Mar 2023 09:43:27 +0100 Subject: [PATCH 58/59] Actually fix version parsing for Friendica MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/utils/features.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index d488bcf9bd..11e9182e35 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -91,8 +91,6 @@ const getInstanceFeatures = (instance: Instance) => { const features = instance.pleroma.getIn(['metadata', 'features'], ImmutableList()) as ImmutableList; const federation = instance.pleroma.getIn(['metadata', 'federation'], ImmutableMap()) as ImmutableMap; - console.log(v); - return { /** * Can view and manage ActivityPub aliases through the API. @@ -152,7 +150,6 @@ const getInstanceFeatures = (instance: Instance) => { * @see POST /api/v1/accounts/:id/follow */ accountNotifies: any([ - v.software === FRIENDICA, v.software === MASTODON && gte(v.compatVersion, '3.3.0'), v.software === PLEROMA && gte(v.version, '2.4.50'), v.software === TRUTHSOCIAL, @@ -791,7 +788,6 @@ const getInstanceFeatures = (instance: Instance) => { * @see POST /api/v2/search */ searchFromAccount: any([ - v.software === FRIENDICA, v.software === MASTODON && gte(v.version, '2.8.0'), v.software === PLEROMA && gte(v.version, '1.0.0'), ]), @@ -897,10 +893,7 @@ const getInstanceFeatures = (instance: Instance) => { * Whether the backend allows adding users you don't follow to lists. * @see POST /api/v1/lists/:id/accounts */ - unrestrictedLists: any([ - v.software === FRIENDICA, - v.software === PLEROMA, - ]), + unrestrictedLists: v.software === PLEROMA, }; }; @@ -933,7 +926,9 @@ export const parseVersion = (version: string): Backend => { const match = regex.exec(version); const semverString = match && (match[3] || match[1]); - const semver = match ? semverParse(semverString) || semverCoerce(semverString) : null; + const semver = match ? semverParse(semverString) || semverCoerce(semverString, { + loose: true, + }) : null; const compat = match ? semverParse(match[1]) || semverCoerce(match[1]) : null; if (match && semver && compat) { From 4990e1eaa75c9e15bc025c82fe76c8b5922b100f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 25 Mar 2023 10:02:21 +0100 Subject: [PATCH 59/59] Check version for Friendica features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/utils/features.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 11e9182e35..b9d0e2210d 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -359,7 +359,7 @@ const getInstanceFeatures = (instance: Instance) => { ]), editStatuses: any([ - v.software === FRIENDICA, + v.software === FRIENDICA && gte(v.version, '2022.12.0'), v.software === MASTODON && gte(v.version, '3.5.0'), features.includes('editing'), ]), @@ -857,7 +857,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see GET /api/v1/trends/statuses */ trendingStatuses: any([ - v.software === FRIENDICA, + v.software === FRIENDICA && gte(v.version, '2022.12.0'), v.software === MASTODON && gte(v.compatVersion, '3.5.0'), ]), @@ -872,7 +872,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see GET /api/v1/trends */ trends: any([ - v.software === FRIENDICA, + v.software === FRIENDICA && gte(v.version, '2022.12.0'), v.software === MASTODON && gte(v.compatVersion, '3.0.0'), v.software === TRUTHSOCIAL, ]),