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"