Listen to and share the music in the Atmosphere. musicsky.up.railway.app/
nextjs atproto music typescript react
3
fork

Configure Feed

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

feat: comments prototype

Signed-off-by: mejsiejdev <mejsiejdev@gmail.com>

authored by

mejsiejdev and committed by tangled.org 56e69c27 ed3e31e3

+657 -102
+26
apps/appview/src/db/migrations.ts
··· 130 130 await db.schema.alterTable("comment").dropColumn("cid").execute(); 131 131 }, 132 132 }, 133 + 134 + "004": { 135 + async up(db: Kysely<unknown>) { 136 + await db.schema 137 + .alterTable("comment") 138 + .addColumn("deleted", "integer", (col) => col.notNull().defaultTo(0)) 139 + .execute(); 140 + await db.schema 141 + .alterTable("comment") 142 + .addColumn("parent_cid", "text", (col) => 143 + col.notNull().defaultTo(""), 144 + ) 145 + .execute(); 146 + await db.schema 147 + .createIndex("idx_comment_parent_uri") 148 + .on("comment") 149 + .column("parent_uri") 150 + .execute(); 151 + }, 152 + 153 + async down(db: Kysely<unknown>) { 154 + await db.schema.dropIndex("idx_comment_parent_uri").execute(); 155 + await db.schema.alterTable("comment").dropColumn("parent_cid").execute(); 156 + await db.schema.alterTable("comment").dropColumn("deleted").execute(); 157 + }, 158 + }, 133 159 }; 134 160 135 161 export function getMigrator() {
+2
apps/appview/src/db/schema.ts
··· 39 39 rkey: string; 40 40 subject_uri: string; 41 41 parent_uri: string; 42 + parent_cid: string; 42 43 text: string; 43 44 created_at: string; 45 + deleted: number; 44 46 } 45 47 46 48 export interface DatabaseSchema {
+10 -1
apps/appview/src/routes/comments.ts
··· 33 33 "c.did", 34 34 "c.text", 35 35 "c.created_at", 36 + "c.parent_uri", 37 + "c.parent_cid", 38 + "c.subject_uri", 39 + "c.deleted", 36 40 "i.handle", 37 41 "i.pds", 38 42 ]) ··· 63 67 .selectFrom("comment") 64 68 .select((eb) => eb.fn.count<number>("uri").as("count")) 65 69 .where("subject_uri", "=", uri) 70 + .where("deleted", "=", 0) 66 71 .executeTakeFirstOrThrow(), 67 72 ]); 68 73 ··· 71 76 const comments: CommentView[] = rows.map((row) => ({ 72 77 uri: row.uri, 73 78 cid: row.cid, 74 - text: row.text, 79 + text: row.deleted ? "" : row.text, 75 80 author: { 76 81 did: row.did, 77 82 handle: row.handle, 78 83 pds: row.pds, 79 84 }, 80 85 createdAt: row.created_at, 86 + ...(row.parent_uri !== row.subject_uri 87 + ? { parent: { uri: row.parent_uri, cid: row.parent_cid } } 88 + : {}), 89 + ...(row.deleted ? { deleted: true } : {}), 81 90 })); 82 91 83 92 const lastRow = rows[rows.length - 1];
+7 -3
apps/appview/src/tap/consumer.test.ts
··· 258 258 expect(row?.parent_uri).toBe( 259 259 "at://did:plc:abc/app.musicsky.temp.song/3jui7kd2zcszw", 260 260 ); 261 + expect(row?.parent_cid).toBe("bafyreiabc"); 262 + expect(row?.deleted).toBe(0); 261 263 }); 262 264 263 - it("deletes a comment on delete", async () => { 265 + it("soft-deletes a comment on delete", async () => { 264 266 await handleComment( 265 267 { 266 268 action: "create", ··· 296 298 db, 297 299 ); 298 300 299 - const rows = await db.selectFrom("comment").selectAll().execute(); 300 - expect(rows).toHaveLength(0); 301 + const row = await db.selectFrom("comment").selectAll().executeTakeFirst(); 302 + expect(row).toBeDefined(); 303 + expect(row?.deleted).toBe(1); 304 + expect(row?.text).toBe(""); 301 305 }); 302 306 303 307 it("is idempotent — second insert with same URI is ignored", async () => {
+11 -2
apps/appview/src/tap/consumer.ts
··· 122 122 const uri = AtUri.make(evt.did, evt.collection, evt.rkey).toString(); 123 123 124 124 if (evt.action === "delete") { 125 - await db.deleteFrom("comment").where("uri", "=", uri).execute(); 125 + await db 126 + .updateTable("comment") 127 + .set({ deleted: 1, text: "" }) 128 + .where("uri", "=", uri) 129 + .execute(); 126 130 return; 127 131 } 128 132 129 133 if (!evt.record || !evt.cid) return; 130 134 131 135 const reply = evt.record["reply"] as 132 - | { root?: { uri: string }; parent?: { uri: string } } 136 + | { 137 + root?: { uri: string; cid?: string }; 138 + parent?: { uri: string; cid?: string }; 139 + } 133 140 | undefined; 134 141 const text = evt.record["text"] as string | undefined; 135 142 if (!reply?.root?.uri || !reply?.parent?.uri || !text) return; ··· 143 150 rkey: evt.rkey, 144 151 subject_uri: reply.root.uri, 145 152 parent_uri: reply.parent.uri, 153 + parent_cid: reply.parent.cid ?? "", 146 154 text, 147 155 created_at: getCreatedAtFromRkey(evt.rkey), 156 + deleted: 0, 148 157 }) 149 158 .onConflict((oc) => oc.column("uri").doNothing()) 150 159 .execute();
+2
apps/appview/src/types/comments.ts
··· 6 6 text: string; 7 7 author: AuthorView; 8 8 createdAt: string; 9 + parent?: { uri: string; cid: string }; 10 + deleted?: boolean; 9 11 } 10 12 11 13 export interface CommentsOutput {
+6
apps/web/src/app/(main)/[handle]/[rkey]/song-view.tsx
··· 7 7 import { getSession } from "@/lib/auth/session"; 8 8 import { 9 9 getDid, 10 + getHandleFromDid, 10 11 getPds, 11 12 getUserInteractions, 12 13 mapRecordToSong, ··· 60 61 } 61 62 62 63 const isOwner = session?.did === song.uri.split("/")[2]; 64 + const userHandle = session 65 + ? await getHandleFromDid(session.did) 66 + : undefined; 63 67 64 68 return ( 65 69 <> ··· 75 79 cid={song.cid} 76 80 songTitle={song.title} 77 81 isLoggedIn={session !== null} 82 + userDid={session?.did} 83 + userHandle={userHandle} 78 84 /> 79 85 </> 80 86 );
+30 -1
apps/web/src/components/comment/actions.ts
··· 13 13 const text = (formData.get("text") as string).trim(); 14 14 const trackUri = formData.get("trackUri") as string; 15 15 const trackCid = formData.get("trackCid") as string; 16 + const parentUri = formData.get("parentUri") as string | null; 17 + const parentCid = formData.get("parentCid") as string | null; 16 18 17 19 if (!text || text.length === 0) { 18 20 return fail(new Error("Comment cannot be empty.")); ··· 31 33 32 34 try { 33 35 const trackRef = { uri: trackUri, cid: trackCid }; 36 + const parentRef = 37 + parentUri && parentCid 38 + ? { uri: parentUri, cid: parentCid } 39 + : trackRef; 34 40 35 41 await agent.com.atproto.repo.createRecord({ 36 42 repo: agent.assertDid, ··· 40 46 text, 41 47 reply: { 42 48 root: trackRef, 43 - parent: trackRef, 49 + parent: parentRef, 44 50 }, 45 51 createdAt: new Date().toISOString(), 46 52 }, ··· 53 59 return fail(error); 54 60 } 55 61 } 62 + 63 + export async function deleteComment( 64 + commentUri: string, 65 + trackUri: string, 66 + ): Promise<ActionResult> { 67 + const session = await requireSession(); 68 + const agent = new Agent(session); 69 + const rkey = commentUri.split("/").pop()!; 70 + 71 + try { 72 + await agent.com.atproto.repo.deleteRecord({ 73 + repo: agent.assertDid, 74 + collection: COLLECTIONS.comment, 75 + rkey, 76 + }); 77 + 78 + updateTag(`comments-${trackUri}`); 79 + return ok(); 80 + } catch (error) { 81 + console.error("Failed to delete comment:", error); 82 + return fail(error); 83 + } 84 + }
+41 -8
apps/web/src/components/comment/comment-input.tsx
··· 1 1 "use client"; 2 2 3 - import { startTransition, useActionState, useRef, useState } from "react"; 4 - import { Loader2Icon, MessageCirclePlusIcon } from "lucide-react"; 3 + import { startTransition, useActionState, useState } from "react"; 4 + import { Loader2Icon, MessageCirclePlusIcon, XIcon } from "lucide-react"; 5 5 import { Textarea } from "@/components/ui/textarea"; 6 6 import { Button } from "@/components/ui/button"; 7 7 import type { ActionResult } from "@/lib/action-result"; ··· 15 15 songTitle, 16 16 onClose, 17 17 onCommentPosted, 18 + parentUri, 19 + parentCid, 20 + replyToHandle, 18 21 }: { 19 22 uri: string; 20 23 cid: string; 21 24 songTitle: string; 22 25 onClose: () => void; 23 - onCommentPosted?: () => void; 26 + onCommentPosted?: (text: string) => void; 27 + parentUri?: string; 28 + parentCid?: string; 29 + replyToHandle?: string; 24 30 }) { 25 31 const [text, setText] = useState(""); 26 - const textareaRef = useRef<HTMLTextAreaElement>(null); 27 32 28 33 const [state, action, pending] = useActionState( 29 34 async (prevState: ActionResult | null, formData: FormData) => { 35 + const submittedText = (formData.get("text") as string).trim(); 30 36 const result = await createComment(prevState, formData); 31 37 if (result.success) { 32 38 setText(""); 33 39 onClose(); 34 - onCommentPosted?.(); 40 + onCommentPosted?.(submittedText); 35 41 } 36 42 return result; 37 43 }, ··· 46 52 formData.set("trackUri", uri); 47 53 formData.set("trackCid", cid); 48 54 55 + if (parentUri && parentCid) { 56 + formData.set("parentUri", parentUri); 57 + formData.set("parentCid", parentCid); 58 + } 59 + 49 60 startTransition(() => { 50 61 action(formData); 51 62 }); ··· 58 69 } 59 70 } 60 71 72 + const placeholder = replyToHandle 73 + ? `Reply to @${replyToHandle}...` 74 + : `Comment on ${songTitle}...`; 75 + 76 + const ariaLabel = replyToHandle 77 + ? `Reply to @${replyToHandle}` 78 + : `Comment on ${songTitle}`; 79 + 61 80 return ( 62 81 <div className="flex flex-col gap-2"> 82 + {replyToHandle && ( 83 + <div className="flex flex-row items-center gap-2"> 84 + <span className="text-xs text-muted-foreground"> 85 + Replying to @{replyToHandle} 86 + </span> 87 + <Button 88 + variant="ghost" 89 + size="sm" 90 + className="h-5 w-5 p-0" 91 + onClick={onClose} 92 + > 93 + <XIcon className="size-3" /> 94 + </Button> 95 + </div> 96 + )} 63 97 {state && !state.success && ( 64 98 <p className="text-sm text-destructive">{state.error}</p> 65 99 )} 66 100 <div className="flex flex-row items-end gap-2"> 67 101 <Textarea 68 - ref={textareaRef} 69 102 value={text} 70 103 onChange={(event) => setText(event.target.value)} 71 104 onKeyDown={handleKeyDown} 72 105 maxLength={MAX_LENGTH} 73 - placeholder={`Comment on ${songTitle}...`} 74 - aria-label={`Comment on ${songTitle}`} 106 + placeholder={placeholder} 107 + aria-label={ariaLabel} 75 108 disabled={pending} 76 109 className="min-h-10" 77 110 />
+173 -46
apps/web/src/components/comment/comment-section.tsx
··· 16 16 17 17 interface CommentView { 18 18 uri: string; 19 + cid: string; 19 20 text: string; 20 21 author: CommentAuthor; 21 22 createdAt: string; 23 + parent?: { uri: string; cid: string }; 24 + deleted?: boolean; 22 25 } 23 26 24 27 interface CommentsResponse { ··· 27 30 totalCount: number; 28 31 } 29 32 33 + interface CommentNode { 34 + comment: CommentView; 35 + children: CommentNode[]; 36 + } 37 + 38 + const VISIBLE_REPLIES = 3; 39 + 40 + function buildThread(comments: CommentView[]): CommentNode[] { 41 + const nodeMap = new Map<string, CommentNode>(); 42 + const roots: CommentNode[] = []; 43 + 44 + for (const comment of comments) { 45 + nodeMap.set(comment.uri, { comment, children: [] }); 46 + } 47 + 48 + for (const comment of comments) { 49 + const node = nodeMap.get(comment.uri)!; 50 + if (comment.parent) { 51 + const parentNode = nodeMap.get(comment.parent.uri); 52 + if (parentNode) { 53 + parentNode.children.push(node); 54 + continue; 55 + } 56 + } 57 + roots.push(node); 58 + } 59 + 60 + return pruneDeletedLeaves(roots); 61 + } 62 + 63 + function pruneDeletedLeaves(nodes: CommentNode[]): CommentNode[] { 64 + return nodes.filter((node) => { 65 + node.children = pruneDeletedLeaves(node.children); 66 + return !node.comment.deleted || node.children.length > 0; 67 + }); 68 + } 69 + 30 70 async function fetchComments(url: string): Promise<CommentsResponse> { 31 71 const res = await fetch(url); 32 72 if (!res.ok) throw new Error("Failed to fetch comments"); ··· 38 78 cid, 39 79 songTitle, 40 80 isLoggedIn, 81 + userDid, 82 + userHandle, 41 83 }: { 42 84 uri: string; 43 85 cid: string | undefined; 44 86 songTitle: string; 45 87 isLoggedIn: boolean; 88 + userDid?: string; 89 + userHandle?: string; 46 90 }) { 47 - const [extraComments, setExtraComments] = useState<CommentView[]>([]); 48 - const [cursor, setCursor] = useState<string | undefined>(); 49 - const [loadingMore, setLoadingMore] = useState(false); 91 + const [replyTarget, setReplyTarget] = useState<{ 92 + uri: string; 93 + cid: string; 94 + handle: string; 95 + } | null>(null); 50 96 51 97 const { data, error, isLoading, mutate } = useSWR( 52 - `/api/comments?uri=${encodeURIComponent(uri)}&limit=20`, 98 + `/api/comments?uri=${encodeURIComponent(uri)}&limit=${MAX_LIMIT}`, 53 99 fetchComments, 100 + { refreshInterval: 60_000 }, 54 101 ); 55 102 56 - const handleCommentPosted = useCallback(() => { 57 - setExtraComments([]); 58 - setCursor(undefined); 103 + const handleCommentPosted = useCallback( 104 + (text: string) => { 105 + const parent = replyTarget; 106 + setReplyTarget(null); 107 + 108 + if (userDid && userHandle) { 109 + const optimisticComment: CommentView = { 110 + uri: `at://${userDid}/app.musicsky.temp.comment/${Date.now()}`, 111 + cid: "optimistic", 112 + text, 113 + author: { did: userDid, handle: userHandle, pds: "" }, 114 + createdAt: new Date().toISOString(), 115 + ...(parent ? { parent: { uri: parent.uri, cid: parent.cid } } : {}), 116 + }; 117 + 118 + void mutate( 119 + (current) => { 120 + if (!current) return current; 121 + return { 122 + ...current, 123 + totalCount: current.totalCount + 1, 124 + comments: [...current.comments, optimisticComment], 125 + }; 126 + }, 127 + { revalidate: true }, 128 + ); 129 + } else { 130 + void mutate(); 131 + } 132 + }, 133 + [mutate, replyTarget, userDid, userHandle], 134 + ); 135 + 136 + const handleReply = useCallback( 137 + (commentUri: string, commentCid: string, handle: string) => { 138 + setReplyTarget({ uri: commentUri, cid: commentCid, handle }); 139 + }, 140 + [], 141 + ); 142 + 143 + const handleDeleted = useCallback(() => { 59 144 void mutate(); 60 145 }, [mutate]); 61 146 62 - const initialCursor = data?.cursor; 63 - const activeCursor = cursor ?? initialCursor; 64 - 65 - async function handleLoadMore() { 66 - if (!activeCursor) return; 67 - setLoadingMore(true); 68 - try { 69 - const params = new URLSearchParams({ 70 - uri, 71 - limit: "20", 72 - cursor: activeCursor, 73 - }); 74 - const res = await fetchComments( 75 - `/api/comments?${params.toString()}`, 76 - ); 77 - setExtraComments((prev) => [...prev, ...res.comments]); 78 - setCursor(res.cursor); 79 - } finally { 80 - setLoadingMore(false); 81 - } 82 - } 83 - 84 - const allComments = [...(data?.comments ?? []), ...extraComments]; 147 + const allComments = data?.comments ?? []; 85 148 const totalCount = data?.totalCount ?? 0; 86 - const hasMore = Boolean(activeCursor) && allComments.length < totalCount; 149 + const threadRoots = allComments.length > 0 ? buildThread(allComments) : []; 87 150 88 151 return ( 89 152 <div className="flex flex-col gap-4"> ··· 101 164 uri={uri} 102 165 cid={cid} 103 166 songTitle={songTitle} 104 - onClose={() => {}} 167 + onClose={() => setReplyTarget(null)} 105 168 onCommentPosted={handleCommentPosted} 169 + parentUri={replyTarget?.uri} 170 + parentCid={replyTarget?.cid} 171 + replyToHandle={replyTarget?.handle} 106 172 /> 107 173 )} 108 174 ··· 132 198 </p> 133 199 )} 134 200 135 - {allComments.length > 0 && ( 136 - <div className="flex flex-col gap-4"> 137 - {allComments.map((comment) => ( 138 - <Comment 139 - key={comment.uri} 140 - text={comment.text} 141 - author={comment.author} 142 - createdAt={comment.createdAt} 201 + {threadRoots.length > 0 && ( 202 + <div className="flex flex-col"> 203 + {threadRoots.map((node) => ( 204 + <CommentThread 205 + key={node.comment.uri} 206 + node={node} 207 + trackUri={uri} 208 + userDid={userDid} 209 + isLoggedIn={isLoggedIn} 210 + onReply={handleReply} 211 + onDeleted={handleDeleted} 143 212 /> 144 213 ))} 145 214 </div> 146 215 )} 216 + </div> 217 + ); 218 + } 147 219 148 - {hasMore && ( 220 + const MAX_LIMIT = 50; 221 + 222 + function CommentThread({ 223 + node, 224 + trackUri, 225 + userDid, 226 + isLoggedIn, 227 + onReply, 228 + onDeleted, 229 + }: { 230 + node: CommentNode; 231 + trackUri: string; 232 + userDid?: string; 233 + isLoggedIn: boolean; 234 + onReply: (uri: string, cid: string, handle: string) => void; 235 + onDeleted: () => void; 236 + }) { 237 + const [expanded, setExpanded] = useState(false); 238 + const { comment, children } = node; 239 + 240 + const visibleChildren = expanded 241 + ? children 242 + : children.slice(0, VISIBLE_REPLIES); 243 + const hiddenCount = expanded ? 0 : children.length - VISIBLE_REPLIES; 244 + const hasVisibleChildren = visibleChildren.length > 0 || hiddenCount > 0; 245 + 246 + return ( 247 + <> 248 + <Comment 249 + uri={comment.uri} 250 + cid={comment.cid} 251 + text={comment.text} 252 + author={comment.author} 253 + createdAt={comment.createdAt} 254 + deleted={comment.deleted} 255 + isOwn={userDid === comment.author.did} 256 + isLoggedIn={isLoggedIn} 257 + trackUri={trackUri} 258 + showThreadLine={hasVisibleChildren} 259 + onReply={onReply} 260 + onDeleted={onDeleted} 261 + /> 262 + 263 + {visibleChildren.map((child) => ( 264 + <CommentThread 265 + key={child.comment.uri} 266 + node={child} 267 + trackUri={trackUri} 268 + userDid={userDid} 269 + isLoggedIn={isLoggedIn} 270 + onReply={onReply} 271 + onDeleted={onDeleted} 272 + /> 273 + ))} 274 + 275 + {hiddenCount > 0 && ( 149 276 <Button 150 - variant="outline" 277 + variant="ghost" 151 278 size="sm" 152 - onClick={() => void handleLoadMore()} 153 - disabled={loadingMore} 279 + className="h-7 text-xs text-muted-foreground" 280 + onClick={() => setExpanded(true)} 154 281 > 155 - {loadingMore ? "Loading..." : "Load more comments"} 282 + Show {hiddenCount} more {hiddenCount === 1 ? "reply" : "replies"} 156 283 </Button> 157 284 )} 158 - </div> 285 + </> 159 286 ); 160 287 }
+135 -37
apps/web/src/components/comment/comment.tsx
··· 1 1 "use client"; 2 2 3 + import { type ReactNode, useState } from "react"; 3 4 import Link from "next/link"; 4 5 import useSWR from "swr"; 5 - import { formatDistanceToNow } from "date-fns"; 6 + import { formatDistanceToNow, format } from "date-fns"; 7 + import { MessageCircleReplyIcon, TrashIcon, Loader2Icon } from "lucide-react"; 6 8 import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 7 9 import { 8 10 Tooltip, 9 11 TooltipContent, 10 12 TooltipTrigger, 11 13 } from "@/components/ui/tooltip"; 12 - import { format } from "date-fns"; 14 + import { Button } from "@/components/ui/button"; 15 + import { deleteComment } from "./actions"; 13 16 14 17 interface CommentAuthor { 15 18 did: string; ··· 36 39 } 37 40 38 41 export function Comment({ 42 + uri, 43 + cid, 39 44 text, 40 45 author, 41 46 createdAt, 47 + deleted, 48 + isOwn, 49 + isLoggedIn, 50 + trackUri, 51 + showThreadLine, 52 + onReply, 53 + onDeleted, 42 54 }: { 55 + uri: string; 56 + cid: string; 43 57 text: string; 44 58 author: CommentAuthor; 45 59 createdAt: string; 60 + deleted?: boolean; 61 + isOwn?: boolean; 62 + isLoggedIn?: boolean; 63 + trackUri: string; 64 + showThreadLine?: boolean; 65 + onReply?: (uri: string, cid: string, handle: string) => void; 66 + onDeleted?: () => void; 46 67 }) { 47 - const { data: profile } = useSWR(`profile-${author.did}`, () => 48 - fetchProfile(author.did), 68 + const [deleting, setDeleting] = useState(false); 69 + const { data: profile } = useSWR( 70 + deleted ? null : `profile-${author.did}`, 71 + () => fetchProfile(author.did), 49 72 ); 50 73 51 74 const createdAtDate = new Date(createdAt); 52 75 76 + async function handleDelete() { 77 + setDeleting(true); 78 + try { 79 + await deleteComment(uri, trackUri); 80 + onDeleted?.(); 81 + } finally { 82 + setDeleting(false); 83 + } 84 + } 85 + 86 + if (deleted) { 87 + return ( 88 + <CommentLayout 89 + showThreadLine={showThreadLine} 90 + avatar={ 91 + <Avatar size="lg"> 92 + <AvatarFallback>?</AvatarFallback> 93 + </Avatar> 94 + } 95 + > 96 + <p className="text-sm text-muted-foreground italic">[deleted]</p> 97 + </CommentLayout> 98 + ); 99 + } 100 + 53 101 return ( 54 - <div className="flex flex-row gap-3"> 55 - <Link href={`/${author.handle}`} className="shrink-0"> 56 - <Avatar size="lg"> 57 - {profile?.avatar && ( 58 - <AvatarImage src={profile.avatar} alt={author.handle} /> 59 - )} 60 - <AvatarFallback>{author.handle.slice(0, 2)}</AvatarFallback> 61 - </Avatar> 62 - </Link> 63 - <div className="flex flex-col gap-0.5 min-w-0"> 64 - <div className="flex flex-row items-center gap-2"> 65 - <Link 66 - href={`/${author.handle}`} 67 - className="text-sm font-medium hover:underline truncate" 102 + <CommentLayout 103 + showThreadLine={showThreadLine} 104 + avatar={ 105 + <Link href={`/${author.handle}`}> 106 + <Avatar size="lg"> 107 + {profile?.avatar && ( 108 + <AvatarImage src={profile.avatar} alt={author.handle} /> 109 + )} 110 + <AvatarFallback>{author.handle.slice(0, 2)}</AvatarFallback> 111 + </Avatar> 112 + </Link> 113 + } 114 + > 115 + <div className="flex flex-row items-center gap-2"> 116 + <Link 117 + href={`/${author.handle}`} 118 + className="text-sm font-medium hover:underline truncate" 119 + > 120 + {profile?.displayName ?? author.handle} 121 + </Link> 122 + <p className="text-sm text-muted-foreground">@{author.handle}</p> 123 + <p className="text-sm text-muted-foreground">·</p> 124 + <Tooltip> 125 + <TooltipTrigger asChild> 126 + <time 127 + dateTime={createdAt} 128 + className="text-xs text-muted-foreground shrink-0" 129 + > 130 + {formatDistanceToNow(createdAtDate, { 131 + addSuffix: true, 132 + })} 133 + </time> 134 + </TooltipTrigger> 135 + <TooltipContent>{format(createdAtDate, "PPP p")}</TooltipContent> 136 + </Tooltip> 137 + </div> 138 + <p className="text-sm wrap-break-word">{text}</p> 139 + <div className="flex flex-row items-center gap-1 -ml-2"> 140 + {isLoggedIn && ( 141 + <Button 142 + variant="ghost" 143 + size="sm" 144 + className="h-7 px-2 text-xs text-muted-foreground" 145 + onClick={() => onReply?.(uri, cid, author.handle)} 68 146 > 69 - {profile?.displayName ?? author.handle} 70 - </Link> 71 - <p className="text-sm text-muted-foreground">@{author.handle}</p> 72 - <p className="text-sm text-muted-foreground">·</p> 73 - <Tooltip> 74 - <TooltipTrigger asChild> 75 - <time 76 - dateTime={createdAt} 77 - className="text-xs text-muted-foreground shrink-0" 78 - > 79 - {formatDistanceToNow(createdAtDate, { 80 - addSuffix: true, 81 - })} 82 - </time> 83 - </TooltipTrigger> 84 - <TooltipContent>{format(createdAtDate, "PPP p")}</TooltipContent> 85 - </Tooltip> 86 - </div> 87 - <p className="text-sm break-words">{text}</p> 147 + <MessageCircleReplyIcon className="size-3.5" /> 148 + Reply 149 + </Button> 150 + )} 151 + {isOwn && ( 152 + <Button 153 + variant="ghost" 154 + size="sm" 155 + className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive" 156 + onClick={() => void handleDelete()} 157 + disabled={deleting} 158 + > 159 + {deleting ? ( 160 + <Loader2Icon className="size-3.5 animate-spin" /> 161 + ) : ( 162 + <TrashIcon className="size-3.5" /> 163 + )} 164 + </Button> 165 + )} 166 + </div> 167 + </CommentLayout> 168 + ); 169 + } 170 + 171 + function CommentLayout({ 172 + showThreadLine, 173 + avatar, 174 + children, 175 + }: { 176 + showThreadLine?: boolean; 177 + avatar: ReactNode; 178 + children: ReactNode; 179 + }) { 180 + return ( 181 + <div className="flex flex-row gap-4"> 182 + <div className="flex flex-col items-center"> 183 + {avatar} 184 + {showThreadLine && <div className="w-0.5 h-full pb-4 bg-primary" />} 88 185 </div> 186 + <div className="flex flex-col gap-1">{children}</div> 89 187 </div> 90 188 ); 91 189 }
+16 -2
packages/lexicons/defs/app/musicsky/temp/getComments.json
··· 52 52 }, 53 53 "commentView": { 54 54 "type": "object", 55 - "required": ["uri", "text", "author", "createdAt"], 55 + "required": ["uri", "cid", "text", "author", "createdAt"], 56 56 "properties": { 57 57 "uri": { "type": "string" }, 58 + "cid": { "type": "string" }, 58 59 "text": { "type": "string" }, 59 60 "author": { 60 61 "type": "ref", 61 62 "ref": "app.musicsky.temp.getFeed#authorView" 62 63 }, 63 - "createdAt": { "type": "string" } 64 + "createdAt": { "type": "string" }, 65 + "parent": { 66 + "type": "ref", 67 + "ref": "#parentRef" 68 + }, 69 + "deleted": { "type": "boolean" } 70 + } 71 + }, 72 + "parentRef": { 73 + "type": "object", 74 + "required": ["uri", "cid"], 75 + "properties": { 76 + "uri": { "type": "string" }, 77 + "cid": { "type": "string" } 64 78 } 65 79 } 66 80 }
+14
packages/lexicons/src/index.ts
··· 10 10 import { CID } from 'multiformats/cid' 11 11 import { type OmitKey, type Un$Typed } from './util.js' 12 12 import * as AppMusicskyTempComment from './types/app/musicsky/temp/comment.js' 13 + import * as AppMusicskyTempGetComments from './types/app/musicsky/temp/getComments.js' 13 14 import * as AppMusicskyTempGetFeed from './types/app/musicsky/temp/getFeed.js' 14 15 import * as AppMusicskyTempLike from './types/app/musicsky/temp/like.js' 15 16 import * as AppMusicskyTempPlaylist from './types/app/musicsky/temp/playlist.js' ··· 17 18 import * as AppMusicskyTempSong from './types/app/musicsky/temp/song.js' 18 19 19 20 export * as AppMusicskyTempComment from './types/app/musicsky/temp/comment.js' 21 + export * as AppMusicskyTempGetComments from './types/app/musicsky/temp/getComments.js' 20 22 export * as AppMusicskyTempGetFeed from './types/app/musicsky/temp/getFeed.js' 21 23 export * as AppMusicskyTempLike from './types/app/musicsky/temp/like.js' 22 24 export * as AppMusicskyTempPlaylist from './types/app/musicsky/temp/playlist.js' ··· 72 74 this.playlist = new AppMusicskyTempPlaylistRecord(client) 73 75 this.repost = new AppMusicskyTempRepostRecord(client) 74 76 this.song = new AppMusicskyTempSongRecord(client) 77 + } 78 + 79 + getComments( 80 + params?: AppMusicskyTempGetComments.QueryParams, 81 + opts?: AppMusicskyTempGetComments.CallOptions, 82 + ): Promise<AppMusicskyTempGetComments.Response> { 83 + return this._client.call( 84 + 'app.musicsky.temp.getComments', 85 + params, 86 + undefined, 87 + opts, 88 + ) 75 89 } 76 90 77 91 getFeed(
+98 -2
packages/lexicons/src/lexicons.ts
··· 26 26 text: { 27 27 type: 'string', 28 28 minLength: 1, 29 - maxLength: 5000, 30 - maxGraphemes: 1000, 29 + maxLength: 300, 30 + maxGraphemes: 300, 31 31 description: 'The comment text.', 32 32 }, 33 33 reply: { ··· 60 60 ref: 'lex:com.atproto.repo.strongRef', 61 61 description: 62 62 "Strong reference to the immediate parent comment, or to the track itself if it's a top-level comment.", 63 + }, 64 + }, 65 + }, 66 + }, 67 + }, 68 + AppMusicskyTempGetComments: { 69 + lexicon: 1, 70 + id: 'app.musicsky.temp.getComments', 71 + defs: { 72 + main: { 73 + type: 'query', 74 + description: 'Get comments for a track.', 75 + parameters: { 76 + type: 'params', 77 + required: ['uri'], 78 + properties: { 79 + uri: { 80 + type: 'string', 81 + description: 'AT URI of the track to get comments for.', 82 + }, 83 + limit: { 84 + type: 'integer', 85 + minimum: 1, 86 + maximum: 50, 87 + default: 20, 88 + description: 'Maximum number of results.', 89 + }, 90 + cursor: { 91 + type: 'string', 92 + description: 'Pagination cursor.', 93 + }, 94 + }, 95 + }, 96 + output: { 97 + encoding: 'application/json', 98 + schema: { 99 + type: 'object', 100 + required: ['comments', 'totalCount'], 101 + properties: { 102 + cursor: { 103 + type: 'string', 104 + }, 105 + totalCount: { 106 + type: 'integer', 107 + description: 'Total number of comments on this track.', 108 + }, 109 + comments: { 110 + type: 'array', 111 + items: { 112 + type: 'ref', 113 + ref: 'lex:app.musicsky.temp.getComments#commentView', 114 + }, 115 + }, 116 + }, 117 + }, 118 + }, 119 + }, 120 + commentView: { 121 + type: 'object', 122 + required: ['uri', 'cid', 'text', 'author', 'createdAt'], 123 + properties: { 124 + uri: { 125 + type: 'string', 126 + }, 127 + cid: { 128 + type: 'string', 129 + }, 130 + text: { 131 + type: 'string', 132 + }, 133 + author: { 134 + type: 'ref', 135 + ref: 'lex:app.musicsky.temp.getFeed#authorView', 136 + }, 137 + createdAt: { 138 + type: 'string', 139 + }, 140 + parent: { 141 + type: 'ref', 142 + ref: 'lex:app.musicsky.temp.getComments#parentRef', 143 + }, 144 + deleted: { 145 + type: 'boolean', 146 + }, 147 + }, 148 + }, 149 + parentRef: { 150 + type: 'object', 151 + required: ['uri', 'cid'], 152 + properties: { 153 + uri: { 154 + type: 'string', 155 + }, 156 + cid: { 157 + type: 'string', 63 158 }, 64 159 }, 65 160 }, ··· 406 501 407 502 export const ids = { 408 503 AppMusicskyTempComment: 'app.musicsky.temp.comment', 504 + AppMusicskyTempGetComments: 'app.musicsky.temp.getComments', 409 505 AppMusicskyTempGetFeed: 'app.musicsky.temp.getFeed', 410 506 AppMusicskyTempLike: 'app.musicsky.temp.like', 411 507 AppMusicskyTempPlaylist: 'app.musicsky.temp.playlist',
+86
packages/lexicons/src/types/app/musicsky/temp/getComments.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { HeadersMap, XRPCError } from '@atproto/xrpc' 5 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 6 + import { CID } from 'multiformats/cid' 7 + import { validate as _validate } from '../../../../lexicons' 8 + import { 9 + type $Typed, 10 + is$typed as _is$typed, 11 + type OmitKey, 12 + } from '../../../../util' 13 + import type * as AppMusicskyTempGetFeed from './getFeed.js' 14 + 15 + const is$typed = _is$typed, 16 + validate = _validate 17 + const id = 'app.musicsky.temp.getComments' 18 + 19 + export type QueryParams = { 20 + /** AT URI of the track to get comments for. */ 21 + uri: string 22 + /** Maximum number of results. */ 23 + limit?: number 24 + /** Pagination cursor. */ 25 + cursor?: string 26 + } 27 + export type InputSchema = undefined 28 + 29 + export interface OutputSchema { 30 + cursor?: string 31 + /** Total number of comments on this track. */ 32 + totalCount: number 33 + comments: CommentView[] 34 + } 35 + 36 + export interface CallOptions { 37 + signal?: AbortSignal 38 + headers?: HeadersMap 39 + } 40 + 41 + export interface Response { 42 + success: boolean 43 + headers: HeadersMap 44 + data: OutputSchema 45 + } 46 + 47 + export function toKnownErr(e: any) { 48 + return e 49 + } 50 + 51 + export interface CommentView { 52 + $type?: 'app.musicsky.temp.getComments#commentView' 53 + uri: string 54 + cid: string 55 + text: string 56 + author: AppMusicskyTempGetFeed.AuthorView 57 + createdAt: string 58 + parent?: ParentRef 59 + deleted?: boolean 60 + } 61 + 62 + const hashCommentView = 'commentView' 63 + 64 + export function isCommentView<V>(v: V) { 65 + return is$typed(v, id, hashCommentView) 66 + } 67 + 68 + export function validateCommentView<V>(v: V) { 69 + return validate<CommentView & V>(v, id, hashCommentView) 70 + } 71 + 72 + export interface ParentRef { 73 + $type?: 'app.musicsky.temp.getComments#parentRef' 74 + uri: string 75 + cid: string 76 + } 77 + 78 + const hashParentRef = 'parentRef' 79 + 80 + export function isParentRef<V>(v: V) { 81 + return is$typed(v, id, hashParentRef) 82 + } 83 + 84 + export function validateParentRef<V>(v: V) { 85 + return validate<ParentRef & V>(v, id, hashParentRef) 86 + }