👁️
5
fork

Configure Feed

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

working basic replies!

+1146 -18
+1
CLAUDE.md
··· 179 179 ### Code Standards 180 180 - **Stay focused on your current task** - If a global check (typecheck, lint) reports errors in files you haven't modified in this session, don't automatically fix them—mention them and let the user decide. Errors in files you did modify are your responsibility to fix. When in doubt, ask 181 181 - **This is a TypeScript project** - ALL code (including scripts) must use TypeScript with proper types 182 + - **Use optimistic-utils for mutations** - All TanStack Query mutations should use helpers from `src/lib/optimistic-utils.ts` (`runOptimistic`, `optimisticRecord`, `optimisticCount`, `optimisticBacklinks`, etc). Pattern: `onMutate` returns `{ rollback }`, `onError` calls `context?.rollback()`. Never write raw cache manipulation—use the helpers. See `like-queries.ts` or `comment-queries.ts` for examples. 182 183 - **Use `nix-shell -p <package>` for missing commands** - If a command isn't in PATH, use nix-shell to get it temporarily 183 184 - **Prefer functional style over exceptions** - Avoid throwing errors for control flow. Use type predicates, Option/Result patterns, and early returns instead. Throwing is like GOTO—it breaks local reasoning and makes code harder to follow 184 185 - **Avoid unnecessary try/catch blocks** - Don't wrap code in try/catch without a specific reason. It's not defensive coding—it's noisy and masks real errors. If a function can return null/undefined, use that instead of throwing. Let exceptions bubble naturally unless you have a specific recovery strategy
+50 -2
package-lock.json
··· 11 11 "@atcute/client": "^4.0.5", 12 12 "@atcute/identity-resolver": "^1.1.4", 13 13 "@atcute/oauth-browser-client": "^2.0.1", 14 + "@atcute/tid": "^1.1.1", 14 15 "@braintree/sanitize-url": "^7.1.1", 15 16 "@cloudflare/vite-plugin": "^1.13.19", 16 17 "@dnd-kit/core": "^6.3.1", ··· 254 255 }, 255 256 "engines": { 256 257 "node": "^18 || >=20" 258 + } 259 + }, 260 + "node_modules/@atcute/tid": { 261 + "version": "1.1.1", 262 + "resolved": "https://registry.npmjs.org/@atcute/tid/-/tid-1.1.1.tgz", 263 + "integrity": "sha512-djJ8UGhLkTU5V51yCnBEruMg35qETjWzWy5sJG/2gEOl2Gd7rQWHSaf+yrO6vMS5EFA38U2xOWE3EDUPzvc2ZQ==", 264 + "license": "0BSD", 265 + "dependencies": { 266 + "@atcute/time-ms": "^1.0.0" 267 + } 268 + }, 269 + "node_modules/@atcute/time-ms": { 270 + "version": "1.2.0", 271 + "resolved": "https://registry.npmjs.org/@atcute/time-ms/-/time-ms-1.2.0.tgz", 272 + "integrity": "sha512-dtNKebVIbr1+yu3a6vgtL4sfkNgxkL3aA+ohHsjtW83WWMjjGvX8GVTVmYCJ2dYSxIoxK0q1yWs11PmlqzmQ/A==", 273 + "hasInstallScript": true, 274 + "license": "0BSD", 275 + "dependencies": { 276 + "@types/bun": "^1.3.6", 277 + "node-gyp-build": "^4.8.4" 257 278 } 258 279 }, 259 280 "node_modules/@atcute/uint8array": { ··· 4639 4660 "@babel/types": "^7.28.2" 4640 4661 } 4641 4662 }, 4663 + "node_modules/@types/bun": { 4664 + "version": "1.3.6", 4665 + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.6.tgz", 4666 + "integrity": "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==", 4667 + "license": "MIT", 4668 + "dependencies": { 4669 + "bun-types": "1.3.6" 4670 + } 4671 + }, 4642 4672 "node_modules/@types/chai": { 4643 4673 "version": "5.2.3", 4644 4674 "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", ··· 4752 4782 "version": "22.18.12", 4753 4783 "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.12.tgz", 4754 4784 "integrity": "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==", 4755 - "devOptional": true, 4756 4785 "license": "MIT", 4757 4786 "dependencies": { 4758 4787 "undici-types": "~6.21.0" ··· 5219 5248 }, 5220 5249 "engines": { 5221 5250 "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" 5251 + } 5252 + }, 5253 + "node_modules/bun-types": { 5254 + "version": "1.3.6", 5255 + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.6.tgz", 5256 + "integrity": "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ==", 5257 + "license": "MIT", 5258 + "dependencies": { 5259 + "@types/node": "*" 5222 5260 } 5223 5261 }, 5224 5262 "node_modules/cac": { ··· 7190 7228 "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 7191 7229 } 7192 7230 }, 7231 + "node_modules/node-gyp-build": { 7232 + "version": "4.8.4", 7233 + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", 7234 + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", 7235 + "license": "MIT", 7236 + "bin": { 7237 + "node-gyp-build": "bin.js", 7238 + "node-gyp-build-optional": "optional.js", 7239 + "node-gyp-build-test": "build-test.js" 7240 + } 7241 + }, 7193 7242 "node_modules/node-releases": { 7194 7243 "version": "2.0.26", 7195 7244 "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", ··· 8476 8525 "version": "6.21.0", 8477 8526 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 8478 8527 "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 8479 - "devOptional": true, 8480 8528 "license": "MIT" 8481 8529 }, 8482 8530 "node_modules/unenv": {
+1
package.json
··· 26 26 "@atcute/client": "^4.0.5", 27 27 "@atcute/identity-resolver": "^1.1.4", 28 28 "@atcute/oauth-browser-client": "^2.0.1", 29 + "@atcute/tid": "^1.1.1", 29 30 "@braintree/sanitize-url": "^7.1.1", 30 31 "@cloudflare/vite-plugin": "^1.13.19", 31 32 "@dnd-kit/core": "^6.3.1",
+115
src/components/comments/CommentForm.tsx
··· 1 + import { useCallback, useMemo, useState } from "react"; 2 + import { ProseMirrorEditor } from "@/components/richtext/ProseMirrorEditor"; 3 + import { schema } from "@/components/richtext/schema"; 4 + import type { Document } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 5 + import { lexiconToTree, treeToLexicon } from "@/lib/richtext-convert"; 6 + import { useProseMirror } from "@/lib/useProseMirror"; 7 + 8 + interface CommentFormProps { 9 + initialContent?: Document; 10 + onSubmit: (content: Document) => void; 11 + onCancel?: () => void; 12 + isPending?: boolean; 13 + placeholder?: string; 14 + submitLabel?: string; 15 + availableTags?: string[]; 16 + } 17 + 18 + export function CommentForm({ 19 + initialContent, 20 + onSubmit, 21 + onCancel, 22 + isPending, 23 + placeholder = "Write a comment...", 24 + submitLabel, 25 + availableTags, 26 + }: CommentFormProps) { 27 + const isEditMode = !!initialContent; 28 + const [key, setKey] = useState(0); 29 + 30 + const initialPMDoc = useMemo(() => { 31 + if (!initialContent) return undefined; 32 + return lexiconToTree(initialContent).toJSON(); 33 + }, [initialContent]); 34 + 35 + const { doc, onChange, isDirty } = useProseMirror({ 36 + initialDoc: initialPMDoc, 37 + }); 38 + 39 + const hasContent = doc.textContent.trim().length > 0; 40 + 41 + const handleSubmit = useCallback(() => { 42 + if (!hasContent || isPending) return; 43 + if (isEditMode && !isDirty) { 44 + onSubmit(initialContent); 45 + return; 46 + } 47 + const content = treeToLexicon(doc); 48 + onSubmit(content); 49 + if (!isEditMode) { 50 + setKey((k) => k + 1); 51 + } 52 + }, [ 53 + doc, 54 + hasContent, 55 + isDirty, 56 + initialContent, 57 + isEditMode, 58 + isPending, 59 + onSubmit, 60 + ]); 61 + 62 + const label = submitLabel ?? (isEditMode ? "Done" : "Post"); 63 + 64 + return ( 65 + <div className="space-y-1"> 66 + <ProseMirrorEditor 67 + key={key} 68 + defaultValue={ 69 + isEditMode 70 + ? doc 71 + : schema.node("doc", null, [schema.node("paragraph")]) 72 + } 73 + onChange={onChange} 74 + placeholder={placeholder} 75 + showToolbar 76 + availableTags={availableTags} 77 + className="text-sm" 78 + /> 79 + <div className="flex items-center justify-end gap-2"> 80 + {isPending && ( 81 + <span className="text-sm text-gray-500 dark:text-gray-400"> 82 + Saving... 83 + </span> 84 + )} 85 + {!isPending && isEditMode && isDirty && ( 86 + <span className="text-sm text-gray-500 dark:text-gray-400"> 87 + Unsaved changes 88 + </span> 89 + )} 90 + {onCancel && ( 91 + <button 92 + type="button" 93 + onClick={onCancel} 94 + disabled={isPending} 95 + className="px-3 py-1.5 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300 disabled:opacity-50" 96 + > 97 + Cancel 98 + </button> 99 + )} 100 + <button 101 + type="button" 102 + onClick={handleSubmit} 103 + disabled={!hasContent || isPending} 104 + className={`px-3 py-1.5 text-sm font-medium rounded-md disabled:opacity-50 disabled:cursor-not-allowed ${ 105 + isEditMode 106 + ? "bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300" 107 + : "bg-blue-600 hover:bg-blue-700 text-white" 108 + }`} 109 + > 110 + {isPending ? "Saving..." : label} 111 + </button> 112 + </div> 113 + </div> 114 + ); 115 + }
+175
src/components/comments/CommentItem.tsx
··· 1 + import { useQuery } from "@tanstack/react-query"; 2 + import { Link } from "@tanstack/react-router"; 3 + import { MessageSquare, Trash2 } from "lucide-react"; 4 + import { ClientDate } from "@/components/ClientDate"; 5 + import { RichtextRenderer } from "@/components/richtext/RichtextRenderer"; 6 + import { type AtUri, asRkey } from "@/lib/atproto-client"; 7 + import { 8 + getCommentQueryOptions, 9 + getReplyQueryOptions, 10 + useDeleteCommentMutation, 11 + useDeleteReplyMutation, 12 + } from "@/lib/comment-queries"; 13 + import type { BacklinkRecord } from "@/lib/constellation-client"; 14 + import { 15 + directReplyCountQueryOptions, 16 + type SocialItemUri, 17 + } from "@/lib/constellation-queries"; 18 + import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 19 + import { useAuth } from "@/lib/useAuth"; 20 + 21 + type CommentType = "comment" | "reply"; 22 + 23 + interface CommentItemProps { 24 + backlink: BacklinkRecord; 25 + type: CommentType; 26 + subjectUri?: SocialItemUri; 27 + parentUri?: AtUri; 28 + onReply?: () => void; 29 + } 30 + 31 + export function CommentItem({ 32 + backlink, 33 + type, 34 + subjectUri, 35 + parentUri, 36 + onReply, 37 + }: CommentItemProps) { 38 + const { session } = useAuth(); 39 + const did = backlink.did; 40 + const rkey = asRkey(backlink.rkey); 41 + 42 + const isOwner = session?.info.sub === did; 43 + const uri = `at://${did}/${backlink.collection}/${rkey}` satisfies AtUri; 44 + 45 + const deleteCommentMutation = useDeleteCommentMutation(); 46 + const deleteReplyMutation = useDeleteReplyMutation(); 47 + 48 + const commentQuery = useQuery({ 49 + ...getCommentQueryOptions(did, rkey), 50 + enabled: type === "comment", 51 + }); 52 + 53 + const replyQuery = useQuery({ 54 + ...getReplyQueryOptions(did, rkey), 55 + enabled: type === "reply", 56 + }); 57 + 58 + const replyCountQuery = useQuery({ 59 + ...directReplyCountQueryOptions(uri), 60 + enabled: !!onReply, 61 + }); 62 + 63 + const { data: didDoc } = useQuery(didDocumentQueryOptions(did)); 64 + const handle = extractHandle(didDoc ?? null); 65 + 66 + const isLoading = 67 + type === "comment" ? commentQuery.isLoading : replyQuery.isLoading; 68 + const isError = 69 + type === "comment" ? commentQuery.isError : replyQuery.isError; 70 + const record = 71 + type === "comment" ? commentQuery.data?.comment : replyQuery.data?.reply; 72 + 73 + if (isLoading) { 74 + return <CommentSkeleton />; 75 + } 76 + 77 + if (isError || !record) { 78 + return ( 79 + <div className="py-3 text-sm text-gray-400 dark:text-gray-500 italic"> 80 + [Failed to load {type}] 81 + </div> 82 + ); 83 + } 84 + 85 + const handleDelete = () => { 86 + if (type === "comment" && subjectUri) { 87 + deleteCommentMutation.mutate({ rkey, subjectUri, did }); 88 + } else if (type === "reply" && parentUri) { 89 + deleteReplyMutation.mutate({ rkey, parentUri, did }); 90 + } 91 + }; 92 + 93 + const replyCount = replyCountQuery.data ?? 0; 94 + 95 + return ( 96 + <div className="py-3"> 97 + <div className="flex items-start gap-3"> 98 + <div className="flex-1 min-w-0"> 99 + <div className="flex items-center gap-2 text-sm"> 100 + <Link 101 + to="/profile/$did" 102 + params={{ did }} 103 + className="font-medium text-gray-900 dark:text-gray-100 hover:underline" 104 + > 105 + @{handle ?? did.slice(0, 16)} 106 + </Link> 107 + <span className="text-gray-400 dark:text-gray-500">·</span> 108 + <ClientDate 109 + dateString={record.createdAt} 110 + format="relative" 111 + className="text-gray-500 dark:text-gray-400" 112 + /> 113 + {record.updatedAt && record.updatedAt !== record.createdAt && ( 114 + <span className="text-gray-400 dark:text-gray-500 text-xs"> 115 + (edited) 116 + </span> 117 + )} 118 + </div> 119 + 120 + <div className="mt-1 text-gray-800 dark:text-gray-200"> 121 + <RichtextRenderer doc={record.content} /> 122 + </div> 123 + 124 + <div className="mt-2 flex items-center gap-4"> 125 + {onReply && ( 126 + <button 127 + type="button" 128 + onClick={onReply} 129 + className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200" 130 + > 131 + <MessageSquare className="w-4 h-4" /> 132 + {replyCount > 0 && <span>{replyCount}</span>} 133 + </button> 134 + )} 135 + 136 + {isOwner && 137 + ((type === "comment" && subjectUri) || 138 + (type === "reply" && parentUri)) && ( 139 + <button 140 + type="button" 141 + onClick={handleDelete} 142 + disabled={ 143 + deleteCommentMutation.isPending || 144 + deleteReplyMutation.isPending 145 + } 146 + className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400" 147 + > 148 + <Trash2 className="w-4 h-4" /> 149 + </button> 150 + )} 151 + </div> 152 + </div> 153 + </div> 154 + </div> 155 + ); 156 + } 157 + 158 + function CommentSkeleton() { 159 + return ( 160 + <div className="py-3"> 161 + <div className="flex items-start gap-3"> 162 + <div className="flex-1"> 163 + <div className="flex items-center gap-2"> 164 + <div className="h-4 w-24 bg-gray-200 dark:bg-slate-700 rounded animate-pulse" /> 165 + <div className="h-4 w-16 bg-gray-200 dark:bg-slate-700 rounded animate-pulse" /> 166 + </div> 167 + <div className="mt-2 space-y-2"> 168 + <div className="h-4 w-full bg-gray-200 dark:bg-slate-700 rounded animate-pulse" /> 169 + <div className="h-4 w-3/4 bg-gray-200 dark:bg-slate-700 rounded animate-pulse" /> 170 + </div> 171 + </div> 172 + </div> 173 + </div> 174 + ); 175 + }
+113
src/components/comments/CommentThread.tsx
··· 1 + import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 2 + import { useState } from "react"; 3 + import { type AtUri, asRkey } from "@/lib/atproto-client"; 4 + import type { BacklinkRecord } from "@/lib/constellation-client"; 5 + import { 6 + directRepliesQueryOptions, 7 + directReplyCountQueryOptions, 8 + type SocialItemUri, 9 + } from "@/lib/constellation-queries"; 10 + import { CommentItem } from "./CommentItem"; 11 + 12 + interface CommentThreadProps { 13 + backlink: BacklinkRecord; 14 + type: "comment" | "reply"; 15 + subjectUri: SocialItemUri; 16 + /** URI of the parent comment/reply (for deletion cache updates) */ 17 + parentUri?: AtUri; 18 + depth?: number; 19 + maxDepth?: number; 20 + } 21 + 22 + export function CommentThread({ 23 + backlink, 24 + type, 25 + subjectUri, 26 + parentUri, 27 + depth = 0, 28 + maxDepth = 10, 29 + }: CommentThreadProps) { 30 + const [showReplies, setShowReplies] = useState(depth < 2); 31 + const [showReplyForm, setShowReplyForm] = useState(false); 32 + 33 + const did = backlink.did; 34 + const rkey = asRkey(backlink.rkey); 35 + const uri = `at://${did}/${backlink.collection}/${rkey}` satisfies AtUri; 36 + 37 + const replyCountQuery = useQuery(directReplyCountQueryOptions(uri)); 38 + const replyCount = replyCountQuery.data ?? 0; 39 + 40 + const repliesQuery = useInfiniteQuery({ 41 + ...directRepliesQueryOptions(uri), 42 + enabled: showReplies, 43 + }); 44 + 45 + const replies = repliesQuery.data?.pages.flatMap((p) => p.records) ?? []; 46 + const hasReplies = replyCount > 0 || replies.length > 0; 47 + 48 + const handleReply = () => { 49 + setShowReplyForm(true); 50 + setShowReplies(true); 51 + }; 52 + 53 + return ( 54 + <div 55 + className={ 56 + depth > 0 ? "pl-4 border-l border-gray-200 dark:border-slate-700" : "" 57 + } 58 + > 59 + <CommentItem 60 + backlink={backlink} 61 + type={type} 62 + subjectUri={subjectUri} 63 + parentUri={parentUri} 64 + onReply={depth < maxDepth ? handleReply : undefined} 65 + /> 66 + 67 + {showReplyForm && ( 68 + <div className="pl-4 py-2"> 69 + <div className="text-sm text-gray-500 dark:text-gray-400 italic"> 70 + Reply form coming soon... 71 + </div> 72 + </div> 73 + )} 74 + 75 + {hasReplies && !showReplies && ( 76 + <button 77 + type="button" 78 + onClick={() => setShowReplies(true)} 79 + className="ml-4 text-sm text-blue-600 dark:text-blue-400 hover:underline" 80 + > 81 + {replyCount === 1 ? "Show 1 reply" : `Show ${replyCount} replies`} 82 + </button> 83 + )} 84 + 85 + {showReplies && replies.length > 0 && ( 86 + <div className="mt-1"> 87 + {replies.map((reply) => ( 88 + <CommentThread 89 + key={`${reply.did}/${reply.rkey}`} 90 + backlink={reply} 91 + type="reply" 92 + subjectUri={subjectUri} 93 + parentUri={uri} 94 + depth={depth + 1} 95 + maxDepth={maxDepth} 96 + /> 97 + ))} 98 + </div> 99 + )} 100 + 101 + {showReplies && repliesQuery.hasNextPage && ( 102 + <button 103 + type="button" 104 + onClick={() => repliesQuery.fetchNextPage()} 105 + disabled={repliesQuery.isFetchingNextPage} 106 + className="ml-4 text-sm text-blue-600 dark:text-blue-400 hover:underline" 107 + > 108 + {repliesQuery.isFetchingNextPage ? "Loading..." : "Load more replies"} 109 + </button> 110 + )} 111 + </div> 112 + ); 113 + }
+154
src/components/comments/CommentsPanel.tsx
··· 1 + import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 2 + import { MessageSquare, X } from "lucide-react"; 3 + import { useCallback } from "react"; 4 + import { generateRkey, useCreateCommentMutation } from "@/lib/comment-queries"; 5 + import { 6 + itemCommentCountQueryOptions, 7 + itemCommentsQueryOptions, 8 + type SocialItemType, 9 + type SocialItemUri, 10 + } from "@/lib/constellation-queries"; 11 + import type { ComDeckbelcherSocialComment } from "@/lib/lexicons/index"; 12 + import type { Document } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 13 + import { useAuth } from "@/lib/useAuth"; 14 + import { CommentForm } from "./CommentForm"; 15 + import { CommentThread } from "./CommentThread"; 16 + 17 + type CommentSubject = ComDeckbelcherSocialComment.Main["subject"]; 18 + 19 + interface CommentsPanelProps { 20 + subject: CommentSubject; 21 + subjectUri: SocialItemUri; 22 + itemType: SocialItemType; 23 + title?: string; 24 + onClose?: () => void; 25 + availableTags?: string[]; 26 + /** Tailwind max-height class for internal scrolling (default: max-h-[64rem]) */ 27 + maxHeight?: string; 28 + } 29 + 30 + export function CommentsPanel({ 31 + subject, 32 + subjectUri, 33 + itemType, 34 + title = "Comments", 35 + onClose, 36 + availableTags, 37 + maxHeight = "max-h-[64rem]", 38 + }: CommentsPanelProps) { 39 + const { session } = useAuth(); 40 + const createComment = useCreateCommentMutation(); 41 + 42 + const countQuery = useQuery( 43 + itemCommentCountQueryOptions(subjectUri, itemType), 44 + ); 45 + 46 + const commentsQuery = useInfiniteQuery( 47 + itemCommentsQueryOptions(subjectUri, itemType), 48 + ); 49 + 50 + const comments = commentsQuery.data?.pages.flatMap((p) => p.records) ?? []; 51 + const count = countQuery.data ?? 0; 52 + 53 + const handleSubmit = useCallback( 54 + (content: Document) => { 55 + if (!session) return; 56 + createComment.mutate({ 57 + record: { 58 + $type: "com.deckbelcher.social.comment", 59 + subject, 60 + content, 61 + createdAt: new Date().toISOString(), 62 + }, 63 + rkey: generateRkey(), 64 + }); 65 + }, 66 + [createComment, session, subject], 67 + ); 68 + 69 + return ( 70 + <div className={`flex flex-col ${maxHeight} bg-white dark:bg-slate-900`}> 71 + <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-slate-700"> 72 + <div className="flex items-center gap-2"> 73 + <MessageSquare className="w-5 h-5 text-gray-600 dark:text-gray-400" /> 74 + <h2 className="font-semibold text-gray-900 dark:text-gray-100"> 75 + {title} 76 + </h2> 77 + {count > 0 && ( 78 + <span className="text-sm text-gray-500 dark:text-gray-400"> 79 + ({count}) 80 + </span> 81 + )} 82 + </div> 83 + {onClose && ( 84 + <button 85 + type="button" 86 + onClick={onClose} 87 + className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-slate-800 text-gray-500 dark:text-gray-400" 88 + > 89 + <X className="w-5 h-5" /> 90 + </button> 91 + )} 92 + </div> 93 + 94 + <div className="flex-1 overflow-y-auto px-4"> 95 + {commentsQuery.isLoading && ( 96 + <div className="py-8 text-center text-gray-500 dark:text-gray-400"> 97 + Loading comments... 98 + </div> 99 + )} 100 + 101 + {!commentsQuery.isLoading && comments.length === 0 && ( 102 + <div className="py-8 text-center text-gray-500 dark:text-gray-400"> 103 + No comments yet. Be the first to comment! 104 + </div> 105 + )} 106 + 107 + {comments.length > 0 && ( 108 + <div className="divide-y divide-gray-100 dark:divide-slate-800"> 109 + {comments.map((comment) => ( 110 + <CommentThread 111 + key={`${comment.did}/${comment.rkey}`} 112 + backlink={comment} 113 + type="comment" 114 + subjectUri={subjectUri} 115 + /> 116 + ))} 117 + </div> 118 + )} 119 + 120 + {commentsQuery.hasNextPage && ( 121 + <div className="py-4 text-center"> 122 + <button 123 + type="button" 124 + onClick={() => commentsQuery.fetchNextPage()} 125 + disabled={commentsQuery.isFetchingNextPage} 126 + className="text-sm text-blue-600 dark:text-blue-400 hover:underline" 127 + > 128 + {commentsQuery.isFetchingNextPage 129 + ? "Loading..." 130 + : "Load more comments"} 131 + </button> 132 + </div> 133 + )} 134 + </div> 135 + 136 + {session && ( 137 + <div className="border-t border-gray-200 dark:border-slate-700 px-4 py-2"> 138 + <CommentForm 139 + onSubmit={handleSubmit} 140 + isPending={createComment.isPending} 141 + placeholder="Write a comment..." 142 + availableTags={availableTags} 143 + /> 144 + </div> 145 + )} 146 + 147 + {!session && ( 148 + <div className="border-t border-gray-200 dark:border-slate-700 px-4 py-3 text-center text-sm text-gray-500 dark:text-gray-400"> 149 + Sign in to comment 150 + </div> 151 + )} 152 + </div> 153 + ); 154 + }
+4
src/components/comments/index.ts
··· 1 + export { CommentForm } from "./CommentForm"; 2 + export { CommentItem } from "./CommentItem"; 3 + export { CommentsPanel } from "./CommentsPanel"; 4 + export { CommentThread } from "./CommentThread";
+38 -2
src/components/social/SocialStats.tsx
··· 1 - import { Bookmark, Heart, Rows3 } from "lucide-react"; 1 + import { useQuery } from "@tanstack/react-query"; 2 + import { Bookmark, Heart, MessageSquare, Rows3 } from "lucide-react"; 2 3 import { useState } from "react"; 3 4 import type { SaveItem } from "@/lib/collection-list-types"; 4 5 import type { SocialItemUri } from "@/lib/constellation-queries"; 5 - import { useItemSocialStats } from "@/lib/constellation-queries"; 6 + import { 7 + itemCommentCountQueryOptions, 8 + useItemSocialStats, 9 + } from "@/lib/constellation-queries"; 6 10 import { useLikeMutation } from "@/lib/like-queries"; 7 11 import { toOracleUri } from "@/lib/scryfall-types"; 8 12 import { useAuth } from "@/lib/useAuth"; ··· 14 18 item: SaveItem; 15 19 itemName?: string; 16 20 showCount?: boolean; 21 + /** Hide comment count (useful when comments section is always visible) */ 22 + hideCommentCount?: boolean; 17 23 className?: string; 24 + onCommentClick?: () => void; 18 25 } 19 26 20 27 function getItemUri(item: SaveItem): SocialItemUri { ··· 25 32 item, 26 33 itemName, 27 34 showCount = true, 35 + hideCommentCount = false, 28 36 className = "", 37 + onCommentClick, 29 38 }: SocialStatsProps) { 30 39 const { session } = useAuth(); 31 40 const [isDialogOpen, setIsDialogOpen] = useState(false); ··· 44 53 deckCount, 45 54 isDeckCountLoading, 46 55 } = useItemSocialStats(itemUri, item.type); 56 + 57 + const commentCountQuery = useQuery( 58 + itemCommentCountQueryOptions(itemUri, item.type), 59 + ); 60 + const commentCount = commentCountQuery.data ?? 0; 47 61 48 62 const handleSaveClick = () => { 49 63 if (session) { ··· 178 192 > 179 193 {deckCount} 180 194 </button> 195 + </div> 196 + )} 197 + 198 + {/* Comments button */} 199 + {onCommentClick && ( 200 + <div className="flex items-center"> 201 + <button 202 + type="button" 203 + onClick={onCommentClick} 204 + className={`${statBase} text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer transition-colors`} 205 + aria-label="Comments" 206 + title="Comments" 207 + > 208 + <MessageSquare className="w-5 h-5" /> 209 + </button> 210 + {showCount && !hideCommentCount && ( 211 + <span 212 + className={`text-sm tabular-nums px-1 py-2 text-gray-600 dark:text-gray-400 ${commentCountQuery.isLoading ? "opacity-50" : ""}`} 213 + > 214 + {commentCount} 215 + </span> 216 + )} 181 217 </div> 182 218 )} 183 219
+32 -9
src/lib/__tests__/optimistic-utils.test.ts
··· 1 1 import { QueryClient } from "@tanstack/react-query"; 2 2 import { beforeEach, describe, expect, it } from "vitest"; 3 - import type { BacklinksResponse } from "../constellation-client"; 3 + import type { 4 + BacklinkRecord, 5 + BacklinksResponse, 6 + } from "../constellation-client"; 4 7 import { 5 8 combineRollbacks, 6 9 optimisticBacklinks, ··· 290 293 did: "did:plc:test", 291 294 collection: "app.test.like", 292 295 rkey: "abc123", 293 - }; 296 + } satisfies BacklinkRecord; 294 297 295 298 it("adds record to first page", async () => { 296 299 const initial: BacklinksResponse = { 297 300 total: 2, 298 301 records: [ 299 - { did: "did:plc:other1", collection: "app.test.like", rkey: "xyz" }, 300 - { did: "did:plc:other2", collection: "app.test.like", rkey: "xyz" }, 302 + { 303 + did: "did:plc:other1", 304 + collection: "app.test.like", 305 + rkey: "xyz", 306 + } satisfies BacklinkRecord, 307 + { 308 + did: "did:plc:other2", 309 + collection: "app.test.like", 310 + rkey: "xyz", 311 + } satisfies BacklinkRecord, 301 312 ], 302 313 }; 303 314 queryClient.setQueryData(key, { ··· 330 341 total: 2, 331 342 records: [ 332 343 record, 333 - { did: "did:plc:other", collection: "app.test.like", rkey: "xyz" }, 344 + { 345 + did: "did:plc:other", 346 + collection: "app.test.like", 347 + rkey: "xyz", 348 + } satisfies BacklinkRecord, 334 349 ], 335 350 }; 336 351 queryClient.setQueryData(key, { ··· 354 369 did: "did:plc:test", 355 370 collection: "com.deckbelcher.collection.list", 356 371 rkey: "list1", 357 - }; 372 + } satisfies BacklinkRecord; 358 373 const list2 = { 359 374 did: "did:plc:test", 360 375 collection: "com.deckbelcher.collection.list", 361 376 rkey: "list2", 362 - }; 377 + } satisfies BacklinkRecord; 363 378 const initial: BacklinksResponse = { 364 379 total: 3, 365 380 records: [ 366 381 list1, 367 382 list2, 368 - { did: "did:plc:other", collection: "app.test.like", rkey: "xyz" }, 383 + { 384 + did: "did:plc:other", 385 + collection: "app.test.like", 386 + rkey: "xyz", 387 + } satisfies BacklinkRecord, 369 388 ], 370 389 }; 371 390 queryClient.setQueryData(key, { ··· 407 426 const initial: BacklinksResponse = { 408 427 total: 1, 409 428 records: [ 410 - { did: "did:plc:other", collection: "app.test.like", rkey: "xyz" }, 429 + { 430 + did: "did:plc:other", 431 + collection: "app.test.like", 432 + rkey: "xyz", 433 + } satisfies BacklinkRecord, 411 434 ], 412 435 }; 413 436 queryClient.setQueryData(key, {
+17 -3
src/lib/atproto-client.ts
··· 21 21 ComDeckbelcherSocialReply, 22 22 } from "./lexicons/index"; 23 23 24 - type AtUri = `at://${string}`; 24 + export type AtUri = `at://${string}`; 25 25 26 26 const SLINGSHOT_BASE = "https://slingshot.microcosm.blue"; 27 27 ··· 161 161 agent: OAuthUserAgent, 162 162 record: InferOutput<TSchema>, 163 163 schema: TSchema, 164 + rkey?: Rkey, 164 165 ): Promise<Result<{ uri: AtUri; cid: string; rkey: Rkey }>> { 165 166 const collection = getCollectionFromSchema(schema); 166 167 try { ··· 178 179 repo: agent.sub, 179 180 collection, 180 181 record: record as Record<string, unknown>, 182 + rkey, 181 183 }, 182 184 }); 183 185 ··· 587 589 export function createCommentRecord( 588 590 agent: OAuthUserAgent, 589 591 record: ComDeckbelcherSocialComment.Main, 592 + rkey?: Rkey, 590 593 ) { 591 - return createRecord(agent, record, ComDeckbelcherSocialComment.mainSchema); 594 + return createRecord( 595 + agent, 596 + record, 597 + ComDeckbelcherSocialComment.mainSchema, 598 + rkey, 599 + ); 592 600 } 593 601 594 602 export function updateCommentRecord( ··· 622 630 export function createReplyRecord( 623 631 agent: OAuthUserAgent, 624 632 record: ComDeckbelcherSocialReply.Main, 633 + rkey?: Rkey, 625 634 ) { 626 - return createRecord(agent, record, ComDeckbelcherSocialReply.mainSchema); 635 + return createRecord( 636 + agent, 637 + record, 638 + ComDeckbelcherSocialReply.mainSchema, 639 + rkey, 640 + ); 627 641 } 628 642 629 643 export function updateReplyRecord(
+397
src/lib/comment-queries.ts
··· 1 + /** 2 + * TanStack Query integration for comment and reply records. 3 + * - Query options for fetching record content (PDS) 4 + * - Mutations with optimistic updates 5 + * 6 + * Backlink queries (who commented, counts) are in constellation-queries.ts 7 + */ 8 + 9 + import type { Did } from "@atcute/lexicons"; 10 + import { now as createTid } from "@atcute/tid"; 11 + import { queryOptions, useQueryClient } from "@tanstack/react-query"; 12 + import { toast } from "sonner"; 13 + import { 14 + type AtUri, 15 + asRkey, 16 + createCommentRecord, 17 + createReplyRecord, 18 + deleteCommentRecord, 19 + deleteReplyRecord, 20 + getCommentRecord, 21 + getReplyRecord, 22 + type Rkey, 23 + updateCommentRecord, 24 + updateReplyRecord, 25 + } from "./atproto-client"; 26 + import { COMMENT_NSID, REPLY_NSID } from "./constellation-client"; 27 + import type { SocialItemUri } from "./constellation-queries"; 28 + import type { 29 + ComDeckbelcherSocialComment, 30 + ComDeckbelcherSocialReply, 31 + } from "./lexicons/index"; 32 + import { 33 + optimisticBacklinks, 34 + optimisticCount, 35 + optimisticRecord, 36 + runOptimistic, 37 + } from "./optimistic-utils"; 38 + import { useAuth } from "./useAuth"; 39 + import { useMutationWithToast } from "./useMutationWithToast"; 40 + 41 + type CommentRecord = ComDeckbelcherSocialComment.Main; 42 + type ReplyRecord = ComDeckbelcherSocialReply.Main; 43 + 44 + /** 45 + * Marker for optimistic CIDs that haven't been confirmed by the server. 46 + * Using a symbol ensures TypeScript will error if you try to use an 47 + * optimistic CID in a strongRef, and it can't be accidentally serialized. 48 + * 49 + * WARN: Do not use optimistic CIDs when creating replies - the strongRef 50 + * would be invalid. Compute real CID client-side or wait for server confirmation. 51 + */ 52 + const OPTIMISTIC_CID_SYMBOL = Symbol("optimistic-cid"); 53 + type OptimisticCid = { readonly [OPTIMISTIC_CID_SYMBOL]: true }; 54 + const OPTIMISTIC_CID: OptimisticCid = { [OPTIMISTIC_CID_SYMBOL]: true }; 55 + 56 + // ============================================================================ 57 + // Query Options (fetch record content from PDS) 58 + // ============================================================================ 59 + 60 + export interface CommentRecordData { 61 + comment: CommentRecord; 62 + cid: string | OptimisticCid; 63 + } 64 + 65 + export const getCommentQueryOptions = (did: Did, rkey: Rkey) => 66 + queryOptions({ 67 + queryKey: ["comment", did, rkey] as const, 68 + queryFn: async (): Promise<CommentRecordData> => { 69 + const result = await getCommentRecord(did, rkey); 70 + if (!result.success) { 71 + throw result.error; 72 + } 73 + return { 74 + comment: result.data.value, 75 + cid: result.data.cid, 76 + }; 77 + }, 78 + staleTime: 60 * 1000, 79 + }); 80 + 81 + export interface ReplyRecordData { 82 + reply: ReplyRecord; 83 + cid: string | OptimisticCid; 84 + } 85 + 86 + export const getReplyQueryOptions = (did: Did, rkey: Rkey) => 87 + queryOptions({ 88 + queryKey: ["reply", did, rkey] as const, 89 + queryFn: async (): Promise<ReplyRecordData> => { 90 + const result = await getReplyRecord(did, rkey); 91 + if (!result.success) { 92 + throw result.error; 93 + } 94 + return { 95 + reply: result.data.value, 96 + cid: result.data.cid, 97 + }; 98 + }, 99 + staleTime: 60 * 1000, 100 + }); 101 + 102 + // ============================================================================ 103 + // Mutations 104 + // ============================================================================ 105 + 106 + function getSubjectUri(subject: CommentRecord["subject"]): string | undefined { 107 + if ("ref" in subject) { 108 + const ref = subject.ref; 109 + if ("oracleUri" in ref) return ref.oracleUri; 110 + if ("uri" in ref) return ref.uri; 111 + } 112 + return undefined; 113 + } 114 + 115 + interface CreateCommentParams { 116 + record: CommentRecord; 117 + rkey: Rkey; 118 + } 119 + 120 + export function useCreateCommentMutation() { 121 + const queryClient = useQueryClient(); 122 + const { agent, session } = useAuth(); 123 + 124 + return useMutationWithToast({ 125 + mutationFn: async ({ record, rkey }: CreateCommentParams) => { 126 + if (!agent) throw new Error("Not authenticated"); 127 + const result = await createCommentRecord(agent, record, rkey); 128 + if (!result.success) throw result.error; 129 + return result.data; 130 + }, 131 + onMutate: async ({ record, rkey }) => { 132 + const subjectUri = getSubjectUri(record.subject); 133 + const userDid = session?.info.sub; 134 + if (!subjectUri || !userDid) return; 135 + 136 + const rollback = await runOptimistic([ 137 + optimisticCount( 138 + queryClient, 139 + ["constellation", "commentCount", subjectUri], 140 + 1, 141 + ), 142 + optimisticBacklinks( 143 + queryClient, 144 + ["constellation", "comments", subjectUri], 145 + "add", 146 + { 147 + did: userDid, 148 + collection: COMMENT_NSID, 149 + rkey, 150 + }, 151 + ), 152 + // Seed the comment record cache so CommentItem doesn't show skeleton 153 + optimisticRecord<CommentRecordData>( 154 + queryClient, 155 + ["comment", userDid, rkey], 156 + { comment: record, cid: OPTIMISTIC_CID }, 157 + ), 158 + ]); 159 + 160 + return { rollback }; 161 + }, 162 + onError: (_err, _params, context) => { 163 + context?.rollback(); 164 + }, 165 + onSuccess: () => { 166 + toast.success("Comment posted"); 167 + }, 168 + }); 169 + } 170 + 171 + /** Generate a TID for use as an rkey */ 172 + export function generateRkey(): Rkey { 173 + return asRkey(createTid()); 174 + } 175 + 176 + interface CreateReplyParams { 177 + record: ReplyRecord; 178 + rkey: Rkey; 179 + } 180 + 181 + export function useCreateReplyMutation() { 182 + const queryClient = useQueryClient(); 183 + const { agent, session } = useAuth(); 184 + 185 + return useMutationWithToast({ 186 + mutationFn: async ({ record, rkey }: CreateReplyParams) => { 187 + if (!agent) throw new Error("Not authenticated"); 188 + const result = await createReplyRecord(agent, record, rkey); 189 + if (!result.success) throw result.error; 190 + return result.data; 191 + }, 192 + onMutate: async ({ record, rkey }) => { 193 + const userDid = session?.info.sub; 194 + if (!userDid) return; 195 + 196 + const parentUri = record.parent.uri; 197 + 198 + const rollback = await runOptimistic([ 199 + optimisticCount( 200 + queryClient, 201 + ["constellation", "directReplyCount", parentUri], 202 + 1, 203 + ), 204 + optimisticBacklinks( 205 + queryClient, 206 + ["constellation", "directReplies", parentUri], 207 + "add", 208 + { 209 + did: userDid, 210 + collection: REPLY_NSID, 211 + rkey, 212 + }, 213 + ), 214 + // Seed the reply record cache so CommentItem doesn't show skeleton 215 + optimisticRecord<ReplyRecordData>( 216 + queryClient, 217 + ["reply", userDid, rkey], 218 + { reply: record, cid: OPTIMISTIC_CID }, 219 + ), 220 + ]); 221 + 222 + return { rollback }; 223 + }, 224 + onError: (_err, _params, context) => { 225 + context?.rollback(); 226 + }, 227 + onSuccess: () => { 228 + toast.success("Reply posted"); 229 + }, 230 + }); 231 + } 232 + 233 + interface DeleteCommentParams { 234 + rkey: Rkey; 235 + subjectUri: SocialItemUri; 236 + did: Did; 237 + } 238 + 239 + export function useDeleteCommentMutation() { 240 + const queryClient = useQueryClient(); 241 + const { agent } = useAuth(); 242 + 243 + return useMutationWithToast({ 244 + mutationFn: async ({ rkey }: DeleteCommentParams) => { 245 + if (!agent) throw new Error("Not authenticated"); 246 + const result = await deleteCommentRecord(agent, rkey); 247 + if (!result.success) throw result.error; 248 + return result.data; 249 + }, 250 + onMutate: async ({ rkey, subjectUri, did }) => { 251 + const rollback = await runOptimistic([ 252 + optimisticCount( 253 + queryClient, 254 + ["constellation", "commentCount", subjectUri], 255 + -1, 256 + ), 257 + optimisticBacklinks( 258 + queryClient, 259 + ["constellation", "comments", subjectUri], 260 + "remove", 261 + { 262 + did, 263 + collection: COMMENT_NSID, 264 + rkey, 265 + }, 266 + ), 267 + ]); 268 + 269 + return { rollback }; 270 + }, 271 + onError: (_err, _vars, context) => { 272 + context?.rollback(); 273 + }, 274 + onSuccess: () => { 275 + toast.success("Comment deleted"); 276 + }, 277 + }); 278 + } 279 + 280 + interface DeleteReplyParams { 281 + rkey: Rkey; 282 + parentUri: AtUri; 283 + did: Did; 284 + } 285 + 286 + export function useDeleteReplyMutation() { 287 + const queryClient = useQueryClient(); 288 + const { agent } = useAuth(); 289 + 290 + return useMutationWithToast({ 291 + mutationFn: async ({ rkey }: DeleteReplyParams) => { 292 + if (!agent) throw new Error("Not authenticated"); 293 + const result = await deleteReplyRecord(agent, rkey); 294 + if (!result.success) throw result.error; 295 + return result.data; 296 + }, 297 + onMutate: async ({ rkey, parentUri, did }) => { 298 + const rollback = await runOptimistic([ 299 + optimisticCount( 300 + queryClient, 301 + ["constellation", "directReplyCount", parentUri], 302 + -1, 303 + ), 304 + optimisticBacklinks( 305 + queryClient, 306 + ["constellation", "directReplies", parentUri], 307 + "remove", 308 + { 309 + did, 310 + collection: REPLY_NSID, 311 + rkey, 312 + }, 313 + ), 314 + ]); 315 + 316 + return { rollback }; 317 + }, 318 + onError: (_err, _vars, context) => { 319 + context?.rollback(); 320 + }, 321 + onSuccess: () => { 322 + toast.success("Reply deleted"); 323 + }, 324 + }); 325 + } 326 + 327 + interface UpdateCommentParams { 328 + did: Did; 329 + rkey: Rkey; 330 + record: CommentRecord; 331 + } 332 + 333 + export function useUpdateCommentMutation() { 334 + const queryClient = useQueryClient(); 335 + const { agent } = useAuth(); 336 + 337 + return useMutationWithToast({ 338 + mutationFn: async ({ rkey, record }: UpdateCommentParams) => { 339 + if (!agent) throw new Error("Not authenticated"); 340 + const result = await updateCommentRecord(agent, rkey, record); 341 + if (!result.success) throw result.error; 342 + return result.data; 343 + }, 344 + onMutate: async ({ did, rkey, record }) => { 345 + const rollback = await runOptimistic([ 346 + optimisticRecord<CommentRecordData>( 347 + queryClient, 348 + ["comment", did, rkey], 349 + (old) => (old ? { ...old, comment: record } : undefined), 350 + ), 351 + ]); 352 + return { rollback }; 353 + }, 354 + onError: (_err, _vars, context) => { 355 + context?.rollback(); 356 + }, 357 + onSuccess: () => { 358 + toast.success("Comment updated"); 359 + }, 360 + }); 361 + } 362 + 363 + interface UpdateReplyParams { 364 + did: Did; 365 + rkey: Rkey; 366 + record: ReplyRecord; 367 + } 368 + 369 + export function useUpdateReplyMutation() { 370 + const queryClient = useQueryClient(); 371 + const { agent } = useAuth(); 372 + 373 + return useMutationWithToast({ 374 + mutationFn: async ({ rkey, record }: UpdateReplyParams) => { 375 + if (!agent) throw new Error("Not authenticated"); 376 + const result = await updateReplyRecord(agent, rkey, record); 377 + if (!result.success) throw result.error; 378 + return result.data; 379 + }, 380 + onMutate: async ({ did, rkey, record }) => { 381 + const rollback = await runOptimistic([ 382 + optimisticRecord<ReplyRecordData>( 383 + queryClient, 384 + ["reply", did, rkey], 385 + (old) => (old ? { ...old, reply: record } : undefined), 386 + ), 387 + ]); 388 + return { rollback }; 389 + }, 390 + onError: (_err, _vars, context) => { 391 + context?.rollback(); 392 + }, 393 + onSuccess: () => { 394 + toast.success("Reply updated"); 395 + }, 396 + }); 397 + }
+2 -1
src/lib/constellation-client.ts
··· 3 3 * See .claude/CONSTELLATION.md for full API documentation 4 4 */ 5 5 6 + import type { Did } from "@atcute/lexicons"; 6 7 import { getCollectionFromSchema, type Result } from "./atproto-client"; 7 8 import { 8 9 ComDeckbelcherCollectionList, ··· 57 58 export const REPLY_PARENT_PATH = ".parent.uri"; 58 59 59 60 export interface BacklinkRecord { 60 - did: string; 61 + did: Did; 61 62 collection: string; 62 63 rkey: string; 63 64 }
+23
src/lib/constellation-queries.ts
··· 510 510 staleTime: 60 * 1000, 511 511 }); 512 512 } 513 + 514 + /** 515 + * Query for direct replies to a comment or reply. 516 + * Each node uses this to find its children. 517 + */ 518 + export function directRepliesQueryOptions(parentUri: string) { 519 + return infiniteQueryOptions({ 520 + queryKey: ["constellation", "directReplies", parentUri] as const, 521 + queryFn: async ({ pageParam }) => { 522 + const result = await getBacklinks({ 523 + subject: parentUri, 524 + source: buildSource(REPLY_NSID, REPLY_PARENT_PATH), 525 + limit: 50, 526 + cursor: pageParam, 527 + }); 528 + if (!result.success) throw result.error; 529 + return result.data; 530 + }, 531 + initialPageParam: undefined as string | undefined, 532 + getNextPageParam: (lastPage) => lastPage.cursor ?? undefined, 533 + staleTime: 30 * 1000, 534 + }); 535 + }
+24 -1
src/routes/card/$id.tsx
··· 2 2 import { createFileRoute, Link } from "@tanstack/react-router"; 3 3 import { useEffect, useRef, useState } from "react"; 4 4 import { CardImage } from "@/components/CardImage"; 5 + import { CommentsPanel } from "@/components/comments"; 5 6 import { ManaCost } from "@/components/ManaCost"; 6 7 import { OracleText } from "@/components/OracleText"; 7 8 import { SocialStats } from "@/components/social/SocialStats"; ··· 19 20 OracleId, 20 21 ScryfallId, 21 22 } from "@/lib/scryfall-types"; 22 - import { asOracleId, isScryfallId, toOracleUri } from "@/lib/scryfall-types"; 23 + import { 24 + asOracleId, 25 + isScryfallId, 26 + toOracleUri, 27 + toScryfallUri, 28 + } from "@/lib/scryfall-types"; 23 29 import { getImageUri } from "@/lib/scryfall-utils"; 24 30 25 31 const NOT_FOUND_META = { ··· 447 453 ) : null} 448 454 449 455 {card.legalities && <LegalityTable legalities={card.legalities} />} 456 + 457 + {card.oracle_id && ( 458 + <div className="border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden"> 459 + <CommentsPanel 460 + subject={{ 461 + $type: "com.deckbelcher.social.comment#cardSubject", 462 + ref: { 463 + oracleUri: toOracleUri(asOracleId(card.oracle_id)), 464 + scryfallUri: toScryfallUri(id), 465 + }, 466 + }} 467 + subjectUri={toOracleUri(asOracleId(card.oracle_id))} 468 + itemType="card" 469 + title={`Comments on ${card.name}`} 470 + /> 471 + </div> 472 + )} 450 473 </div> 451 474 </div> 452 475 </div>