@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
@@ -10,33 +11,55 @@ interface ReactionBarProps {
|
|||||||
reactions: Reactions;
|
reactions: Reactions;
|
||||||
currentHumanId: string;
|
currentHumanId: string;
|
||||||
humans?: Human[];
|
humans?: Human[];
|
||||||
onToggle: (emoji: string) => void;
|
onToggle: (key: string) => void;
|
||||||
|
onOpenTextReaction: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReactorNames(
|
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
||||||
humanIds: string[],
|
|
||||||
humans?: Human[],
|
function getReactorNames(humanIds: string[], humans?: Human[]): string {
|
||||||
): string {
|
|
||||||
return humanIds
|
return humanIds
|
||||||
.map((id) => resolveHumanDisplay(id, humans).displayName)
|
.map((id) => resolveHumanDisplay(id, humans).displayName)
|
||||||
.join(", ");
|
.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 [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const activeEmojis = REACTION_EMOJIS.filter(
|
const activeEmojis = REACTION_EMOJIS.filter(
|
||||||
(emoji) => reactions?.[emoji] && reactions[emoji].length > 0,
|
(emoji) => reactions?.[emoji] && reactions[emoji].length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToggle = (emoji: string) => {
|
const activeTextReactions = Object.keys(reactions ?? {}).filter(
|
||||||
onToggle(emoji);
|
(key) => !EMOJI_SET.has(key) && (reactions?.[key]?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggle = (key: string) => {
|
||||||
|
onToggle(key);
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-1.5">
|
<div className="flex flex-col items-end gap-1.5">
|
||||||
{/* Existing reaction pills */}
|
{/* Emoji reaction pills */}
|
||||||
{activeEmojis.map((emoji) => {
|
{activeEmojis.map((emoji) => {
|
||||||
const reactors = reactions![emoji];
|
const reactors = reactions![emoji];
|
||||||
const isMine = reactors.includes(currentHumanId);
|
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 ? (
|
{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">
|
<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) => {
|
{REACTION_EMOJIS.map((emoji) => {
|
||||||
// Skip emojis that already have pills
|
|
||||||
if (activeEmojis.includes(emoji)) return null;
|
if (activeEmojis.includes(emoji)) return null;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -87,12 +156,27 @@ export function ReactionBar({ reactions, currentHumanId, humans, onToggle }: Rea
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div className="flex flex-col items-center gap-1">
|
||||||
onClick={(e) => { e.stopPropagation(); setExpanded(true); }}
|
<Tooltip>
|
||||||
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
|
<TooltipTrigger asChild>
|
||||||
>
|
<button
|
||||||
<Plus className="size-3 text-white/60" />
|
onClick={(e) => { e.stopPropagation(); onOpenTextReaction(); }}
|
||||||
</button>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindin
|
|||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from "@/hooks/use-networks";
|
||||||
import { toggleParticleReaction } from "@/lib/firestore-particles";
|
import { toggleParticleReaction } from "@/lib/firestore-particles";
|
||||||
import { ReactionBar } from "@/features/particles/reaction-bar";
|
import { ReactionBar } from "@/features/particles/reaction-bar";
|
||||||
|
import { TextReactionInput } from "@/features/particles/text-reaction-input";
|
||||||
import { TopBar } from "@/features/particles/stream-top-bar";
|
import { TopBar } from "@/features/particles/stream-top-bar";
|
||||||
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
||||||
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
|
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
|
||||||
@@ -121,6 +122,7 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
|
|||||||
label: "Reactions",
|
label: "Reactions",
|
||||||
bindings: [
|
bindings: [
|
||||||
{ keys: ["1-7"], description: "Toggle emoji reaction" },
|
{ 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 paused = usePlaybackPauseStore(selectIsPaused);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [showKeybindings, setShowKeybindings] = useState(false);
|
const [showKeybindings, setShowKeybindings] = useState(false);
|
||||||
|
const [textReactionOpen, setTextReactionOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmitTextReaction = useCallback((text: string) => {
|
||||||
|
handleToggleReaction(text);
|
||||||
|
}, [handleToggleReaction]);
|
||||||
|
|
||||||
const { fastPlayback } = usePlaybackKeys({ mediaRef });
|
const { fastPlayback } = usePlaybackKeys({ mediaRef });
|
||||||
|
|
||||||
@@ -231,6 +238,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
onOpenHuddle: handleOpenHuddle,
|
onOpenHuddle: handleOpenHuddle,
|
||||||
onToggleRecordingMode: handleToggleRecordingMode,
|
onToggleRecordingMode: handleToggleRecordingMode,
|
||||||
onToggleKeybindings: handleToggleKeybindings,
|
onToggleKeybindings: handleToggleKeybindings,
|
||||||
|
onOpenTextReaction: () => setTextReactionOpen(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Broadcast composing state to other viewers
|
// Broadcast composing state to other viewers
|
||||||
@@ -394,12 +402,18 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
{/* Reaction bar — always visible */}
|
{/* Reaction bar — always visible */}
|
||||||
{currentParticle && !isParticleDeleted(currentParticle) && (
|
{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
|
<ReactionBar
|
||||||
reactions={getReactions(currentParticle)}
|
reactions={getReactions(currentParticle)}
|
||||||
currentHumanId={authedUser?.id ?? ""}
|
currentHumanId={authedUser?.id ?? ""}
|
||||||
humans={network?.humans}
|
humans={network?.humans}
|
||||||
onToggle={handleToggleReaction}
|
onToggle={handleToggleReaction}
|
||||||
|
onOpenTextReaction={() => setTextReactionOpen(true)}
|
||||||
|
/>
|
||||||
|
<TextReactionInput
|
||||||
|
open={textReactionOpen}
|
||||||
|
onSubmit={handleSubmitTextReaction}
|
||||||
|
onClose={() => setTextReactionOpen(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,17 +8,19 @@ interface UseStreamActionKeysOptions {
|
|||||||
onOpenHuddle: () => void;
|
onOpenHuddle: () => void;
|
||||||
onToggleRecordingMode: () => void;
|
onToggleRecordingMode: () => void;
|
||||||
onToggleKeybindings: () => void;
|
onToggleKeybindings: () => void;
|
||||||
|
onOpenTextReaction: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactions 1–7, `h` huddle, `v` toggle recording mode, `?` toggle
|
* Reactions 1–7, `r` quick text reply, `h` huddle, `v` toggle recording mode,
|
||||||
* keybindings overlay. Skipped while playback is paused for any reason.
|
* `?` toggle keybindings overlay. Skipped while playback is paused for any reason.
|
||||||
*/
|
*/
|
||||||
export function useStreamActionKeys({
|
export function useStreamActionKeys({
|
||||||
onToggleReaction,
|
onToggleReaction,
|
||||||
onOpenHuddle,
|
onOpenHuddle,
|
||||||
onToggleRecordingMode,
|
onToggleRecordingMode,
|
||||||
onToggleKeybindings,
|
onToggleKeybindings,
|
||||||
|
onOpenTextReaction,
|
||||||
}: UseStreamActionKeysOptions) {
|
}: UseStreamActionKeysOptions) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -34,6 +36,10 @@ export function useStreamActionKeys({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onToggleRecordingMode();
|
onToggleRecordingMode();
|
||||||
break;
|
break;
|
||||||
|
case "r":
|
||||||
|
e.preventDefault();
|
||||||
|
onOpenTextReaction();
|
||||||
|
break;
|
||||||
case "?":
|
case "?":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onToggleKeybindings();
|
onToggleKeybindings();
|
||||||
@@ -53,5 +59,5 @@ export function useStreamActionKeys({
|
|||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
}, [onToggleReaction, onOpenHuddle, onToggleRecordingMode, onToggleKeybindings]);
|
}, [onToggleReaction, onOpenHuddle, onToggleRecordingMode, onToggleKeybindings, onOpenTextReaction]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user