This repository has no description
0

Configure Feed

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

web/components: add reaction picker to comments on hover

Signed-off-by: oppiliappan <me@oppi.li>

author
oppiliappan
committer
dawn
date (Jul 31, 2026, 10:57 PM +0300) commit 102b9566 parent e2789770 change-id wpqmpolu
+78 -20
+75 -18
web/src/lib/components/comment/CommentCard.svelte
··· 6 6 import { getAuth } from "$lib/auth.svelte"; 7 7 import { type MarkupContext } from "$lib/markup"; 8 8 import ErrorAlert from "$lib/components/ui/Error.svelte"; 9 + import ReactionPicker from "$lib/components/reaction/ReactionPicker.svelte"; 10 + import { 11 + upsertViewerReaction, 12 + withoutViewerReaction, 13 + type ReactionGroup, 14 + type ReactionKind 15 + } from "$lib/components/reaction/reactions"; 9 16 import Comment from "./Comment.svelte"; 10 17 import CommentBox from "./CommentBox.svelte"; 11 18 import CommentEditor from "./CommentEditor.svelte"; ··· 32 39 let deletingUri = $state<string | null>(null); 33 40 let deleteError = $state<string | null>(null); 34 41 42 + let reactionsByUri = $derived.by(() => { 43 + const map: Record<string, ReactionGroup[]> = {}; 44 + map[thread.self.uri] = thread.self.reactions ?? []; 45 + for (const reply of thread.replies) map[reply.uri] = reply.reactions ?? []; 46 + return map; 47 + }); 48 + 49 + const setReactions = (uri: string, next: ReactionGroup[]) => { 50 + reactionsByUri = { ...reactionsByUri, [uri]: next }; 51 + }; 52 + 53 + // kinds the viewer reacted with, mapped to their record's rkey so the picker can toggle 54 + const reactedMap = (uri: string): Map<ReactionKind, string> => 55 + new Map( 56 + (reactionsByUri[uri] ?? []) 57 + .filter((g) => g.isReacted && g.viewerRkey) 58 + .map((g) => [g.kind, g.viewerRkey!] as [ReactionKind, string]) 59 + ); 60 + 61 + const onReacted = (uri: string) => (kind: ReactionKind, rkey: string) => { 62 + const viewer = currentUser; 63 + if (!viewer) return; 64 + setReactions(uri, upsertViewerReaction(reactionsByUri[uri] ?? [], kind, viewer.handle, rkey)); 65 + }; 66 + 67 + const onUnreacted = (uri: string) => (kind: ReactionKind) => { 68 + const viewer = currentUser; 69 + if (!viewer) return; 70 + setReactions( 71 + uri, 72 + (reactionsByUri[uri] ?? []) 73 + .map((g) => (g.kind === kind ? withoutViewerReaction(g, viewer.handle) : g)) 74 + .filter((g) => g.count > 0) 75 + ); 76 + }; 77 + 35 78 const handleDelete = async (comment: CommentView) => { 36 79 const agent = auth?.agent; 37 80 if (!agent || deletingUri) return; ··· 50 93 </script> 51 94 52 95 {#snippet commentActions(comment: CommentView)} 53 - {#if currentUser?.did === comment.authorDid} 96 + {#if currentUser} 54 97 <div 55 98 class="absolute top-4 right-4 flex items-center gap-2 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100" 56 99 > 57 - <button 58 - type="button" 59 - aria-label="Edit comment" 60 - class="cursor-pointer text-foreground-subtle hover:text-foreground-default" 61 - onclick={() => (editingUri = comment.uri)} 62 - > 63 - <Pencil class="size-3" /> 64 - </button> 65 - <button 66 - type="button" 67 - aria-label="Delete comment" 68 - disabled={deletingUri === comment.uri} 69 - class="cursor-pointer text-foreground-danger hover:text-foreground-danger-strong disabled:opacity-50" 70 - onclick={() => handleDelete(comment)} 71 - > 72 - <Trash2 class="size-3" /> 73 - </button> 100 + <ReactionPicker 101 + subjectUri={comment.uri} 102 + reacted={reactedMap(comment.uri)} 103 + onreacted={onReacted(comment.uri)} 104 + onunreacted={onUnreacted(comment.uri)} 105 + /> 106 + {#if currentUser.did === comment.authorDid} 107 + <button 108 + type="button" 109 + aria-label="Edit comment" 110 + class="cursor-pointer text-foreground-subtle hover:text-foreground-default" 111 + onclick={() => (editingUri = comment.uri)} 112 + > 113 + <Pencil class="size-3" /> 114 + </button> 115 + <button 116 + type="button" 117 + aria-label="Delete comment" 118 + disabled={deletingUri === comment.uri} 119 + class="cursor-pointer text-foreground-danger hover:text-foreground-danger-strong disabled:opacity-50" 120 + onclick={() => handleDelete(comment)} 121 + > 122 + <Trash2 class="size-3" /> 123 + </button> 124 + {/if} 74 125 </div> 75 126 {/if} 76 127 {/snippet} ··· 119 170 bodyHtml={thread.self.bodyHtml} 120 171 variant="top" 121 172 deleted={thread.self.deleted} 173 + reactions={reactionsByUri[thread.self.uri]} 174 + subjectUri={thread.self.uri} 175 + onreactionschange={(next) => setReactions(thread.self.uri, next)} 122 176 editor={editingUri === thread.self.uri ? selfEditor : undefined} 123 177 /> 124 178 {#if editingUri !== thread.self.uri && !thread.self.deleted} ··· 144 198 body={reply.body} 145 199 bodyHtml={reply.bodyHtml} 146 200 variant="reply" 201 + reactions={reactionsByUri[reply.uri]} 202 + subjectUri={reply.uri} 203 + onreactionschange={(next) => setReactions(reply.uri, next)} 147 204 editor={editingUri === reply.uri ? replyEditor : undefined} 148 205 /> 149 206 {#if editingUri !== reply.uri}
+3 -2
web/src/lib/components/comment/comments.ts
··· 1 + import type { ReactionGroup } from "$lib/components/reaction/reactions"; 2 + 1 3 export interface CommentView { 2 4 uri: string; 3 5 cid?: string; ··· 7 9 createdAt: string; 8 10 body: string; 9 11 bodyHtml: string | null; 10 - // the record is gone but we keep it to anchor its replies 12 + reactions?: ReactionGroup[]; 11 13 deleted?: boolean; 12 14 } 13 15 ··· 16 18 replies: CommentView[]; 17 19 } 18 20 19 - // pairs a rendered view with the parent uri it replies to 20 21 export interface ThreadInput { 21 22 comment: CommentView; 22 23 replyTo: string | null;