👁️
5
fork

Configure Feed

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

finish basic replies

+158 -76
+26 -3
package-lock.json
··· 8 8 "hasInstallScript": true, 9 9 "dependencies": { 10 10 "@atcute/bluesky": "^3.2.8", 11 + "@atcute/cbor": "^2.3.0", 12 + "@atcute/cid": "^2.4.0", 11 13 "@atcute/client": "^4.0.5", 12 14 "@atcute/identity-resolver": "^1.1.4", 13 15 "@atcute/oauth-browser-client": "^2.0.1", ··· 143 145 "@atcute/lexicons": "^1.2.2" 144 146 } 145 147 }, 148 + "node_modules/@atcute/cbor": { 149 + "version": "2.3.0", 150 + "resolved": "https://registry.npmjs.org/@atcute/cbor/-/cbor-2.3.0.tgz", 151 + "integrity": "sha512-7G2AndkfYzIXMBOBqUPUWP6oIJJm77KY5nYzS4Mr5NNxnmnrBrXEQqp+seCE3X5TV8FUSWQK5YRTU87uPjafMQ==", 152 + "license": "0BSD", 153 + "dependencies": { 154 + "@atcute/cid": "^2.4.0", 155 + "@atcute/multibase": "^1.1.6", 156 + "@atcute/uint8array": "^1.0.6" 157 + } 158 + }, 159 + "node_modules/@atcute/cid": { 160 + "version": "2.4.0", 161 + "resolved": "https://registry.npmjs.org/@atcute/cid/-/cid-2.4.0.tgz", 162 + "integrity": "sha512-6+5u9MpUrgSRQ94z7vaIX4BYk8fYr2KXUBS+rrr2NhlPy8xam8nbTlmd3hvBbtpSwShbhRAE4tA5Ab7eYUp2Yw==", 163 + "license": "0BSD", 164 + "dependencies": { 165 + "@atcute/multibase": "^1.1.6", 166 + "@atcute/uint8array": "^1.0.6" 167 + } 168 + }, 146 169 "node_modules/@atcute/client": { 147 170 "version": "4.0.5", 148 171 "resolved": "https://registry.npmjs.org/@atcute/client/-/client-4.0.5.tgz", ··· 278 301 } 279 302 }, 280 303 "node_modules/@atcute/uint8array": { 281 - "version": "1.0.5", 282 - "resolved": "https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.0.5.tgz", 283 - "integrity": "sha512-XLWWxoR2HNl2qU+FCr0rp1APwJXci7HnzbOQLxK55OaMNBXZ19+xNC5ii4QCsThsDxa4JS/JTzuiQLziITWf2Q==", 304 + "version": "1.0.6", 305 + "resolved": "https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.0.6.tgz", 306 + "integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==", 284 307 "license": "0BSD" 285 308 }, 286 309 "node_modules/@atcute/util-fetch": {
+2
package.json
··· 23 23 }, 24 24 "dependencies": { 25 25 "@atcute/bluesky": "^3.2.8", 26 + "@atcute/cbor": "^2.3.0", 27 + "@atcute/cid": "^2.4.0", 26 28 "@atcute/client": "^4.0.5", 27 29 "@atcute/identity-resolver": "^1.1.4", 28 30 "@atcute/oauth-browser-client": "^2.0.1",
+78 -22
src/components/comments/CommentItem.tsx
··· 1 1 import { useQuery } from "@tanstack/react-query"; 2 2 import { Link } from "@tanstack/react-router"; 3 3 import { MessageSquare, Trash2 } from "lucide-react"; 4 + import { useCallback, useState } from "react"; 4 5 import { ClientDate } from "@/components/ClientDate"; 5 6 import { RichtextRenderer } from "@/components/richtext/RichtextRenderer"; 6 7 import { type AtUri, asRkey } from "@/lib/atproto-client"; 7 8 import { 9 + generateRkey, 8 10 getCommentQueryOptions, 9 11 getReplyQueryOptions, 12 + useCreateReplyMutation, 10 13 useDeleteCommentMutation, 11 14 useDeleteReplyMutation, 12 15 } from "@/lib/comment-queries"; ··· 16 19 type SocialItemUri, 17 20 } from "@/lib/constellation-queries"; 18 21 import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 22 + import type { Document } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 19 23 import { useAuth } from "@/lib/useAuth"; 24 + import { CommentForm } from "./CommentForm"; 20 25 21 26 type CommentType = "comment" | "reply"; 22 27 ··· 25 30 type: CommentType; 26 31 subjectUri?: SocialItemUri; 27 32 parentUri?: AtUri; 28 - onReply?: () => void; 29 33 } 30 34 31 35 export function CommentItem({ ··· 33 37 type, 34 38 subjectUri, 35 39 parentUri, 36 - onReply, 37 40 }: CommentItemProps) { 38 41 const { session } = useAuth(); 42 + const [showReplyForm, setShowReplyForm] = useState(false); 43 + const createReply = useCreateReplyMutation(); 44 + 39 45 const did = backlink.did; 40 46 const rkey = asRkey(backlink.rkey); 41 47 ··· 55 61 enabled: type === "reply", 56 62 }); 57 63 58 - const replyCountQuery = useQuery({ 59 - ...directReplyCountQueryOptions(uri), 60 - enabled: !!onReply, 61 - }); 64 + const replyCountQuery = useQuery(directReplyCountQueryOptions(uri)); 62 65 63 66 const { data: didDoc } = useQuery(didDocumentQueryOptions(did)); 64 67 const handle = extractHandle(didDoc ?? null); ··· 69 72 type === "comment" ? commentQuery.isError : replyQuery.isError; 70 73 const record = 71 74 type === "comment" ? commentQuery.data?.comment : replyQuery.data?.reply; 75 + const cid = 76 + type === "comment" ? commentQuery.data?.cid : replyQuery.data?.cid; 77 + const rootRef = 78 + type === "comment" && cid ? { uri, cid } : replyQuery.data?.reply.root; 79 + const replyCount = replyCountQuery.data ?? 0; 80 + 81 + const handleReplySubmit = useCallback( 82 + (content: Document) => { 83 + if (!session || !cid || !rootRef) return; 84 + 85 + createReply.mutate( 86 + { 87 + record: { 88 + $type: "com.deckbelcher.social.reply", 89 + parent: { uri, cid }, 90 + root: rootRef, 91 + content, 92 + createdAt: new Date().toISOString(), 93 + }, 94 + rkey: generateRkey(), 95 + }, 96 + { 97 + onSuccess: () => setShowReplyForm(false), 98 + }, 99 + ); 100 + }, 101 + [session, uri, cid, rootRef, createReply], 102 + ); 103 + 104 + const handleDelete = useCallback(() => { 105 + if (type === "comment" && subjectUri) { 106 + deleteCommentMutation.mutate({ rkey, subjectUri, did }); 107 + } else if (type === "reply" && parentUri) { 108 + deleteReplyMutation.mutate({ rkey, parentUri, did }); 109 + } 110 + }, [ 111 + type, 112 + subjectUri, 113 + parentUri, 114 + rkey, 115 + did, 116 + deleteCommentMutation, 117 + deleteReplyMutation, 118 + ]); 72 119 73 120 if (isLoading) { 74 121 return <CommentSkeleton />; ··· 81 128 </div> 82 129 ); 83 130 } 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 131 95 132 return ( 96 133 <div className="py-3"> ··· 122 159 </div> 123 160 124 161 <div className="mt-2 flex items-center gap-4"> 125 - {onReply && ( 162 + {session ? ( 126 163 <button 127 164 type="button" 128 - onClick={onReply} 165 + onClick={() => setShowReplyForm(!showReplyForm)} 129 166 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 167 > 131 168 <MessageSquare className="w-4 h-4" /> 132 169 {replyCount > 0 && <span>{replyCount}</span>} 133 170 </button> 171 + ) : ( 172 + replyCount > 0 && ( 173 + <span className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400"> 174 + <MessageSquare className="w-4 h-4" /> 175 + <span>{replyCount}</span> 176 + </span> 177 + ) 134 178 )} 135 179 136 180 {isOwner && ··· 149 193 </button> 150 194 )} 151 195 </div> 196 + 197 + {showReplyForm && ( 198 + <div className="mt-3"> 199 + <CommentForm 200 + onSubmit={handleReplySubmit} 201 + onCancel={() => setShowReplyForm(false)} 202 + isPending={createReply.isPending} 203 + placeholder="Write a reply..." 204 + submitLabel="Reply" 205 + /> 206 + </div> 207 + )} 152 208 </div> 153 209 </div> 154 210 </div> ··· 161 217 <div className="flex items-start gap-3"> 162 218 <div className="flex-1"> 163 219 <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" /> 220 + <div className="h-4 w-24 bg-gray-200 dark:bg-slate-700 rounded motion-safe:animate-pulse" /> 221 + <div className="h-4 w-16 bg-gray-200 dark:bg-slate-700 rounded motion-safe:animate-pulse" /> 166 222 </div> 167 223 <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" /> 224 + <div className="h-4 w-full bg-gray-200 dark:bg-slate-700 rounded motion-safe:animate-pulse" /> 225 + <div className="h-4 w-3/4 bg-gray-200 dark:bg-slate-700 rounded motion-safe:animate-pulse" /> 170 226 </div> 171 227 </div> 172 228 </div>
-18
src/components/comments/CommentThread.tsx
··· 16 16 /** URI of the parent comment/reply (for deletion cache updates) */ 17 17 parentUri?: AtUri; 18 18 depth?: number; 19 - maxDepth?: number; 20 19 } 21 20 22 21 export function CommentThread({ ··· 25 24 subjectUri, 26 25 parentUri, 27 26 depth = 0, 28 - maxDepth = 10, 29 27 }: CommentThreadProps) { 30 28 const [showReplies, setShowReplies] = useState(depth < 2); 31 - const [showReplyForm, setShowReplyForm] = useState(false); 32 29 33 30 const did = backlink.did; 34 31 const rkey = asRkey(backlink.rkey); ··· 45 42 const replies = repliesQuery.data?.pages.flatMap((p) => p.records) ?? []; 46 43 const hasReplies = replyCount > 0 || replies.length > 0; 47 44 48 - const handleReply = () => { 49 - setShowReplyForm(true); 50 - setShowReplies(true); 51 - }; 52 - 53 45 return ( 54 46 <div 55 47 className={ ··· 61 53 type={type} 62 54 subjectUri={subjectUri} 63 55 parentUri={parentUri} 64 - onReply={depth < maxDepth ? handleReply : undefined} 65 56 /> 66 57 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 58 {hasReplies && !showReplies && ( 76 59 <button 77 60 type="button" ··· 92 75 subjectUri={subjectUri} 93 76 parentUri={uri} 94 77 depth={depth + 1} 95 - maxDepth={maxDepth} 96 78 /> 97 79 ))} 98 80 </div>
+30 -15
src/components/comments/CommentsPanel.tsx
··· 1 1 import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 2 2 import { MessageSquare, X } from "lucide-react"; 3 - import { useCallback } from "react"; 3 + import { useCallback, useState } from "react"; 4 4 import { generateRkey, useCreateCommentMutation } from "@/lib/comment-queries"; 5 5 import { 6 6 itemCommentCountQueryOptions, ··· 37 37 maxHeight = "max-h-[64rem]", 38 38 }: CommentsPanelProps) { 39 39 const { session } = useAuth(); 40 + const [showForm, setShowForm] = useState(false); 40 41 const createComment = useCreateCommentMutation(); 41 42 42 43 const countQuery = useQuery( ··· 53 54 const handleSubmit = useCallback( 54 55 (content: Document) => { 55 56 if (!session) return; 56 - createComment.mutate({ 57 - record: { 58 - $type: "com.deckbelcher.social.comment", 59 - subject, 60 - content, 61 - createdAt: new Date().toISOString(), 57 + createComment.mutate( 58 + { 59 + record: { 60 + $type: "com.deckbelcher.social.comment", 61 + subject, 62 + content, 63 + createdAt: new Date().toISOString(), 64 + }, 65 + rkey: generateRkey(), 62 66 }, 63 - rkey: generateRkey(), 64 - }); 67 + { onSuccess: () => setShowForm(false) }, 68 + ); 65 69 }, 66 70 [createComment, session, subject], 67 71 ); ··· 135 139 136 140 {session && ( 137 141 <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 - /> 142 + {showForm ? ( 143 + <CommentForm 144 + onSubmit={handleSubmit} 145 + onCancel={() => setShowForm(false)} 146 + isPending={createComment.isPending} 147 + placeholder="Write a comment..." 148 + availableTags={availableTags} 149 + /> 150 + ) : ( 151 + <button 152 + type="button" 153 + onClick={() => setShowForm(true)} 154 + className="w-full text-left px-3 py-2 text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-slate-800 rounded-md hover:bg-gray-100 dark:hover:bg-slate-700" 155 + > 156 + Write a comment... 157 + </button> 158 + )} 144 159 </div> 145 160 )} 146 161
+14
src/lib/atproto-client.ts
··· 4 4 */ 5 5 6 6 import type {} from "@atcute/atproto"; 7 + import { encode as encodeCbor } from "@atcute/cbor"; 8 + import { 9 + CODEC_DCBOR, 10 + toString as cidToString, 11 + create as createCid, 12 + } from "@atcute/cid"; 7 13 import { Client } from "@atcute/client"; 8 14 import type { Did } from "@atcute/lexicons"; 9 15 import { ··· 527 533 .replace(/=/g, ""); 528 534 529 535 return asRkey(base64url); 536 + } 537 + 538 + export async function computeRecordCid<T extends { $type: string }>( 539 + record: T, 540 + ): Promise<string> { 541 + const bytes = encodeCbor(record); 542 + const cid = await createCid(CODEC_DCBOR, bytes); 543 + return cidToString(cid); 530 544 } 531 545 532 546 export async function createLikeRecord(
+8 -18
src/lib/comment-queries.ts
··· 13 13 import { 14 14 type AtUri, 15 15 asRkey, 16 + computeRecordCid, 16 17 createCommentRecord, 17 18 createReplyRecord, 18 19 deleteCommentRecord, ··· 40 41 41 42 type CommentRecord = ComDeckbelcherSocialComment.Main; 42 43 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 44 56 45 // ============================================================================ 57 46 // Query Options (fetch record content from PDS) ··· 59 48 60 49 export interface CommentRecordData { 61 50 comment: CommentRecord; 62 - cid: string | OptimisticCid; 51 + cid: string; 63 52 } 64 53 65 54 export const getCommentQueryOptions = (did: Did, rkey: Rkey) => ··· 80 69 81 70 export interface ReplyRecordData { 82 71 reply: ReplyRecord; 83 - cid: string | OptimisticCid; 72 + cid: string; 84 73 } 85 74 86 75 export const getReplyQueryOptions = (did: Did, rkey: Rkey) => ··· 133 122 const userDid = session?.info.sub; 134 123 if (!subjectUri || !userDid) return; 135 124 125 + const cid = await computeRecordCid(record); 126 + 136 127 const rollback = await runOptimistic([ 137 128 optimisticCount( 138 129 queryClient, ··· 149 140 rkey, 150 141 }, 151 142 ), 152 - // Seed the comment record cache so CommentItem doesn't show skeleton 153 143 optimisticRecord<CommentRecordData>( 154 144 queryClient, 155 145 ["comment", userDid, rkey], 156 - { comment: record, cid: OPTIMISTIC_CID }, 146 + { comment: record, cid }, 157 147 ), 158 148 ]); 159 149 ··· 194 184 if (!userDid) return; 195 185 196 186 const parentUri = record.parent.uri; 187 + const cid = await computeRecordCid(record); 197 188 198 189 const rollback = await runOptimistic([ 199 190 optimisticCount( ··· 211 202 rkey, 212 203 }, 213 204 ), 214 - // Seed the reply record cache so CommentItem doesn't show skeleton 215 205 optimisticRecord<ReplyRecordData>( 216 206 queryClient, 217 207 ["reply", userDid, rkey], 218 - { reply: record, cid: OPTIMISTIC_CID }, 208 + { reply: record, cid }, 219 209 ), 220 210 ]); 221 211