This repository has no description
0

Configure Feed

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

web/components: add components for single reaction, reaction list and picker

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

author
oppiliappan
committer
dawn
date (Jul 31, 2026, 10:57 PM +0300) commit 26f0d501 parent 3a47584b change-id nnzmlozu
+354 -18
+29 -13
web/src/lib/components/reaction/Reaction.svelte
··· 3 3 4 4 interface Props { 5 5 reaction: ReactionGroup; 6 + ontoggle?: () => void; // null for loggedout users 7 + pending?: boolean; 6 8 } 7 9 8 - let { reaction }: Props = $props(); 10 + let { reaction, ontoggle, pending = false }: Props = $props(); 9 11 10 - // keep the tooltip bounded; overflow becomes "and N more", like the go appview 11 12 const MAX_NAMES = 10; 12 13 const title = $derived.by(() => { 13 14 const shown = reaction.users.slice(0, MAX_NAMES); ··· 15 16 const names = shown.join(", "); 16 17 return more > 0 ? `${names}, and ${more} more` : names; 17 18 }); 19 + 20 + const chipClass = $derived( 21 + `flex min-h-6 items-center justify-center gap-1.5 rounded border px-2 text-xs leading-4 text-foreground-default ${ 22 + reaction.isReacted 23 + ? "border-border-strong bg-background-info-subtle" 24 + : "border-border-default" 25 + }` 26 + ); 18 27 </script> 19 28 20 - <span 21 - {title} 22 - class={`flex min-h-6 items-center justify-center gap-1.5 rounded border px-2 text-sm leading-4 ${ 23 - reaction.isReacted 24 - ? "border-border-strong bg-background-muted text-foreground-default" 25 - : "border-border-default text-foreground-subtle" 26 - }`} 27 - > 28 - <span>{reaction.kind}</span> 29 - <span>{reaction.count}</span> 30 - </span> 29 + {#if ontoggle} 30 + <button 31 + type="button" 32 + {title} 33 + disabled={pending} 34 + aria-pressed={reaction.isReacted} 35 + onclick={ontoggle} 36 + class={`${chipClass} cursor-pointer transition hover:border-border-strong disabled:opacity-50`} 37 + > 38 + <span>{reaction.kind}</span> 39 + <span>{reaction.count}</span> 40 + </button> 41 + {:else} 42 + <span {title} class={chipClass}> 43 + <span>{reaction.kind}</span> 44 + <span>{reaction.count}</span> 45 + </span> 46 + {/if}
+26
web/src/lib/components/reaction/ReactionPicker.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import ReactionPicker from "./ReactionPicker.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "Reaction/ReactionPicker", 7 + component: ReactionPicker, 8 + tags: ["autodocs"] 9 + }); 10 + 11 + const subjectUri = "at://did:plc:example/sh.tangled.repo.issue/3kabc"; 12 + </script> 13 + 14 + <Story name="Default" asChild> 15 + <ReactionPicker {subjectUri} onreacted={(kind, rkey) => console.log("reacted", kind, rkey)} /> 16 + </Story> 17 + 18 + <Story name="With existing reactions" asChild> 19 + <ReactionPicker 20 + {subjectUri} 21 + reacted={new Map([ 22 + ["👍", "3karkey1"], 23 + ["🎉", "3karkey2"] 24 + ])} 25 + /> 26 + </Story>
+90
web/src/lib/components/reaction/ReactionPicker.svelte
··· 1 + <script lang="ts"> 2 + import { now as tidNow } from "@atcute/tid"; 3 + import SmilePlus from "$icon/smile-plus"; 4 + import { putReaction, deleteReaction } from "$lib/api/reaction"; 5 + import { getAuth } from "$lib/auth.svelte"; 6 + import type { ReactionRecord } from "$lib/api/records"; 7 + import Button from "$lib/components/ui/Button.svelte"; 8 + import { ORDERED_REACTION_KINDS, type ReactionKind } from "./reactions"; 9 + 10 + interface Props { 11 + subjectUri: string; 12 + // kinds the viewer reacted with, mapped to their record's rkey so a repeat pick deletes it 13 + reacted?: Map<ReactionKind, string>; 14 + onreacted?: (kind: ReactionKind, rkey: string) => void; 15 + onunreacted?: (kind: ReactionKind) => void; 16 + } 17 + 18 + let { subjectUri, reacted, onreacted, onunreacted }: Props = $props(); 19 + 20 + const auth = getAuth(); 21 + 22 + const rawId = $props.id(); 23 + const popoverId = `reaction-picker-${rawId.replace(/[^a-zA-Z0-9]/g, "")}`; 24 + const anchorName = `--${popoverId}`; 25 + 26 + let panel = $state<HTMLElement>(); 27 + let pending = $state<ReactionKind | null>(null); 28 + 29 + const pick = async (kind: ReactionKind) => { 30 + const agent = auth?.agent; 31 + if (!agent || pending) return; 32 + 33 + pending = kind; 34 + try { 35 + const existingRkey = reacted?.get(kind); 36 + if (existingRkey) { 37 + await deleteReaction(agent, existingRkey); 38 + onunreacted?.(kind); 39 + } else { 40 + const rkey = tidNow(); 41 + const record: ReactionRecord = { 42 + $type: "sh.tangled.feed.reaction", 43 + subject: subjectUri as ReactionRecord["subject"], 44 + reaction: kind, 45 + createdAt: new Date().toISOString() 46 + }; 47 + await putReaction(agent, rkey, record); 48 + onreacted?.(kind, rkey); 49 + } 50 + panel?.hidePopover(); 51 + } catch { 52 + // leave the panel open on failure so the user can retry 53 + } finally { 54 + pending = null; 55 + } 56 + }; 57 + </script> 58 + 59 + <button 60 + type="button" 61 + popovertarget={popoverId} 62 + style={`anchor-name: ${anchorName}`} 63 + aria-label="Add reaction" 64 + class="py-2 flex cursor-pointer items-center justify-center rounded text-foreground-subtle transition hover:text-foreground-default" 65 + > 66 + <SmilePlus class="size-3" /> 67 + </button> 68 + 69 + <div 70 + bind:this={panel} 71 + id={popoverId} 72 + popover="auto" 73 + style={`position-anchor: ${anchorName}`} 74 + class="inset-auto mt-1 h-fit w-max rounded border border-border-default bg-background-default text-foreground-default shadow-menu [position-area:bottom_center]" 75 + > 76 + <div class="grid grid-cols-4 p-1"> 77 + {#each ORDERED_REACTION_KINDS as kind (kind)} 78 + <Button 79 + variant="ghost" 80 + size="sm" 81 + aria-label={`React with ${kind}`} 82 + aria-pressed={reacted?.has(kind) ?? false} 83 + disabled={pending !== null} 84 + onclick={() => pick(kind)} 85 + > 86 + {kind} 87 + </Button> 88 + {/each} 89 + </div> 90 + </div>
+119 -5
web/src/lib/components/reaction/Reactions.svelte
··· 1 1 <script lang="ts"> 2 + import { SvelteSet } from "svelte/reactivity"; 3 + import { now as tidNow } from "@atcute/tid"; 4 + import { putReaction, deleteReaction } from "$lib/api/reaction"; 5 + import { getAuth } from "$lib/auth.svelte"; 6 + import type { ReactionRecord } from "$lib/api/records"; 2 7 import Reaction from "./Reaction.svelte"; 3 - import type { ReactionGroup } from "./reactions"; 8 + import ReactionPicker from "./ReactionPicker.svelte"; 9 + import { 10 + withViewerReaction, 11 + withoutViewerReaction, 12 + upsertViewerReaction, 13 + type ReactionGroup, 14 + type ReactionKind 15 + } from "./reactions"; 4 16 5 17 interface Props { 6 18 reactions: ReactionGroup[]; 19 + subjectUri: string; 7 20 class?: string; 21 + // controlled mode: report changes up instead of keeping them only in local state 22 + onchange?: (next: ReactionGroup[]) => void; 23 + showPicker?: boolean; 24 + // keep the bar mounted even with no reactions yet 25 + alwaysShow?: boolean; 8 26 } 9 27 10 - let { reactions, class: className = "" }: Props = $props(); 28 + let { 29 + reactions, 30 + subjectUri, 31 + class: className = "", 32 + onchange, 33 + showPicker = true, 34 + alwaysShow = false 35 + }: Props = $props(); 36 + 37 + const auth = getAuth(); 38 + 39 + // seeded from the prop, reassigned optimistically, reset by svelte on fresh reactions 40 + let groups = $derived(reactions); 41 + // kinds with an in-flight PDS write, disabled until it settles 42 + const pending = new SvelteSet<ReactionKind>(); 43 + 44 + const commit = (next: ReactionGroup[]) => { 45 + groups = next; 46 + onchange?.(next); 47 + }; 48 + 49 + const setPending = (kind: ReactionKind, on: boolean) => { 50 + if (on) pending.add(kind); 51 + else pending.delete(kind); 52 + }; 53 + 54 + const toggle = async (group: ReactionGroup) => { 55 + const agent = auth.agent; 56 + const viewer = auth.currentUser; 57 + if (!agent || !viewer || pending.has(group.kind)) return; 58 + 59 + setPending(group.kind, true); 60 + try { 61 + if (group.isReacted) { 62 + if (group.viewerRkey) await deleteReaction(agent, group.viewerRkey); 63 + commit( 64 + groups 65 + .map((g) => (g.kind === group.kind ? withoutViewerReaction(g, viewer.handle) : g)) 66 + .filter((g) => g.count > 0) 67 + ); 68 + } else { 69 + const rkey = tidNow(); 70 + const record: ReactionRecord = { 71 + $type: "sh.tangled.feed.reaction", 72 + subject: subjectUri as ReactionRecord["subject"], 73 + reaction: group.kind, 74 + createdAt: new Date().toISOString() 75 + }; 76 + await putReaction(agent, rkey, record); 77 + commit( 78 + groups.map((g) => 79 + g.kind === group.kind ? withViewerReaction(g, viewer.handle, rkey) : g 80 + ) 81 + ); 82 + } 83 + } catch { 84 + // leave the rendered state untouched on failure; a reload reconciles it 85 + } finally { 86 + setPending(group.kind, false); 87 + } 88 + }; 89 + 90 + const canReact = $derived(auth.currentUser !== null); 91 + 92 + // kinds the viewer reacted with, mapped to their record's rkey so the picker can toggle 93 + const reacted = $derived( 94 + new Map( 95 + groups 96 + .filter((g) => g.isReacted && g.viewerRkey) 97 + .map((g) => [g.kind, g.viewerRkey!] as [ReactionKind, string]) 98 + ) 99 + ); 100 + 101 + const onreacted = (kind: ReactionKind, rkey: string) => { 102 + const viewer = auth.currentUser; 103 + if (!viewer) return; 104 + commit(upsertViewerReaction(groups, kind, viewer.handle, rkey)); 105 + }; 106 + 107 + const onunreacted = (kind: ReactionKind) => { 108 + const viewer = auth.currentUser; 109 + if (!viewer) return; 110 + commit( 111 + groups 112 + .map((g) => (g.kind === kind ? withoutViewerReaction(g, viewer.handle) : g)) 113 + .filter((g) => g.count > 0) 114 + ); 115 + }; 116 + 117 + const pickerAlways = $derived(alwaysShow && canReact && showPicker); 11 118 </script> 12 119 13 - {#if reactions.length} 120 + {#if groups.length || pickerAlways} 14 121 <div class={`flex flex-wrap items-center gap-2 ${className}`}> 15 - {#each reactions as reaction (reaction.kind)} 16 - <Reaction {reaction} /> 122 + {#each groups as reaction (reaction.kind)} 123 + <Reaction 124 + {reaction} 125 + ontoggle={canReact ? () => toggle(reaction) : undefined} 126 + pending={pending.has(reaction.kind)} 127 + /> 17 128 {/each} 129 + {#if canReact && showPicker} 130 + <ReactionPicker {subjectUri} {reacted} {onreacted} {onunreacted} /> 131 + {/if} 18 132 </div> 19 133 {/if}
+90
web/src/lib/components/reaction/reactions.ts
··· 1 + import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 2 + import type { ReactionRecord, RecordView } from "$lib/api/records"; 3 + 4 + export const ORDERED_REACTION_KINDS = ["👍", "👎", "😆", "🎉", "🫤", "❤️", "🚀", "👀"] as const; 5 + export type ReactionKind = (typeof ORDERED_REACTION_KINDS)[number]; 6 + 7 + const KIND_SET = new Set<string>(ORDERED_REACTION_KINDS); 8 + 9 + export interface ReactionGroup { 10 + kind: ReactionKind; 11 + count: number; 12 + users: string[]; 13 + isReacted: boolean; 14 + viewerRkey?: string; 15 + } 16 + 17 + // groups flat reaction records by emoji, keeping OrderedReactionKinds order and 18 + // dropping kinds nobody used. handleOf resolves a reactor did to a display handle. 19 + export function buildReactions( 20 + items: RecordView<ReactionRecord>[], 21 + viewerDid?: string, 22 + handleOf?: (did: string) => string 23 + ): ReactionGroup[] { 24 + const groups = new Map<ReactionKind, ReactionGroup>(); 25 + 26 + for (const item of items) { 27 + const kind = item.value.reaction; 28 + if (!KIND_SET.has(kind)) continue; 29 + const did = didFromUri(item.uri); 30 + let group = groups.get(kind as ReactionKind); 31 + if (!group) { 32 + group = { kind: kind as ReactionKind, count: 0, users: [], isReacted: false }; 33 + groups.set(kind as ReactionKind, group); 34 + } 35 + group.count++; 36 + group.users.push(handleOf ? handleOf(did) : did); 37 + if (viewerDid && did === viewerDid) { 38 + group.isReacted = true; 39 + group.viewerRkey = rkeyFromUri(item.uri); 40 + } 41 + } 42 + 43 + return ORDERED_REACTION_KINDS.filter((k) => groups.has(k)).map((k) => groups.get(k)!); 44 + } 45 + 46 + // optimistic addition for a reaction group 47 + export function withViewerReaction( 48 + group: ReactionGroup, 49 + viewerHandle: string, 50 + rkey: string 51 + ): ReactionGroup { 52 + return { 53 + ...group, 54 + count: group.count + 1, 55 + users: [...group.users, viewerHandle], 56 + isReacted: true, 57 + viewerRkey: rkey 58 + }; 59 + } 60 + 61 + // optimistic removal for a reaction group 62 + export function withoutViewerReaction(group: ReactionGroup, viewerHandle: string): ReactionGroup { 63 + // drop one occurrence of the viewer; count never dips below zero 64 + const idx = group.users.indexOf(viewerHandle); 65 + const users = idx === -1 ? group.users : group.users.filter((_, i) => i !== idx); 66 + return { 67 + ...group, 68 + count: Math.max(0, group.count - 1), 69 + users, 70 + isReacted: false, 71 + viewerRkey: undefined 72 + }; 73 + } 74 + 75 + // optimistic add of a fresh reaction (e.g. from the picker): bumps the group if the 76 + // kind is already present, otherwise inserts a new one, keeping canonical emoji order. 77 + export function upsertViewerReaction( 78 + groups: ReactionGroup[], 79 + kind: ReactionKind, 80 + viewerHandle: string, 81 + rkey: string 82 + ): ReactionGroup[] { 83 + const exists = groups.some((g) => g.kind === kind); 84 + const next = exists 85 + ? groups.map((g) => (g.kind === kind ? withViewerReaction(g, viewerHandle, rkey) : g)) 86 + : [...groups, { kind, count: 1, users: [viewerHandle], isReacted: true, viewerRkey: rkey }]; 87 + return ORDERED_REACTION_KINDS.filter((k) => next.some((g) => g.kind === k)).map((k) => 88 + next.find((g) => g.kind === k)! 89 + ); 90 + }