Skip to content
File

Blob: src/client/components/workspace/share-dialog/share-controller.ts

typescript312 lines
1import { useCallback, useMemo, useState } from "react";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3 
4import { confirm } from "@/client/components/confirm-store";
5import { useWorkspaceMembers } from "@/client/components/workspace/use-workspace-view";
6import { useCopyFeedback } from "@/shared/browser/use-copy-feedback";
7import { isActionEnabled } from "@/client/lib/affordance/action-state";
8import { deriveShareDialogAffordance } from "@/client/lib/affordance/share-dialog";
9import { api, toApiError } from "@/client/lib/api";
10import { pageSharesQueryKey, pageSharesQueryOptions } from "@/client/lib/queries/page-shares";
11import { sharedInboxQueryKey } from "@/client/lib/queries/shared-inbox";
12import type { PageShare, WorkspaceMember } from "@/shared/types";
13 
14import type { ShareDialogAffordance } from "@/client/lib/affordance/share-dialog";
15import type { ShareLinkValue, SharePeopleValue, SharePermission, WorkspaceRole } from "./types";
16 
17interface UseShareControllerInput {
18 pageId: string;
19 disabled: boolean | undefined;
20 open: boolean;
21 workspaceRole: WorkspaceRole;
22 online: boolean;
23 currentUserId: string | undefined;
24}
25 
26export 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 
37export 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}