👁️
5
fork

Configure Feed

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

like record, wip

+507 -71
+34 -3
lexicons/com/deckbelcher/social/like.json
··· 9 9 "type": "object", 10 10 "properties": { 11 11 "subject": { 12 - "type": "ref", 13 - "ref": "com.atproto.repo.strongRef", 12 + "type": "union", 13 + "refs": [ 14 + "#cardSubject", 15 + "#recordSubject" 16 + ], 14 17 "description": "Reference to the content being liked." 15 18 }, 16 19 "createdAt": { ··· 24 27 "createdAt" 25 28 ] 26 29 }, 27 - "description": "Record declaring a 'like' of a piece of content (decklist, reply, etc)." 30 + "description": "Record declaring a 'like' of a piece of content (card, deck, reply, etc)." 31 + }, 32 + "cardSubject": { 33 + "type": "object", 34 + "properties": { 35 + "ref": { 36 + "type": "ref", 37 + "ref": "com.deckbelcher.defs#cardRef", 38 + "description": "Reference to the card." 39 + } 40 + }, 41 + "description": "Subject for liking a card.", 42 + "required": [ 43 + "ref" 44 + ] 45 + }, 46 + "recordSubject": { 47 + "type": "object", 48 + "properties": { 49 + "ref": { 50 + "type": "ref", 51 + "ref": "com.atproto.repo.strongRef", 52 + "description": "Reference to the record." 53 + } 54 + }, 55 + "description": "Subject for liking an ATProto record (deck, reply, etc).", 56 + "required": [ 57 + "ref" 58 + ] 28 59 } 29 60 } 30 61 }
+65 -15
src/components/social/SocialStats.tsx
··· 1 - import { Bookmark } from "lucide-react"; 1 + import { Bookmark, Heart } from "lucide-react"; 2 2 import { useState } from "react"; 3 3 import type { SaveItem } from "@/lib/collection-list-types"; 4 4 import type { SocialItemUri } from "@/lib/constellation-queries"; 5 5 import { useItemSocialStats } from "@/lib/constellation-queries"; 6 + import { useLikeMutation } from "@/lib/like-queries"; 6 7 import { toOracleUri } from "@/lib/scryfall-types"; 7 8 import { useAuth } from "@/lib/useAuth"; 8 9 import { SaveToListDialog } from "../list/SaveToListDialog"; ··· 26 27 }: SocialStatsProps) { 27 28 const { session } = useAuth(); 28 29 const [isDialogOpen, setIsDialogOpen] = useState(false); 30 + const likeMutation = useLikeMutation(); 29 31 30 32 const itemUri = getItemUri(item); 31 - const { isSavedByUser, saveCount, isLoading } = useItemSocialStats( 32 - itemUri, 33 - item.type, 34 - ); 33 + const { 34 + isSavedByUser, 35 + saveCount, 36 + isSaveLoading, 37 + isLikedByUser, 38 + likeCount, 39 + isLikeLoading, 40 + } = useItemSocialStats(itemUri, item.type); 35 41 36 - const handleClick = () => { 42 + const handleSaveClick = () => { 37 43 if (session) { 38 44 setIsDialogOpen(true); 39 45 } 40 46 }; 41 47 48 + const handleLikeClick = () => { 49 + if (!session) return; 50 + likeMutation.mutate({ 51 + item, 52 + isLiked: isLikedByUser, 53 + itemName, 54 + }); 55 + }; 56 + 57 + const buttonBase = `flex items-center gap-1 p-2 rounded-lg transition-colors ${ 58 + session 59 + ? "hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer" 60 + : "cursor-default opacity-75" 61 + }`; 62 + 42 63 return ( 43 - <> 64 + <div className={`flex items-center ${className}`}> 65 + {/* Like button */} 44 66 <button 45 67 type="button" 46 - onClick={handleClick} 68 + onClick={handleLikeClick} 69 + disabled={!session || likeMutation.isPending} 70 + className={buttonBase} 71 + aria-label={isLikedByUser ? "Unlike" : "Like"} 72 + title={ 73 + session ? (isLikedByUser ? "Unlike" : "Like") : "Sign in to like" 74 + } 75 + > 76 + <Heart 77 + className={`w-5 h-5 ${ 78 + isLikedByUser 79 + ? "text-red-500 dark:text-red-400" 80 + : "text-gray-600 dark:text-gray-400" 81 + }`} 82 + fill={isLikedByUser ? "currentColor" : "none"} 83 + /> 84 + {showCount && ( 85 + <span 86 + className={`text-sm tabular-nums ${isLikeLoading ? "opacity-50" : ""} ${ 87 + isLikedByUser 88 + ? "text-red-500 dark:text-red-400" 89 + : "text-gray-600 dark:text-gray-400" 90 + }`} 91 + > 92 + {likeCount} 93 + </span> 94 + )} 95 + </button> 96 + 97 + {/* Save button */} 98 + <button 99 + type="button" 100 + onClick={handleSaveClick} 47 101 disabled={!session} 48 - className={`flex items-center gap-1 p-2 rounded-lg transition-colors ${ 49 - session 50 - ? "hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer" 51 - : "cursor-default opacity-75" 52 - } ${className}`} 102 + className={buttonBase} 53 103 aria-label={isSavedByUser ? "Saved to list" : "Save to list"} 54 104 title={ 55 105 session ··· 69 119 /> 70 120 {showCount && ( 71 121 <span 72 - className={`text-sm tabular-nums ${isLoading ? "opacity-50" : ""} ${ 122 + className={`text-sm tabular-nums ${isSaveLoading ? "opacity-50" : ""} ${ 73 123 isSavedByUser 74 124 ? "text-blue-500 dark:text-blue-400" 75 125 : "text-gray-600 dark:text-gray-400" ··· 89 139 onClose={() => setIsDialogOpen(false)} 90 140 /> 91 141 )} 92 - </> 142 + </div> 93 143 ); 94 144 }
+73 -10
src/lib/atproto-client.ts
··· 15 15 import { 16 16 ComDeckbelcherCollectionList, 17 17 ComDeckbelcherDeckList, 18 + ComDeckbelcherSocialLike, 18 19 } from "./lexicons/index"; 19 20 20 21 type AtUri = `at://${string}`; ··· 129 130 agent: OAuthUserAgent, 130 131 record: InferOutput<TSchema>, 131 132 schema: TSchema, 133 + rkey?: Rkey, 132 134 ): Promise<Result<{ uri: AtUri; cid: string; rkey: Rkey }>> { 133 135 const collection = getCollectionFromSchema(schema); 134 136 try { ··· 141 143 } 142 144 143 145 const client = new Client({ handler: agent }); 144 - const response = await client.post("com.atproto.repo.createRecord", { 145 - input: { 146 - repo: agent.sub, 147 - collection, 148 - record: record as Record<string, unknown>, 149 - }, 150 - }); 146 + 147 + // Use putRecord if rkey provided (deterministic), createRecord otherwise (auto-generate) 148 + const response = rkey 149 + ? await client.post("com.atproto.repo.putRecord", { 150 + input: { 151 + repo: agent.sub, 152 + collection, 153 + rkey, 154 + record: record as Record<string, unknown>, 155 + }, 156 + }) 157 + : await client.post("com.atproto.repo.createRecord", { 158 + input: { 159 + repo: agent.sub, 160 + collection, 161 + record: record as Record<string, unknown>, 162 + }, 163 + }); 151 164 152 165 if (!response.ok) { 153 166 return { ··· 160 173 161 174 const uri = response.data.uri as string; 162 175 const cid = response.data.cid as string; 163 - const rkey = uri.split("/").pop(); 164 - if (!rkey) { 176 + const extractedRkey = uri.split("/").pop(); 177 + if (!extractedRkey) { 165 178 return { 166 179 success: false, 167 180 error: new Error("Invalid URI returned from createRecord"), ··· 170 183 171 184 return { 172 185 success: true, 173 - data: { uri: uri as AtUri, cid, rkey: asRkey(rkey) }, 186 + data: { uri: uri as AtUri, cid, rkey: asRkey(extractedRkey) }, 174 187 }; 175 188 } catch (error) { 176 189 return { ··· 407 420 export function deleteCollectionListRecord(agent: OAuthUserAgent, rkey: Rkey) { 408 421 return deleteRecord(agent, rkey, ComDeckbelcherCollectionList.mainSchema); 409 422 } 423 + 424 + // ============================================================================ 425 + // Like Records 426 + // ============================================================================ 427 + 428 + export type LikeRecordResponse = RecordResponse<ComDeckbelcherSocialLike.Main>; 429 + 430 + type LikeSubject = ComDeckbelcherSocialLike.Main["subject"]; 431 + 432 + /** 433 + * Hash an object to a deterministic rkey using SHA-256 + base64url. 434 + * Full hash (43 chars) for maximum collision resistance. 435 + * Valid rkey chars: A-Za-z0-9.-_:~ (base64url uses A-Za-z0-9-_) 436 + */ 437 + export async function hashToRkey(obj: unknown): Promise<Rkey> { 438 + const json = JSON.stringify(obj); 439 + const encoder = new TextEncoder(); 440 + const data = encoder.encode(json); 441 + const hashBuffer = await crypto.subtle.digest("SHA-256", data); 442 + 443 + const hashArray = new Uint8Array(hashBuffer); 444 + const base64 = btoa(String.fromCharCode(...hashArray)); 445 + const base64url = base64 446 + .replace(/\+/g, "-") 447 + .replace(/\//g, "_") 448 + .replace(/=/g, ""); 449 + 450 + return asRkey(base64url); 451 + } 452 + 453 + export async function createLikeRecord( 454 + agent: OAuthUserAgent, 455 + subject: LikeSubject, 456 + ) { 457 + const rkey = await hashToRkey(subject); 458 + const record: ComDeckbelcherSocialLike.Main = { 459 + $type: "com.deckbelcher.social.like", 460 + subject, 461 + createdAt: new Date().toISOString(), 462 + }; 463 + return createRecord(agent, record, ComDeckbelcherSocialLike.mainSchema, rkey); 464 + } 465 + 466 + export async function deleteLikeRecord( 467 + agent: OAuthUserAgent, 468 + subject: LikeSubject, 469 + ) { 470 + const rkey = await hashToRkey(subject); 471 + return deleteRecord(agent, rkey, ComDeckbelcherSocialLike.mainSchema); 472 + }
+1 -1
src/lib/collection-list-types.ts
··· 14 14 */ 15 15 export type SaveItem = 16 16 | { type: "card"; scryfallId: ScryfallId; oracleId: OracleId } 17 - | { type: "deck"; deckUri: DeckItemUri }; 17 + | { type: "deck"; deckUri: DeckItemUri; cid: string }; 18 18 19 19 /** 20 20 * App-side card item with flat typed IDs.
+7
src/lib/constellation-client.ts
··· 18 18 // Future: cards in decks (also uses oracleUri for aggregation) 19 19 export const DECK_LIST_CARD_PATH = ".cards[].ref.oracleUri"; 20 20 21 + // Like paths (subject is a union, so includes $type in path) 22 + export const LIKE_CARD_PATH = 23 + ".subject[com.deckbelcher.social.like#cardSubject].ref.oracleUri"; 24 + export const LIKE_RECORD_PATH = 25 + ".subject[com.deckbelcher.social.like#recordSubject].ref.uri"; 26 + 21 27 export const COLLECTION_LIST_NSID = "com.deckbelcher.collection.list"; 22 28 export const DECK_LIST_NSID = "com.deckbelcher.deck.list"; 29 + export const LIKE_NSID = "com.deckbelcher.social.like"; 23 30 24 31 export interface BacklinkRecord { 25 32 uri: string;
+78 -13
src/lib/constellation-queries.ts
··· 11 11 COLLECTION_LIST_NSID, 12 12 getBacklinks, 13 13 getLinksCount, 14 + LIKE_CARD_PATH, 15 + LIKE_NSID, 16 + LIKE_RECORD_PATH, 14 17 } from "./constellation-client"; 15 18 import type { OracleUri } from "./scryfall-types"; 16 19 import { useAuth } from "./useAuth"; ··· 95 98 export interface ItemSocialStats { 96 99 isSavedByUser: boolean; 97 100 saveCount: number; 98 - isLoading: boolean; 99 - isSavedLoading: boolean; 100 - isCountLoading: boolean; 101 + isSaveLoading: boolean; 102 + isLikedByUser: boolean; 103 + likeCount: number; 104 + isLikeLoading: boolean; 101 105 } 102 106 103 107 /** 104 - * Combined hook for item social stats (save state + count) 108 + * Combined hook for item social stats (saves + likes) 105 109 */ 106 110 export function useItemSocialStats<T extends SocialItemType>( 107 111 itemUri: T extends "card" ? CardItemUri : DeckItemUri, ··· 112 116 const savedQuery = useQuery( 113 117 userSavedItemQueryOptions(itemUri, session?.info.sub, itemType), 114 118 ); 119 + const saveCountQuery = useQuery(itemSaveCountQueryOptions(itemUri, itemType)); 115 120 116 - const countQuery = useQuery(itemSaveCountQueryOptions(itemUri, itemType)); 121 + const likedQuery = useQuery( 122 + userLikedItemQueryOptions(itemUri, session?.info.sub, itemType), 123 + ); 124 + const likeCountQuery = useQuery(itemLikeCountQueryOptions(itemUri, itemType)); 117 125 118 126 return { 119 127 isSavedByUser: savedQuery.data ?? false, 120 - saveCount: countQuery.data ?? 0, 121 - isLoading: savedQuery.isLoading || countQuery.isLoading, 122 - isSavedLoading: savedQuery.isLoading, 123 - isCountLoading: countQuery.isLoading, 128 + saveCount: saveCountQuery.data ?? 0, 129 + isSaveLoading: savedQuery.isLoading || saveCountQuery.isLoading, 130 + isLikedByUser: likedQuery.data ?? false, 131 + likeCount: likeCountQuery.data ?? 0, 132 + isLikeLoading: likedQuery.isLoading || likeCountQuery.isLoading, 124 133 }; 125 134 } 126 135 ··· 134 143 return { 135 144 userSaved: ["constellation", "userSaved", itemUri, userDid] as const, 136 145 saveCount: ["constellation", "saveCount", itemUri] as const, 146 + userLiked: ["constellation", "userLiked", itemUri, userDid] as const, 147 + likeCount: ["constellation", "likeCount", itemUri] as const, 137 148 }; 138 149 } 139 150 151 + // ============================================================================ 152 + // Like Queries 153 + // ============================================================================ 154 + 155 + function getLikePathForItemType(itemType: SocialItemType): string { 156 + return itemType === "card" ? LIKE_CARD_PATH : LIKE_RECORD_PATH; 157 + } 158 + 140 159 /** 141 - * Preload social stats for SSR 142 - * Only prefetches count (no auth needed), user saved query runs client-side 160 + * Query options for checking if current user has liked an item 161 + */ 162 + export function userLikedItemQueryOptions<T extends SocialItemType>( 163 + itemUri: T extends "card" ? CardItemUri : DeckItemUri, 164 + userDid: Did | undefined, 165 + itemType: T, 166 + ) { 167 + return queryOptions({ 168 + queryKey: ["constellation", "userLiked", itemUri, userDid] as const, 169 + queryFn: async (): Promise<boolean> => { 170 + if (!userDid) return false; 171 + 172 + const result = await getBacklinks({ 173 + subject: itemUri, 174 + source: buildSource(LIKE_NSID, getLikePathForItemType(itemType)), 175 + did: userDid, 176 + limit: 1, 177 + }); 178 + 179 + if (!result.success) { 180 + throw result.error; 181 + } 182 + 183 + return result.data.records.length > 0; 184 + }, 185 + enabled: !!userDid, 186 + staleTime: 30 * 1000, 187 + }); 188 + } 189 + 190 + /** 191 + * Query options for getting total like count for an item 143 192 */ 144 - export function socialStatsPreload<T extends SocialItemType>( 193 + export function itemLikeCountQueryOptions<T extends SocialItemType>( 145 194 itemUri: T extends "card" ? CardItemUri : DeckItemUri, 146 195 itemType: T, 147 196 ) { 148 - return [itemSaveCountQueryOptions(itemUri, itemType)]; 197 + return queryOptions({ 198 + queryKey: ["constellation", "likeCount", itemUri] as const, 199 + queryFn: async (): Promise<number> => { 200 + const result = await getLinksCount({ 201 + target: itemUri, 202 + collection: LIKE_NSID, 203 + path: getLikePathForItemType(itemType), 204 + }); 205 + 206 + if (!result.success) { 207 + throw result.error; 208 + } 209 + 210 + return result.data.total; 211 + }, 212 + staleTime: 60 * 1000, 213 + }); 149 214 }
+36 -8
src/lib/deck-queries.ts
··· 55 55 }; 56 56 } 57 57 58 + export interface DeckRecord { 59 + deck: Deck; 60 + cid: string; 61 + } 62 + 58 63 /** 59 64 * Query options for fetching a single deck 60 65 * Uses Slingshot for cached reads ··· 62 67 export const getDeckQueryOptions = (did: Did, rkey: Rkey) => 63 68 queryOptions({ 64 69 queryKey: ["deck", did, rkey] as const, 65 - queryFn: async (): Promise<Deck> => { 70 + queryFn: async (): Promise<DeckRecord> => { 66 71 const result = await getDeckRecord(did, rkey); 67 72 if (!result.success) { 68 73 throw result.error; 69 74 } 70 - return transformDeckRecord(result.data.value); 75 + return { 76 + deck: transformDeckRecord(result.data.value), 77 + cid: result.data.cid, 78 + }; 71 79 }, 72 80 staleTime: 30 * 1000, // 30 seconds - balance between freshness and cache hits 73 81 }); ··· 211 219 await queryClient.cancelQueries({ queryKey: ["deck", did, rkey] }); 212 220 213 221 // Snapshot previous value 214 - const previous = queryClient.getQueryData<Deck>(["deck", did, rkey]); 222 + const previous = queryClient.getQueryData<DeckRecord>([ 223 + "deck", 224 + did, 225 + rkey, 226 + ]); 215 227 216 - // Optimistically update cache 217 - queryClient.setQueryData<Deck>(["deck", did, rkey], newDeck); 228 + // WARN: We preserve the old cid during optimistic updates. This means the 229 + // cid will be stale until onSuccess updates it. If someone likes the deck 230 + // during this window, the like will reference the old cid. This is unlikely 231 + // but could cause issues if the deck is being rapidly edited while liked. 232 + if (previous) { 233 + queryClient.setQueryData<DeckRecord>(["deck", did, rkey], { 234 + deck: newDeck, 235 + cid: previous.cid, 236 + }); 237 + } 218 238 219 239 // Return context for rollback 220 240 return { previous }; 221 241 }, 242 + onSuccess: (data, newDeck) => { 243 + // Update cache with the new cid from the server response 244 + queryClient.setQueryData<DeckRecord>(["deck", did, rkey], { 245 + deck: newDeck, 246 + cid: data.cid, 247 + }); 248 + }, 222 249 onError: (_err, _newDeck, context) => { 223 250 // Rollback on error and refetch 224 251 if (context?.previous) { 225 - queryClient.setQueryData<Deck>(["deck", did, rkey], context.previous); 252 + queryClient.setQueryData<DeckRecord>( 253 + ["deck", did, rkey], 254 + context.previous, 255 + ); 226 256 } 227 257 queryClient.invalidateQueries({ queryKey: ["deck", did, rkey] }); 228 258 }, 229 - // Don't refetch on success - optimistic update is correct 230 - // Slingshot (cache) might be stale anyway 231 259 }); 232 260 } 233 261
+33 -1
src/lib/lexicons/types/com/deckbelcher/social/like.ts
··· 2 2 import * as v from "@atcute/lexicons/validations"; 3 3 import type {} from "@atcute/lexicons/ambient"; 4 4 import * as ComAtprotoRepoStrongRef from "../../atproto/repo/strongRef.js"; 5 + import * as ComDeckbelcherDefs from "../defs.js"; 5 6 7 + const _cardSubjectSchema = /*#__PURE__*/ v.object({ 8 + $type: /*#__PURE__*/ v.optional( 9 + /*#__PURE__*/ v.literal("com.deckbelcher.social.like#cardSubject"), 10 + ), 11 + /** 12 + * Reference to the card. 13 + */ 14 + get ref() { 15 + return ComDeckbelcherDefs.cardRefSchema; 16 + }, 17 + }); 6 18 const _mainSchema = /*#__PURE__*/ v.record( 7 19 /*#__PURE__*/ v.tidString(), 8 20 /*#__PURE__*/ v.object({ ··· 15 27 * Reference to the content being liked. 16 28 */ 17 29 get subject() { 18 - return ComAtprotoRepoStrongRef.mainSchema; 30 + return /*#__PURE__*/ v.variant([cardSubjectSchema, recordSubjectSchema]); 19 31 }, 20 32 }), 21 33 ); 34 + const _recordSubjectSchema = /*#__PURE__*/ v.object({ 35 + $type: /*#__PURE__*/ v.optional( 36 + /*#__PURE__*/ v.literal("com.deckbelcher.social.like#recordSubject"), 37 + ), 38 + /** 39 + * Reference to the record. 40 + */ 41 + get ref() { 42 + return ComAtprotoRepoStrongRef.mainSchema; 43 + }, 44 + }); 22 45 46 + type cardSubject$schematype = typeof _cardSubjectSchema; 23 47 type main$schematype = typeof _mainSchema; 48 + type recordSubject$schematype = typeof _recordSubjectSchema; 24 49 50 + export interface cardSubjectSchema extends cardSubject$schematype {} 25 51 export interface mainSchema extends main$schematype {} 52 + export interface recordSubjectSchema extends recordSubject$schematype {} 26 53 54 + export const cardSubjectSchema = _cardSubjectSchema as cardSubjectSchema; 27 55 export const mainSchema = _mainSchema as mainSchema; 56 + export const recordSubjectSchema = _recordSubjectSchema as recordSubjectSchema; 28 57 58 + export interface CardSubject extends v.InferInput<typeof cardSubjectSchema> {} 29 59 export interface Main extends v.InferInput<typeof mainSchema> {} 60 + export interface RecordSubject 61 + extends v.InferInput<typeof recordSubjectSchema> {} 30 62 31 63 declare module "@atcute/lexicons/ambient" { 32 64 interface Records {
+137
src/lib/like-queries.ts
··· 1 + /** 2 + * TanStack Query mutations for like operations 3 + */ 4 + 5 + import type { ResourceUri } from "@atcute/lexicons"; 6 + import { useQueryClient } from "@tanstack/react-query"; 7 + import { toast } from "sonner"; 8 + import { createLikeRecord, deleteLikeRecord } from "./atproto-client"; 9 + import type { SaveItem } from "./collection-list-types"; 10 + import { getConstellationQueryKeys } from "./constellation-queries"; 11 + import type { ComDeckbelcherSocialLike } from "./lexicons/index"; 12 + import type { OracleId, ScryfallId } from "./scryfall-types"; 13 + import { toOracleUri, toScryfallUri } from "./scryfall-types"; 14 + import { useAuth } from "./useAuth"; 15 + import { useMutationWithToast } from "./useMutationWithToast"; 16 + 17 + type LikeSubject = ComDeckbelcherSocialLike.Main["subject"]; 18 + 19 + function buildCardSubject( 20 + scryfallId: ScryfallId, 21 + oracleId: OracleId, 22 + ): ComDeckbelcherSocialLike.CardSubject & { 23 + $type: "com.deckbelcher.social.like#cardSubject"; 24 + } { 25 + return { 26 + $type: "com.deckbelcher.social.like#cardSubject", 27 + ref: { 28 + scryfallUri: toScryfallUri(scryfallId), 29 + oracleUri: toOracleUri(oracleId), 30 + }, 31 + }; 32 + } 33 + 34 + function buildRecordSubject( 35 + uri: string, 36 + cid: string, 37 + ): ComDeckbelcherSocialLike.RecordSubject & { 38 + $type: "com.deckbelcher.social.like#recordSubject"; 39 + } { 40 + return { 41 + $type: "com.deckbelcher.social.like#recordSubject", 42 + ref: { 43 + uri: uri as ResourceUri, 44 + cid, 45 + }, 46 + }; 47 + } 48 + 49 + interface ToggleLikeParams { 50 + item: SaveItem; 51 + isLiked: boolean; 52 + itemName?: string; 53 + } 54 + 55 + /** 56 + * Mutation for toggling a like on a card or deck 57 + * Handles optimistic updates for constellation queries 58 + */ 59 + export function useLikeMutation() { 60 + const { agent, session } = useAuth(); 61 + const queryClient = useQueryClient(); 62 + 63 + return useMutationWithToast({ 64 + mutationFn: async (params: ToggleLikeParams) => { 65 + if (!agent || !session) { 66 + throw new Error("Must be authenticated to like"); 67 + } 68 + 69 + let subject: LikeSubject; 70 + if (params.item.type === "deck") { 71 + subject = buildRecordSubject(params.item.deckUri, params.item.cid); 72 + } else { 73 + subject = buildCardSubject( 74 + params.item.scryfallId, 75 + params.item.oracleId, 76 + ); 77 + } 78 + 79 + if (params.isLiked) { 80 + const result = await deleteLikeRecord(agent, subject); 81 + if (!result.success) { 82 + throw result.error; 83 + } 84 + return { wasLiked: true }; 85 + } 86 + 87 + const result = await createLikeRecord(agent, subject); 88 + if (!result.success) { 89 + throw result.error; 90 + } 91 + return { wasLiked: false }; 92 + }, 93 + onMutate: async (params: ToggleLikeParams) => { 94 + const userDid = session?.info.sub; 95 + const itemUri = 96 + params.item.type === "deck" 97 + ? (params.item.deckUri as `at://${string}`) 98 + : toOracleUri(params.item.oracleId); 99 + 100 + const keys = getConstellationQueryKeys(itemUri, userDid); 101 + 102 + await queryClient.cancelQueries({ queryKey: keys.userLiked }); 103 + await queryClient.cancelQueries({ queryKey: keys.likeCount }); 104 + 105 + const previousLiked = queryClient.getQueryData<boolean>(keys.userLiked); 106 + const previousCount = queryClient.getQueryData<number>(keys.likeCount); 107 + 108 + queryClient.setQueryData<boolean>(keys.userLiked, !params.isLiked); 109 + queryClient.setQueryData<number>(keys.likeCount, (old) => 110 + params.isLiked ? Math.max(0, (old ?? 1) - 1) : (old ?? 0) + 1, 111 + ); 112 + 113 + return { previousLiked, previousCount, keys }; 114 + }, 115 + onError: (_err, _params, context) => { 116 + if (!context) return; 117 + 118 + queryClient.setQueryData<boolean>( 119 + context.keys.userLiked, 120 + context.previousLiked, 121 + ); 122 + queryClient.setQueryData<number>( 123 + context.keys.likeCount, 124 + context.previousCount, 125 + ); 126 + }, 127 + onSuccess: (data, params) => { 128 + const what = 129 + params.itemName ?? (params.item.type === "card" ? "Card" : "Deck"); 130 + if (data.wasLiked) { 131 + toast.success(`Unliked ${what}`); 132 + } else { 133 + toast.success(`Liked ${what}`); 134 + } 135 + }, 136 + }); 137 + }
+11 -5
src/routes/card/$id.tsx
··· 6 6 import { OracleText } from "@/components/OracleText"; 7 7 import { SocialStats } from "@/components/social/SocialStats"; 8 8 import { getAllFaces } from "@/lib/card-faces"; 9 - import { socialStatsPreload } from "@/lib/constellation-queries"; 9 + import { 10 + itemLikeCountQueryOptions, 11 + itemSaveCountQueryOptions, 12 + } from "@/lib/constellation-queries"; 10 13 import { FORMAT_GROUPS } from "@/lib/format-utils"; 11 14 import { 12 15 getCardByIdQueryOptions, ··· 51 54 const socialPromise = cardPromise.then((card) => { 52 55 if (!card?.oracle_id) return; 53 56 const oracleUri = toOracleUri(asOracleId(card.oracle_id)); 54 - return Promise.all( 55 - socialStatsPreload(oracleUri, "card").map((opts) => 56 - context.queryClient.ensureQueryData(opts), 57 + return Promise.all([ 58 + context.queryClient.ensureQueryData( 59 + itemSaveCountQueryOptions(oracleUri, "card"), 60 + ), 61 + context.queryClient.ensureQueryData( 62 + itemLikeCountQueryOptions(oracleUri, "card"), 57 63 ), 58 - ); 64 + ]); 59 65 }); 60 66 61 67 const [card] = await Promise.all([
+4 -4
src/routes/profile/$did/deck/$rkey/bulk-edit.tsx
··· 21 21 export const Route = createFileRoute("/profile/$did/deck/$rkey/bulk-edit")({ 22 22 component: BulkEditPage, 23 23 loader: async ({ context, params }) => { 24 - const deck = await context.queryClient.ensureQueryData( 24 + const { deck } = await context.queryClient.ensureQueryData( 25 25 getDeckQueryOptions(params.did as Did, asRkey(params.rkey)), 26 26 ); 27 27 await Promise.all( ··· 46 46 const { session } = useAuth(); 47 47 const queryClient = Route.useRouteContext().queryClient; 48 48 49 - const { data: deck } = useSuspenseQuery( 50 - getDeckQueryOptions(did as Did, asRkey(rkey)), 51 - ); 49 + const { 50 + data: { deck }, 51 + } = useSuspenseQuery(getDeckQueryOptions(did as Did, asRkey(rkey))); 52 52 53 53 const mutation = useUpdateDeckMutation(did as Did, asRkey(rkey)); 54 54
+4 -2
src/routes/profile/$did/deck/$rkey/index.tsx
··· 50 50 component: DeckEditorPage, 51 51 loader: async ({ context, params }) => { 52 52 // Prefetch deck data during SSR 53 - const deck = await context.queryClient.ensureQueryData( 53 + const { deck } = await context.queryClient.ensureQueryData( 54 54 getDeckQueryOptions(params.did as Did, asRkey(params.rkey)), 55 55 ); 56 56 ··· 114 114 function DeckEditorPage() { 115 115 const { did, rkey } = Route.useParams(); 116 116 const { session } = useAuth(); 117 - const { data: deck } = useSuspenseQuery( 117 + const { data: deckRecord } = useSuspenseQuery( 118 118 getDeckQueryOptions(did as Did, asRkey(rkey)), 119 119 ); 120 + const deck = deckRecord.deck; 120 121 121 122 const [groupBy, setGroupBy] = usePersistedState<GroupBy>( 122 123 "deckbelcher:viewConfig:groupBy", ··· 586 587 item={{ 587 588 type: "deck", 588 589 deckUri: `at://${did}/com.deckbelcher.deck.list/${rkey}`, 590 + cid: deckRecord.cid, 589 591 }} 590 592 itemName={deck.name} 591 593 />
+4 -4
src/routes/profile/$did/deck/$rkey/play.tsx
··· 14 14 export const Route = createFileRoute("/profile/$did/deck/$rkey/play")({ 15 15 component: PlaytestPage, 16 16 loader: async ({ context, params }) => { 17 - const deck = await context.queryClient.ensureQueryData( 17 + const { deck } = await context.queryClient.ensureQueryData( 18 18 getDeckQueryOptions(params.did as Did, asRkey(params.rkey)), 19 19 ); 20 20 ··· 36 36 37 37 function PlaytestPage() { 38 38 const { did, rkey } = Route.useParams(); 39 - const { data: deck } = useSuspenseQuery( 40 - getDeckQueryOptions(did as Did, asRkey(rkey)), 41 - ); 39 + const { 40 + data: { deck }, 41 + } = useSuspenseQuery(getDeckQueryOptions(did as Did, asRkey(rkey))); 42 42 43 43 const playtestCards = [ 44 44 ...getCardsInSection(deck, "commander"),
+3 -3
src/routes/profile/$did/list/$rkey/index.tsx
··· 326 326 const deckDid = parts[2] as Did; 327 327 const deckRkey = parts[4] as Rkey; 328 328 329 - const { data: deck, isError } = useQuery({ 329 + const { data, isError } = useQuery({ 330 330 ...getDeckQueryOptions(deckDid, deckRkey), 331 331 retry: false, 332 332 }); 333 333 334 - if (isError || !deck) { 334 + if (isError || !data) { 335 335 return ( 336 336 <div className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg opacity-50"> 337 337 <div className="flex-1"> ··· 356 356 return ( 357 357 <div className="flex items-center gap-4"> 358 358 <div className="flex-1"> 359 - <DeckPreview did={deckDid} rkey={deckRkey} deck={deck} /> 359 + <DeckPreview did={deckDid} rkey={deckRkey} deck={data.deck} /> 360 360 </div> 361 361 {onRemove && ( 362 362 <button
+17 -2
typelex/social-like.tsp
··· 1 1 import "@typelex/emitter"; 2 2 import "./externals.tsp"; 3 + import "./defs.tsp"; 3 4 4 5 namespace com.deckbelcher.social.like { 5 - /** Record declaring a 'like' of a piece of content (decklist, reply, etc). */ 6 + /** Record declaring a 'like' of a piece of content (card, deck, reply, etc). */ 6 7 @rec("tid") 7 8 model Main { 8 9 /** Reference to the content being liked. */ 9 10 @required 10 - subject: com.atproto.repo.strongRef.Main; 11 + subject: CardSubject | RecordSubject | unknown; 11 12 12 13 /** Timestamp when the like was created. */ 13 14 @required 14 15 createdAt: datetime; 16 + } 17 + 18 + /** Subject for liking a card. */ 19 + model CardSubject { 20 + /** Reference to the card. */ 21 + @required 22 + ref: com.deckbelcher.defs.CardRef; 23 + } 24 + 25 + /** Subject for liking an ATProto record (deck, reply, etc). */ 26 + model RecordSubject { 27 + /** Reference to the record. */ 28 + @required 29 + ref: com.atproto.repo.strongRef.Main; 15 30 } 16 31 }