From 1a4f986cc3deacfe6489c1998abd35fde11f4372 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:00:32 +0200 Subject: [PATCH] refactor(collections): share the list sharing logic between desktop and phone useCollectionSharing holds the invitable users lookup, the roster order, invite, role, cancel, remove and leave for both the share dialog and the share sheet. The dialog keeps one busy member per kind of row action, the sheet one for all. --- .../Collections/ShareCollectionModal.tsx | 131 +-------- .../Collections/useCollectionSharing.test.ts | 266 ++++++++++++++++++ .../Collections/useCollectionSharing.ts | 189 +++++++++++++ .../screens/collections/MCollShareSheet.tsx | 101 +------ 4 files changed, 480 insertions(+), 207 deletions(-) create mode 100644 client/src/components/Collections/useCollectionSharing.test.ts create mode 100644 client/src/components/Collections/useCollectionSharing.ts diff --git a/client/src/components/Collections/ShareCollectionModal.tsx b/client/src/components/Collections/ShareCollectionModal.tsx index 9573ca507..a6ae21ffa 100644 --- a/client/src/components/Collections/ShareCollectionModal.tsx +++ b/client/src/components/Collections/ShareCollectionModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useId, useMemo, useState, type ReactNode } from 'react' +import React, { useId, type ReactNode } from 'react' import { avatarSrc } from '../../utils/avatarSrc' import { UserPlus, UserMinus, UserX, Loader2, Clock, Crown, LogOut, Share2 } from 'lucide-react' import type { CollectionMember, CollectionRole } from '@trek/shared' @@ -6,13 +6,9 @@ import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, D import ConfirmDialog from '../shared/ConfirmDialog' import CustomSelect from '../shared/CustomSelect' import { Tooltip } from '../shared/Tooltip' -import { useToast } from '../shared/Toast' import { TripMemberAvatar } from '../Trips/TripMemberAvatar' -import { useCollectionStore } from '../../store/collectionStore' -import { useAuthStore } from '../../store/authStore' -import { collectionsApi } from '../../api/collections' -import { getApiErrorMessage } from '../../utils/apiError' import type { TranslationFn } from '../../types' +import { useCollectionSharing } from './useCollectionSharing' interface ShareCollectionModalProps { isOpen: boolean @@ -64,114 +60,17 @@ export default function ShareCollectionModal({ onAfterLeave, t, }: ShareCollectionModalProps): React.ReactElement | null { - const toast = useToast() const labelId = useId() - const currentUserId = useAuthStore(s => s.user?.id) - const invite = useCollectionStore(s => s.invite) - const cancelInvite = useCollectionStore(s => s.cancelInvite) - const removeMember = useCollectionStore(s => s.removeMember) - const setMemberRole = useCollectionStore(s => s.setMemberRole) - const leave = useCollectionStore(s => s.leave) - - const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([]) - const [selectedUserId, setSelectedUserId] = useState('') - const [inviteRole, setInviteRole] = useState('editor') - const [settingRoleId, setSettingRoleId] = useState(null) - const [inviting, setInviting] = useState(false) - const [cancellingId, setCancellingId] = useState(null) - const [removingId, setRemovingId] = useState(null) - const [confirmLeave, setConfirmLeave] = useState(false) - const [leaving, setLeaving] = useState(false) - - // Load the invitable users whenever an owner opens the modal. - useEffect(() => { - if (!isOpen || !isOwner) return - let cancelled = false - collectionsApi.availableUsers(collectionId) - .then(data => { if (!cancelled) setAvailableUsers(data.users) }) - .catch(() => { if (!cancelled) setAvailableUsers([]) }) - return () => { cancelled = true } - }, [isOpen, isOwner, collectionId, members.length]) - - // Reset transient state on close. - useEffect(() => { - if (!isOpen) { - setSelectedUserId('') - setConfirmLeave(false) - } - }, [isOpen]) - - const sortedMembers = useMemo(() => { - // Owner first, then accepted, then pending — alphabetised within each band. - const rank = (m: CollectionMember) => (m.is_owner ? 0 : m.status === 'accepted' ? 1 : 2) - return [...members].sort((a, b) => rank(a) - rank(b) || a.username.localeCompare(b.username)) - }, [members]) + const { + currentUserId, availableUsers, sortedMembers, selectedUserId, setSelectedUserId, inviteRole, setInviteRole, + inviting, confirmLeave, setConfirmLeave, leaving, busyUserId, setRole, cancel, remove, handleInvite, handleLeave, + } = useCollectionSharing({ open: isOpen, collectionId, isOwner, members, onAfterLeave, t }) + const settingRoleId = busyUserId('role') + const cancellingId = busyUserId('cancel') + const removingId = busyUserId('remove') if (!isOpen) return null - const handleInvite = async () => { - if (selectedUserId === '' || inviting) return - setInviting(true) - try { - await invite(collectionId, Number(selectedUserId), inviteRole) - toast.success(t('collections.invite.sent')) - setSelectedUserId('') - } catch (err) { - toast.error(getApiErrorMessage(err, t('collections.invite.error'))) - } finally { - setInviting(false) - } - } - - const handleSetRole = async (userId: number, role: CollectionRole) => { - if (settingRoleId != null) return - setSettingRoleId(userId) - try { - await setMemberRole(collectionId, userId, role) - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setSettingRoleId(null) - } - } - - const handleCancel = async (userId: number) => { - if (cancellingId != null) return - setCancellingId(userId) - try { - await cancelInvite(collectionId, userId) - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setCancellingId(null) - } - } - - const handleRemove = async (userId: number) => { - if (removingId != null) return - setRemovingId(userId) - try { - await removeMember(collectionId, userId) - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setRemovingId(null) - } - } - - const handleLeave = async () => { - setLeaving(true) - try { - await leave(collectionId) - toast.success(t('collections.share.left')) - onAfterLeave() - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setLeaving(false) - } - } - const roleOptions = ROLE_ORDER.map(r => ({ value: r, label: t(`collections.role.${r}`) })) return ( @@ -214,8 +113,8 @@ export default function ShareCollectionModal({
setSelectedUserId(v === '' ? '' : Number(v))} + value={selectedUserId ?? ''} + onChange={v => setSelectedUserId(v === '' ? null : Number(v))} options={availableUsers.map(u => ({ value: u.id, label: u.username }))} placeholder={t('collections.share.inviteUser')} searchable @@ -228,7 +127,7 @@ export default function ShareCollectionModal({ void handleInvite()} - disabled={selectedUserId === '' || inviting} + disabled={selectedUserId == null || inviting} icon={inviting ? : } > {t('collections.share.sendInvite')} @@ -259,7 +158,7 @@ export default function ShareCollectionModal({ handleSetRole(member.user_id, v as CollectionRole)} + onChange={v => setRole(member.user_id, v as CollectionRole)} options={roleOptions} disabled={settingRoleId === member.user_id} /> @@ -282,12 +181,12 @@ export default function ShareCollectionModal({
{standing} {isOwner && pending && ( - handleCancel(member.user_id)} disabled={cancellingId === member.user_id}> + cancel(member.user_id)} disabled={cancellingId === member.user_id}> {cancellingId === member.user_id ? : } )} {isOwner && !member.is_owner && member.status === 'accepted' && ( - handleRemove(member.user_id)} disabled={removingId === member.user_id}> + remove(member.user_id)} disabled={removingId === member.user_id}> {removingId === member.user_id ? : } )} diff --git a/client/src/components/Collections/useCollectionSharing.test.ts b/client/src/components/Collections/useCollectionSharing.test.ts new file mode 100644 index 000000000..651f46ae1 --- /dev/null +++ b/client/src/components/Collections/useCollectionSharing.test.ts @@ -0,0 +1,266 @@ +// FE-COMP-COLSHAREHOOK-001 to FE-COMP-COLSHAREHOOK-012: the list sharing logic behind +// both the desktop dialog and the phone sheet. +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { CollectionMember } from '@trek/shared'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest'; + +import { buildUser } from '../../../tests/helpers/factories'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { collectionsApi } from '../../api/collections'; +import { useAuthStore } from '../../store/authStore'; +import { useCollectionStore } from '../../store/collectionStore'; +import { + nextCollectionRole, + sortCollectionMembers, + useCollectionSharing, + type CollectionSharingOptions, +} from './useCollectionSharing'; + +const t = (k: string, p?: Record) => (p ? `${k}:${JSON.stringify(p)}` : k); + +const OWNER: CollectionMember = { user_id: 1, username: 'maurice', status: 'accepted', is_owner: true }; +const EDITOR: CollectionMember = { user_id: 2, username: 'julien', status: 'accepted', role: 'editor' }; +const VIEWER: CollectionMember = { user_id: 4, username: 'ada', status: 'accepted', role: 'viewer' }; +const PENDING: CollectionMember = { user_id: 3, username: 'zoe', status: 'pending' }; + +type CollectionState = ReturnType; +const initialCollectionState = useCollectionStore.getState(); +let actions: { + invite: Mock; + cancelInvite: Mock; + removeMember: Mock; + setMemberRole: Mock; + leave: Mock; +}; +let addToast: Mock>; +let availableUsers: MockInstance; + +function setup(over: Partial = {}) { + const props: CollectionSharingOptions = { + open: true, + collectionId: 7, + isOwner: true, + members: [PENDING, EDITOR, OWNER], + onAfterLeave: vi.fn(), + t, + ...over, + }; + const hook = renderHook((p: CollectionSharingOptions) => useCollectionSharing(p), { initialProps: props }); + return { ...hook, props }; +} + +/** A promise the test settles by hand, to look at the hook while a write is in flight. */ +function deferred() { + let resolve: () => void = () => {}; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +beforeEach(() => { + resetAllStores(); + useCollectionStore.setState(initialCollectionState, true); + actions = { + invite: vi.fn().mockResolvedValue(undefined), + cancelInvite: vi.fn().mockResolvedValue(undefined), + removeMember: vi.fn().mockResolvedValue(undefined), + setMemberRole: vi.fn().mockResolvedValue(undefined), + leave: vi.fn().mockResolvedValue(undefined), + }; + useCollectionStore.setState(actions); + seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'maurice' }) }); + addToast = vi.fn>(); + window.__addToast = addToast; + availableUsers = vi + .spyOn(collectionsApi, 'availableUsers') + .mockResolvedValue({ users: [{ id: 9, username: 'nina' }] }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + delete window.__addToast; +}); + +describe('collection sharing helpers', () => { + it('FE-COMP-COLSHAREHOOK-001: sorts owner, then accepted, then pending, by name within a band', () => { + expect(sortCollectionMembers([PENDING, EDITOR, VIEWER, OWNER]).map((m) => m.user_id)).toEqual([1, 4, 2, 3]); + }); + + it('FE-COMP-COLSHAREHOOK-002: cycles viewer, editor, admin and back', () => { + expect(nextCollectionRole('viewer')).toBe('editor'); + expect(nextCollectionRole('editor')).toBe('admin'); + expect(nextCollectionRole('admin')).toBe('viewer'); + }); +}); + +describe('useCollectionSharing', () => { + it('FE-COMP-COLSHAREHOOK-003: loads the invitable users for an owner and reloads when the roster changes size', async () => { + const { result, rerender, props } = setup(); + await waitFor(() => expect(result.current.availableUsers).toEqual([{ id: 9, username: 'nina' }])); + expect(availableUsers).toHaveBeenCalledWith(7); + expect(result.current.currentUserId).toBe(1); + rerender({ ...props, members: [OWNER] }); + await waitFor(() => expect(availableUsers).toHaveBeenCalledTimes(2)); + }); + + it('FE-COMP-COLSHAREHOOK-004: loads nothing for a member, while closed, or without a list', () => { + setup({ isOwner: false }); + setup({ open: false }); + setup({ collectionId: null }); + expect(availableUsers).not.toHaveBeenCalled(); + }); + + it('FE-COMP-COLSHAREHOOK-005: an empty invitee list when the lookup fails', async () => { + availableUsers.mockRejectedValueOnce(new Error('boom')); + const { result } = setup(); + await waitFor(() => expect(availableUsers).toHaveBeenCalled()); + expect(result.current.availableUsers).toEqual([]); + }); + + it('FE-COMP-COLSHAREHOOK-006: invites the picked user with the picked role, toasts and clears the pick', async () => { + const { result } = setup(); + await act(async () => { + await result.current.handleInvite(); + }); + expect(actions.invite).not.toHaveBeenCalled(); + + act(() => { + result.current.setSelectedUserId(9); + result.current.setInviteRole('admin'); + }); + await act(async () => { + await result.current.handleInvite(); + }); + expect(actions.invite).toHaveBeenCalledWith(7, 9, 'admin'); + expect(addToast).toHaveBeenCalledWith('collections.invite.sent', 'success', undefined); + expect(result.current.selectedUserId).toBeNull(); + expect(result.current.inviting).toBe(false); + }); + + it('FE-COMP-COLSHAREHOOK-007: a failed invite keeps the pick and shows the server message', async () => { + actions.invite.mockRejectedValueOnce({ response: { data: { error: 'Already invited' } } }); + const { result } = setup(); + act(() => result.current.setSelectedUserId(9)); + await act(async () => { + await result.current.handleInvite(); + }); + expect(addToast).toHaveBeenCalledWith('Already invited', 'error', undefined); + expect(result.current.selectedUserId).toBe(9); + }); + + it('FE-COMP-COLSHAREHOOK-008: closing drops the pick and the leave question', () => { + const { result, rerender, props } = setup(); + act(() => { + result.current.setSelectedUserId(9); + result.current.setConfirmLeave(true); + }); + rerender({ ...props, open: false }); + expect(result.current.selectedUserId).toBeNull(); + expect(result.current.confirmLeave).toBe(false); + }); + + it('FE-COMP-COLSHAREHOOK-009: one busy member per kind of row action on desktop', async () => { + const role = deferred(); + actions.setMemberRole.mockReturnValueOnce(role.promise); + const { result } = setup(); + act(() => { + void result.current.setRole(2, 'viewer'); + }); + expect(result.current.busyUserId('role')).toBe(2); + expect(result.current.busyUserId('cancel')).toBeNull(); + + // A cancel on another row runs alongside, a second role change does not. + await act(async () => { + await result.current.cancel(3); + await result.current.setRole(4, 'admin'); + }); + expect(actions.cancelInvite).toHaveBeenCalledWith(7, 3); + expect(actions.setMemberRole).toHaveBeenCalledTimes(1); + + act(() => role.resolve()); + await waitFor(() => expect(result.current.busyUserId('role')).toBeNull()); + expect(actions.setMemberRole).toHaveBeenCalledWith(7, 2, 'viewer'); + }); + + it('FE-COMP-COLSHAREHOOK-010: one busy member for every row action on the phone', async () => { + const role = deferred(); + actions.setMemberRole.mockReturnValueOnce(role.promise); + const { result } = setup({ singleBusySlot: true }); + act(() => result.current.cycleRole(EDITOR)); + expect(actions.setMemberRole).toHaveBeenCalledWith(7, 2, 'admin'); + expect(result.current.busyUserId('remove')).toBe(2); + + await act(async () => { + await result.current.cancel(3); + await result.current.remove(4); + }); + expect(actions.cancelInvite).not.toHaveBeenCalled(); + expect(actions.removeMember).not.toHaveBeenCalled(); + + act(() => role.resolve()); + await waitFor(() => expect(result.current.busyUserId('remove')).toBeNull()); + await act(async () => { + await result.current.remove(4); + }); + expect(actions.removeMember).toHaveBeenCalledWith(7, 4); + }); + + it('FE-COMP-COLSHAREHOOK-011: a failed row action toasts the generic error and frees the row', async () => { + actions.removeMember.mockRejectedValueOnce(new Error('nope')); + const { result } = setup(); + await act(async () => { + await result.current.remove(2); + }); + expect(addToast).toHaveBeenCalledWith('common.error', 'error', undefined); + expect(result.current.busyUserId('remove')).toBeNull(); + }); + + it('FE-COMP-COLSHAREHOOK-012: leaving toasts and hands over, a failure only toasts', async () => { + const onAfterLeave = vi.fn(); + const { result } = setup({ isOwner: false, onAfterLeave }); + await act(async () => { + await result.current.handleLeave(); + }); + expect(actions.leave).toHaveBeenCalledWith(7); + expect(addToast).toHaveBeenCalledWith('collections.share.left', 'success', undefined); + expect(onAfterLeave).toHaveBeenCalledTimes(1); + + actions.leave.mockRejectedValueOnce(new Error('nope')); + await act(async () => { + await result.current.handleLeave(); + }); + expect(addToast).toHaveBeenLastCalledWith('common.error', 'error', undefined); + expect(onAfterLeave).toHaveBeenCalledTimes(1); + expect(result.current.leaving).toBe(false); + }); + + it('FE-COMP-COLSHAREHOOK-013: only the phone ignores a second leave while one is in flight', async () => { + const first = deferred(); + actions.leave.mockReturnValueOnce(first.promise); + const phone = setup({ isOwner: false, singleBusySlot: true }); + act(() => { + void phone.result.current.handleLeave(); + }); + expect(phone.result.current.leaving).toBe(true); + await act(async () => { + await phone.result.current.handleLeave(); + }); + expect(actions.leave).toHaveBeenCalledTimes(1); + await act(async () => first.resolve()); + phone.unmount(); + + const second = deferred(); + actions.leave.mockReturnValueOnce(second.promise); + const desktop = setup({ isOwner: false }); + act(() => { + void desktop.result.current.handleLeave(); + }); + expect(desktop.result.current.leaving).toBe(true); + await act(async () => { + await desktop.result.current.handleLeave(); + }); + expect(actions.leave).toHaveBeenCalledTimes(3); + await act(async () => second.resolve()); + }); +}); diff --git a/client/src/components/Collections/useCollectionSharing.ts b/client/src/components/Collections/useCollectionSharing.ts new file mode 100644 index 000000000..95690de99 --- /dev/null +++ b/client/src/components/Collections/useCollectionSharing.ts @@ -0,0 +1,189 @@ +import type { CollectionMember, CollectionRole } from '@trek/shared'; +import { COLLECTION_ROLES } from '@trek/shared'; +import { useEffect, useMemo, useState } from 'react'; + +import { collectionsApi } from '../../api/collections'; +import { useAuthStore } from '../../store/authStore'; +import { useCollectionStore } from '../../store/collectionStore'; +import type { TranslationFn } from '../../types'; +import { getApiErrorMessage } from '../../utils/apiError'; +import { useToast } from '../shared/Toast'; + +/** A per-member write: change the role, cancel a pending invite, remove an accepted member. */ +export type MemberAction = 'role' | 'cancel' | 'remove'; + +export interface CollectionSharingOptions { + open: boolean; + collectionId: number | null; + isOwner: boolean; + members: CollectionMember[]; + /** Called after the current (member) user successfully leaves the list. */ + onAfterLeave: () => void; + t: TranslationFn; + /** + * One busy member for every kind of row action (the phone sheet), instead of one + * busy member per kind (the desktop dialog, where a role change and a cancel on + * two different rows can run side by side). The phone sheet also ignores a second + * leave while one is in flight. + */ + singleBusySlot?: boolean; +} + +/** Owner first, then accepted, then pending, alphabetised within each band. */ +export function sortCollectionMembers(members: CollectionMember[]): CollectionMember[] { + const rank = (m: CollectionMember) => (m.is_owner ? 0 : m.status === 'accepted' ? 1 : 2); + return [...members].sort((a, b) => rank(a) - rank(b) || a.username.localeCompare(b.username)); +} + +/** The role after `current` in the Viewer, Editor, Admin cycle. */ +export function nextCollectionRole(current: CollectionRole): CollectionRole { + return COLLECTION_ROLES[(COLLECTION_ROLES.indexOf(current) + 1) % COLLECTION_ROLES.length]; +} + +/** + * Fusion sharing for one list: the logic behind both the desktop dialog and the phone + * sheet, which render their own markup over it. An owner gets the invitable users on + * every opening (and after the roster changes size), invites, changes roles, cancels + * pending invites and removes members; a member can leave. + */ +export function useCollectionSharing({ + open, + collectionId, + isOwner, + members, + onAfterLeave, + t, + singleBusySlot = false, +}: CollectionSharingOptions) { + const toast = useToast(); + const currentUserId = useAuthStore((s) => s.user?.id); + const invite = useCollectionStore((s) => s.invite); + const cancelInvite = useCollectionStore((s) => s.cancelInvite); + const removeMember = useCollectionStore((s) => s.removeMember); + const setMemberRole = useCollectionStore((s) => s.setMemberRole); + const leave = useCollectionStore((s) => s.leave); + + const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([]); + const [selectedUserId, setSelectedUserId] = useState(null); + const [inviteRole, setInviteRole] = useState('editor'); + const [busy, setBusy] = useState>>({}); + const [inviting, setInviting] = useState(false); + const [confirmLeave, setConfirmLeave] = useState(false); + const [leaving, setLeaving] = useState(false); + + // Load the invitable users whenever an owner opens the surface. + useEffect(() => { + if (!open || !isOwner || collectionId == null) return; + let cancelled = false; + collectionsApi + .availableUsers(collectionId) + .then((data) => { + if (!cancelled) setAvailableUsers(data.users); + }) + .catch(() => { + if (!cancelled) setAvailableUsers([]); + }); + return () => { + cancelled = true; + }; + }, [open, isOwner, collectionId, members.length]); + + // Reset transient state on close. + useEffect(() => { + if (open) return; + setSelectedUserId(null); + setConfirmLeave(false); + }, [open]); + + const sortedMembers = useMemo(() => sortCollectionMembers(members), [members]); + + const slotOf = (kind: MemberAction): MemberAction => (singleBusySlot ? 'role' : kind); + const busyUserId = (kind: MemberAction): number | null => busy[slotOf(kind)] ?? null; + + const runMemberAction = async (kind: MemberAction, userId: number, action: () => Promise) => { + const slot = slotOf(kind); + if (busy[slot] != null) return; + setBusy((b) => ({ ...b, [slot]: userId })); + try { + await action(); + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))); + } finally { + setBusy((b) => { + const rest = { ...b }; + delete rest[slot]; + return rest; + }); + } + }; + + const setRole = (userId: number, role: CollectionRole) => { + if (collectionId == null) return Promise.resolve(); + return runMemberAction('role', userId, () => setMemberRole(collectionId, userId, role)); + }; + + const cycleRole = (member: CollectionMember) => { + if (collectionId == null) return; + void setRole(member.user_id, nextCollectionRole(member.role ?? 'editor')); + }; + + const cancel = (userId: number) => { + if (collectionId == null) return Promise.resolve(); + return runMemberAction('cancel', userId, () => cancelInvite(collectionId, userId)); + }; + + const remove = (userId: number) => { + if (collectionId == null) return Promise.resolve(); + return runMemberAction('remove', userId, () => removeMember(collectionId, userId)); + }; + + const handleInvite = async () => { + if (collectionId == null || selectedUserId == null || inviting) return; + setInviting(true); + try { + await invite(collectionId, selectedUserId, inviteRole); + toast.success(t('collections.invite.sent')); + setSelectedUserId(null); + } catch (err) { + toast.error(getApiErrorMessage(err, t('collections.invite.error'))); + } finally { + setInviting(false); + } + }; + + const handleLeave = async () => { + // Only the phone sheet ignores a second leave while one is in flight. + if (collectionId == null || (singleBusySlot && leaving)) return; + setLeaving(true); + try { + await leave(collectionId); + toast.success(t('collections.share.left')); + onAfterLeave(); + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))); + } finally { + setLeaving(false); + } + }; + + return { + currentUserId, + availableUsers, + sortedMembers, + selectedUserId, + setSelectedUserId, + inviteRole, + setInviteRole, + inviting, + confirmLeave, + setConfirmLeave, + leaving, + busyUserId, + setRole, + cycleRole, + cancel, + remove, + handleInvite, + handleLeave, + }; +} diff --git a/client/src/mobile/screens/collections/MCollShareSheet.tsx b/client/src/mobile/screens/collections/MCollShareSheet.tsx index a4ba1b480..054f8e555 100644 --- a/client/src/mobile/screens/collections/MCollShareSheet.tsx +++ b/client/src/mobile/screens/collections/MCollShareSheet.tsx @@ -1,14 +1,10 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { Check, ChevronDown, Clock, Crown, Loader2, LogOut, UserPlus, X } from 'lucide-react' -import type { CollectionMember, CollectionRole } from '@trek/shared' +import type { CollectionMember } from '@trek/shared' import { COLLECTION_ROLES } from '@trek/shared' import type { TranslationFn } from '../../../types' -import { collectionsApi } from '../../../api/collections' -import { useCollectionStore } from '../../../store/collectionStore' -import { useAuthStore } from '../../../store/authStore' -import { useToast } from '../../../components/shared/Toast' -import { getApiErrorMessage } from '../../../utils/apiError' import { avatarSrc } from '../../../utils/avatarSrc' +import { useCollectionSharing } from '../../../components/Collections/useCollectionSharing' import MSheet from '../../components/MSheet' import { CancelPill, Eyebrow, PrimaryPill, SheetHeader } from './MCollSheetKit' @@ -44,91 +40,16 @@ function MemberAvatar({ member }: { member: CollectionMember }) { export default function MCollShareSheet({ open, collectionId, collectionName, isOwner, members, onClose, onAfterLeave, t, }: MCollShareSheetProps) { - const toast = useToast() - const currentUserId = useAuthStore(s => s.user?.id) - const invite = useCollectionStore(s => s.invite) - const cancelInvite = useCollectionStore(s => s.cancelInvite) - const removeMember = useCollectionStore(s => s.removeMember) - const setMemberRole = useCollectionStore(s => s.setMemberRole) - const leave = useCollectionStore(s => s.leave) - - const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([]) + const { + currentUserId, availableUsers, sortedMembers, selectedUserId, setSelectedUserId, inviteRole, setInviteRole, + inviting, confirmLeave, setConfirmLeave, leaving, busyUserId, cycleRole, cancel, remove, handleInvite, handleLeave, + } = useCollectionSharing({ open, collectionId, isOwner, members, onAfterLeave, t, singleBusySlot: true }) const [userPickerOpen, setUserPickerOpen] = useState(false) - const [selectedUserId, setSelectedUserId] = useState(null) - const [inviteRole, setInviteRole] = useState('editor') - const [busyUserId, setBusyUserId] = useState(null) - const [inviting, setInviting] = useState(false) - const [confirmLeave, setConfirmLeave] = useState(false) - const [leaving, setLeaving] = useState(false) useEffect(() => { - if (!open || !isOwner || collectionId == null) return - let cancelled = false - collectionsApi.availableUsers(collectionId) - .then(data => { if (!cancelled) setAvailableUsers(data.users) }) - .catch(() => { if (!cancelled) setAvailableUsers([]) }) - return () => { cancelled = true } - }, [open, isOwner, collectionId, members.length]) - - useEffect(() => { - if (open) return - setSelectedUserId(null) - setUserPickerOpen(false) - setConfirmLeave(false) + if (!open) setUserPickerOpen(false) }, [open]) - const sortedMembers = useMemo(() => { - const rank = (m: CollectionMember) => (m.is_owner ? 0 : m.status === 'accepted' ? 1 : 2) - return [...members].sort((a, b) => rank(a) - rank(b) || a.username.localeCompare(b.username)) - }, [members]) - - const run = async (userId: number, action: () => Promise) => { - if (busyUserId != null) return - setBusyUserId(userId) - try { - await action() - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setBusyUserId(null) - } - } - - const cycleRole = (member: CollectionMember) => { - if (collectionId == null) return - const current = member.role ?? 'editor' - const next = COLLECTION_ROLES[(COLLECTION_ROLES.indexOf(current) + 1) % COLLECTION_ROLES.length] - void run(member.user_id, () => setMemberRole(collectionId, member.user_id, next)) - } - - const handleInvite = async () => { - if (collectionId == null || selectedUserId == null || inviting) return - setInviting(true) - try { - await invite(collectionId, selectedUserId, inviteRole) - toast.success(t('collections.invite.sent')) - setSelectedUserId(null) - } catch (err) { - toast.error(getApiErrorMessage(err, t('collections.invite.error'))) - } finally { - setInviting(false) - } - } - - const handleLeave = async () => { - if (collectionId == null || leaving) return - setLeaving(true) - try { - await leave(collectionId) - toast.success(t('collections.share.left')) - onAfterLeave() - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setLeaving(false) - } - } - const selectedUser = availableUsers.find(u => u.id === selectedUserId) ?? null const badge = 'inline-flex flex-none items-center gap-[3px] font-geist text-[0.625rem] font-extrabold' @@ -145,7 +66,7 @@ export default function MCollShareSheet({ {sortedMembers.map(member => { const isSelf = member.user_id === currentUserId const pending = member.status === 'pending' - const busy = busyUserId === member.user_id + const busy = busyUserId('role') === member.user_id return (
run(member.user_id, () => - pending ? cancelInvite(collectionId, member.user_id) : removeMember(collectionId, member.user_id), - )} + onClick={() => (pending ? cancel(member.user_id) : remove(member.user_id))} disabled={busy} aria-label={pending ? t('collections.share.cancel') : t('collections.share.remove')} className="flex h-7 w-7 flex-none items-center justify-center rounded-full text-m-faint active:bg-[color:var(--m-ic)] disabled:opacity-50"