···326326 expect(result?.pages[0].records.length).toBe(3);
327327 });
328328329329- it("seeds cache if empty", async () => {
329329+ it("does not seed cache when empty (lets query fetch real data)", async () => {
330330+ // If we seed empty cache with just the new user, opening the modal
331331+ // shows only them instead of fetching the real list of likers.
332332+ // The count is updated separately via optimisticCount, so button shows
333333+ // correct count. Cache stays empty so modal fetches fresh on open.
330334 await optimisticBacklinks(queryClient, key, "add", record)();
331335332336 const result = queryClient.getQueryData<{ pages: BacklinksResponse[] }>(
333337 key,
334338 );
335335- expect(result?.pages[0].total).toBe(1);
336336- expect(result?.pages[0].records).toEqual([record]);
339339+ expect(result).toBeUndefined();
337340 });
338341339342 it("removes record from first page by did", async () => {
+3-3
src/lib/collection-list-queries.ts
···3232 type ListItem,
3333 removeCardFromList,
3434 removeDeckFromList,
3535- type SaveItem,
3635} from "./collection-list-types";
3736import {
3837 type BacklinksResponse,
···5453 toOracleUri,
5554 toScryfallUri,
5655} from "./scryfall-types";
5656+import type { SaveableItem } from "./social-item-types";
5757import { useAuth } from "./useAuth";
5858import { useMutationWithToast } from "./useMutationWithToast";
5959···153153154154interface CreateListParams {
155155 name: string;
156156- initialItem?: SaveItem;
156156+ initialItem?: SaveableItem;
157157}
158158159159/**
···338338339339interface ToggleListItemParams {
340340 list: CollectionList;
341341- item: SaveItem;
341341+ item: SaveableItem;
342342 itemName?: string;
343343}
344344
-10
src/lib/collection-list-types.ts
···44 */
5566import type { ResourceUri } from "@atcute/lexicons";
77-import type { DeckItemUri } from "./constellation-queries";
87import type { ComDeckbelcherCollectionList } from "./lexicons/index";
98import type { OracleId, ScryfallId } from "./scryfall-types";
1010-1111-/**
1212- * Item to save to a list (card or deck)
1313- * Shared by SaveToListDialog and SocialStats
1414- * Deck items use strongRef (uri + cid) matching the lexicon
1515- */
1616-export type SaveItem =
1717- | { type: "card"; scryfallId: ScryfallId; oracleId: OracleId }
1818- | { type: "deck"; uri: DeckItemUri; cid: string };
1992010/**
2111 * App-side card item with flat typed IDs.
+1-1
src/lib/comment-queries.ts
···2525 updateReplyRecord,
2626} from "./atproto-client";
2727import { COMMENT_NSID, REPLY_NSID } from "./constellation-client";
2828-import type { SocialItemUri } from "./constellation-queries";
2928import type {
3029 ComDeckbelcherSocialComment,
3130 ComDeckbelcherSocialReply,
···3635 optimisticRecord,
3736 runOptimistic,
3837} from "./optimistic-utils";
3838+import type { SocialItemUri } from "./social-item-types";
3939import { useAuth } from "./useAuth";
4040import { useMutationWithToast } from "./useMutationWithToast";
4141
+137-101
src/lib/constellation-queries.ts
···2828 REPLY_PARENT_PATH,
2929 REPLY_ROOT_PATH,
3030} from "./constellation-client";
3131-import type { OracleUri } from "./scryfall-types";
3131+import {
3232+ type CardItem,
3333+ getSocialItemUri,
3434+ isSaveable,
3535+ type SaveableItem,
3636+ type SaveableItemType,
3737+ type SocialItem,
3838+ type SocialItemType,
3939+ type SocialItemUri,
4040+} from "./social-item-types";
3241import { useAuth } from "./useAuth";
33423434-/**
3535- * Item types that can have social stats
3636- */
3737-export type SocialItemType = "card" | "deck";
3838-3939-/**
4040- * URI types for each item type
4141- * - Cards use oracle:<uuid> URIs (aggregates across printings)
4242- * - Decks use at://<did>/com.deckbelcher.deck.list/<rkey> URIs
4343- */
4444-export type CardItemUri = OracleUri;
4545-export type DeckItemUri = `at://${string}`;
4646-export type SocialItemUri = CardItemUri | DeckItemUri;
4747-4848-function getPathForItemType(itemType: SocialItemType): string {
4343+function getPathForItemType(itemType: SaveableItemType): string {
4944 return itemType === "card"
5045 ? COLLECTION_LIST_CARD_PATH
5146 : COLLECTION_LIST_DECK_PATH;
5247}
53485449/**
5555- * Query options for checking if current user has saved an item to any list
5050+ * Query options for checking if current user has saved an item to any list.
5151+ * Auto-disables when item is undefined.
5652 */
5757-export function userSavedItemQueryOptions<T extends SocialItemType>(
5858- itemUri: T extends "card" ? CardItemUri : DeckItemUri,
5353+export function userSavedItemQueryOptions(
5454+ item: SaveableItem | undefined,
5955 userDid: Did | undefined,
6060- itemType: T,
6156) {
5757+ const itemUri = item ? getSocialItemUri(item) : undefined;
5858+ const itemType = item?.type;
6259 return queryOptions({
6360 queryKey: ["constellation", "userSaved", itemUri, userDid] as const,
6461 queryFn: async (): Promise<boolean> => {
6565- if (!userDid) return false;
6262+ if (!userDid || !itemUri || !itemType) return false;
66636764 const result = await getBacklinks({
6865 subject: itemUri,
···77747875 return result.data.records.length > 0;
7976 },
8080- enabled: !!userDid,
7777+ enabled: !!userDid && !!item,
8178 staleTime: 30 * 1000,
8279 });
8380}
84818582/**
8686- * Query options for getting total save count for an item
8383+ * Query options for getting total save count for an item.
8484+ * Auto-disables when item is undefined.
8785 */
8888-export function itemSaveCountQueryOptions<T extends SocialItemType>(
8989- itemUri: T extends "card" ? CardItemUri : DeckItemUri,
9090- itemType: T,
9191-) {
8686+export function itemSaveCountQueryOptions(item: SaveableItem | undefined) {
8787+ const itemUri = item ? getSocialItemUri(item) : undefined;
8888+ const itemType = item?.type;
9289 return queryOptions({
9390 queryKey: ["constellation", "saveCount", itemUri] as const,
9491 queryFn: async (): Promise<number> => {
9292+ if (!itemUri || !itemType) return 0;
9393+9594 const result = await getLinksCount({
9695 target: itemUri,
9796 collection: COLLECTION_LIST_NSID,
···104103105104 return result.data.total;
106105 },
106106+ enabled: !!item,
107107 staleTime: 60 * 1000,
108108 });
109109}
110110111111/**
112112- * Query options for checking if current user has any deck containing a card
112112+ * Query options for checking if current user has any deck containing a card.
113113+ * Auto-disables when item is undefined.
113114 */
114115export function userDeckContainsCardQueryOptions(
115115- itemUri: CardItemUri,
116116+ item: CardItem | undefined,
116117 userDid: Did | undefined,
117118) {
119119+ const itemUri = item ? getSocialItemUri(item) : undefined;
118120 return queryOptions({
119121 queryKey: ["constellation", "userDeckContains", itemUri, userDid] as const,
120122 queryFn: async (): Promise<boolean> => {
121121- if (!userDid) return false;
123123+ if (!userDid || !itemUri) return false;
122124123125 const result = await getBacklinks({
124126 subject: itemUri,
···133135134136 return result.data.records.length > 0;
135137 },
136136- enabled: !!userDid,
138138+ enabled: !!userDid && !!item,
137139 staleTime: 30 * 1000,
138140 });
139141}
140142141143/**
142142- * Query options for getting count of decks containing a card (cards only)
144144+ * Query options for getting count of decks containing a card (cards only).
145145+ * Auto-disables when item is undefined.
143146 */
144144-export function cardDeckCountQueryOptions(itemUri: CardItemUri) {
147147+export function cardDeckCountQueryOptions(item: CardItem | undefined) {
148148+ const itemUri = item ? getSocialItemUri(item) : undefined;
145149 return queryOptions({
146150 queryKey: ["constellation", "deckCount", itemUri] as const,
147151 queryFn: async (): Promise<number> => {
152152+ if (!itemUri) return 0;
153153+148154 const result = await getLinksCount({
149155 target: itemUri,
150156 collection: DECK_LIST_NSID,
···157163158164 return result.data.total;
159165 },
166166+ enabled: !!item,
160167 staleTime: 60 * 1000,
161168 });
162169}
···171178 isInUserDeck: boolean;
172179 deckCount: number;
173180 isDeckCountLoading: boolean;
181181+ /** Comment count for cards/decks, reply count for comments/replies */
182182+ commentOrReplyCount: number;
183183+ isCommentOrReplyCountLoading: boolean;
174184}
175185176186/**
177177- * Combined hook for item social stats (saves + likes + deck count for cards)
187187+ * Combined hook for item social stats (saves + likes + deck count for cards + comment/reply count)
188188+ * For comments/replies, save-related stats are disabled and "comment count" shows reply count instead.
178189 */
179179-export function useItemSocialStats<T extends SocialItemType>(
180180- itemUri: T extends "card" ? CardItemUri : DeckItemUri,
181181- itemType: T,
182182-): ItemSocialStats {
190190+export function useItemSocialStats(item: SocialItem): ItemSocialStats {
183191 const { session } = useAuth();
184192193193+ // Extract narrowed items (undefined if not applicable type)
194194+ const saveableItem = isSaveable(item) ? item : undefined;
195195+ const cardItem = item.type === "card" ? item : undefined;
196196+ const isCommentOrReply = item.type === "comment" || item.type === "reply";
197197+198198+ // Like queries apply to all item types
199199+ const likedQuery = useQuery(
200200+ userLikedItemQueryOptions(item, session?.info.sub),
201201+ );
202202+ const likeCountQuery = useQuery(itemLikeCountQueryOptions(item));
203203+204204+ // Save queries only apply to cards and decks (auto-disabled when undefined)
185205 const savedQuery = useQuery(
186186- userSavedItemQueryOptions(itemUri, session?.info.sub, itemType),
206206+ userSavedItemQueryOptions(saveableItem, session?.info.sub),
187207 );
188188- const saveCountQuery = useQuery(itemSaveCountQueryOptions(itemUri, itemType));
208208+ const saveCountQuery = useQuery(itemSaveCountQueryOptions(saveableItem));
189209190190- const likedQuery = useQuery(
191191- userLikedItemQueryOptions(itemUri, session?.info.sub, itemType),
210210+ // Deck queries only apply to cards (auto-disabled when undefined)
211211+ const userDeckQuery = useQuery(
212212+ userDeckContainsCardQueryOptions(cardItem, session?.info.sub),
192213 );
193193- const likeCountQuery = useQuery(itemLikeCountQueryOptions(itemUri, itemType));
214214+ const deckCountQuery = useQuery(cardDeckCountQueryOptions(cardItem));
194215195195- // Deck queries only apply to cards
196196- const userDeckQuery = useQuery({
197197- ...userDeckContainsCardQueryOptions(
198198- itemUri as CardItemUri,
199199- session?.info.sub,
200200- ),
201201- enabled: itemType === "card" && !!session,
202202- });
203203- const deckCountQuery = useQuery({
204204- ...cardDeckCountQueryOptions(itemUri as CardItemUri),
205205- enabled: itemType === "card",
216216+ // Comment count for cards/decks, reply count for comments/replies
217217+ const commentCountQuery = useQuery(
218218+ itemCommentCountQueryOptions(saveableItem),
219219+ );
220220+ const replyCountQuery = useQuery({
221221+ ...directReplyCountQueryOptions(getSocialItemUri(item)),
222222+ enabled: isCommentOrReply,
206223 });
207224208225 return {
···214231 isLikeLoading: likedQuery.isLoading || likeCountQuery.isLoading,
215232 isInUserDeck: userDeckQuery.data ?? false,
216233 deckCount: deckCountQuery.data ?? 0,
217217- isDeckCountLoading: itemType === "card" && deckCountQuery.isLoading,
234234+ isDeckCountLoading: item.type === "card" && deckCountQuery.isLoading,
235235+ commentOrReplyCount: isCommentOrReply
236236+ ? (replyCountQuery.data ?? 0)
237237+ : (commentCountQuery.data ?? 0),
238238+ isCommentOrReplyCountLoading: isCommentOrReply
239239+ ? replyCountQuery.isLoading
240240+ : commentCountQuery.isLoading,
218241 };
219242}
220243···241264// ============================================================================
242265243266function getLikePathForItemType(itemType: SocialItemType): string {
267267+ // Cards use oracleUri path, everything else (decks, comments, replies) uses record URI path
244268 return itemType === "card" ? LIKE_CARD_PATH : LIKE_RECORD_PATH;
245269}
246270247271/**
248248- * Query options for checking if current user has liked an item
272272+ * Query options for checking if current user has liked an item.
273273+ * Works for all social item types.
249274 */
250250-export function userLikedItemQueryOptions<T extends SocialItemType>(
251251- itemUri: T extends "card" ? CardItemUri : DeckItemUri,
275275+export function userLikedItemQueryOptions(
276276+ item: SocialItem,
252277 userDid: Did | undefined,
253253- itemType: T,
254278) {
279279+ const itemUri = getSocialItemUri(item);
255280 return queryOptions({
256281 queryKey: ["constellation", "userLiked", itemUri, userDid] as const,
257282 queryFn: async (): Promise<boolean> => {
···259284260285 const result = await getBacklinks({
261286 subject: itemUri,
262262- source: buildSource(LIKE_NSID, getLikePathForItemType(itemType)),
287287+ source: buildSource(LIKE_NSID, getLikePathForItemType(item.type)),
263288 did: userDid,
264289 limit: 1,
265290 });
···276301}
277302278303/**
279279- * Query options for getting total like count for an item
304304+ * Query options for getting total like count for an item.
305305+ * Works for all social item types.
280306 */
281281-export function itemLikeCountQueryOptions<T extends SocialItemType>(
282282- itemUri: T extends "card" ? CardItemUri : DeckItemUri,
283283- itemType: T,
284284-) {
307307+export function itemLikeCountQueryOptions(item: SocialItem) {
308308+ const itemUri = getSocialItemUri(item);
285309 return queryOptions({
286310 queryKey: ["constellation", "likeCount", itemUri] as const,
287311 queryFn: async (): Promise<number> => {
288312 const result = await getLinksCount({
289313 target: itemUri,
290314 collection: LIKE_NSID,
291291- path: getLikePathForItemType(itemType),
315315+ path: getLikePathForItemType(item.type),
292316 });
293317294318 if (!result.success) {
···306330// ============================================================================
307331308332/**
309309- * Infinite query for users who liked an item
333333+ * Infinite query for users who liked an item.
334334+ * Works for all social item types. Auto-disables when item is undefined.
310335 */
311311-export function itemLikersQueryOptions<T extends SocialItemType>(
312312- itemUri: T extends "card" ? CardItemUri : DeckItemUri,
313313- itemType: T,
314314-) {
336336+export function itemLikersQueryOptions(item: SocialItem | undefined) {
337337+ const itemUri = item ? getSocialItemUri(item) : undefined;
315338 return infiniteQueryOptions({
316339 queryKey: ["constellation", "likers", itemUri] as const,
317340 queryFn: async ({ pageParam }) => {
341341+ if (!item || !itemUri) throw new Error("No item");
318342 const result = await getBacklinks({
319343 subject: itemUri,
320320- source: buildSource(LIKE_NSID, getLikePathForItemType(itemType)),
344344+ source: buildSource(LIKE_NSID, getLikePathForItemType(item.type)),
321345 limit: 25,
322346 cursor: pageParam,
323347 });
···326350 },
327351 initialPageParam: undefined as string | undefined,
328352 getNextPageParam: (lastPage) => lastPage.cursor ?? undefined,
353353+ enabled: !!item,
329354 staleTime: 60 * 1000,
330355 });
331356}
332357333358/**
334334- * Infinite query for lists that saved an item
359359+ * Infinite query for lists that saved an item (only for saveable items: cards/decks).
360360+ * Auto-disables when item is undefined.
335361 */
336336-export function itemSaversQueryOptions<T extends SocialItemType>(
337337- itemUri: T extends "card" ? CardItemUri : DeckItemUri,
338338- itemType: T,
339339-) {
362362+export function itemSaversQueryOptions(item: SaveableItem | undefined) {
363363+ const itemUri = item ? getSocialItemUri(item) : undefined;
340364 return infiniteQueryOptions({
341365 queryKey: ["constellation", "savers", itemUri] as const,
342366 queryFn: async ({ pageParam }) => {
367367+ if (!item || !itemUri) throw new Error("No item");
343368 const result = await getBacklinks({
344369 subject: itemUri,
345345- source: buildSource(COLLECTION_LIST_NSID, getPathForItemType(itemType)),
370370+ source: buildSource(
371371+ COLLECTION_LIST_NSID,
372372+ getPathForItemType(item.type),
373373+ ),
346374 limit: 25,
347375 cursor: pageParam,
348376 });
···351379 },
352380 initialPageParam: undefined as string | undefined,
353381 getNextPageParam: (lastPage) => lastPage.cursor ?? undefined,
382382+ enabled: !!item,
354383 staleTime: 60 * 1000,
355384 });
356385}
357386358387/**
359359- * Infinite query for decks containing a card
388388+ * Infinite query for decks containing a card.
389389+ * Auto-disables when item is undefined.
360390 */
361361-export function cardDeckBacklinksQueryOptions(itemUri: CardItemUri) {
391391+export function cardDeckBacklinksQueryOptions(item: CardItem | undefined) {
392392+ const itemUri = item ? getSocialItemUri(item) : undefined;
362393 return infiniteQueryOptions({
363394 queryKey: ["constellation", "deckBacklinks", itemUri] as const,
364395 queryFn: async ({ pageParam }) => {
396396+ if (!itemUri) throw new Error("No item");
365397 const result = await getBacklinks({
366398 subject: itemUri,
367399 source: buildSource(DECK_LIST_NSID, DECK_LIST_CARD_PATH),
···373405 },
374406 initialPageParam: undefined as string | undefined,
375407 getNextPageParam: (lastPage) => lastPage.cursor ?? undefined,
408408+ enabled: !!item,
376409 staleTime: 60 * 1000,
377410 });
378411}
···385418 * Prefetch social stats for an item (card or deck).
386419 * Use in route loaders to warm the cache before render.
387420 */
388388-export function prefetchSocialStats<T extends SocialItemType>(
421421+export function prefetchSocialStats(
389422 queryClient: QueryClient,
390390- itemUri: T extends "card" ? CardItemUri : DeckItemUri,
391391- itemType: T,
423423+ item: SaveableItem,
392424) {
425425+ const cardItem = item.type === "card" ? item : undefined;
393426 return Promise.all([
394394- queryClient.prefetchQuery(itemSaveCountQueryOptions(itemUri, itemType)),
395395- queryClient.prefetchQuery(itemLikeCountQueryOptions(itemUri, itemType)),
396396- itemType === "card"
397397- ? queryClient.prefetchQuery(
398398- cardDeckCountQueryOptions(itemUri as CardItemUri),
399399- )
427427+ queryClient.prefetchQuery(itemSaveCountQueryOptions(item)),
428428+ queryClient.prefetchQuery(itemLikeCountQueryOptions(item)),
429429+ queryClient.prefetchQuery(itemCommentCountQueryOptions(item)),
430430+ cardItem
431431+ ? queryClient.prefetchQuery(cardDeckCountQueryOptions(cardItem))
400432 : null,
401433 ] as const);
402434}
···405437// Comment Queries
406438// ============================================================================
407439408408-function getCommentPathForItemType(itemType: SocialItemType): string {
440440+function getCommentPathForItemType(itemType: SaveableItemType): string {
409441 return itemType === "card" ? COMMENT_CARD_PATH : COMMENT_RECORD_PATH;
410442}
411443412444/**
413413- * Query options for getting top-level comment count for an item
445445+ * Query options for getting top-level comment count for an item.
446446+ * Auto-disables when item is undefined.
414447 */
415415-export function itemCommentCountQueryOptions<T extends SocialItemType>(
416416- itemUri: T extends "card" ? CardItemUri : DeckItemUri,
417417- itemType: T,
418418-) {
448448+export function itemCommentCountQueryOptions(item: SaveableItem | undefined) {
449449+ const itemUri = item ? getSocialItemUri(item) : undefined;
450450+ const itemType = item?.type;
419451 return queryOptions({
420452 queryKey: ["constellation", "commentCount", itemUri] as const,
421453 queryFn: async (): Promise<number> => {
454454+ if (!itemUri || !itemType) return 0;
455455+422456 const result = await getLinksCount({
423457 target: itemUri,
424458 collection: COMMENT_NSID,
···431465432466 return result.data.total;
433467 },
468468+ enabled: !!item,
434469 staleTime: 60 * 1000,
435470 });
436471}
437472438473/**
439439- * Infinite query for top-level comments on an item (card or deck/collection)
474474+ * Infinite query for top-level comments on an item (card or deck/collection).
475475+ * Auto-disables when item is undefined.
440476 */
441441-export function itemCommentsQueryOptions<T extends SocialItemType>(
442442- itemUri: T extends "card" ? CardItemUri : DeckItemUri,
443443- itemType: T,
444444-) {
477477+export function itemCommentsQueryOptions(item: SaveableItem | undefined) {
478478+ const itemUri = item ? getSocialItemUri(item) : undefined;
445479 return infiniteQueryOptions({
446480 queryKey: ["constellation", "comments", itemUri] as const,
447481 queryFn: async ({ pageParam }) => {
482482+ if (!item || !itemUri) throw new Error("No item");
448483 const result = await getBacklinks({
449484 subject: itemUri,
450450- source: buildSource(COMMENT_NSID, getCommentPathForItemType(itemType)),
485485+ source: buildSource(COMMENT_NSID, getCommentPathForItemType(item.type)),
451486 limit: 25,
452487 cursor: pageParam,
453488 });
···456491 },
457492 initialPageParam: undefined as string | undefined,
458493 getNextPageParam: (lastPage) => lastPage.cursor ?? undefined,
494494+ enabled: !!item,
459495 staleTime: 60 * 1000,
460496 });
461497}
+32-22
src/lib/like-queries.ts
···55import type { ResourceUri } from "@atcute/lexicons";
66import { useQueryClient } from "@tanstack/react-query";
77import { toast } from "sonner";
88-import { createLikeRecord, deleteLikeRecord } from "./atproto-client";
99-import type { SaveItem } from "./collection-list-types";
88+import {
99+ createLikeRecord,
1010+ deleteLikeRecord,
1111+ hashToRkey,
1212+} from "./atproto-client";
1013import { LIKE_NSID } from "./constellation-client";
1114import { getConstellationQueryKeys } from "./constellation-queries";
1215import type { ComDeckbelcherSocialLike } from "./lexicons/index";
···1821} from "./optimistic-utils";
1922import type { OracleId, ScryfallId } from "./scryfall-types";
2023import { toOracleUri, toScryfallUri } from "./scryfall-types";
2424+import {
2525+ getItemTypeName,
2626+ getSocialItemUri,
2727+ type SocialItem,
2828+} from "./social-item-types";
2129import { useAuth } from "./useAuth";
2230import { useMutationWithToast } from "./useMutationWithToast";
2331···5462}
55635664interface ToggleLikeParams {
5757- item: SaveItem;
6565+ item: SocialItem;
5866 isLiked: boolean;
5967 itemName?: string;
6068}
61696270/**
6363- * Mutation for toggling a like on a card or deck
7171+ * Build the like subject for any social item type.
7272+ * Cards use cardSubject, everything else uses recordSubject.
7373+ */
7474+function buildLikeSubject(item: SocialItem): LikeSubject {
7575+ if (item.type === "card") {
7676+ return buildCardSubject(item.scryfallId, item.oracleId);
7777+ }
7878+ // deck, comment, reply all use recordSubject with uri + cid
7979+ return buildRecordSubject(item.uri, item.cid);
8080+}
8181+8282+/**
8383+ * Mutation for toggling a like on any social item (card, deck, comment, reply)
6484 * Handles optimistic updates for constellation queries
6585 */
6686export function useLikeMutation() {
···7393 throw new Error("Must be authenticated to like");
7494 }
75957676- let subject: LikeSubject;
7777- if (params.item.type === "deck") {
7878- subject = buildRecordSubject(params.item.uri, params.item.cid);
7979- } else {
8080- subject = buildCardSubject(
8181- params.item.scryfallId,
8282- params.item.oracleId,
8383- );
8484- }
9696+ const subject = buildLikeSubject(params.item);
85978698 if (params.isLiked) {
8799 const result = await deleteLikeRecord(agent, subject);
···99111 },
100112 onMutate: async (params: ToggleLikeParams) => {
101113 const userDid = session?.info.sub;
102102- const itemUri =
103103- params.item.type === "deck"
104104- ? (params.item.uri as `at://${string}`)
105105- : toOracleUri(params.item.oracleId);
114114+ const itemUri = getSocialItemUri(params.item);
106115107116 const keys = getConstellationQueryKeys(itemUri, userDid);
108117 const newLikedState = !params.isLiked;
118118+119119+ // Compute the deterministic rkey from subject ref (same as create/delete)
120120+ const subject = buildLikeSubject(params.item);
121121+ const rkey = await hashToRkey(subject.ref);
109122110123 const rollback = await runOptimistic([
111124 optimisticToggle(
···114127 keys.likeCount,
115128 newLikedState,
116129 ),
117117- // rkey is deterministic (hash of subject) but empty is fine here
118118- // since backlinks filtering is by did, not rkey
119130 when(userDid, (did) =>
120131 optimisticBacklinks(
121132 queryClient,
···124135 {
125136 did,
126137 collection: LIKE_NSID,
127127- rkey: "",
138138+ rkey,
128139 },
129140 ),
130141 ),
···136147 context?.rollback();
137148 },
138149 onSuccess: (data, params) => {
139139- const what =
140140- params.itemName ?? (params.item.type === "card" ? "Card" : "Deck");
150150+ const what = params.itemName ?? getItemTypeName(params.item);
141151 if (data.wasLiked) {
142152 toast.success(`Unliked ${what}`);
143153 } else {
+6-9
src/lib/optimistic-utils.ts
···165165166166/**
167167 * Optimistically add or remove a record from a backlinks infinite query
168168- * Adds to first page, removes from any page
168168+ * Adds to first page (if cache exists), removes from any page.
169169+ * Does NOT seed empty cache - that would show incomplete data when the query is first fetched.
169170 */
170171export function optimisticBacklinks(
171172 queryClient: QueryClient,
···181182 queryClient.setQueryData<InfiniteData<BacklinksResponse>>(
182183 queryKey,
183184 (old) => {
185185+ // Don't modify cache if it doesn't exist - let the query fetch real data
186186+ if (!old) return old;
187187+184188 if (op === "remove") {
185185- if (!old) return old;
186189 return {
187190 ...old,
188191 pages: old.pages.map((page, i) =>
···204207 };
205208 }
206209207207- // Add to first page, seed cache if empty
208208- if (!old) {
209209- return {
210210- pages: [{ records: [record], total: 1 }],
211211- pageParams: [undefined],
212212- };
213213- }
210210+ // Add to first page
214211 return {
215212 ...old,
216213 pages: old.pages.map((page, i) =>
+117
src/lib/social-item-types.ts
···11+/**
22+ * Type definitions for social items that can have engagement (likes, saves, comments)
33+ * This is the single source of truth for polymorphic social item types.
44+ */
55+66+import type { OracleId, OracleUri, ScryfallId } from "./scryfall-types";
77+import { toOracleUri } from "./scryfall-types";
88+99+/**
1010+ * URI types for social items
1111+ * - Cards use oracle:<uuid> URIs (aggregates across printings)
1212+ * - Decks/comments/replies use at://<did>/<collection>/<rkey> URIs
1313+ */
1414+export type CardItemUri = OracleUri;
1515+export type DeckItemUri = `at://${string}`;
1616+export type CommentUri =
1717+ `at://${string}/com.deckbelcher.social.comment/${string}`;
1818+export type ReplyUri = `at://${string}/com.deckbelcher.social.reply/${string}`;
1919+2020+/**
2121+ * SocialItem - ALL items that can have social engagement (likes)
2222+ * Discriminated union on 'type' field
2323+ */
2424+export type SocialItem =
2525+ | { type: "card"; scryfallId: ScryfallId; oracleId: OracleId }
2626+ | { type: "deck"; uri: DeckItemUri; cid: string }
2727+ | { type: "comment"; uri: CommentUri; cid: string }
2828+ | { type: "reply"; uri: ReplyUri; cid: string };
2929+3030+/**
3131+ * SaveableItem - subset of SocialItem that can be saved to collection lists
3232+ * Only cards and decks are saveable
3333+ */
3434+export type SaveableItem = Extract<SocialItem, { type: "card" | "deck" }>;
3535+3636+/**
3737+ * CardItem - narrowed to just cards (for deck-count queries, etc.)
3838+ */
3939+export type CardItem = Extract<SocialItem, { type: "card" }>;
4040+4141+/**
4242+ * Type guard: checks if item can be saved to collection lists
4343+ */
4444+export function isSaveable(item: SocialItem): item is SaveableItem {
4545+ return item.type === "card" || item.type === "deck";
4646+}
4747+4848+/**
4949+ * Type guard: checks if item has deck count (only cards)
5050+ */
5151+export function hasDeckCount(
5252+ item: SocialItem,
5353+): item is Extract<SocialItem, { type: "card" }> {
5454+ return item.type === "card";
5555+}
5656+5757+/**
5858+ * SocialItemType discriminator values
5959+ */
6060+export type SocialItemType = SocialItem["type"];
6161+6262+/**
6363+ * URI type for any social item (for Constellation queries)
6464+ */
6565+export type SocialItemUri = CardItemUri | DeckItemUri | CommentUri | ReplyUri;
6666+6767+/**
6868+ * Saveable item types (can be saved to collection lists)
6969+ */
7070+export type SaveableItemType = "card" | "deck";
7171+7272+/**
7373+ * Get the URI for any social item (used for Constellation queries)
7474+ * Overloads preserve type information based on item type.
7575+ */
7676+export function getSocialItemUri(
7777+ item: Extract<SocialItem, { type: "card" }>,
7878+): CardItemUri;
7979+export function getSocialItemUri(
8080+ item: Extract<SocialItem, { type: "deck" }>,
8181+): DeckItemUri;
8282+export function getSocialItemUri(
8383+ item: Extract<SocialItem, { type: "comment" }>,
8484+): CommentUri;
8585+export function getSocialItemUri(
8686+ item: Extract<SocialItem, { type: "reply" }>,
8787+): ReplyUri;
8888+export function getSocialItemUri(item: SaveableItem): CardItemUri | DeckItemUri;
8989+export function getSocialItemUri(item: SocialItem): SocialItemUri;
9090+export function getSocialItemUri(item: SocialItem): SocialItemUri {
9191+ switch (item.type) {
9292+ case "card":
9393+ return toOracleUri(item.oracleId);
9494+ case "deck":
9595+ return item.uri;
9696+ case "comment":
9797+ return item.uri;
9898+ case "reply":
9999+ return item.uri;
100100+ }
101101+}
102102+103103+/**
104104+ * Get a human-readable name for an item type (for toasts)
105105+ */
106106+export function getItemTypeName(item: SocialItem): string {
107107+ switch (item.type) {
108108+ case "card":
109109+ return "Card";
110110+ case "deck":
111111+ return "Deck";
112112+ case "comment":
113113+ return "Comment";
114114+ case "reply":
115115+ return "Reply";
116116+ }
117117+}