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.

refactor: convert comment section to RSC with server-side caching

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

authored by

mejsiejdev and committed by tangled.org 5fc0cdd7 56e69c27

+187 -259
+11 -8
apps/web/src/app/(main)/[handle]/[rkey]/song-view.tsx
··· 1 + import { Suspense } from "react"; 1 2 import { Song } from "@/components/song"; 2 3 import { CommentSection } from "@/components/comment/comment-section"; 3 4 import { type TrackRecord } from "@/types/song"; ··· 74 75 likeRkey={likedUris.get(song.uri) ?? null} 75 76 repostRkey={repostedUris.get(song.uri) ?? null} 76 77 /> 77 - <CommentSection 78 - uri={song.uri} 79 - cid={song.cid} 80 - songTitle={song.title} 81 - isLoggedIn={session !== null} 82 - userDid={session?.did} 83 - userHandle={userHandle} 84 - /> 78 + <Suspense> 79 + <CommentSection 80 + uri={song.uri} 81 + cid={song.cid} 82 + songTitle={song.title} 83 + isLoggedIn={session !== null} 84 + userDid={session?.did} 85 + userHandle={userHandle} 86 + /> 87 + </Suspense> 85 88 </> 86 89 ); 87 90 }
-41
apps/web/src/app/api/comments/route.ts
··· 1 - import { type NextRequest } from "next/server"; 2 - import { APPVIEW_URL } from "@/lib/api"; 3 - 4 - export async function GET(request: NextRequest) { 5 - const searchParams = request.nextUrl.searchParams; 6 - const uri = searchParams.get("uri"); 7 - 8 - if (!uri) { 9 - return Response.json( 10 - { error: "Missing required parameter: uri" }, 11 - { status: 400 }, 12 - ); 13 - } 14 - 15 - const params = new URLSearchParams({ uri }); 16 - const limit = searchParams.get("limit"); 17 - if (limit) params.set("limit", limit); 18 - const cursor = searchParams.get("cursor"); 19 - if (cursor) params.set("cursor", cursor); 20 - 21 - try { 22 - const res = await fetch( 23 - `${APPVIEW_URL}/xrpc/app.musicsky.temp.getComments?${params.toString()}`, 24 - ); 25 - 26 - if (!res.ok) { 27 - return Response.json( 28 - { error: "Failed to fetch comments" }, 29 - { status: res.status }, 30 - ); 31 - } 32 - 33 - const data = await res.json(); 34 - return Response.json(data); 35 - } catch { 36 - return Response.json( 37 - { error: "Failed to fetch comments" }, 38 - { status: 500 }, 39 - ); 40 - } 41 - }
-4
apps/web/src/components/comment/comment-input.tsx
··· 14 14 cid, 15 15 songTitle, 16 16 onClose, 17 - onCommentPosted, 18 17 parentUri, 19 18 parentCid, 20 19 replyToHandle, ··· 23 22 cid: string; 24 23 songTitle: string; 25 24 onClose: () => void; 26 - onCommentPosted?: (text: string) => void; 27 25 parentUri?: string; 28 26 parentCid?: string; 29 27 replyToHandle?: string; ··· 32 30 33 31 const [state, action, pending] = useActionState( 34 32 async (prevState: ActionResult | null, formData: FormData) => { 35 - const submittedText = (formData.get("text") as string).trim(); 36 33 const result = await createComment(prevState, formData); 37 34 if (result.success) { 38 35 setText(""); 39 36 onClose(); 40 - onCommentPosted?.(submittedText); 41 37 } 42 38 return result; 43 39 },
+139
apps/web/src/components/comment/comment-list.tsx
··· 1 + "use client"; 2 + 3 + import { useCallback, useState } from "react"; 4 + import { Comment } from "./comment"; 5 + import { CommentInput } from "./comment-input"; 6 + import { Button } from "@/components/ui/button"; 7 + import type { CommentNode } from "./comment-section"; 8 + 9 + const VISIBLE_REPLIES = 3; 10 + 11 + export function CommentList({ 12 + uri, 13 + cid, 14 + songTitle, 15 + isLoggedIn, 16 + userDid, 17 + threadRoots, 18 + }: { 19 + uri: string; 20 + cid: string | undefined; 21 + songTitle: string; 22 + isLoggedIn: boolean; 23 + userDid?: string; 24 + userHandle?: string; 25 + threadRoots: CommentNode[]; 26 + }) { 27 + const [replyTarget, setReplyTarget] = useState<{ 28 + uri: string; 29 + cid: string; 30 + handle: string; 31 + } | null>(null); 32 + 33 + const handleReply = useCallback( 34 + (commentUri: string, commentCid: string, handle: string) => { 35 + setReplyTarget({ uri: commentUri, cid: commentCid, handle }); 36 + }, 37 + [], 38 + ); 39 + 40 + return ( 41 + <> 42 + {isLoggedIn && cid && ( 43 + <CommentInput 44 + uri={uri} 45 + cid={cid} 46 + songTitle={songTitle} 47 + onClose={() => setReplyTarget(null)} 48 + parentUri={replyTarget?.uri} 49 + parentCid={replyTarget?.cid} 50 + replyToHandle={replyTarget?.handle} 51 + /> 52 + )} 53 + 54 + {threadRoots.length === 0 && ( 55 + <p className="text-sm text-muted-foreground"> 56 + No comments yet. Be the first to comment! 57 + </p> 58 + )} 59 + 60 + {threadRoots.length > 0 && ( 61 + <div className="flex flex-col"> 62 + {threadRoots.map((node) => ( 63 + <CommentThread 64 + key={node.comment.uri} 65 + node={node} 66 + trackUri={uri} 67 + userDid={userDid} 68 + isLoggedIn={isLoggedIn} 69 + onReply={handleReply} 70 + /> 71 + ))} 72 + </div> 73 + )} 74 + </> 75 + ); 76 + } 77 + 78 + function CommentThread({ 79 + node, 80 + trackUri, 81 + userDid, 82 + isLoggedIn, 83 + onReply, 84 + }: { 85 + node: CommentNode; 86 + trackUri: string; 87 + userDid?: string; 88 + isLoggedIn: boolean; 89 + onReply: (uri: string, cid: string, handle: string) => void; 90 + }) { 91 + const [expanded, setExpanded] = useState(false); 92 + const { comment, children } = node; 93 + 94 + const visibleChildren = expanded 95 + ? children 96 + : children.slice(0, VISIBLE_REPLIES); 97 + const hiddenCount = expanded ? 0 : children.length - VISIBLE_REPLIES; 98 + const hasVisibleChildren = visibleChildren.length > 0 || hiddenCount > 0; 99 + 100 + return ( 101 + <> 102 + <Comment 103 + uri={comment.uri} 104 + cid={comment.cid} 105 + text={comment.text} 106 + author={comment.author} 107 + createdAt={comment.createdAt} 108 + deleted={comment.deleted} 109 + isOwn={userDid === comment.author.did} 110 + isLoggedIn={isLoggedIn} 111 + trackUri={trackUri} 112 + showThreadLine={hasVisibleChildren} 113 + onReply={onReply} 114 + /> 115 + 116 + {visibleChildren.map((child) => ( 117 + <CommentThread 118 + key={child.comment.uri} 119 + node={child} 120 + trackUri={trackUri} 121 + userDid={userDid} 122 + isLoggedIn={isLoggedIn} 123 + onReply={onReply} 124 + /> 125 + ))} 126 + 127 + {hiddenCount > 0 && ( 128 + <Button 129 + variant="ghost" 130 + size="sm" 131 + className="h-7 text-xs text-muted-foreground" 132 + onClick={() => setExpanded(true)} 133 + > 134 + Show {hiddenCount} more {hiddenCount === 1 ? "reply" : "replies"} 135 + </Button> 136 + )} 137 + </> 138 + ); 139 + }
+36 -202
apps/web/src/components/comment/comment-section.tsx
··· 1 - "use client"; 2 - 3 - import { useCallback, useState } from "react"; 4 - import useSWR from "swr"; 1 + import { cacheLife, cacheTag } from "next/cache"; 5 2 import { MessageCircleIcon } from "lucide-react"; 6 - import { Comment } from "./comment"; 7 - import { CommentInput } from "./comment-input"; 8 - import { Button } from "@/components/ui/button"; 9 - import { Skeleton } from "@/components/ui/skeleton"; 3 + import { APPVIEW_URL } from "@/lib/api"; 4 + import { CommentList } from "./comment-list"; 10 5 11 6 interface CommentAuthor { 12 7 did: string; ··· 14 9 pds: string; 15 10 } 16 11 17 - interface CommentView { 12 + export interface CommentView { 18 13 uri: string; 19 14 cid: string; 20 15 text: string; ··· 30 25 totalCount: number; 31 26 } 32 27 33 - interface CommentNode { 28 + export interface CommentNode { 34 29 comment: CommentView; 35 30 children: CommentNode[]; 36 31 } 37 32 38 - const VISIBLE_REPLIES = 3; 33 + const MAX_LIMIT = 50; 34 + 35 + async function getComments(uri: string): Promise<CommentsResponse> { 36 + "use cache"; 37 + cacheLife("minutes"); 38 + cacheTag(`comments-${uri}`); 39 + 40 + const params = new URLSearchParams({ uri, limit: String(MAX_LIMIT) }); 41 + const res = await fetch( 42 + `${APPVIEW_URL}/xrpc/app.musicsky.temp.getComments?${params.toString()}`, 43 + ); 44 + 45 + if (!res.ok) { 46 + return { comments: [], totalCount: 0 }; 47 + } 48 + 49 + return (await res.json()) as CommentsResponse; 50 + } 39 51 40 52 function buildThread(comments: CommentView[]): CommentNode[] { 41 53 const nodeMap = new Map<string, CommentNode>(); ··· 67 79 }); 68 80 } 69 81 70 - async function fetchComments(url: string): Promise<CommentsResponse> { 71 - const res = await fetch(url); 72 - if (!res.ok) throw new Error("Failed to fetch comments"); 73 - return (await res.json()) as CommentsResponse; 74 - } 75 - 76 - export function CommentSection({ 82 + export async function CommentSection({ 77 83 uri, 78 84 cid, 79 85 songTitle, ··· 88 94 userDid?: string; 89 95 userHandle?: string; 90 96 }) { 91 - const [replyTarget, setReplyTarget] = useState<{ 92 - uri: string; 93 - cid: string; 94 - handle: string; 95 - } | null>(null); 96 - 97 - const { data, error, isLoading, mutate } = useSWR( 98 - `/api/comments?uri=${encodeURIComponent(uri)}&limit=${MAX_LIMIT}`, 99 - fetchComments, 100 - { refreshInterval: 60_000 }, 101 - ); 102 - 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(() => { 144 - void mutate(); 145 - }, [mutate]); 146 - 147 - const allComments = data?.comments ?? []; 148 - const totalCount = data?.totalCount ?? 0; 149 - const threadRoots = allComments.length > 0 ? buildThread(allComments) : []; 97 + const data = await getComments(uri); 98 + const threadRoots = buildThread(data.comments); 150 99 151 100 return ( 152 101 <div className="flex flex-col gap-4"> 153 102 <div className="flex flex-row items-center gap-2"> 154 103 <MessageCircleIcon size={18} /> 155 104 <h2 className="text-sm font-medium"> 156 - {isLoading 157 - ? "Comments" 158 - : `${totalCount} ${totalCount === 1 ? "comment" : "comments"}`} 105 + {data.totalCount}{" "} 106 + {data.totalCount === 1 ? "comment" : "comments"} 159 107 </h2> 160 108 </div> 161 109 162 - {isLoggedIn && cid && ( 163 - <CommentInput 164 - uri={uri} 165 - cid={cid} 166 - songTitle={songTitle} 167 - onClose={() => setReplyTarget(null)} 168 - onCommentPosted={handleCommentPosted} 169 - parentUri={replyTarget?.uri} 170 - parentCid={replyTarget?.cid} 171 - replyToHandle={replyTarget?.handle} 172 - /> 173 - )} 174 - 175 - {isLoading && ( 176 - <div className="flex flex-col gap-3"> 177 - {Array.from({ length: 3 }).map((_, index) => ( 178 - <div key={index} className="flex flex-row gap-3"> 179 - <Skeleton className="size-6 rounded-full shrink-0" /> 180 - <div className="flex flex-col gap-1 w-full"> 181 - <Skeleton className="h-4 w-32" /> 182 - <Skeleton className="h-4 w-full" /> 183 - </div> 184 - </div> 185 - ))} 186 - </div> 187 - )} 188 - 189 - {error && ( 190 - <p className="text-sm text-muted-foreground"> 191 - Failed to load comments. 192 - </p> 193 - )} 194 - 195 - {!isLoading && !error && allComments.length === 0 && ( 196 - <p className="text-sm text-muted-foreground"> 197 - No comments yet. Be the first to comment! 198 - </p> 199 - )} 200 - 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} 212 - /> 213 - ))} 214 - </div> 215 - )} 216 - </div> 217 - ); 218 - } 219 - 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} 110 + <CommentList 111 + uri={uri} 112 + cid={cid} 113 + songTitle={songTitle} 256 114 isLoggedIn={isLoggedIn} 257 - trackUri={trackUri} 258 - showThreadLine={hasVisibleChildren} 259 - onReply={onReply} 260 - onDeleted={onDeleted} 115 + userDid={userDid} 116 + userHandle={userHandle} 117 + threadRoots={threadRoots} 261 118 /> 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 && ( 276 - <Button 277 - variant="ghost" 278 - size="sm" 279 - className="h-7 text-xs text-muted-foreground" 280 - onClick={() => setExpanded(true)} 281 - > 282 - Show {hiddenCount} more {hiddenCount === 1 ? "reply" : "replies"} 283 - </Button> 284 - )} 285 - </> 119 + </div> 286 120 ); 287 121 }
+1 -4
apps/web/src/components/comment/comment.tsx
··· 50 50 trackUri, 51 51 showThreadLine, 52 52 onReply, 53 - onDeleted, 54 53 }: { 55 54 uri: string; 56 55 cid: string; ··· 63 62 trackUri: string; 64 63 showThreadLine?: boolean; 65 64 onReply?: (uri: string, cid: string, handle: string) => void; 66 - onDeleted?: () => void; 67 65 }) { 68 66 const [deleting, setDeleting] = useState(false); 69 67 const { data: profile } = useSWR( ··· 77 75 setDeleting(true); 78 76 try { 79 77 await deleteComment(uri, trackUri); 80 - onDeleted?.(); 81 78 } finally { 82 79 setDeleting(false); 83 80 } ··· 181 178 <div className="flex flex-row gap-4"> 182 179 <div className="flex flex-col items-center"> 183 180 {avatar} 184 - {showThreadLine && <div className="w-0.5 h-full pb-4 bg-primary" />} 181 + {showThreadLine && <div className="w-0.5 h-full pb-12 bg-primary" />} 185 182 </div> 186 183 <div className="flex flex-col gap-1">{children}</div> 187 184 </div>