diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 49cf4bf..2e99ee8 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -152,6 +152,13 @@ export const PaperPropertiesSchema = z.object({ }); export type PaperProperties = z.infer; +// --- Reactions --- + +export const ReactionsSchema = z.record(z.string(), z.array(z.string())).optional(); +export type Reactions = z.infer; + +export const REACTION_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F525}", "\u{1F440}", "\u{2705}", "\u{2753}"] as const; + export interface ParticlePropertiesMap { stream: StreamProperties; folder: FolderProperties; @@ -193,9 +200,9 @@ export const ParticleSchema = z.discriminatedUnion("type", [ // e.g. ["network:123"] - visible to everyone in the network visible_to: z.array(z.string()), }), - ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema, reactions: ReactionsSchema }), ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }), - ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema, reactions: ReactionsSchema }), ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema }), ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema }), ]); diff --git a/js/src/features/particles/reaction-bar.tsx b/js/src/features/particles/reaction-bar.tsx new file mode 100644 index 0000000..87aaf03 --- /dev/null +++ b/js/src/features/particles/reaction-bar.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { Plus, X } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { REACTION_EMOJIS, type Reactions } from "@/api/types"; +import { cn } from "@/lib/utils"; +import type { Human } from "@/api/types"; + +interface ReactionBarProps { + reactions: Reactions; + currentHumanId: string; + humans?: Human[]; + onToggle: (emoji: string) => void; +} + +function getReactorNames( + humanIds: string[], + humans?: Human[], +): string { + return humanIds + .map((id) => { + const human = humans?.find((h) => h.id === id); + return human?.email_prefix ?? id; + }) + .join(", "); +} + +export function ReactionBar({ reactions, currentHumanId, humans, onToggle }: ReactionBarProps) { + const [expanded, setExpanded] = useState(false); + + const activeEmojis = REACTION_EMOJIS.filter( + (emoji) => reactions?.[emoji] && reactions[emoji].length > 0, + ); + + const handleToggle = (emoji: string) => { + onToggle(emoji); + setExpanded(false); + }; + + return ( +
+ {/* Existing reaction pills */} + {activeEmojis.map((emoji) => { + const reactors = reactions![emoji]; + const isMine = reactors.includes(currentHumanId); + return ( + + + + + + {getReactorNames(reactors, humans)} + + + ); + })} + + {/* Expand / picker toggle */} + {expanded ? ( +
+ {REACTION_EMOJIS.map((emoji) => { + // Skip emojis that already have pills + if (activeEmojis.includes(emoji)) return null; + return ( + + ); + })} + +
+ ) : ( + + )} +
+ ); +} diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index 784c3e2..31a1f2b 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useEffectEvent, useCallback, useRef } from "react" import { useNavigate } from "react-router-dom"; import { useAuthStore } from "@/stores/auth-store"; import { apiClient } from "@/api/client"; -import type { Particle } from "@/api/types"; +import { type Particle, REACTION_EMOJIS } from "@/api/types"; import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; import { ComposeOverlay } from "@/features/compose/compose-overlay"; import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator"; @@ -22,7 +22,8 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Settings, CircleCheckBig, CircleDot, EllipsisVertical } from "lucide-react"; -import { updateStreamStatus } from "@/lib/firestore-particles"; +import { updateStreamStatus, toggleParticleReaction } from "@/lib/firestore-particles"; +import { ReactionBar } from "@/features/particles/reaction-bar"; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"; import { WindowControls } from "@/components/window-controls"; import { RelativeTimestamp } from "@/components/relative-timestamp"; @@ -32,6 +33,11 @@ import { usePresencePositions } from "@/hooks/use-presence-positions"; import { cn, getInitials } from "@/lib/utils"; import { useMount } from "react-use"; +function getReactions(particle: Particle): Record | undefined { + if (particle.type === "media" || particle.type === "text") return particle.reactions; + return undefined; +} + function getParticleDisplayName(particle: Particle): string { switch (particle.type) { case "stream": @@ -128,6 +134,12 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [ { keys: ["H"], description: "Join huddle" }, ], }, + { + label: "Reactions", + bindings: [ + { keys: ["1-6"], description: "Toggle emoji reaction" }, + ], + }, ]; // --- StreamView --- @@ -171,6 +183,19 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { const mediaRef = useRef(null); + const handleToggleReaction = useCallback((emoji: string) => { + if (!authedUser || !currentParticle) return; + + + const currentParticleDocPath = currentParticle + ? toFirestoreDocPath(particlePath(networkId, [streamParticle.id, currentParticle.id])) + : null; + if (!currentParticleDocPath) return; + + const reactions = getReactions(currentParticle); + toggleParticleReaction(currentParticleDocPath, emoji, authedUser.id, reactions); + }, [authedUser, currentParticle]); + const [composeActive, setComposeActive] = useState(false); const [progress, setProgress] = useState(0); const [fastPlayback, setFastPlayback] = useState(false); @@ -262,6 +287,10 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { e.preventDefault(); setShowKeybindings((v) => !v); break; + case "1": case "2": case "3": case "4": case "5": case "6": + e.preventDefault(); + handleToggleReaction(REACTION_EMOJIS[parseInt(e.key) - 1]); + break; } }; @@ -286,7 +315,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { window.removeEventListener("keyup", handleKeyUp); }; }, - [composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings], + [composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings, handleToggleReaction], ); // Click-to-navigate: left 30% = prev, right 70% = next @@ -387,6 +416,18 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { )} + {/* Reaction bar — always visible */} + {currentParticle && ( +
+ +
+ )} + = { created_at: (raw.created_at as Timestamp).toDate(), created_by_human_id: raw.created_by_human_id, updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, + reactions: raw.reactions ?? undefined, }); default: throw new Error(`Unknown particle type: ${type}`); @@ -342,3 +345,18 @@ export async function updateStreamPlaybackMarker( updated_at: serverTimestamp(), }); } + +export async function toggleParticleReaction( + docPath: string, + emoji: string, + humanId: string, + currentReactions?: Reactions, +): Promise { + const particleRef = typedDoc(docPath); + const field = `reactions.${emoji}`; + const alreadyReacted = currentReactions?.[emoji]?.includes(humanId) ?? false; + await updateDoc(particleRef, { + [field]: alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId), + updated_at: serverTimestamp(), + }); +}