From aacea01c1f553091f095ffec94c3d365782c490f Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Wed, 29 Apr 2026 07:44:26 -0700 Subject: [PATCH] feat: text reactions Closes #180 --- js/src/features/particles/reaction-bar.tsx | 122 +++++++++++++++--- js/src/features/particles/stream-view.tsx | 16 ++- .../particles/text-reaction-input.tsx | 86 ++++++++++++ js/src/hooks/use-stream-action-keys.ts | 12 +- 4 files changed, 213 insertions(+), 23 deletions(-) create mode 100644 js/src/features/particles/text-reaction-input.tsx diff --git a/js/src/features/particles/reaction-bar.tsx b/js/src/features/particles/reaction-bar.tsx index d04c077..4243542 100644 --- a/js/src/features/particles/reaction-bar.tsx +++ b/js/src/features/particles/reaction-bar.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; -import { Plus, X } from "lucide-react"; +import { Plus, Type, X } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { REACTION_EMOJIS, type Reactions } from "@/api/types"; import { cn } from "@/lib/utils"; import { resolveHumanDisplay } from "@/lib/humans"; @@ -10,33 +11,55 @@ interface ReactionBarProps { reactions: Reactions; currentHumanId: string; humans?: Human[]; - onToggle: (emoji: string) => void; + onToggle: (key: string) => void; + onOpenTextReaction: () => void; } -function getReactorNames( - humanIds: string[], - humans?: Human[], -): string { +const EMOJI_SET = new Set(REACTION_EMOJIS); + +function getReactorNames(humanIds: string[], humans?: Human[]): string { return humanIds .map((id) => resolveHumanDisplay(id, humans).displayName) .join(", "); } -export function ReactionBar({ reactions, currentHumanId, humans, onToggle }: ReactionBarProps) { +function getReactorList( + humanIds: string[], + humans: Human[] | undefined, + currentHumanId: string, +): { id: string; label: string; isMine: boolean }[] { + return humanIds.map((id) => ({ + id, + label: resolveHumanDisplay(id, humans).displayName, + isMine: id === currentHumanId, + })); +} + +export function ReactionBar({ + reactions, + currentHumanId, + humans, + onToggle, + onOpenTextReaction, +}: ReactionBarProps) { const [expanded, setExpanded] = useState(false); const activeEmojis = REACTION_EMOJIS.filter( (emoji) => reactions?.[emoji] && reactions[emoji].length > 0, ); - const handleToggle = (emoji: string) => { - onToggle(emoji); + const activeTextReactions = Object.keys(reactions ?? {}).filter( + (key) => !EMOJI_SET.has(key) && (reactions?.[key]?.length ?? 0) > 0, + ); + + const handleToggle = (key: string) => { + onToggle(key); setExpanded(false); }; return ( -
- {/* Existing reaction pills */} +
+ {/* Emoji reaction pills */} {activeEmojis.map((emoji) => { const reactors = reactions![emoji]; const isMine = reactors.includes(currentHumanId); @@ -63,11 +86,57 @@ export function ReactionBar({ reactions, currentHumanId, humans, onToggle }: Rea ); })} - {/* Expand / picker toggle */} + {/* Text reaction pills */} + {activeTextReactions.map((text) => { + const reactors = reactions![text]; + const isMine = reactors.includes(currentHumanId); + const firstReactor = resolveHumanDisplay(reactors[0], humans); + const reactorList = getReactorList(reactors, humans, currentHumanId); + return ( + + + + + +
“{text}”
+
    + {reactorList.map((r) => ( +
  • + {r.label} + {r.isMine && (you)} +
  • + ))} +
+
+ {isMine ? "Click to remove" : "Click to add yours"} +
+
+
+ ); + })} + + {/* Picker / actions */} {expanded ? (
{REACTION_EMOJIS.map((emoji) => { - // Skip emojis that already have pills if (activeEmojis.includes(emoji)) return null; return (
) : ( - +
+ + + + + + Quick reply R + + + +
)}
); diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index b164081..3d16eb3 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -16,6 +16,7 @@ import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindin import { useNetwork } from "@/hooks/use-networks"; import { toggleParticleReaction } from "@/lib/firestore-particles"; import { ReactionBar } from "@/features/particles/reaction-bar"; +import { TextReactionInput } from "@/features/particles/text-reaction-input"; import { TopBar } from "@/features/particles/stream-top-bar"; import { useStreamPlayback } from "@/hooks/use-stream-playback"; import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media"; @@ -121,6 +122,7 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [ label: "Reactions", bindings: [ { keys: ["1-7"], description: "Toggle emoji reaction" }, + { keys: ["R"], description: "Quick text reply" }, ], }, ]; @@ -200,6 +202,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { const paused = usePlaybackPauseStore(selectIsPaused); const [progress, setProgress] = useState(0); const [showKeybindings, setShowKeybindings] = useState(false); + const [textReactionOpen, setTextReactionOpen] = useState(false); + + const handleSubmitTextReaction = useCallback((text: string) => { + handleToggleReaction(text); + }, [handleToggleReaction]); const { fastPlayback } = usePlaybackKeys({ mediaRef }); @@ -231,6 +238,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { onOpenHuddle: handleOpenHuddle, onToggleRecordingMode: handleToggleRecordingMode, onToggleKeybindings: handleToggleKeybindings, + onOpenTextReaction: () => setTextReactionOpen(true), }); // Broadcast composing state to other viewers @@ -394,12 +402,18 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { {/* Reaction bar — always visible */} {currentParticle && !isParticleDeleted(currentParticle) && ( -
+
setTextReactionOpen(true)} + /> + setTextReactionOpen(false)} />
)} diff --git a/js/src/features/particles/text-reaction-input.tsx b/js/src/features/particles/text-reaction-input.tsx new file mode 100644 index 0000000..5f2c172 --- /dev/null +++ b/js/src/features/particles/text-reaction-input.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef, useState } from "react"; +import { Send } from "lucide-react"; +import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; +import { cn } from "@/lib/utils"; + +const MAX_LENGTH = 40; + +interface TextReactionInputProps { + open: boolean; + onSubmit: (text: string) => void; + onClose: () => void; +} + +export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInputProps) { + const [value, setValue] = useState(""); + const inputRef = useRef(null); + + useSuspendPlayback(open, "text-reaction"); + + useEffect(() => { + if (!open) return; + setValue(""); + const id = requestAnimationFrame(() => inputRef.current?.focus()); + return () => cancelAnimationFrame(id); + }, [open]); + + if (!open) return null; + + const trimmed = value.trim(); + const canSubmit = trimmed.length > 0; + const remaining = MAX_LENGTH - value.length; + + const handleSubmit = () => { + if (!canSubmit) return; + onSubmit(trimmed); + onClose(); + }; + + return ( +
e.stopPropagation()} + className="flex items-center gap-1 rounded-full bg-black/60 py-1 pl-3 pr-1 shadow-lg ring-1 ring-white/15 backdrop-blur-md" + > + setValue(e.target.value.slice(0, MAX_LENGTH))} + onBlur={onClose} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSubmit(); + } else if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }} + placeholder="Quick reply…" + maxLength={MAX_LENGTH} + className="w-24 bg-transparent text-sm text-white outline-none placeholder:text-white/40" + /> + + {remaining} + + +
+ ); +} diff --git a/js/src/hooks/use-stream-action-keys.ts b/js/src/hooks/use-stream-action-keys.ts index 8ea23db..8596446 100644 --- a/js/src/hooks/use-stream-action-keys.ts +++ b/js/src/hooks/use-stream-action-keys.ts @@ -8,17 +8,19 @@ interface UseStreamActionKeysOptions { onOpenHuddle: () => void; onToggleRecordingMode: () => void; onToggleKeybindings: () => void; + onOpenTextReaction: () => void; } /** - * Reactions 1–7, `h` huddle, `v` toggle recording mode, `?` toggle - * keybindings overlay. Skipped while playback is paused for any reason. + * Reactions 1–7, `r` quick text reply, `h` huddle, `v` toggle recording mode, + * `?` toggle keybindings overlay. Skipped while playback is paused for any reason. */ export function useStreamActionKeys({ onToggleReaction, onOpenHuddle, onToggleRecordingMode, onToggleKeybindings, + onOpenTextReaction, }: UseStreamActionKeysOptions) { useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { @@ -34,6 +36,10 @@ export function useStreamActionKeys({ e.preventDefault(); onToggleRecordingMode(); break; + case "r": + e.preventDefault(); + onOpenTextReaction(); + break; case "?": e.preventDefault(); onToggleKeybindings(); @@ -53,5 +59,5 @@ export function useStreamActionKeys({ window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [onToggleReaction, onOpenHuddle, onToggleRecordingMode, onToggleKeybindings]); + }, [onToggleReaction, onOpenHuddle, onToggleRecordingMode, onToggleKeybindings, onOpenTextReaction]); }