374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
import { create } from "@bufbuild/protobuf";
|
|
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
|
import { Code, ConnectError } from "@connectrpc/connect";
|
|
import {
|
|
type InfiniteData,
|
|
type QueryClient,
|
|
queryOptions,
|
|
useInfiniteQuery,
|
|
useMutation,
|
|
useQuery,
|
|
useQueryClient,
|
|
} from "@tanstack/react-query";
|
|
import { memoServiceClient } from "@/connect";
|
|
import { attachmentKeys } from "@/hooks/useAttachmentQueries";
|
|
import { userKeys } from "@/hooks/useUserQueries";
|
|
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/lib/constants";
|
|
import { shouldRetry } from "@/lib/query-client";
|
|
import type { ListMemosRequest, ListMemosResponse, Memo } from "@/types/proto/api/v1/memo_service_pb";
|
|
import { ListMemoCommentsRequestSchema, ListMemosRequestSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
|
|
|
|
// Query keys factory for consistent cache management
|
|
export const memoKeys = {
|
|
all: ["memos"] as const,
|
|
lists: () => [...memoKeys.all, "list"] as const,
|
|
list: (filters: Partial<ListMemosRequest>) => [...memoKeys.lists(), filters] as const,
|
|
details: () => [...memoKeys.all, "detail"] as const,
|
|
detail: (name: string) => [...memoKeys.details(), name] as const,
|
|
comments: (name: string) => [...memoKeys.all, "comments", name] as const,
|
|
linkMetadata: (url: string) => [...memoKeys.all, "linkMetadata", url] as const,
|
|
};
|
|
|
|
export const memoDetailQueryOptions = (name: string) =>
|
|
queryOptions({
|
|
queryKey: memoKeys.detail(name),
|
|
queryFn: async ({ client, signal }) => {
|
|
try {
|
|
return await memoServiceClient.getMemo({ name }, { signal });
|
|
} catch (error) {
|
|
if (!isMemoUnavailableError(error)) throw error;
|
|
// Store a content-free result, replacing any formerly readable detail.
|
|
// Remove copies and snippets so collection fallbacks cannot revive them.
|
|
discardUnavailableMemo(client, name);
|
|
return null;
|
|
}
|
|
},
|
|
retry: shouldRetry,
|
|
staleTime: 1000 * 10,
|
|
// A previous denial cannot establish access on a new visit, even while fresh.
|
|
refetchOnMount: (query) => (query.state.data === null ? "always" : true),
|
|
});
|
|
|
|
export function isMemoUnavailableError(error: unknown): boolean {
|
|
return error instanceof ConnectError && (error.code === Code.PermissionDenied || error.code === Code.NotFound);
|
|
}
|
|
|
|
function discardUnavailableMemo(client: QueryClient, name: string) {
|
|
const stripRelations = (memo: Memo): Memo => {
|
|
const relations = memo.relations.filter((relation) => relation.memo?.name !== name && relation.relatedMemo?.name !== name);
|
|
return relations.length === memo.relations.length ? memo : { ...memo, relations };
|
|
};
|
|
const stripList = (data: ListMemosResponse): ListMemosResponse => {
|
|
const memos = data.memos.filter((memo) => memo.name !== name).map(stripRelations);
|
|
return memos.length === data.memos.length && memos.every((memo, index) => memo === data.memos[index]) ? data : { ...data, memos };
|
|
};
|
|
for (const [key, data] of client.getQueriesData<MemoCollectionQueryData | Memo | null>({ queryKey: memoKeys.all })) {
|
|
let next = data;
|
|
if (isMemoListResponse(data)) next = stripList(data);
|
|
else if (isInfiniteMemoListData(data)) {
|
|
const pages = data.pages.map(stripList);
|
|
if (pages.some((page, index) => page !== data.pages[index])) next = { ...data, pages };
|
|
} else if (key[1] === "detail" && data && "name" in data && data.name !== name) next = stripRelations(data);
|
|
if (next !== data) client.setQueryData(key, next);
|
|
}
|
|
}
|
|
|
|
type MemoPatch = Partial<Memo> & Pick<Memo, "name">;
|
|
type MemoCollectionQueryData = ListMemosResponse | InfiniteData<ListMemosResponse>;
|
|
|
|
function isMemoListResponse(data: unknown): data is ListMemosResponse {
|
|
return typeof data === "object" && data !== null && Array.isArray((data as { memos?: unknown }).memos);
|
|
}
|
|
|
|
function isInfiniteMemoListData(data: unknown): data is InfiniteData<ListMemosResponse> {
|
|
return typeof data === "object" && data !== null && Array.isArray((data as { pages?: unknown }).pages);
|
|
}
|
|
|
|
function patchMemoListResponse(response: ListMemosResponse, update: MemoPatch): ListMemosResponse {
|
|
let changed = false;
|
|
const memos = response.memos.map((memo) => {
|
|
if (memo.name !== update.name) {
|
|
return memo;
|
|
}
|
|
|
|
changed = true;
|
|
return { ...memo, ...update };
|
|
});
|
|
|
|
return changed ? { ...response, memos } : response;
|
|
}
|
|
|
|
function patchMemoListQueryData<T>(data: T | undefined, update: MemoPatch): T | undefined {
|
|
if (!data) {
|
|
return data;
|
|
}
|
|
|
|
if (isMemoListResponse(data)) {
|
|
return patchMemoListResponse(data, update) as T;
|
|
}
|
|
|
|
if (isInfiniteMemoListData(data)) {
|
|
let changed = false;
|
|
const pages = data.pages.map((page) => {
|
|
const patchedPage = patchMemoListResponse(page, update);
|
|
if (patchedPage !== page) {
|
|
changed = true;
|
|
}
|
|
return patchedPage;
|
|
});
|
|
|
|
return (changed ? { ...data, pages } : data) as T;
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
function findMemoInListResponse(response: ListMemosResponse, name: string): Memo | undefined {
|
|
return response.memos.find((memo) => memo.name === name);
|
|
}
|
|
|
|
function findMemoInQueryData(data: unknown, name: string): Memo | undefined {
|
|
if (!data) {
|
|
return undefined;
|
|
}
|
|
|
|
if (isMemoListResponse(data)) {
|
|
return findMemoInListResponse(data, name);
|
|
}
|
|
|
|
if (isInfiniteMemoListData(data)) {
|
|
for (const page of data.pages) {
|
|
const memo = findMemoInListResponse(page, name);
|
|
if (memo) {
|
|
return memo;
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
export function findMemoInCollectionQueries(queryClient: QueryClient, name: string, freshOnly = false): Memo | undefined {
|
|
if (queryClient.getQueryData(memoKeys.detail(name)) === null) return undefined;
|
|
for (const [key, data] of queryClient.getQueriesData<unknown>({ queryKey: memoKeys.all })) {
|
|
const state = queryClient.getQueryState(key);
|
|
if (freshOnly && (!state || state.isInvalidated || state.dataUpdatedAt < Date.now() - 10_000)) continue;
|
|
const memo = findMemoInQueryData(data, name);
|
|
if (memo) {
|
|
return memo;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function patchMemoInCollectionQueries(queryClient: QueryClient, update: MemoPatch) {
|
|
queryClient.setQueriesData<MemoCollectionQueryData>({ queryKey: memoKeys.all }, (data) => patchMemoListQueryData(data, update));
|
|
}
|
|
|
|
export function useMemos(request: Partial<ListMemosRequest> = {}) {
|
|
return useQuery({
|
|
queryKey: memoKeys.list(request),
|
|
queryFn: async () => {
|
|
const response = await memoServiceClient.listMemos(create(ListMemosRequestSchema, request as Record<string, unknown>));
|
|
return response;
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useInfiniteMemos(request: Partial<ListMemosRequest> = {}, options?: { enabled?: boolean }) {
|
|
return useInfiniteQuery({
|
|
queryKey: memoKeys.list(request),
|
|
queryFn: async ({ pageParam }) => {
|
|
const response = await memoServiceClient.listMemos(
|
|
create(ListMemosRequestSchema, {
|
|
...request,
|
|
pageToken: pageParam || "",
|
|
} as Record<string, unknown>),
|
|
);
|
|
return response;
|
|
},
|
|
initialPageParam: "",
|
|
getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined,
|
|
staleTime: 1000 * 60,
|
|
gcTime: 1000 * 60 * 5,
|
|
enabled: options?.enabled ?? true,
|
|
});
|
|
}
|
|
|
|
export function useMemo(name: string, options?: { enabled?: boolean }) {
|
|
const query = useQuery({
|
|
...memoDetailQueryOptions(name),
|
|
enabled: options?.enabled ?? true,
|
|
});
|
|
return { ...query, data: query.data ?? undefined, isUnavailable: query.data === null };
|
|
}
|
|
|
|
function isHTTPURL(url: string): boolean {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function useLinkMetadata(url: string, options?: { enabled?: boolean }) {
|
|
const trimmedUrl = url.trim();
|
|
|
|
return useQuery({
|
|
queryKey: memoKeys.linkMetadata(trimmedUrl),
|
|
queryFn: async () => {
|
|
const metadata = await memoServiceClient.getLinkMetadata({ url: trimmedUrl });
|
|
return metadata;
|
|
},
|
|
enabled: (options?.enabled ?? true) && isHTTPURL(trimmedUrl),
|
|
staleTime: 1000 * 60 * 60 * 24,
|
|
gcTime: 1000 * 60 * 60 * 24,
|
|
});
|
|
}
|
|
|
|
export function useCreateMemo() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (memoToCreate: Memo) => {
|
|
const memo = await memoServiceClient.createMemo({ memo: memoToCreate });
|
|
return memo;
|
|
},
|
|
onSuccess: (newMemo) => {
|
|
// Invalidate memo lists to refetch
|
|
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
|
|
// Add new memo to cache
|
|
queryClient.setQueryData(memoKeys.detail(newMemo.name), newMemo);
|
|
// Invalidate user stats
|
|
queryClient.invalidateQueries({ queryKey: userKeys.stats() });
|
|
// Creating a memo can bind previously unlinked attachments.
|
|
queryClient.invalidateQueries({ queryKey: attachmentKeys.lists() });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateMemo() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ update, updateMask }: { update: Partial<Memo>; updateMask: string[] }) => {
|
|
const memo = await memoServiceClient.updateMemo({
|
|
memo: create(MemoSchema, update as Record<string, unknown>),
|
|
updateMask: create(FieldMaskSchema, { paths: updateMask }),
|
|
});
|
|
return memo;
|
|
},
|
|
onMutate: async ({ update, updateMask }) => {
|
|
if (updateMask.includes("space")) return { previousMemo: undefined };
|
|
if (!update.name) {
|
|
return { previousMemo: undefined };
|
|
}
|
|
|
|
// Cancel outgoing refetches to prevent race conditions
|
|
await queryClient.cancelQueries({ queryKey: memoKeys.all });
|
|
|
|
// Snapshot previous value for rollback on error
|
|
const previousMemo =
|
|
queryClient.getQueryData<Memo>(memoKeys.detail(update.name)) || findMemoInCollectionQueries(queryClient, update.name);
|
|
const memoPatch: MemoPatch = { ...update, name: update.name };
|
|
|
|
// Optimistically update the cache
|
|
if (previousMemo) {
|
|
queryClient.setQueryData(memoKeys.detail(update.name), { ...previousMemo, ...memoPatch });
|
|
}
|
|
patchMemoInCollectionQueries(queryClient, memoPatch);
|
|
|
|
return { previousMemo };
|
|
},
|
|
onError: (_err, { update }, context) => {
|
|
// Rollback on error
|
|
if (context?.previousMemo && update.name) {
|
|
queryClient.setQueryData(memoKeys.detail(update.name), context.previousMemo);
|
|
patchMemoInCollectionQueries(queryClient, context.previousMemo);
|
|
} else {
|
|
queryClient.invalidateQueries({ queryKey: memoKeys.all });
|
|
}
|
|
},
|
|
onSuccess: (updatedMemo, { updateMask }) => {
|
|
if (updateMask.includes("space")) {
|
|
queryClient.invalidateQueries({ queryKey: memoKeys.all });
|
|
}
|
|
// Update cache with server response
|
|
queryClient.setQueryData(memoKeys.detail(updatedMemo.name), updatedMemo);
|
|
patchMemoInCollectionQueries(queryClient, updatedMemo);
|
|
// Invalidate lists to refresh
|
|
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
|
|
if (updatedMemo.parent) {
|
|
queryClient.invalidateQueries({ queryKey: memoKeys.comments(updatedMemo.parent) });
|
|
}
|
|
// Invalidate user stats
|
|
queryClient.invalidateQueries({ queryKey: userKeys.stats() });
|
|
// Placement changes move linked attachments between scoped libraries.
|
|
queryClient.invalidateQueries({ queryKey: attachmentKeys.lists() });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteMemo() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (name: string) => {
|
|
await memoServiceClient.deleteMemo({ name });
|
|
return name;
|
|
},
|
|
onSuccess: (name) => {
|
|
// Remove from cache
|
|
queryClient.removeQueries({ queryKey: memoKeys.detail(name) });
|
|
// Invalidate lists
|
|
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
|
|
// Invalidate user stats
|
|
queryClient.invalidateQueries({ queryKey: userKeys.stats() });
|
|
// Memo deletion can remove or unlink associated attachments.
|
|
queryClient.invalidateQueries({ queryKey: attachmentKeys.lists() });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useMemoComments(name: string, options?: { enabled?: boolean; pageSize?: number }) {
|
|
return useQuery({
|
|
queryKey: [...memoKeys.comments(name), options?.pageSize ?? 0],
|
|
queryFn: async () => {
|
|
const response = await memoServiceClient.listMemoComments(
|
|
create(ListMemoCommentsRequestSchema, {
|
|
name,
|
|
pageSize: options?.pageSize ?? 0,
|
|
}),
|
|
);
|
|
return response;
|
|
},
|
|
enabled: options?.enabled ?? true,
|
|
staleTime: 1000 * 60, // 1 minute
|
|
});
|
|
}
|
|
|
|
// useInfiniteMemoComments paginates through every comment via nextPageToken, instead of
|
|
// stopping at the server's default page size (the cause of comments being truncated to 10).
|
|
export function useInfiniteMemoComments(name: string, options?: { enabled?: boolean; pageSize?: number }) {
|
|
const pageSize = options?.pageSize ?? DEFAULT_LIST_MEMOS_PAGE_SIZE;
|
|
return useInfiniteQuery({
|
|
queryKey: [...memoKeys.comments(name), "infinite", pageSize],
|
|
queryFn: async ({ pageParam }) => {
|
|
const response = await memoServiceClient.listMemoComments(
|
|
create(ListMemoCommentsRequestSchema, {
|
|
name,
|
|
pageSize,
|
|
pageToken: pageParam || "",
|
|
}),
|
|
);
|
|
return response;
|
|
},
|
|
initialPageParam: "",
|
|
getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined,
|
|
select: (data) => data.pages.flatMap((page) => page.memos),
|
|
enabled: options?.enabled ?? true,
|
|
staleTime: 1000 * 60, // 1 minute
|
|
});
|
|
}
|