diff --git a/web/src/lib/components/reaction/Reaction.svelte b/web/src/lib/components/reaction/Reaction.svelte index 5f5bff78..fabc6ca2 100644 --- a/web/src/lib/components/reaction/Reaction.svelte +++ b/web/src/lib/components/reaction/Reaction.svelte @@ -3,11 +3,12 @@ interface Props { reaction: ReactionGroup; + ontoggle?: () => void; // null for loggedout users + pending?: boolean; } - let { reaction }: Props = $props(); + let { reaction, ontoggle, pending = false }: Props = $props(); - // keep the tooltip bounded; overflow becomes "and N more", like the go appview const MAX_NAMES = 10; const title = $derived.by(() => { const shown = reaction.users.slice(0, MAX_NAMES); @@ -15,16 +16,31 @@ const names = shown.join(", "); return more > 0 ? `${names}, and ${more} more` : names; }); + + const chipClass = $derived( + `flex min-h-6 items-center justify-center gap-1.5 rounded border px-2 text-xs leading-4 text-foreground-default ${ + reaction.isReacted + ? "border-border-strong bg-background-info-subtle" + : "border-border-default" + }` + ); - - {reaction.kind} - {reaction.count} - +{#if ontoggle} + +{:else} + + {reaction.kind} + {reaction.count} + +{/if} diff --git a/web/src/lib/components/reaction/ReactionPicker.stories.svelte b/web/src/lib/components/reaction/ReactionPicker.stories.svelte new file mode 100644 index 00000000..408e6ce0 --- /dev/null +++ b/web/src/lib/components/reaction/ReactionPicker.stories.svelte @@ -0,0 +1,26 @@ + + + + console.log("reacted", kind, rkey)} /> + + + + + diff --git a/web/src/lib/components/reaction/ReactionPicker.svelte b/web/src/lib/components/reaction/ReactionPicker.svelte new file mode 100644 index 00000000..f4c5fd07 --- /dev/null +++ b/web/src/lib/components/reaction/ReactionPicker.svelte @@ -0,0 +1,90 @@ + + + + +
+
+ {#each ORDERED_REACTION_KINDS as kind (kind)} + + {/each} +
+
diff --git a/web/src/lib/components/reaction/Reactions.svelte b/web/src/lib/components/reaction/Reactions.svelte index 3067e865..4880b82d 100644 --- a/web/src/lib/components/reaction/Reactions.svelte +++ b/web/src/lib/components/reaction/Reactions.svelte @@ -1,19 +1,133 @@ -{#if reactions.length} +{#if groups.length || pickerAlways}
- {#each reactions as reaction (reaction.kind)} - + {#each groups as reaction (reaction.kind)} + toggle(reaction) : undefined} + pending={pending.has(reaction.kind)} + /> {/each} + {#if canReact && showPicker} + + {/if}
{/if} diff --git a/web/src/lib/components/reaction/reactions.ts b/web/src/lib/components/reaction/reactions.ts new file mode 100644 index 00000000..1187afe7 --- /dev/null +++ b/web/src/lib/components/reaction/reactions.ts @@ -0,0 +1,90 @@ +import { didFromUri, rkeyFromUri } from "$lib/api/uri"; +import type { ReactionRecord, RecordView } from "$lib/api/records"; + +export const ORDERED_REACTION_KINDS = ["👍", "👎", "😆", "🎉", "🫤", "❤️", "🚀", "👀"] as const; +export type ReactionKind = (typeof ORDERED_REACTION_KINDS)[number]; + +const KIND_SET = new Set(ORDERED_REACTION_KINDS); + +export interface ReactionGroup { + kind: ReactionKind; + count: number; + users: string[]; + isReacted: boolean; + viewerRkey?: string; +} + +// groups flat reaction records by emoji, keeping OrderedReactionKinds order and +// dropping kinds nobody used. handleOf resolves a reactor did to a display handle. +export function buildReactions( + items: RecordView[], + viewerDid?: string, + handleOf?: (did: string) => string +): ReactionGroup[] { + const groups = new Map(); + + for (const item of items) { + const kind = item.value.reaction; + if (!KIND_SET.has(kind)) continue; + const did = didFromUri(item.uri); + let group = groups.get(kind as ReactionKind); + if (!group) { + group = { kind: kind as ReactionKind, count: 0, users: [], isReacted: false }; + groups.set(kind as ReactionKind, group); + } + group.count++; + group.users.push(handleOf ? handleOf(did) : did); + if (viewerDid && did === viewerDid) { + group.isReacted = true; + group.viewerRkey = rkeyFromUri(item.uri); + } + } + + return ORDERED_REACTION_KINDS.filter((k) => groups.has(k)).map((k) => groups.get(k)!); +} + +// optimistic addition for a reaction group +export function withViewerReaction( + group: ReactionGroup, + viewerHandle: string, + rkey: string +): ReactionGroup { + return { + ...group, + count: group.count + 1, + users: [...group.users, viewerHandle], + isReacted: true, + viewerRkey: rkey + }; +} + +// optimistic removal for a reaction group +export function withoutViewerReaction(group: ReactionGroup, viewerHandle: string): ReactionGroup { + // drop one occurrence of the viewer; count never dips below zero + const idx = group.users.indexOf(viewerHandle); + const users = idx === -1 ? group.users : group.users.filter((_, i) => i !== idx); + return { + ...group, + count: Math.max(0, group.count - 1), + users, + isReacted: false, + viewerRkey: undefined + }; +} + +// optimistic add of a fresh reaction (e.g. from the picker): bumps the group if the +// kind is already present, otherwise inserts a new one, keeping canonical emoji order. +export function upsertViewerReaction( + groups: ReactionGroup[], + kind: ReactionKind, + viewerHandle: string, + rkey: string +): ReactionGroup[] { + const exists = groups.some((g) => g.kind === kind); + const next = exists + ? groups.map((g) => (g.kind === kind ? withViewerReaction(g, viewerHandle, rkey) : g)) + : [...groups, { kind, count: 1, users: [viewerHandle], isReacted: true, viewerRkey: rkey }]; + return ORDERED_REACTION_KINDS.filter((k) => next.some((g) => g.kind === k)).map((k) => + next.find((g) => g.kind === k)! + ); +}