feat: text reactions

Closes #180
This commit is contained in:
Arjun Patel
2026-04-29 07:44:26 -07:00
parent eb1e77b4c1
commit aacea01c1f
4 changed files with 213 additions and 23 deletions
+103 -19
View File
@@ -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<string>(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 (
<div className="flex flex-col items-center gap-1.5">
{/* Existing reaction pills */}
<div className="flex flex-col items-end gap-1.5">
{/* 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 (
<Tooltip key={text}>
<TooltipTrigger asChild>
<button
onClick={(e) => { e.stopPropagation(); handleToggle(text); }}
className={cn(
"flex max-w-[200px] items-center gap-1.5 rounded-full py-0.5 pl-0.5 pr-2.5 text-xs backdrop-blur-sm transition-colors",
isMine
? "bg-white/20 ring-1 ring-white/40"
: "bg-black/40 hover:bg-black/50",
)}
>
<Avatar size="xs" className="shrink-0">
<AvatarFallback className="bg-white/15 text-[9px] font-medium text-white">
{firstReactor.initials}
</AvatarFallback>
</Avatar>
<span className="truncate text-white/90">{text}</span>
{reactors.length > 1 && (
<span className="shrink-0 text-white/60">{reactors.length}</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-[260px] space-y-1.5 text-xs">
<div className="font-medium">{text}</div>
<ul className="flex flex-col gap-0.5 opacity-80">
{reactorList.map((r) => (
<li key={r.id} className={cn(r.isMine && "font-medium opacity-100")}>
{r.label}
{r.isMine && <span className="ml-1 opacity-60">(you)</span>}
</li>
))}
</ul>
<div className="border-t border-current/15 pt-1 text-[10px] opacity-60">
{isMine ? "Click to remove" : "Click to add yours"}
</div>
</TooltipContent>
</Tooltip>
);
})}
{/* Picker / actions */}
{expanded ? (
<div className="flex flex-col items-center gap-0.5 rounded-full bg-black/40 px-0.5 py-1.5 backdrop-blur-sm">
{REACTION_EMOJIS.map((emoji) => {
// Skip emojis that already have pills
if (activeEmojis.includes(emoji)) return null;
return (
<button
@@ -87,12 +156,27 @@ export function ReactionBar({ reactions, currentHumanId, humans, onToggle }: Rea
</button>
</div>
) : (
<button
onClick={(e) => { e.stopPropagation(); setExpanded(true); }}
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
>
<Plus className="size-3 text-white/60" />
</button>
<div className="flex flex-col items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => { e.stopPropagation(); onOpenTextReaction(); }}
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
>
<Type className="size-3 text-white/60" />
</button>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
Quick reply <kbd className="ml-1 rounded bg-white/10 px-1 font-mono text-[10px]">R</kbd>
</TooltipContent>
</Tooltip>
<button
onClick={(e) => { e.stopPropagation(); setExpanded(true); }}
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
>
<Plus className="size-3 text-white/60" />
</button>
</div>
)}
</div>
);
+15 -1
View File
@@ -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) && (
<div className="absolute right-4 top-1/2 -translate-y-1/2 z-10">
<div className="absolute right-4 top-1/2 z-10 flex -translate-y-1/2 flex-col items-end gap-2">
<ReactionBar
reactions={getReactions(currentParticle)}
currentHumanId={authedUser?.id ?? ""}
humans={network?.humans}
onToggle={handleToggleReaction}
onOpenTextReaction={() => setTextReactionOpen(true)}
/>
<TextReactionInput
open={textReactionOpen}
onSubmit={handleSubmitTextReaction}
onClose={() => setTextReactionOpen(false)}
/>
</div>
)}
@@ -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<HTMLInputElement>(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 (
<div
onClick={(e) => 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"
>
<input
ref={inputRef}
value={value}
onChange={(e) => 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"
/>
<span
className={cn(
"min-w-[1.5ch] text-right text-[10px] tabular-nums",
remaining <= 8 ? "text-amber-300/80" : "text-white/30",
)}
>
{remaining}
</span>
<button
onMouseDown={(e) => e.preventDefault()}
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
"ml-1 flex size-6 items-center justify-center rounded-full transition-colors",
canSubmit
? "bg-white/20 text-white hover:bg-white/30"
: "text-white/30",
)}
aria-label="Send reaction"
>
<Send className="size-3" />
</button>
</div>
);
}
+9 -3
View File
@@ -8,17 +8,19 @@ interface UseStreamActionKeysOptions {
onOpenHuddle: () => void;
onToggleRecordingMode: () => void;
onToggleKeybindings: () => void;
onOpenTextReaction: () => void;
}
/**
* Reactions 17, `h` huddle, `v` toggle recording mode, `?` toggle
* keybindings overlay. Skipped while playback is paused for any reason.
* Reactions 17, `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]);
}