👁️
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor social stats, show like on comments/replies

+463 -305
+27 -26
src/components/comments/CommentItem.tsx
··· 1 1 import { useQuery } from "@tanstack/react-query"; 2 2 import { Link } from "@tanstack/react-router"; 3 - import { MessageSquare, Trash2 } from "lucide-react"; 3 + import { Trash2 } from "lucide-react"; 4 4 import { useCallback, useState } from "react"; 5 5 import { ClientDate } from "@/components/ClientDate"; 6 6 import { RichtextRenderer } from "@/components/richtext/RichtextRenderer"; 7 + import { SocialStats } from "@/components/social/SocialStats"; 7 8 import { type AtUri, asRkey } from "@/lib/atproto-client"; 8 9 import { 9 10 generateRkey, ··· 14 15 useDeleteReplyMutation, 15 16 } from "@/lib/comment-queries"; 16 17 import type { BacklinkRecord } from "@/lib/constellation-client"; 17 - import { 18 - directReplyCountQueryOptions, 19 - type SocialItemUri, 20 - } from "@/lib/constellation-queries"; 21 18 import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 22 19 import type { Document } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 20 + import type { 21 + CommentUri, 22 + ReplyUri, 23 + SocialItem, 24 + SocialItemUri, 25 + } from "@/lib/social-item-types"; 23 26 import { useAuth } from "@/lib/useAuth"; 24 27 import { CommentForm } from "./CommentForm"; 25 28 ··· 61 64 enabled: type === "reply", 62 65 }); 63 66 64 - const replyCountQuery = useQuery(directReplyCountQueryOptions(uri)); 65 - 66 67 const { data: didDoc } = useQuery(didDocumentQueryOptions(did)); 67 68 const handle = extractHandle(didDoc ?? null); 68 69 ··· 76 77 type === "comment" ? commentQuery.data?.cid : replyQuery.data?.cid; 77 78 const rootRef = 78 79 type === "comment" && cid ? { uri, cid } : replyQuery.data?.reply.root; 79 - const replyCount = replyCountQuery.data ?? 0; 80 + 81 + // Construct SocialItem for SocialStats - requires cid from loaded record 82 + const socialItem: SocialItem | null = cid 83 + ? type === "comment" 84 + ? { type: "comment", uri: uri as CommentUri, cid } 85 + : { type: "reply", uri: uri as ReplyUri, cid } 86 + : null; 80 87 81 88 const handleReplySubmit = useCallback( 82 89 (content: Document) => { ··· 158 165 <RichtextRenderer doc={record.content} /> 159 166 </div> 160 167 161 - <div className="mt-2 flex items-center gap-4"> 162 - {session ? ( 163 - <button 164 - type="button" 165 - onClick={() => setShowReplyForm(!showReplyForm)} 166 - className="flex items-center gap-1 text-sm text-gray-500 dark:text-zinc-300 hover:text-gray-700 dark:hover:text-zinc-200" 167 - > 168 - <MessageSquare className="w-4 h-4" /> 169 - {replyCount > 0 && <span>{replyCount}</span>} 170 - </button> 171 - ) : ( 172 - replyCount > 0 && ( 173 - <span className="flex items-center gap-1 text-sm text-gray-500 dark:text-zinc-300"> 174 - <MessageSquare className="w-4 h-4" /> 175 - <span>{replyCount}</span> 176 - </span> 177 - ) 168 + <div className="mt-2 flex items-center gap-2"> 169 + {/* SocialStats for likes + reply button */} 170 + {socialItem && ( 171 + <SocialStats 172 + item={socialItem} 173 + showCount={true} 174 + onCommentClick={ 175 + session ? () => setShowReplyForm(!showReplyForm) : undefined 176 + } 177 + /> 178 178 )} 179 179 180 + {/* Delete button for owner */} 180 181 {isOwner && 181 182 ((type === "comment" && subjectUri) || 182 183 (type === "reply" && parentUri)) && ( ··· 187 188 deleteCommentMutation.isPending || 188 189 deleteReplyMutation.isPending 189 190 } 190 - className="flex items-center gap-1 text-sm text-gray-500 dark:text-zinc-300 hover:text-red-600 dark:hover:text-red-400" 191 + className="flex items-center gap-1 text-sm text-gray-500 dark:text-zinc-300 hover:text-red-600 dark:hover:text-red-400 p-2" 191 192 > 192 193 <Trash2 className="w-4 h-4" /> 193 194 </button>
+1 -1
src/components/comments/CommentThread.tsx
··· 5 5 import { 6 6 directRepliesQueryOptions, 7 7 directReplyCountQueryOptions, 8 - type SocialItemUri, 9 8 } from "@/lib/constellation-queries"; 9 + import type { SocialItemUri } from "@/lib/social-item-types"; 10 10 import { CommentItem } from "./CommentItem"; 11 11 12 12 interface CommentThreadProps {
+7 -12
src/components/comments/CommentsPanel.tsx
··· 5 5 import { 6 6 itemCommentCountQueryOptions, 7 7 itemCommentsQueryOptions, 8 - type SocialItemType, 9 - type SocialItemUri, 10 8 } from "@/lib/constellation-queries"; 11 9 import type { ComDeckbelcherSocialComment } from "@/lib/lexicons/index"; 12 10 import type { Document } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 11 + import { getSocialItemUri, type SaveableItem } from "@/lib/social-item-types"; 13 12 import { useAuth } from "@/lib/useAuth"; 14 13 import { CommentForm } from "./CommentForm"; 15 14 import { CommentThread } from "./CommentThread"; ··· 18 17 19 18 interface CommentsPanelProps { 20 19 subject: CommentSubject; 21 - subjectUri: SocialItemUri; 22 - itemType: SocialItemType; 20 + item: SaveableItem; 23 21 title?: string; 24 22 onClose?: () => void; 25 23 availableTags?: string[]; ··· 29 27 30 28 export function CommentsPanel({ 31 29 subject, 32 - subjectUri, 33 - itemType, 30 + item, 34 31 title = "Comments", 35 32 onClose, 36 33 availableTags, ··· 40 37 const [showForm, setShowForm] = useState(false); 41 38 const createComment = useCreateCommentMutation(); 42 39 43 - const countQuery = useQuery( 44 - itemCommentCountQueryOptions(subjectUri, itemType), 45 - ); 40 + const subjectUri = getSocialItemUri(item); 41 + 42 + const countQuery = useQuery(itemCommentCountQueryOptions(item)); 46 43 47 - const commentsQuery = useInfiniteQuery( 48 - itemCommentsQueryOptions(subjectUri, itemType), 49 - ); 44 + const commentsQuery = useInfiniteQuery(itemCommentsQueryOptions(item)); 50 45 51 46 const comments = commentsQuery.data?.pages.flatMap((p) => p.records) ?? []; 52 47 const count = countQuery.data ?? 0;
+3 -3
src/components/list/SaveToListDialog.tsx
··· 12 12 type CollectionList, 13 13 hasCard, 14 14 hasDeck, 15 - type SaveItem, 16 15 } from "@/lib/collection-list-types"; 17 16 import { getConstellationQueryKeys } from "@/lib/constellation-queries"; 18 17 import { toOracleUri } from "@/lib/scryfall-types"; 18 + import type { SaveableItem } from "@/lib/social-item-types"; 19 19 20 20 interface SaveToListDialogProps { 21 - item: SaveItem; 21 + item: SaveableItem; 22 22 itemName?: string; 23 23 userDid: Did; 24 24 isOpen: boolean; ··· 206 206 interface ListRowProps { 207 207 list: CollectionList; 208 208 rkey: string; 209 - item: SaveItem; 209 + item: SaveableItem; 210 210 itemName?: string; 211 211 userDid: Did; 212 212 onClose: () => void;
+22 -20
src/components/social/BacklinkModal.tsx
··· 3 3 import { Bookmark, Heart, Loader2, Rows3, X } from "lucide-react"; 4 4 import { useEffect, useId, useRef } from "react"; 5 5 import { 6 - type CardItemUri, 7 6 cardDeckBacklinksQueryOptions, 8 - type DeckItemUri, 9 7 itemLikersQueryOptions, 10 8 itemSaversQueryOptions, 11 - type SocialItemType, 12 - type SocialItemUri, 13 9 } from "@/lib/constellation-queries"; 10 + import { isSaveable, type SocialItem } from "@/lib/social-item-types"; 14 11 import { BacklinkRow, type BacklinkType, RowSkeleton } from "./BacklinkRow"; 15 12 16 13 interface BacklinkModalProps { 17 14 isOpen: boolean; 18 15 onClose: () => void; 19 16 type: BacklinkType; 20 - itemUri: SocialItemUri; 21 - itemType: SocialItemType; 17 + item: SocialItem; 22 18 total: number; 23 19 } 24 20 ··· 47 43 isOpen, 48 44 onClose, 49 45 type, 50 - itemUri, 51 - itemType, 46 + item, 52 47 total, 53 48 }: BacklinkModalProps) { 54 49 const titleId = useId(); ··· 56 51 const config = MODAL_CONFIG[type]; 57 52 const Icon = config.icon; 58 53 59 - const likersQuery = useInfiniteQuery({ 60 - ...itemLikersQueryOptions(itemUri as CardItemUri | DeckItemUri, itemType), 61 - enabled: isOpen && type === "likes", 62 - }); 54 + // Narrow to specific item types for type-safe queries 55 + const saveableItem = isSaveable(item) ? item : undefined; 56 + const cardItem = item.type === "card" ? item : undefined; 63 57 64 - const saversQuery = useInfiniteQuery({ 65 - ...itemSaversQueryOptions(itemUri as CardItemUri | DeckItemUri, itemType), 66 - enabled: isOpen && type === "saves", 67 - }); 58 + // Pass enabled state into query options to avoid spread + override type issues 59 + const likersQuery = useInfiniteQuery( 60 + itemLikersQueryOptions(isOpen && type === "likes" ? item : undefined), 61 + ); 62 + 63 + // Saves only apply to saveable items (card/deck) 64 + const saversQuery = useInfiniteQuery( 65 + itemSaversQueryOptions( 66 + isOpen && type === "saves" ? saveableItem : undefined, 67 + ), 68 + ); 68 69 69 - const decksQuery = useInfiniteQuery({ 70 - ...cardDeckBacklinksQueryOptions(itemUri as CardItemUri), 71 - enabled: isOpen && type === "decks", 72 - }); 70 + const decksQuery = useInfiniteQuery( 71 + cardDeckBacklinksQueryOptions( 72 + isOpen && type === "decks" ? cardItem : undefined, 73 + ), 74 + ); 73 75 74 76 const activeQuery = 75 77 type === "likes"
+75 -73
src/components/social/SocialStats.tsx
··· 1 - import { useQuery } from "@tanstack/react-query"; 2 1 import { Bookmark, Heart, MessageSquare, Rows3 } from "lucide-react"; 3 2 import { useState } from "react"; 4 - import type { SaveItem } from "@/lib/collection-list-types"; 5 - import type { SocialItemUri } from "@/lib/constellation-queries"; 3 + import { useItemSocialStats } from "@/lib/constellation-queries"; 4 + import { useLikeMutation } from "@/lib/like-queries"; 6 5 import { 7 - itemCommentCountQueryOptions, 8 - useItemSocialStats, 9 - } from "@/lib/constellation-queries"; 10 - import { useLikeMutation } from "@/lib/like-queries"; 11 - import { toOracleUri } from "@/lib/scryfall-types"; 6 + hasDeckCount, 7 + isSaveable, 8 + type SocialItem, 9 + } from "@/lib/social-item-types"; 12 10 import { useAuth } from "@/lib/useAuth"; 13 11 import { SaveToListDialog } from "../list/SaveToListDialog"; 14 12 import { BacklinkModal } from "./BacklinkModal"; 15 13 import type { BacklinkType } from "./BacklinkRow"; 16 14 17 15 interface SocialStatsProps { 18 - item: SaveItem; 16 + item: SocialItem; 19 17 itemName?: string; 20 18 showCount?: boolean; 21 - /** Hide comment count (useful when comments section is always visible) */ 22 - hideCommentCount?: boolean; 23 19 className?: string; 20 + /** Show comment/reply button - clicking invokes this handler */ 24 21 onCommentClick?: () => void; 25 22 } 26 23 27 - function getItemUri(item: SaveItem): SocialItemUri { 28 - return item.type === "card" ? toOracleUri(item.oracleId) : item.uri; 29 - } 30 - 31 24 export function SocialStats({ 32 25 item, 33 26 itemName, 34 27 showCount = true, 35 - hideCommentCount = false, 36 28 className = "", 37 29 onCommentClick, 38 30 }: SocialStatsProps) { ··· 41 33 const [openModal, setOpenModal] = useState<BacklinkType | null>(null); 42 34 const likeMutation = useLikeMutation(); 43 35 44 - const itemUri = getItemUri(item); 36 + const itemIsSaveable = isSaveable(item); 37 + const itemHasDeckCount = hasDeckCount(item); 38 + 45 39 const { 46 40 isSavedByUser, 47 41 saveCount, ··· 52 46 isInUserDeck, 53 47 deckCount, 54 48 isDeckCountLoading, 55 - } = useItemSocialStats(itemUri, item.type); 56 - 57 - const commentCountQuery = useQuery( 58 - itemCommentCountQueryOptions(itemUri, item.type), 59 - ); 60 - const commentCount = commentCountQuery.data ?? 0; 49 + commentOrReplyCount, 50 + isCommentOrReplyCountLoading, 51 + } = useItemSocialStats(item); 61 52 62 53 const handleSaveClick = () => { 63 - if (session) { 54 + if (session && itemIsSaveable) { 64 55 setIsDialogOpen(true); 65 56 } 66 57 }; ··· 83 74 84 75 return ( 85 76 <div className={`flex items-center ${className}`}> 86 - {/* Like button */} 77 + {/* Like button - always visible for all item types */} 87 78 <div className="flex items-center"> 88 79 <button 89 80 type="button" ··· 121 112 )} 122 113 </div> 123 114 124 - {/* Save button */} 125 - <div className="flex items-center"> 126 - <button 127 - type="button" 128 - onClick={handleSaveClick} 129 - disabled={!session} 130 - className={buttonBase} 131 - aria-label={isSavedByUser ? "Saved to list" : "Save to list"} 132 - title={ 133 - session 134 - ? isSavedByUser 135 - ? "Saved to list" 136 - : "Save to list" 137 - : "Sign in to save" 138 - } 139 - > 140 - <Bookmark 141 - className={`w-5 h-5 ${ 142 - isSavedByUser 143 - ? "text-blue-500 dark:text-blue-400" 144 - : "text-gray-600 dark:text-zinc-300" 145 - }`} 146 - fill={isSavedByUser ? "currentColor" : "none"} 147 - /> 148 - </button> 149 - {showCount && ( 115 + {/* Save button - only for saveable items (cards/decks) */} 116 + {itemIsSaveable && ( 117 + <div className="flex items-center"> 150 118 <button 151 119 type="button" 152 - onClick={() => saveCount > 0 && setOpenModal("saves")} 153 - disabled={saveCount === 0} 154 - className={`text-sm tabular-nums px-1 py-2 rounded ${isSaveLoading ? "opacity-50" : ""} ${ 155 - isSavedByUser 156 - ? "text-blue-500 dark:text-blue-400" 157 - : "text-gray-600 dark:text-zinc-300" 158 - } ${saveCount > 0 ? "hover:underline cursor-pointer" : "cursor-default"}`} 159 - title={saveCount > 0 ? "See lists with this item" : undefined} 120 + onClick={handleSaveClick} 121 + disabled={!session} 122 + className={buttonBase} 123 + aria-label={isSavedByUser ? "Saved to list" : "Save to list"} 124 + title={ 125 + session 126 + ? isSavedByUser 127 + ? "Saved to list" 128 + : "Save to list" 129 + : "Sign in to save" 130 + } 160 131 > 161 - {saveCount} 132 + <Bookmark 133 + className={`w-5 h-5 ${ 134 + isSavedByUser 135 + ? "text-blue-500 dark:text-blue-400" 136 + : "text-gray-600 dark:text-zinc-300" 137 + }`} 138 + fill={isSavedByUser ? "currentColor" : "none"} 139 + /> 162 140 </button> 163 - )} 164 - </div> 141 + {showCount && ( 142 + <button 143 + type="button" 144 + onClick={() => saveCount > 0 && setOpenModal("saves")} 145 + disabled={saveCount === 0} 146 + className={`text-sm tabular-nums px-1 py-2 rounded ${isSaveLoading ? "opacity-50" : ""} ${ 147 + isSavedByUser 148 + ? "text-blue-500 dark:text-blue-400" 149 + : "text-gray-600 dark:text-zinc-300" 150 + } ${saveCount > 0 ? "hover:underline cursor-pointer" : "cursor-default"}`} 151 + title={saveCount > 0 ? "See lists with this item" : undefined} 152 + > 153 + {saveCount} 154 + </button> 155 + )} 156 + </div> 157 + )} 165 158 166 - {/* Deck count (cards only) */} 167 - {item.type === "card" && showCount && ( 159 + {/* Deck count - only for cards */} 160 + {itemHasDeckCount && showCount && ( 168 161 <div className="flex items-center"> 169 162 <div 170 163 className={`${statBase} ${ ··· 195 188 </div> 196 189 )} 197 190 198 - {/* Comments button */} 191 + {/* Comments/Replies button - shown if handler provided */} 199 192 {onCommentClick && ( 200 193 <div className="flex items-center"> 201 194 <button 202 195 type="button" 203 196 onClick={onCommentClick} 204 197 className={`${statBase} text-gray-600 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 cursor-pointer transition-colors`} 205 - aria-label="Comments" 206 - title="Comments" 198 + aria-label={ 199 + item.type === "comment" || item.type === "reply" 200 + ? "Replies" 201 + : "Comments" 202 + } 203 + title={ 204 + item.type === "comment" || item.type === "reply" 205 + ? "Replies" 206 + : "Comments" 207 + } 207 208 > 208 209 <MessageSquare className="w-5 h-5" /> 209 210 </button> 210 - {showCount && !hideCommentCount && ( 211 + {showCount && ( 211 212 <span 212 - className={`text-sm tabular-nums px-1 py-2 text-gray-600 dark:text-zinc-300 ${commentCountQuery.isLoading ? "opacity-50" : ""}`} 213 + className={`text-sm tabular-nums px-1 py-2 text-gray-600 dark:text-zinc-300 ${isCommentOrReplyCountLoading ? "opacity-50" : ""}`} 213 214 > 214 - {commentCount} 215 + {commentOrReplyCount} 215 216 </span> 216 217 )} 217 218 </div> 218 219 )} 219 220 220 - {session && ( 221 + {/* Save dialog - only for saveable items */} 222 + {session && itemIsSaveable && ( 221 223 <SaveToListDialog 222 224 item={item} 223 225 itemName={itemName} ··· 227 229 /> 228 230 )} 229 231 232 + {/* Backlink modal for viewing who liked/saved/decked */} 230 233 {openModal && ( 231 234 <BacklinkModal 232 235 isOpen={openModal !== null} 233 236 onClose={() => setOpenModal(null)} 234 237 type={openModal} 235 - itemUri={itemUri} 236 - itemType={item.type} 238 + item={item} 237 239 total={ 238 240 openModal === "likes" 239 241 ? likeCount
+6 -3
src/lib/__tests__/optimistic-utils.test.ts
··· 326 326 expect(result?.pages[0].records.length).toBe(3); 327 327 }); 328 328 329 - it("seeds cache if empty", async () => { 329 + it("does not seed cache when empty (lets query fetch real data)", async () => { 330 + // If we seed empty cache with just the new user, opening the modal 331 + // shows only them instead of fetching the real list of likers. 332 + // The count is updated separately via optimisticCount, so button shows 333 + // correct count. Cache stays empty so modal fetches fresh on open. 330 334 await optimisticBacklinks(queryClient, key, "add", record)(); 331 335 332 336 const result = queryClient.getQueryData<{ pages: BacklinksResponse[] }>( 333 337 key, 334 338 ); 335 - expect(result?.pages[0].total).toBe(1); 336 - expect(result?.pages[0].records).toEqual([record]); 339 + expect(result).toBeUndefined(); 337 340 }); 338 341 339 342 it("removes record from first page by did", async () => {
+3 -3
src/lib/collection-list-queries.ts
··· 32 32 type ListItem, 33 33 removeCardFromList, 34 34 removeDeckFromList, 35 - type SaveItem, 36 35 } from "./collection-list-types"; 37 36 import { 38 37 type BacklinksResponse, ··· 54 53 toOracleUri, 55 54 toScryfallUri, 56 55 } from "./scryfall-types"; 56 + import type { SaveableItem } from "./social-item-types"; 57 57 import { useAuth } from "./useAuth"; 58 58 import { useMutationWithToast } from "./useMutationWithToast"; 59 59 ··· 153 153 154 154 interface CreateListParams { 155 155 name: string; 156 - initialItem?: SaveItem; 156 + initialItem?: SaveableItem; 157 157 } 158 158 159 159 /** ··· 338 338 339 339 interface ToggleListItemParams { 340 340 list: CollectionList; 341 - item: SaveItem; 341 + item: SaveableItem; 342 342 itemName?: string; 343 343 } 344 344
-10
src/lib/collection-list-types.ts
··· 4 4 */ 5 5 6 6 import type { ResourceUri } from "@atcute/lexicons"; 7 - import type { DeckItemUri } from "./constellation-queries"; 8 7 import type { ComDeckbelcherCollectionList } from "./lexicons/index"; 9 8 import type { OracleId, ScryfallId } from "./scryfall-types"; 10 - 11 - /** 12 - * Item to save to a list (card or deck) 13 - * Shared by SaveToListDialog and SocialStats 14 - * Deck items use strongRef (uri + cid) matching the lexicon 15 - */ 16 - export type SaveItem = 17 - | { type: "card"; scryfallId: ScryfallId; oracleId: OracleId } 18 - | { type: "deck"; uri: DeckItemUri; cid: string }; 19 9 20 10 /** 21 11 * App-side card item with flat typed IDs.
+1 -1
src/lib/comment-queries.ts
··· 25 25 updateReplyRecord, 26 26 } from "./atproto-client"; 27 27 import { COMMENT_NSID, REPLY_NSID } from "./constellation-client"; 28 - import type { SocialItemUri } from "./constellation-queries"; 29 28 import type { 30 29 ComDeckbelcherSocialComment, 31 30 ComDeckbelcherSocialReply, ··· 36 35 optimisticRecord, 37 36 runOptimistic, 38 37 } from "./optimistic-utils"; 38 + import type { SocialItemUri } from "./social-item-types"; 39 39 import { useAuth } from "./useAuth"; 40 40 import { useMutationWithToast } from "./useMutationWithToast"; 41 41
+137 -101
src/lib/constellation-queries.ts
··· 28 28 REPLY_PARENT_PATH, 29 29 REPLY_ROOT_PATH, 30 30 } from "./constellation-client"; 31 - import type { OracleUri } from "./scryfall-types"; 31 + import { 32 + type CardItem, 33 + getSocialItemUri, 34 + isSaveable, 35 + type SaveableItem, 36 + type SaveableItemType, 37 + type SocialItem, 38 + type SocialItemType, 39 + type SocialItemUri, 40 + } from "./social-item-types"; 32 41 import { useAuth } from "./useAuth"; 33 42 34 - /** 35 - * Item types that can have social stats 36 - */ 37 - export type SocialItemType = "card" | "deck"; 38 - 39 - /** 40 - * URI types for each item type 41 - * - Cards use oracle:<uuid> URIs (aggregates across printings) 42 - * - Decks use at://<did>/com.deckbelcher.deck.list/<rkey> URIs 43 - */ 44 - export type CardItemUri = OracleUri; 45 - export type DeckItemUri = `at://${string}`; 46 - export type SocialItemUri = CardItemUri | DeckItemUri; 47 - 48 - function getPathForItemType(itemType: SocialItemType): string { 43 + function getPathForItemType(itemType: SaveableItemType): string { 49 44 return itemType === "card" 50 45 ? COLLECTION_LIST_CARD_PATH 51 46 : COLLECTION_LIST_DECK_PATH; 52 47 } 53 48 54 49 /** 55 - * Query options for checking if current user has saved an item to any list 50 + * Query options for checking if current user has saved an item to any list. 51 + * Auto-disables when item is undefined. 56 52 */ 57 - export function userSavedItemQueryOptions<T extends SocialItemType>( 58 - itemUri: T extends "card" ? CardItemUri : DeckItemUri, 53 + export function userSavedItemQueryOptions( 54 + item: SaveableItem | undefined, 59 55 userDid: Did | undefined, 60 - itemType: T, 61 56 ) { 57 + const itemUri = item ? getSocialItemUri(item) : undefined; 58 + const itemType = item?.type; 62 59 return queryOptions({ 63 60 queryKey: ["constellation", "userSaved", itemUri, userDid] as const, 64 61 queryFn: async (): Promise<boolean> => { 65 - if (!userDid) return false; 62 + if (!userDid || !itemUri || !itemType) return false; 66 63 67 64 const result = await getBacklinks({ 68 65 subject: itemUri, ··· 77 74 78 75 return result.data.records.length > 0; 79 76 }, 80 - enabled: !!userDid, 77 + enabled: !!userDid && !!item, 81 78 staleTime: 30 * 1000, 82 79 }); 83 80 } 84 81 85 82 /** 86 - * Query options for getting total save count for an item 83 + * Query options for getting total save count for an item. 84 + * Auto-disables when item is undefined. 87 85 */ 88 - export function itemSaveCountQueryOptions<T extends SocialItemType>( 89 - itemUri: T extends "card" ? CardItemUri : DeckItemUri, 90 - itemType: T, 91 - ) { 86 + export function itemSaveCountQueryOptions(item: SaveableItem | undefined) { 87 + const itemUri = item ? getSocialItemUri(item) : undefined; 88 + const itemType = item?.type; 92 89 return queryOptions({ 93 90 queryKey: ["constellation", "saveCount", itemUri] as const, 94 91 queryFn: async (): Promise<number> => { 92 + if (!itemUri || !itemType) return 0; 93 + 95 94 const result = await getLinksCount({ 96 95 target: itemUri, 97 96 collection: COLLECTION_LIST_NSID, ··· 104 103 105 104 return result.data.total; 106 105 }, 106 + enabled: !!item, 107 107 staleTime: 60 * 1000, 108 108 }); 109 109 } 110 110 111 111 /** 112 - * Query options for checking if current user has any deck containing a card 112 + * Query options for checking if current user has any deck containing a card. 113 + * Auto-disables when item is undefined. 113 114 */ 114 115 export function userDeckContainsCardQueryOptions( 115 - itemUri: CardItemUri, 116 + item: CardItem | undefined, 116 117 userDid: Did | undefined, 117 118 ) { 119 + const itemUri = item ? getSocialItemUri(item) : undefined; 118 120 return queryOptions({ 119 121 queryKey: ["constellation", "userDeckContains", itemUri, userDid] as const, 120 122 queryFn: async (): Promise<boolean> => { 121 - if (!userDid) return false; 123 + if (!userDid || !itemUri) return false; 122 124 123 125 const result = await getBacklinks({ 124 126 subject: itemUri, ··· 133 135 134 136 return result.data.records.length > 0; 135 137 }, 136 - enabled: !!userDid, 138 + enabled: !!userDid && !!item, 137 139 staleTime: 30 * 1000, 138 140 }); 139 141 } 140 142 141 143 /** 142 - * Query options for getting count of decks containing a card (cards only) 144 + * Query options for getting count of decks containing a card (cards only). 145 + * Auto-disables when item is undefined. 143 146 */ 144 - export function cardDeckCountQueryOptions(itemUri: CardItemUri) { 147 + export function cardDeckCountQueryOptions(item: CardItem | undefined) { 148 + const itemUri = item ? getSocialItemUri(item) : undefined; 145 149 return queryOptions({ 146 150 queryKey: ["constellation", "deckCount", itemUri] as const, 147 151 queryFn: async (): Promise<number> => { 152 + if (!itemUri) return 0; 153 + 148 154 const result = await getLinksCount({ 149 155 target: itemUri, 150 156 collection: DECK_LIST_NSID, ··· 157 163 158 164 return result.data.total; 159 165 }, 166 + enabled: !!item, 160 167 staleTime: 60 * 1000, 161 168 }); 162 169 } ··· 171 178 isInUserDeck: boolean; 172 179 deckCount: number; 173 180 isDeckCountLoading: boolean; 181 + /** Comment count for cards/decks, reply count for comments/replies */ 182 + commentOrReplyCount: number; 183 + isCommentOrReplyCountLoading: boolean; 174 184 } 175 185 176 186 /** 177 - * Combined hook for item social stats (saves + likes + deck count for cards) 187 + * Combined hook for item social stats (saves + likes + deck count for cards + comment/reply count) 188 + * For comments/replies, save-related stats are disabled and "comment count" shows reply count instead. 178 189 */ 179 - export function useItemSocialStats<T extends SocialItemType>( 180 - itemUri: T extends "card" ? CardItemUri : DeckItemUri, 181 - itemType: T, 182 - ): ItemSocialStats { 190 + export function useItemSocialStats(item: SocialItem): ItemSocialStats { 183 191 const { session } = useAuth(); 184 192 193 + // Extract narrowed items (undefined if not applicable type) 194 + const saveableItem = isSaveable(item) ? item : undefined; 195 + const cardItem = item.type === "card" ? item : undefined; 196 + const isCommentOrReply = item.type === "comment" || item.type === "reply"; 197 + 198 + // Like queries apply to all item types 199 + const likedQuery = useQuery( 200 + userLikedItemQueryOptions(item, session?.info.sub), 201 + ); 202 + const likeCountQuery = useQuery(itemLikeCountQueryOptions(item)); 203 + 204 + // Save queries only apply to cards and decks (auto-disabled when undefined) 185 205 const savedQuery = useQuery( 186 - userSavedItemQueryOptions(itemUri, session?.info.sub, itemType), 206 + userSavedItemQueryOptions(saveableItem, session?.info.sub), 187 207 ); 188 - const saveCountQuery = useQuery(itemSaveCountQueryOptions(itemUri, itemType)); 208 + const saveCountQuery = useQuery(itemSaveCountQueryOptions(saveableItem)); 189 209 190 - const likedQuery = useQuery( 191 - userLikedItemQueryOptions(itemUri, session?.info.sub, itemType), 210 + // Deck queries only apply to cards (auto-disabled when undefined) 211 + const userDeckQuery = useQuery( 212 + userDeckContainsCardQueryOptions(cardItem, session?.info.sub), 192 213 ); 193 - const likeCountQuery = useQuery(itemLikeCountQueryOptions(itemUri, itemType)); 214 + const deckCountQuery = useQuery(cardDeckCountQueryOptions(cardItem)); 194 215 195 - // Deck queries only apply to cards 196 - const userDeckQuery = useQuery({ 197 - ...userDeckContainsCardQueryOptions( 198 - itemUri as CardItemUri, 199 - session?.info.sub, 200 - ), 201 - enabled: itemType === "card" && !!session, 202 - }); 203 - const deckCountQuery = useQuery({ 204 - ...cardDeckCountQueryOptions(itemUri as CardItemUri), 205 - enabled: itemType === "card", 216 + // Comment count for cards/decks, reply count for comments/replies 217 + const commentCountQuery = useQuery( 218 + itemCommentCountQueryOptions(saveableItem), 219 + ); 220 + const replyCountQuery = useQuery({ 221 + ...directReplyCountQueryOptions(getSocialItemUri(item)), 222 + enabled: isCommentOrReply, 206 223 }); 207 224 208 225 return { ··· 214 231 isLikeLoading: likedQuery.isLoading || likeCountQuery.isLoading, 215 232 isInUserDeck: userDeckQuery.data ?? false, 216 233 deckCount: deckCountQuery.data ?? 0, 217 - isDeckCountLoading: itemType === "card" && deckCountQuery.isLoading, 234 + isDeckCountLoading: item.type === "card" && deckCountQuery.isLoading, 235 + commentOrReplyCount: isCommentOrReply 236 + ? (replyCountQuery.data ?? 0) 237 + : (commentCountQuery.data ?? 0), 238 + isCommentOrReplyCountLoading: isCommentOrReply 239 + ? replyCountQuery.isLoading 240 + : commentCountQuery.isLoading, 218 241 }; 219 242 } 220 243 ··· 241 264 // ============================================================================ 242 265 243 266 function getLikePathForItemType(itemType: SocialItemType): string { 267 + // Cards use oracleUri path, everything else (decks, comments, replies) uses record URI path 244 268 return itemType === "card" ? LIKE_CARD_PATH : LIKE_RECORD_PATH; 245 269 } 246 270 247 271 /** 248 - * Query options for checking if current user has liked an item 272 + * Query options for checking if current user has liked an item. 273 + * Works for all social item types. 249 274 */ 250 - export function userLikedItemQueryOptions<T extends SocialItemType>( 251 - itemUri: T extends "card" ? CardItemUri : DeckItemUri, 275 + export function userLikedItemQueryOptions( 276 + item: SocialItem, 252 277 userDid: Did | undefined, 253 - itemType: T, 254 278 ) { 279 + const itemUri = getSocialItemUri(item); 255 280 return queryOptions({ 256 281 queryKey: ["constellation", "userLiked", itemUri, userDid] as const, 257 282 queryFn: async (): Promise<boolean> => { ··· 259 284 260 285 const result = await getBacklinks({ 261 286 subject: itemUri, 262 - source: buildSource(LIKE_NSID, getLikePathForItemType(itemType)), 287 + source: buildSource(LIKE_NSID, getLikePathForItemType(item.type)), 263 288 did: userDid, 264 289 limit: 1, 265 290 }); ··· 276 301 } 277 302 278 303 /** 279 - * Query options for getting total like count for an item 304 + * Query options for getting total like count for an item. 305 + * Works for all social item types. 280 306 */ 281 - export function itemLikeCountQueryOptions<T extends SocialItemType>( 282 - itemUri: T extends "card" ? CardItemUri : DeckItemUri, 283 - itemType: T, 284 - ) { 307 + export function itemLikeCountQueryOptions(item: SocialItem) { 308 + const itemUri = getSocialItemUri(item); 285 309 return queryOptions({ 286 310 queryKey: ["constellation", "likeCount", itemUri] as const, 287 311 queryFn: async (): Promise<number> => { 288 312 const result = await getLinksCount({ 289 313 target: itemUri, 290 314 collection: LIKE_NSID, 291 - path: getLikePathForItemType(itemType), 315 + path: getLikePathForItemType(item.type), 292 316 }); 293 317 294 318 if (!result.success) { ··· 306 330 // ============================================================================ 307 331 308 332 /** 309 - * Infinite query for users who liked an item 333 + * Infinite query for users who liked an item. 334 + * Works for all social item types. Auto-disables when item is undefined. 310 335 */ 311 - export function itemLikersQueryOptions<T extends SocialItemType>( 312 - itemUri: T extends "card" ? CardItemUri : DeckItemUri, 313 - itemType: T, 314 - ) { 336 + export function itemLikersQueryOptions(item: SocialItem | undefined) { 337 + const itemUri = item ? getSocialItemUri(item) : undefined; 315 338 return infiniteQueryOptions({ 316 339 queryKey: ["constellation", "likers", itemUri] as const, 317 340 queryFn: async ({ pageParam }) => { 341 + if (!item || !itemUri) throw new Error("No item"); 318 342 const result = await getBacklinks({ 319 343 subject: itemUri, 320 - source: buildSource(LIKE_NSID, getLikePathForItemType(itemType)), 344 + source: buildSource(LIKE_NSID, getLikePathForItemType(item.type)), 321 345 limit: 25, 322 346 cursor: pageParam, 323 347 }); ··· 326 350 }, 327 351 initialPageParam: undefined as string | undefined, 328 352 getNextPageParam: (lastPage) => lastPage.cursor ?? undefined, 353 + enabled: !!item, 329 354 staleTime: 60 * 1000, 330 355 }); 331 356 } 332 357 333 358 /** 334 - * Infinite query for lists that saved an item 359 + * Infinite query for lists that saved an item (only for saveable items: cards/decks). 360 + * Auto-disables when item is undefined. 335 361 */ 336 - export function itemSaversQueryOptions<T extends SocialItemType>( 337 - itemUri: T extends "card" ? CardItemUri : DeckItemUri, 338 - itemType: T, 339 - ) { 362 + export function itemSaversQueryOptions(item: SaveableItem | undefined) { 363 + const itemUri = item ? getSocialItemUri(item) : undefined; 340 364 return infiniteQueryOptions({ 341 365 queryKey: ["constellation", "savers", itemUri] as const, 342 366 queryFn: async ({ pageParam }) => { 367 + if (!item || !itemUri) throw new Error("No item"); 343 368 const result = await getBacklinks({ 344 369 subject: itemUri, 345 - source: buildSource(COLLECTION_LIST_NSID, getPathForItemType(itemType)), 370 + source: buildSource( 371 + COLLECTION_LIST_NSID, 372 + getPathForItemType(item.type), 373 + ), 346 374 limit: 25, 347 375 cursor: pageParam, 348 376 }); ··· 351 379 }, 352 380 initialPageParam: undefined as string | undefined, 353 381 getNextPageParam: (lastPage) => lastPage.cursor ?? undefined, 382 + enabled: !!item, 354 383 staleTime: 60 * 1000, 355 384 }); 356 385 } 357 386 358 387 /** 359 - * Infinite query for decks containing a card 388 + * Infinite query for decks containing a card. 389 + * Auto-disables when item is undefined. 360 390 */ 361 - export function cardDeckBacklinksQueryOptions(itemUri: CardItemUri) { 391 + export function cardDeckBacklinksQueryOptions(item: CardItem | undefined) { 392 + const itemUri = item ? getSocialItemUri(item) : undefined; 362 393 return infiniteQueryOptions({ 363 394 queryKey: ["constellation", "deckBacklinks", itemUri] as const, 364 395 queryFn: async ({ pageParam }) => { 396 + if (!itemUri) throw new Error("No item"); 365 397 const result = await getBacklinks({ 366 398 subject: itemUri, 367 399 source: buildSource(DECK_LIST_NSID, DECK_LIST_CARD_PATH), ··· 373 405 }, 374 406 initialPageParam: undefined as string | undefined, 375 407 getNextPageParam: (lastPage) => lastPage.cursor ?? undefined, 408 + enabled: !!item, 376 409 staleTime: 60 * 1000, 377 410 }); 378 411 } ··· 385 418 * Prefetch social stats for an item (card or deck). 386 419 * Use in route loaders to warm the cache before render. 387 420 */ 388 - export function prefetchSocialStats<T extends SocialItemType>( 421 + export function prefetchSocialStats( 389 422 queryClient: QueryClient, 390 - itemUri: T extends "card" ? CardItemUri : DeckItemUri, 391 - itemType: T, 423 + item: SaveableItem, 392 424 ) { 425 + const cardItem = item.type === "card" ? item : undefined; 393 426 return Promise.all([ 394 - queryClient.prefetchQuery(itemSaveCountQueryOptions(itemUri, itemType)), 395 - queryClient.prefetchQuery(itemLikeCountQueryOptions(itemUri, itemType)), 396 - itemType === "card" 397 - ? queryClient.prefetchQuery( 398 - cardDeckCountQueryOptions(itemUri as CardItemUri), 399 - ) 427 + queryClient.prefetchQuery(itemSaveCountQueryOptions(item)), 428 + queryClient.prefetchQuery(itemLikeCountQueryOptions(item)), 429 + queryClient.prefetchQuery(itemCommentCountQueryOptions(item)), 430 + cardItem 431 + ? queryClient.prefetchQuery(cardDeckCountQueryOptions(cardItem)) 400 432 : null, 401 433 ] as const); 402 434 } ··· 405 437 // Comment Queries 406 438 // ============================================================================ 407 439 408 - function getCommentPathForItemType(itemType: SocialItemType): string { 440 + function getCommentPathForItemType(itemType: SaveableItemType): string { 409 441 return itemType === "card" ? COMMENT_CARD_PATH : COMMENT_RECORD_PATH; 410 442 } 411 443 412 444 /** 413 - * Query options for getting top-level comment count for an item 445 + * Query options for getting top-level comment count for an item. 446 + * Auto-disables when item is undefined. 414 447 */ 415 - export function itemCommentCountQueryOptions<T extends SocialItemType>( 416 - itemUri: T extends "card" ? CardItemUri : DeckItemUri, 417 - itemType: T, 418 - ) { 448 + export function itemCommentCountQueryOptions(item: SaveableItem | undefined) { 449 + const itemUri = item ? getSocialItemUri(item) : undefined; 450 + const itemType = item?.type; 419 451 return queryOptions({ 420 452 queryKey: ["constellation", "commentCount", itemUri] as const, 421 453 queryFn: async (): Promise<number> => { 454 + if (!itemUri || !itemType) return 0; 455 + 422 456 const result = await getLinksCount({ 423 457 target: itemUri, 424 458 collection: COMMENT_NSID, ··· 431 465 432 466 return result.data.total; 433 467 }, 468 + enabled: !!item, 434 469 staleTime: 60 * 1000, 435 470 }); 436 471 } 437 472 438 473 /** 439 - * Infinite query for top-level comments on an item (card or deck/collection) 474 + * Infinite query for top-level comments on an item (card or deck/collection). 475 + * Auto-disables when item is undefined. 440 476 */ 441 - export function itemCommentsQueryOptions<T extends SocialItemType>( 442 - itemUri: T extends "card" ? CardItemUri : DeckItemUri, 443 - itemType: T, 444 - ) { 477 + export function itemCommentsQueryOptions(item: SaveableItem | undefined) { 478 + const itemUri = item ? getSocialItemUri(item) : undefined; 445 479 return infiniteQueryOptions({ 446 480 queryKey: ["constellation", "comments", itemUri] as const, 447 481 queryFn: async ({ pageParam }) => { 482 + if (!item || !itemUri) throw new Error("No item"); 448 483 const result = await getBacklinks({ 449 484 subject: itemUri, 450 - source: buildSource(COMMENT_NSID, getCommentPathForItemType(itemType)), 485 + source: buildSource(COMMENT_NSID, getCommentPathForItemType(item.type)), 451 486 limit: 25, 452 487 cursor: pageParam, 453 488 }); ··· 456 491 }, 457 492 initialPageParam: undefined as string | undefined, 458 493 getNextPageParam: (lastPage) => lastPage.cursor ?? undefined, 494 + enabled: !!item, 459 495 staleTime: 60 * 1000, 460 496 }); 461 497 }
+32 -22
src/lib/like-queries.ts
··· 5 5 import type { ResourceUri } from "@atcute/lexicons"; 6 6 import { useQueryClient } from "@tanstack/react-query"; 7 7 import { toast } from "sonner"; 8 - import { createLikeRecord, deleteLikeRecord } from "./atproto-client"; 9 - import type { SaveItem } from "./collection-list-types"; 8 + import { 9 + createLikeRecord, 10 + deleteLikeRecord, 11 + hashToRkey, 12 + } from "./atproto-client"; 10 13 import { LIKE_NSID } from "./constellation-client"; 11 14 import { getConstellationQueryKeys } from "./constellation-queries"; 12 15 import type { ComDeckbelcherSocialLike } from "./lexicons/index"; ··· 18 21 } from "./optimistic-utils"; 19 22 import type { OracleId, ScryfallId } from "./scryfall-types"; 20 23 import { toOracleUri, toScryfallUri } from "./scryfall-types"; 24 + import { 25 + getItemTypeName, 26 + getSocialItemUri, 27 + type SocialItem, 28 + } from "./social-item-types"; 21 29 import { useAuth } from "./useAuth"; 22 30 import { useMutationWithToast } from "./useMutationWithToast"; 23 31 ··· 54 62 } 55 63 56 64 interface ToggleLikeParams { 57 - item: SaveItem; 65 + item: SocialItem; 58 66 isLiked: boolean; 59 67 itemName?: string; 60 68 } 61 69 62 70 /** 63 - * Mutation for toggling a like on a card or deck 71 + * Build the like subject for any social item type. 72 + * Cards use cardSubject, everything else uses recordSubject. 73 + */ 74 + function buildLikeSubject(item: SocialItem): LikeSubject { 75 + if (item.type === "card") { 76 + return buildCardSubject(item.scryfallId, item.oracleId); 77 + } 78 + // deck, comment, reply all use recordSubject with uri + cid 79 + return buildRecordSubject(item.uri, item.cid); 80 + } 81 + 82 + /** 83 + * Mutation for toggling a like on any social item (card, deck, comment, reply) 64 84 * Handles optimistic updates for constellation queries 65 85 */ 66 86 export function useLikeMutation() { ··· 73 93 throw new Error("Must be authenticated to like"); 74 94 } 75 95 76 - let subject: LikeSubject; 77 - if (params.item.type === "deck") { 78 - subject = buildRecordSubject(params.item.uri, params.item.cid); 79 - } else { 80 - subject = buildCardSubject( 81 - params.item.scryfallId, 82 - params.item.oracleId, 83 - ); 84 - } 96 + const subject = buildLikeSubject(params.item); 85 97 86 98 if (params.isLiked) { 87 99 const result = await deleteLikeRecord(agent, subject); ··· 99 111 }, 100 112 onMutate: async (params: ToggleLikeParams) => { 101 113 const userDid = session?.info.sub; 102 - const itemUri = 103 - params.item.type === "deck" 104 - ? (params.item.uri as `at://${string}`) 105 - : toOracleUri(params.item.oracleId); 114 + const itemUri = getSocialItemUri(params.item); 106 115 107 116 const keys = getConstellationQueryKeys(itemUri, userDid); 108 117 const newLikedState = !params.isLiked; 118 + 119 + // Compute the deterministic rkey from subject ref (same as create/delete) 120 + const subject = buildLikeSubject(params.item); 121 + const rkey = await hashToRkey(subject.ref); 109 122 110 123 const rollback = await runOptimistic([ 111 124 optimisticToggle( ··· 114 127 keys.likeCount, 115 128 newLikedState, 116 129 ), 117 - // rkey is deterministic (hash of subject) but empty is fine here 118 - // since backlinks filtering is by did, not rkey 119 130 when(userDid, (did) => 120 131 optimisticBacklinks( 121 132 queryClient, ··· 124 135 { 125 136 did, 126 137 collection: LIKE_NSID, 127 - rkey: "", 138 + rkey, 128 139 }, 129 140 ), 130 141 ), ··· 136 147 context?.rollback(); 137 148 }, 138 149 onSuccess: (data, params) => { 139 - const what = 140 - params.itemName ?? (params.item.type === "card" ? "Card" : "Deck"); 150 + const what = params.itemName ?? getItemTypeName(params.item); 141 151 if (data.wasLiked) { 142 152 toast.success(`Unliked ${what}`); 143 153 } else {
+6 -9
src/lib/optimistic-utils.ts
··· 165 165 166 166 /** 167 167 * Optimistically add or remove a record from a backlinks infinite query 168 - * Adds to first page, removes from any page 168 + * Adds to first page (if cache exists), removes from any page. 169 + * Does NOT seed empty cache - that would show incomplete data when the query is first fetched. 169 170 */ 170 171 export function optimisticBacklinks( 171 172 queryClient: QueryClient, ··· 181 182 queryClient.setQueryData<InfiniteData<BacklinksResponse>>( 182 183 queryKey, 183 184 (old) => { 185 + // Don't modify cache if it doesn't exist - let the query fetch real data 186 + if (!old) return old; 187 + 184 188 if (op === "remove") { 185 - if (!old) return old; 186 189 return { 187 190 ...old, 188 191 pages: old.pages.map((page, i) => ··· 204 207 }; 205 208 } 206 209 207 - // Add to first page, seed cache if empty 208 - if (!old) { 209 - return { 210 - pages: [{ records: [record], total: 1 }], 211 - pageParams: [undefined], 212 - }; 213 - } 210 + // Add to first page 214 211 return { 215 212 ...old, 216 213 pages: old.pages.map((page, i) =>
+117
src/lib/social-item-types.ts
··· 1 + /** 2 + * Type definitions for social items that can have engagement (likes, saves, comments) 3 + * This is the single source of truth for polymorphic social item types. 4 + */ 5 + 6 + import type { OracleId, OracleUri, ScryfallId } from "./scryfall-types"; 7 + import { toOracleUri } from "./scryfall-types"; 8 + 9 + /** 10 + * URI types for social items 11 + * - Cards use oracle:<uuid> URIs (aggregates across printings) 12 + * - Decks/comments/replies use at://<did>/<collection>/<rkey> URIs 13 + */ 14 + export type CardItemUri = OracleUri; 15 + export type DeckItemUri = `at://${string}`; 16 + export type CommentUri = 17 + `at://${string}/com.deckbelcher.social.comment/${string}`; 18 + export type ReplyUri = `at://${string}/com.deckbelcher.social.reply/${string}`; 19 + 20 + /** 21 + * SocialItem - ALL items that can have social engagement (likes) 22 + * Discriminated union on 'type' field 23 + */ 24 + export type SocialItem = 25 + | { type: "card"; scryfallId: ScryfallId; oracleId: OracleId } 26 + | { type: "deck"; uri: DeckItemUri; cid: string } 27 + | { type: "comment"; uri: CommentUri; cid: string } 28 + | { type: "reply"; uri: ReplyUri; cid: string }; 29 + 30 + /** 31 + * SaveableItem - subset of SocialItem that can be saved to collection lists 32 + * Only cards and decks are saveable 33 + */ 34 + export type SaveableItem = Extract<SocialItem, { type: "card" | "deck" }>; 35 + 36 + /** 37 + * CardItem - narrowed to just cards (for deck-count queries, etc.) 38 + */ 39 + export type CardItem = Extract<SocialItem, { type: "card" }>; 40 + 41 + /** 42 + * Type guard: checks if item can be saved to collection lists 43 + */ 44 + export function isSaveable(item: SocialItem): item is SaveableItem { 45 + return item.type === "card" || item.type === "deck"; 46 + } 47 + 48 + /** 49 + * Type guard: checks if item has deck count (only cards) 50 + */ 51 + export function hasDeckCount( 52 + item: SocialItem, 53 + ): item is Extract<SocialItem, { type: "card" }> { 54 + return item.type === "card"; 55 + } 56 + 57 + /** 58 + * SocialItemType discriminator values 59 + */ 60 + export type SocialItemType = SocialItem["type"]; 61 + 62 + /** 63 + * URI type for any social item (for Constellation queries) 64 + */ 65 + export type SocialItemUri = CardItemUri | DeckItemUri | CommentUri | ReplyUri; 66 + 67 + /** 68 + * Saveable item types (can be saved to collection lists) 69 + */ 70 + export type SaveableItemType = "card" | "deck"; 71 + 72 + /** 73 + * Get the URI for any social item (used for Constellation queries) 74 + * Overloads preserve type information based on item type. 75 + */ 76 + export function getSocialItemUri( 77 + item: Extract<SocialItem, { type: "card" }>, 78 + ): CardItemUri; 79 + export function getSocialItemUri( 80 + item: Extract<SocialItem, { type: "deck" }>, 81 + ): DeckItemUri; 82 + export function getSocialItemUri( 83 + item: Extract<SocialItem, { type: "comment" }>, 84 + ): CommentUri; 85 + export function getSocialItemUri( 86 + item: Extract<SocialItem, { type: "reply" }>, 87 + ): ReplyUri; 88 + export function getSocialItemUri(item: SaveableItem): CardItemUri | DeckItemUri; 89 + export function getSocialItemUri(item: SocialItem): SocialItemUri; 90 + export function getSocialItemUri(item: SocialItem): SocialItemUri { 91 + switch (item.type) { 92 + case "card": 93 + return toOracleUri(item.oracleId); 94 + case "deck": 95 + return item.uri; 96 + case "comment": 97 + return item.uri; 98 + case "reply": 99 + return item.uri; 100 + } 101 + } 102 + 103 + /** 104 + * Get a human-readable name for an item type (for toasts) 105 + */ 106 + export function getItemTypeName(item: SocialItem): string { 107 + switch (item.type) { 108 + case "card": 109 + return "Card"; 110 + case "deck": 111 + return "Deck"; 112 + case "comment": 113 + return "Comment"; 114 + case "reply": 115 + return "Reply"; 116 + } 117 + }
+12 -5
src/routes/card/$id.tsx
··· 22 22 } from "@/lib/scryfall-types"; 23 23 import { 24 24 asOracleId, 25 + asScryfallId, 25 26 isScryfallId, 26 27 toOracleUri, 27 28 toScryfallUri, ··· 56 57 // Chain social stats off card (needs oracle_id), runs parallel with volatile 57 58 const socialPromise = cardPromise.then((card) => { 58 59 if (!card?.oracle_id) return; 59 - const oracleUri = toOracleUri(asOracleId(card.oracle_id)); 60 - return prefetchSocialStats(context.queryClient, oracleUri, "card"); 60 + return prefetchSocialStats(context.queryClient, { 61 + type: "card", 62 + scryfallId: asScryfallId(params.id), 63 + oracleId: asOracleId(card.oracle_id), 64 + }); 61 65 }); 62 66 63 67 const [card] = await Promise.all([ ··· 461 465 $type: "com.deckbelcher.social.comment#cardSubject", 462 466 ref: { 463 467 oracleUri: toOracleUri(asOracleId(card.oracle_id)), 464 - scryfallUri: toScryfallUri(id), 468 + scryfallUri: toScryfallUri(asScryfallId(id)), 465 469 }, 466 470 }} 467 - subjectUri={toOracleUri(asOracleId(card.oracle_id))} 468 - itemType="card" 471 + item={{ 472 + type: "card", 473 + scryfallId: asScryfallId(id), 474 + oracleId: asOracleId(card.oracle_id), 475 + }} 469 476 title={`Comments on ${card.name}`} 470 477 /> 471 478 </div>
+11 -13
src/routes/profile/$did/deck/$rkey/index.tsx
··· 57 57 export const Route = createFileRoute("/profile/$did/deck/$rkey/")({ 58 58 component: DeckEditorPage, 59 59 loader: async ({ context, params }) => { 60 - const deckUri = 61 - `at://${params.did}/${DECK_LIST_NSID}/${params.rkey}` as const; 62 - 63 - // Start social stats prefetch immediately (only needs params) 64 - const socialPromise = prefetchSocialStats( 65 - context.queryClient, 66 - deckUri, 67 - "deck", 68 - ); 69 - 70 - // Prefetch deck data, then cards (needs deck.cards) 71 - const { deck } = await context.queryClient.ensureQueryData( 60 + // Prefetch deck data first (need cid for social stats) 61 + const { deck, cid } = await context.queryClient.ensureQueryData( 72 62 getDeckQueryOptions(params.did as Did, asRkey(params.rkey)), 73 63 ); 74 64 65 + const deckUri = 66 + `at://${params.did}/${DECK_LIST_NSID}/${params.rkey}` as const; 67 + 68 + // Prefetch cards and social stats in parallel 75 69 const cardIds = deck.cards.map((card) => card.scryfallId); 76 70 await Promise.all([ 77 71 prefetchCards(context.queryClient, cardIds), 78 - socialPromise, 72 + prefetchSocialStats(context.queryClient, { 73 + type: "deck", 74 + uri: deckUri, 75 + cid, 76 + }), 79 77 ]); 80 78 81 79 // Compute featured card for OG image using same logic as deck preview
+3 -3
src/routes/profile/$did/list/$rkey/index.tsx
··· 24 24 isDeckItem, 25 25 type ListCardItem, 26 26 type ListDeckItem, 27 - type SaveItem, 28 27 } from "@/lib/collection-list-types"; 29 28 import { getDeckQueryOptions } from "@/lib/deck-queries"; 30 29 import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; ··· 32 31 import { getCardByIdQueryOptions } from "@/lib/queries"; 33 32 import { documentToPlainText } from "@/lib/richtext-convert"; 34 33 import { getImageUri } from "@/lib/scryfall-utils"; 34 + import type { SaveableItem } from "@/lib/social-item-types"; 35 35 import { useAuth } from "@/lib/useAuth"; 36 36 37 37 export const Route = createFileRoute("/profile/$did/list/$rkey/")({ ··· 120 120 } 121 121 122 122 const handleRemoveCard = (item: ListCardItem) => { 123 - const saveItem: SaveItem = { 123 + const saveItem: SaveableItem = { 124 124 type: "card", 125 125 scryfallId: item.scryfallId, 126 126 oracleId: item.oracleId, ··· 129 129 }; 130 130 131 131 const handleRemoveDeck = (item: ListDeckItem) => { 132 - const saveItem: SaveItem = { 132 + const saveItem: SaveableItem = { 133 133 type: "deck", 134 134 uri: item.ref.uri, 135 135 cid: item.ref.cid,