File
Blob: src/client/components/workspace/share-dialog/share-controller.ts
| 1 | import { useCallback, useMemo, useState } from "react"; |
| 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | |
| 4 | import { confirm } from "@/client/components/confirm-store"; |
| 5 | import { useWorkspaceMembers } from "@/client/components/workspace/use-workspace-view"; |
| 6 | import { useCopyFeedback } from "@/shared/browser/use-copy-feedback"; |
| 7 | import { isActionEnabled } from "@/client/lib/affordance/action-state"; |
| 8 | import { deriveShareDialogAffordance } from "@/client/lib/affordance/share-dialog"; |
| 9 | import { api, toApiError } from "@/client/lib/api"; |
| 10 | import { pageSharesQueryKey, pageSharesQueryOptions } from "@/client/lib/queries/page-shares"; |
| 11 | import { sharedInboxQueryKey } from "@/client/lib/queries/shared-inbox"; |
| 12 | import type { PageShare, WorkspaceMember } from "@/shared/types"; |
| 13 | |
| 14 | import type { ShareDialogAffordance } from "@/client/lib/affordance/share-dialog"; |
| 15 | import type { ShareLinkValue, SharePeopleValue, SharePermission, WorkspaceRole } from "./types"; |
| 16 | |
| 17 | interface UseShareControllerInput { |
| 18 | pageId: string; |
| 19 | disabled: boolean | undefined; |
| 20 | open: boolean; |
| 21 | workspaceRole: WorkspaceRole; |
| 22 | online: boolean; |
| 23 | currentUserId: string | undefined; |
| 24 | } |
| 25 | |
| 26 | export interface ShareControllerValue { |
| 27 | loading: boolean; |
| 28 | creating: boolean; |
| 29 | error: string | null; |
| 30 | dialogAffordance: ShareDialogAffordance; |
| 31 | people: SharePeopleValue; |
| 32 | link: ShareLinkValue; |
| 33 | deleteShare: (shareId: string) => Promise<void>; |
| 34 | resetTransient: () => void; |
| 35 | } |
| 36 | |
| 37 | export function useShareController({ |
| 38 | pageId, |
| 39 | disabled, |
| 40 | open, |
| 41 | workspaceRole, |
| 42 | online, |
| 43 | currentUserId, |
| 44 | }: UseShareControllerInput): ShareControllerValue { |
| 45 | const [localError, setLocalError] = useState<string | null>(null); |
| 46 | const [peopleInput, setPeopleInput] = useState(""); |
| 47 | const [peoplePermission, setPeoplePermission] = useState<SharePermission>("view"); |
| 48 | const [linkPermission, setLinkPermission] = useState<SharePermission>("view"); |
| 49 | const [showSuggestions, setShowSuggestions] = useState(false); |
| 50 | const { copiedId, copy: copyToClipboard } = useCopyFeedback<string>(); |
| 51 | const members = useWorkspaceMembers(); |
| 52 | const queryClient = useQueryClient(); |
| 53 | |
| 54 | const sharesQuery = useQuery({ |
| 55 | ...pageSharesQueryOptions(pageId), |
| 56 | enabled: open && !disabled, |
| 57 | }); |
| 58 | |
| 59 | const shares = useMemo(() => sharesQuery.data ?? [], [sharesQuery.data]); |
| 60 | const loading = sharesQuery.isLoading; |
| 61 | |
| 62 | const appendShare = useCallback( |
| 63 | (share: PageShare) => { |
| 64 | queryClient.setQueryData<PageShare[]>(pageSharesQueryKey(pageId), (prev = []) => [...prev, share]); |
| 65 | }, |
| 66 | [pageId, queryClient], |
| 67 | ); |
| 68 | |
| 69 | const removeShare = useCallback( |
| 70 | (shareId: string) => { |
| 71 | queryClient.setQueryData<PageShare[]>(pageSharesQueryKey(pageId), (prev = []) => |
| 72 | prev.filter((share) => share.id !== shareId), |
| 73 | ); |
| 74 | }, |
| 75 | [pageId, queryClient], |
| 76 | ); |
| 77 | |
| 78 | const invalidateSharedInbox = useCallback(() => { |
| 79 | // Share mutations affect the grantee's Shared With Me list. Kick the query |
| 80 | // so the inbox reflects revocations/grants without a full reload. |
| 81 | queryClient.invalidateQueries({ queryKey: sharedInboxQueryKey }); |
| 82 | }, [queryClient]); |
| 83 | |
| 84 | const createUserShareMutation = useMutation({ |
| 85 | mutationFn: (input: { grantee_id?: string; grantee_email?: string; permission: SharePermission }) => |
| 86 | api.shares.create(pageId, { grantee_type: "user", ...input }), |
| 87 | onMutate: () => { |
| 88 | setLocalError(null); |
| 89 | }, |
| 90 | onSuccess: (share) => { |
| 91 | appendShare(share); |
| 92 | setPeopleInput(""); |
| 93 | setShowSuggestions(false); |
| 94 | invalidateSharedInbox(); |
| 95 | }, |
| 96 | onError: (err) => { |
| 97 | setLocalError(toApiError(err).message); |
| 98 | }, |
| 99 | }); |
| 100 | |
| 101 | const createLinkShareMutation = useMutation({ |
| 102 | mutationFn: (permission: SharePermission) => api.shares.create(pageId, { grantee_type: "link", permission }), |
| 103 | onMutate: () => { |
| 104 | setLocalError(null); |
| 105 | }, |
| 106 | onSuccess: (share) => { |
| 107 | appendShare(share); |
| 108 | }, |
| 109 | onError: (err) => { |
| 110 | setLocalError(toApiError(err).message); |
| 111 | }, |
| 112 | }); |
| 113 | |
| 114 | const deleteShareMutation = useMutation({ |
| 115 | mutationFn: (shareId: string) => api.shares.delete(pageId, shareId), |
| 116 | onMutate: () => { |
| 117 | setLocalError(null); |
| 118 | }, |
| 119 | onSuccess: (_result, shareId) => { |
| 120 | removeShare(shareId); |
| 121 | invalidateSharedInbox(); |
| 122 | }, |
| 123 | onError: (err) => { |
| 124 | setLocalError(toApiError(err).message); |
| 125 | }, |
| 126 | }); |
| 127 | |
| 128 | const creating = createUserShareMutation.isPending || createLinkShareMutation.isPending; |
| 129 | const error = localError ?? (sharesQuery.error ? toApiError(sharesQuery.error).message : null); |
| 130 | |
| 131 | const { linkShares, userShares, shareableMembers } = useMemo(() => { |
| 132 | const nextLinkShares = shares.filter((share) => share.grantee_type === "link"); |
| 133 | const nextUserShares = shares.filter((share) => share.grantee_type === "user"); |
| 134 | const sharedIds = new Set(nextUserShares.map((share) => share.grantee_id)); |
| 135 | const nextShareableMembers = members.filter( |
| 136 | (member) => member.user_id !== currentUserId && !sharedIds.has(member.user_id), |
| 137 | ); |
| 138 | return { |
| 139 | linkShares: nextLinkShares, |
| 140 | userShares: nextUserShares, |
| 141 | shareableMembers: nextShareableMembers, |
| 142 | }; |
| 143 | }, [shares, members, currentUserId]); |
| 144 | |
| 145 | const dialogAffordance = useMemo( |
| 146 | () => |
| 147 | deriveShareDialogAffordance({ |
| 148 | workspaceRole, |
| 149 | online, |
| 150 | hasUserShares: userShares.length > 0, |
| 151 | hasLinkShares: linkShares.length > 0, |
| 152 | }), |
| 153 | [workspaceRole, online, userShares.length, linkShares.length], |
| 154 | ); |
| 155 | |
| 156 | const filteredSuggestions = useMemo(() => { |
| 157 | if (!peopleInput.trim()) return shareableMembers; |
| 158 | const query = peopleInput.toLowerCase(); |
| 159 | return shareableMembers.filter( |
| 160 | (member) => |
| 161 | (member.user?.name?.toLowerCase().includes(query) ?? false) || |
| 162 | (member.user?.email?.toLowerCase().includes(query) ?? false), |
| 163 | ); |
| 164 | }, [peopleInput, shareableMembers]); |
| 165 | |
| 166 | const matchedMember = useMemo(() => { |
| 167 | const trimmed = peopleInput.trim(); |
| 168 | if (!trimmed) return null; |
| 169 | const lower = trimmed.toLowerCase(); |
| 170 | return ( |
| 171 | shareableMembers.find((member) => member.user?.email?.toLowerCase() === lower || member.user?.name === trimmed) ?? |
| 172 | null |
| 173 | ); |
| 174 | }, [peopleInput, shareableMembers]); |
| 175 | const hasMatchedMember = matchedMember !== null; |
| 176 | |
| 177 | const memberName = useCallback((member: WorkspaceMember): string => { |
| 178 | return member.user?.name ?? member.user?.email ?? member.user_id; |
| 179 | }, []); |
| 180 | |
| 181 | const granteeName = useCallback( |
| 182 | (share: PageShare): string => { |
| 183 | if (share.grantee_user) { |
| 184 | return share.grantee_user.name || share.grantee_user.email; |
| 185 | } |
| 186 | const member = members.find((candidate) => candidate.user_id === share.grantee_id); |
| 187 | return member ? memberName(member) : (share.grantee_id ?? "Unknown"); |
| 188 | }, |
| 189 | [members, memberName], |
| 190 | ); |
| 191 | |
| 192 | const openSuggestions = useCallback(() => setShowSuggestions(true), []); |
| 193 | const dismissSuggestions = useCallback(() => setShowSuggestions(false), []); |
| 194 | |
| 195 | const resetTransient = useCallback(() => { |
| 196 | setLocalError(null); |
| 197 | setShowSuggestions(false); |
| 198 | }, []); |
| 199 | |
| 200 | const selectMember = useCallback((member: WorkspaceMember) => { |
| 201 | setPeopleInput(member.user?.email ?? member.user?.name ?? member.user_id); |
| 202 | setShowSuggestions(false); |
| 203 | }, []); |
| 204 | |
| 205 | const submitPeopleShare = useCallback(async () => { |
| 206 | if (!peopleInput.trim() || creating || !isActionEnabled(dialogAffordance.createUserShare)) return; |
| 207 | if (matchedMember) { |
| 208 | createUserShareMutation.mutate({ grantee_id: matchedMember.user_id, permission: peoplePermission }); |
| 209 | return; |
| 210 | } |
| 211 | // Only owners/admins may invite by free-form email. The input affordance |
| 212 | // already gates this for members (submit stays disabled without a matched |
| 213 | // member); this guard is defense-in-depth for keyboard paths. |
| 214 | if (!dialogAffordance.shareByEmail) return; |
| 215 | createUserShareMutation.mutate({ grantee_email: peopleInput.trim(), permission: peoplePermission }); |
| 216 | }, [ |
| 217 | peopleInput, |
| 218 | creating, |
| 219 | dialogAffordance.shareByEmail, |
| 220 | dialogAffordance.createUserShare, |
| 221 | matchedMember, |
| 222 | peoplePermission, |
| 223 | createUserShareMutation, |
| 224 | ]); |
| 225 | |
| 226 | const createLinkShare = useCallback(async () => { |
| 227 | if (creating || !isActionEnabled(dialogAffordance.createLinkShare)) return; |
| 228 | createLinkShareMutation.mutate(linkPermission); |
| 229 | }, [creating, dialogAffordance.createLinkShare, linkPermission, createLinkShareMutation]); |
| 230 | |
| 231 | const deleteShare = useCallback( |
| 232 | async (shareId: string) => { |
| 233 | const ok = await confirm({ |
| 234 | title: "Remove share", |
| 235 | message: "This person or link will lose access to the page.", |
| 236 | }); |
| 237 | if (!ok) return; |
| 238 | deleteShareMutation.mutate(shareId); |
| 239 | }, |
| 240 | [deleteShareMutation], |
| 241 | ); |
| 242 | |
| 243 | const copyLink = useCallback( |
| 244 | (share: PageShare) => { |
| 245 | if (!share.link_token) return; |
| 246 | copyToClipboard(share.id, `${window.location.origin}/s/${share.link_token}`); |
| 247 | }, |
| 248 | [copyToClipboard], |
| 249 | ); |
| 250 | |
| 251 | const people = useMemo<SharePeopleValue>( |
| 252 | () => ({ |
| 253 | peopleInput, |
| 254 | peoplePermission, |
| 255 | showSuggestions, |
| 256 | userShares, |
| 257 | members, |
| 258 | filteredSuggestions, |
| 259 | hasMatchedMember, |
| 260 | memberName, |
| 261 | granteeName, |
| 262 | setPeopleInput, |
| 263 | setPeoplePermission, |
| 264 | openSuggestions, |
| 265 | dismissSuggestions, |
| 266 | selectMember, |
| 267 | submitPeopleShare, |
| 268 | }), |
| 269 | [ |
| 270 | peopleInput, |
| 271 | peoplePermission, |
| 272 | showSuggestions, |
| 273 | userShares, |
| 274 | members, |
| 275 | filteredSuggestions, |
| 276 | hasMatchedMember, |
| 277 | memberName, |
| 278 | granteeName, |
| 279 | openSuggestions, |
| 280 | dismissSuggestions, |
| 281 | selectMember, |
| 282 | submitPeopleShare, |
| 283 | ], |
| 284 | ); |
| 285 | |
| 286 | const link = useMemo<ShareLinkValue>( |
| 287 | () => ({ |
| 288 | linkPermission, |
| 289 | linkShares, |
| 290 | copiedId, |
| 291 | setLinkPermission, |
| 292 | createLinkShare, |
| 293 | copyLink, |
| 294 | }), |
| 295 | [linkPermission, linkShares, copiedId, createLinkShare, copyLink], |
| 296 | ); |
| 297 | |
| 298 | return useMemo<ShareControllerValue>( |
| 299 | () => ({ |
| 300 | loading, |
| 301 | creating, |
| 302 | error, |
| 303 | dialogAffordance, |
| 304 | people, |
| 305 | link, |
| 306 | deleteShare, |
| 307 | resetTransient, |
| 308 | }), |
| 309 | [loading, creating, error, dialogAffordance, people, link, deleteShare, resetTransient], |
| 310 | ); |
| 311 | } |