This repository has no description
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";
7 import Reaction from "./Reaction.svelte";
8 import ReactionPicker from "./ReactionPicker.svelte";
9 import {
10 withViewerReaction,
11 withoutViewerReaction,
12 upsertViewerReaction,
13 type ReactionGroup,
14 type ReactionKind
15 } from "./reactions";
16
17 interface Props {
18 reactions: ReactionGroup[];
19 subjectUri: string;
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;
26 }
27
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);
118</script>
119
120{#if groups.length || pickerAlways}
121 <div class={`flex flex-wrap items-center gap-2 ${className}`}>
122 {#each groups as reaction (reaction.kind)}
123 <Reaction
124 {reaction}
125 ontoggle={canReact ? () => toggle(reaction) : undefined}
126 pending={pending.has(reaction.kind)}
127 />
128 {/each}
129 {#if canReact && showPicker}
130 <ReactionPicker {subjectUri} {reacted} {onreacted} {onunreacted} />
131 {/if}
132 </div>
133{/if}