feat: allow reactions to particles in stream

Closes #111
This commit is contained in:
talksik
2026-04-08 10:14:47 -07:00
parent ac7336592a
commit 5f80bd15d5
4 changed files with 173 additions and 6 deletions
+101
View File
@@ -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 (
<div className="flex items-center gap-1.5">
{/* Existing reaction pills */}
{activeEmojis.map((emoji) => {
const reactors = reactions![emoji];
const isMine = reactors.includes(currentHumanId);
return (
<Tooltip key={emoji}>
<TooltipTrigger asChild>
<button
onClick={(e) => { e.stopPropagation(); handleToggle(emoji); }}
className={cn(
"flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors",
isMine
? "bg-white/20 ring-1 ring-white/40"
: "bg-black/40 hover:bg-black/50",
)}
>
<span className="text-sm">{emoji}</span>
<span className="text-white/80">{reactors.length}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{getReactorNames(reactors, humans)}
</TooltipContent>
</Tooltip>
);
})}
{/* Expand / picker toggle */}
{expanded ? (
<div className="flex items-center gap-0.5 rounded-full bg-black/40 px-1.5 py-0.5 backdrop-blur-sm">
{REACTION_EMOJIS.map((emoji) => {
// Skip emojis that already have pills
if (activeEmojis.includes(emoji)) return null;
return (
<button
key={emoji}
onClick={(e) => { e.stopPropagation(); handleToggle(emoji); }}
className="rounded-full px-1 py-0.5 text-sm transition-colors hover:bg-white/15"
>
{emoji}
</button>
);
})}
<button
onClick={(e) => { e.stopPropagation(); setExpanded(false); }}
className="flex size-5 items-center justify-center rounded-full transition-colors hover:bg-white/15"
>
<X className="size-3 text-white/60" />
</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>
);
}
+44 -3
View File
@@ -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<string, string[]> | 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<MediaParticleHandle>(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) {
)}
</div>
{/* Reaction bar — always visible */}
{currentParticle && (
<div className="absolute bottom-16 left-4 z-10">
<ReactionBar
reactions={getReactions(currentParticle)}
currentHumanId={authedUser?.id ?? ""}
humans={network?.humans}
onToggle={handleToggleReaction}
/>
</div>
)}
<ComposeOverlay
networkId={networkId}
targetPath={path}