refactor: organize desktop vs. mobile into separate folders

This commit is contained in:
Arjun Patel
2026-04-29 08:42:56 -07:00
parent 3d9fe67936
commit 3a11a82cd3
194 changed files with 213 additions and 213 deletions
@@ -0,0 +1,61 @@
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay";
import { softDeleteParticle } from "@/lib/firestore-particles";
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
import type { Particle } from "@/api/types";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
interface DeleteParticleOverlayProps {
networkId: string;
streamId: string;
particle: Particle;
userId: string;
onClose: () => void;
}
export function DeleteParticleOverlay({
networkId,
streamId,
particle,
userId,
onClose,
}: DeleteParticleOverlayProps) {
useSuspendPlayback(true, "delete-particle");
const [deleting, setDeleting] = useState(false);
const handleDelete = useCallback(async () => {
if (deleting) return;
setDeleting(true);
try {
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamId, particle.id]),
);
await softDeleteParticle(docPath, userId);
toast.success("Particle deleted");
onClose();
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to delete particle";
toast.error(message);
setDeleting(false);
}
}, [deleting, networkId, onClose, particle.id, streamId, userId]);
return (
<ConfirmDestructiveOverlay
title="Delete this particle?"
description={
<p>
This cannot be undone. Other viewers will see a "This particle was
deleted" message in its place.
</p>
}
confirmLabel="Delete"
pendingLabel="Deleting…"
isPending={deleting}
onConfirm={handleDelete}
onClose={onClose}
/>
);
}
@@ -0,0 +1,51 @@
import { useEffect } from "react";
import { Trash2 } from "lucide-react";
import type { Particle } from "@/api/types";
import { useNetwork } from "@/hooks/use-networks";
import { resolveHumanDisplay } from "@/lib/humans";
// How long to linger on a tombstone before auto-advancing. Matches the
// "reading" cadence of a short text particle.
const TOMBSTONE_DURATION_MS = 2000;
interface DeletedParticleViewProps {
particle: Particle;
networkId: string;
paused: boolean;
onEnded: () => void;
}
export function DeletedParticleView({
particle,
networkId,
paused,
onEnded,
}: DeletedParticleViewProps) {
const network = useNetwork(networkId);
const deleterId =
"deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined;
const deleter = deleterId
? resolveHumanDisplay(deleterId, network?.humans)
: null;
useEffect(() => {
if (paused) return;
const timeout = setTimeout(onEnded, TOMBSTONE_DURATION_MS);
return () => clearTimeout(timeout);
}, [paused, onEnded, particle.id]);
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8">
<div className="flex flex-col items-center gap-3 text-center">
<Trash2 className="text-white/40 size-6" />
<p className="text-white/70 text-base font-medium">
This particle was deleted
</p>
{deleter && (
<p className="text-white/40 text-xs">by {deleter.displayName}</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,65 @@
import type { Particle } from "@/api/types";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { FileIcon, HelpCircleIcon, ScrollTextIcon, BookOpenIcon } from "lucide-react";
import { useNetwork } from "@/hooks/use-networks";
import { resolveHumanDisplay } from "@/lib/humans";
const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
quest: { icon: ScrollTextIcon, label: "Quest" },
paper: { icon: BookOpenIcon, label: "Paper" },
file: { icon: FileIcon, label: "File" },
};
interface FallbackParticleViewProps {
particle: Particle;
networkId: string;
}
export function FallbackParticleView({ particle, networkId }: FallbackParticleViewProps) {
const network = useNetwork(networkId);
const creator = resolveHumanDisplay(particle.created_by_human_id, network?.humans);
const meta = TYPE_META[particle.type] ?? {
icon: HelpCircleIcon,
label: particle.type,
};
const Icon = meta.icon;
const title = (() => {
switch (particle.type) {
case "quest":
return particle.properties.title;
case "paper":
return particle.properties.title;
case "file":
return particle.properties.filename;
case "folder":
return particle.properties.name;
default:
return null;
}
})();
return (
<div className="flex h-full w-full items-center justify-center p-8">
<Card className="w-full max-w-sm">
<CardHeader className="flex flex-row items-center gap-3">
<Icon className="text-muted-foreground h-6 w-6 shrink-0" />
<div>
<CardTitle className="text-base">{meta.label}</CardTitle>
{title && <CardDescription>{title}</CardDescription>}
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-xs">
From {creator.displayName}
</p>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,23 @@
import { Particle } from "@/api/types";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
import { ComposeOverlay } from "@/features/compose/compose-overlay";
interface FolderViewProps {
folderParticle: Particle;
path: ParticlePath;
}
export function FolderView({ path, folderParticle }: FolderViewProps) {
const { children, error, isLoading } = useLiveParticleChildren(path);
const { networkId } = parseParticlePath(path);
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Folder view {folderParticle.id}
</p>
<ComposeOverlay networkId={networkId} />
</div>
);
}
@@ -0,0 +1,170 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import type { Particle } from "@/api/types";
import type { ParticlePath } from "@/lib/particle-path";
import { useDownloadUrl } from "@/hooks/use-download-url";
import { useTranscriptPlayback } from "@/hooks/use-transcript-playback";
import { TranscriptOverlay } from "@/features/particles/transcript-overlay";
import { Skeleton } from "@/components/ui/skeleton";
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
import { useAudioSource } from "@/components/audio/use-audio-source";
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
import { ParticleAttachments } from "@/features/particles/particle-attachments";
type MediaParticle = Extract<Particle, { type: "media" }>;
export interface MediaParticleHandle {
/** Seek by delta. Returns true if seeked, false if at boundary (should navigate). */
seek: (deltaSec: number) => boolean;
setPlaybackRate: (rate: number) => void;
}
interface MediaParticleViewProps {
particle: MediaParticle;
streamPath: ParticlePath;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleViewProps>(function MediaParticleView({
particle,
streamPath,
paused,
onEnded,
onProgress,
}, ref) {
const { data: url, error } = useDownloadUrl(particle.properties.object_id);
const { attachments } = useParticleAttachments(streamPath, particle.id);
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const isAudio = particle.properties.mime_type?.startsWith("audio/");
useImperativeHandle(ref, () => ({
seek(deltaSec: number) {
const el = isAudio ? audioRef.current : videoRef.current;
if (!el) return false;
if (deltaSec < 0 && el.currentTime < Math.abs(deltaSec)) return false;
if (deltaSec > 0 && el.duration - el.currentTime < deltaSec) return false;
el.currentTime = Math.max(0, Math.min(el.duration, el.currentTime + deltaSec));
return true;
},
setPlaybackRate(rate: number) {
const el = isAudio ? audioRef.current : videoRef.current;
if (el) el.playbackRate = rate;
},
}), [isAudio]);
const [currentTime, setCurrentTime] = useState(0);
const transcript = particle.properties.transcript;
const { activeSentence, activeWordIndex } = useTranscriptPlayback(
transcript,
currentTime,
);
// WORKAROUND: useAudioSource needs an audio element, but audioRef is only set after mount
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const audioSource = useAudioSource(audioEl);
useEffect(() => {
const el = isAudio ? audioRef.current : videoRef.current;
if (!el) return;
if (paused) {
el.pause();
} else if (!el.ended) {
// Calling play() on a naturally-finished element restarts it from 0.
el.play().catch(() => {
console.warn("Playback failed", { particleId: particle.id });
});
}
}, [paused, isAudio, particle.id]);
if (error) {
return (
<div className="text-muted-foreground flex items-center justify-center text-sm">
Failed to load media
</div>
);
}
if (!url) {
return <Skeleton className="h-full w-full rounded-none" />;
}
const handleTimeUpdate = (e: React.SyntheticEvent<HTMLAudioElement | HTMLVideoElement>) => {
const { currentTime: time, duration } = e.currentTarget;
setCurrentTime(time);
// WebM files from MediaRecorder (screen recordings) often report Infinity/NaN
// duration until fully buffered — fall back to the known duration from metadata.
const effectiveDuration = Number.isFinite(duration) && duration > 0
? duration
: particle.properties.duration_ms / 1000;
if (effectiveDuration > 0) onProgress?.(time / effectiveDuration);
};
const attachmentOverlay = attachments.length > 0 && (
<div className="absolute inset-x-0 top-12 z-10 px-4">
<ParticleAttachments attachments={attachments} variant="compact" />
</div>
);
if (isAudio) {
return (
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
<audio
ref={(el) => {
audioRef.current = el;
setAudioEl(el);
}}
crossOrigin="anonymous"
src={url}
autoPlay
onEnded={onEnded}
onTimeUpdate={handleTimeUpdate}
/>
{audioSource && (
<div className="z-10 absolute bottom-20">
<AudioLevelBars sourceNode={audioSource.sourceNode} />
</div>
)}
{transcript && (
<TranscriptOverlay
transcript={transcript}
activeSentence={activeSentence}
activeWordIndex={activeWordIndex}
centered
/>
)}
{attachmentOverlay}
</div>
);
}
return (
<div className="relative h-full w-full">
<video
ref={videoRef}
src={url}
autoPlay
playsInline
onEnded={onEnded}
onTimeUpdate={handleTimeUpdate}
className={`h-full w-full ${particle.properties.source === "screen" ? "object-contain bg-black" : "object-cover"}`}
/>
{transcript && (
<TranscriptOverlay
transcript={transcript}
activeSentence={activeSentence}
activeWordIndex={activeWordIndex}
/>
)}
{attachmentOverlay}
</div>
);
});
@@ -0,0 +1,275 @@
import { useMemo, useState } from "react";
import { Download, ExternalLink, FileIcon, ImageIcon } from "lucide-react";
import type { Particle } from "@/api/types";
import { useDownloadUrl } from "@/hooks/use-download-url";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import {
AttachmentLightbox,
getAttachmentHandler,
type AttachmentItem,
} from "@/features/attachments/attachment-lightbox";
type FileParticle = Extract<Particle, { type: "file" }>;
interface ParticleAttachmentsProps {
attachments: FileParticle[];
variant?: "inline" | "compact";
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function particleToItem(p: FileParticle): AttachmentItem {
return {
id: p.id,
filename: p.properties.filename,
mimeType: p.properties.mime_type,
sizeBytes: p.properties.size_bytes,
source: { kind: "remote", objectId: p.properties.object_id },
};
}
/**
* Open the lightbox for previewable types; hand off to the OS for files.
*/
function openParticle(
particle: FileParticle,
index: number,
url: string | undefined,
onPreview: (index: number) => void,
) {
if (getAttachmentHandler(particle.properties.mime_type) === "lightbox") {
onPreview(index);
} else if (url) {
window.electronLink.openExternal(url);
}
}
function ImageAttachment({
particle,
onPreview,
}: {
particle: FileParticle;
onPreview: () => void;
}) {
const { data: url, isLoading } = useDownloadUrl(particle.properties.object_id);
if (isLoading || !url) {
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg bg-white/10" />;
}
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
window.electronAttachment.download(url, particle.properties.filename);
};
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onPreview();
}}
className="group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg bg-white/10"
>
<img
src={url}
alt={particle.properties.filename}
className="h-full w-full object-cover"
/>
<span
role="button"
tabIndex={0}
onClick={handleDownload}
className="absolute bottom-1 right-1 rounded-full bg-black/60 p-1 text-white/70 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
>
<Download className="size-3.5" />
</span>
</button>
);
}
function FileAttachment({
particle,
index,
onPreview,
}: {
particle: FileParticle;
index: number;
onPreview: (index: number) => void;
}) {
const { data: url } = useDownloadUrl(particle.properties.object_id);
const handleOpen = (e: React.MouseEvent) => {
e.stopPropagation();
openParticle(particle, index, url, onPreview);
};
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
if (!url) return;
window.electronAttachment.download(url, particle.properties.filename);
};
return (
<div
role="button"
onClick={handleOpen}
className="flex shrink-0 cursor-pointer flex-col gap-1.5 rounded-lg bg-white/10 px-3 py-2 transition-colors hover:bg-white/15"
>
<div className="flex items-center gap-2">
<FileIcon className="size-4 shrink-0 text-white/60" />
<span className="max-w-[10rem] truncate text-xs font-medium text-white/90">
{particle.properties.filename}
</span>
<span className="text-[10px] text-white/40">
{formatFileSize(particle.properties.size_bytes)}
</span>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white"
onClick={handleOpen}
>
<ExternalLink data-icon="inline-start" />
Open
</Button>
<Button
variant="ghost"
size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white"
onClick={handleDownload}
>
<Download data-icon="inline-start" />
Download
</Button>
</div>
</div>
);
}
function CompactAttachmentItem({
particle,
index,
onPreview,
}: {
particle: FileParticle;
index: number;
onPreview: (index: number) => void;
}) {
const isImage = particle.properties.mime_type.startsWith("image/");
const { data: url } = useDownloadUrl(particle.properties.object_id);
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openParticle(particle, index, url, onPreview);
}}
className="flex w-full items-center gap-2 rounded-md bg-white/10 px-2.5 py-1.5 text-left transition-colors hover:bg-white/15"
>
{isImage ? (
url ? (
<img
src={url}
alt={particle.properties.filename}
className="size-5 shrink-0 rounded object-cover"
/>
) : (
<ImageIcon className="size-4 shrink-0 text-white/50" />
)
) : (
<FileIcon className="size-4 shrink-0 text-white/50" />
)}
<span className="min-w-0 truncate text-xs text-white/80">
{particle.properties.filename}
</span>
<span className="shrink-0 text-[10px] text-white/40">
{formatFileSize(particle.properties.size_bytes)}
</span>
</button>
);
}
export function ParticleAttachments({ attachments, variant = "inline" }: ParticleAttachmentsProps) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
// Only previewable attachments populate the lightbox; the index passed to the
// lightbox is the index into this filtered list, not `attachments`.
const previewable = useMemo(
() => attachments.filter((a) => getAttachmentHandler(a.properties.mime_type) === "lightbox"),
[attachments],
);
const items = useMemo(() => previewable.map(particleToItem), [previewable]);
const handlePreview = (attachmentIndex: number) => {
const particle = attachments[attachmentIndex];
if (!particle) return;
const previewIdx = previewable.indexOf(particle);
if (previewIdx >= 0) setOpenIndex(previewIdx);
};
if (attachments.length === 0) return null;
const lightbox = items.length > 0 && (
<AttachmentLightbox
items={items}
openIndex={openIndex}
onOpenChange={setOpenIndex}
/>
);
if (variant === "compact") {
return (
<>
<div className="flex max-w-48 flex-col gap-1">
{attachments.map((attachment, i) => (
<CompactAttachmentItem
key={attachment.id}
particle={attachment}
index={i}
onPreview={handlePreview}
/>
))}
</div>
{lightbox}
</>
);
}
return (
<>
<ScrollArea className="w-full">
<div className="flex items-center gap-2 py-1">
{attachments.map((attachment, i) => {
const isImage = attachment.properties.mime_type.startsWith("image/");
return isImage ? (
<ImageAttachment
key={attachment.id}
particle={attachment}
onPreview={() => handlePreview(i)}
/>
) : (
<FileAttachment
key={attachment.id}
particle={attachment}
index={i}
onPreview={handlePreview}
/>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
{lightbox}
</>
);
}
@@ -0,0 +1,363 @@
import { useMemo, useRef, useEffect, useCallback, memo } from "react";
import { useNavigate } from "react-router-dom";
import {
Radio,
MessageSquare,
Video,
Mic,
Image,
FileText,
CircleCheck,
StickyNote,
Headphones,
Trash2,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useLiveLatestChild } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import { particlePath } from "@/lib/particle-path";
import { getInitials } from "@/lib/utils";
import { resolveHumanDisplay } from "@/lib/humans";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { Progress } from "@/components/ui/progress";
import { Small } from "@/components/ui/typography";
import { Button } from "@/components/ui/button";
import { isParticleDeleted, type Particle, type StreamProperties } from "@/api/types";
import type { StreamParticle } from "@/hooks/use-stream-particles";
import { useNetwork } from "@/hooks/use-networks";
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
import { useDownloadUrl } from "@/hooks/use-download-url";
import { StreamContextMenu } from "@/features/particles/stream-context-menu";
function VideoThumbnail({
objectId,
isUnseen,
}: {
objectId: string;
isUnseen: boolean;
}) {
const { data: url } = useDownloadUrl(objectId);
return (
<div
className={cn(
"size-8 shrink-0 overflow-hidden rounded-md bg-muted",
isUnseen && "ring-2 ring-primary",
)}
>
{url && (
<video
// Seek ~15 frames in so we skip any initial black/fade-in frames
src={`${url}#t=0.5`}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
/>
)}
</div>
);
}
function getParticleTypeIcon(particle: Particle): LucideIcon {
if (isParticleDeleted(particle)) return Trash2;
switch (particle.type) {
case "text":
return MessageSquare;
case "media": {
const mime = particle.properties.mime_type;
if (mime.startsWith("video/")) return Video;
if (mime.startsWith("audio/")) return Mic;
if (mime.startsWith("image/")) return Image;
return Video;
}
case "file":
return FileText;
case "quest":
return CircleCheck;
case "paper":
return StickyNote;
default:
return Radio;
}
}
function getMessagePreview(particle: Particle): string {
if (isParticleDeleted(particle)) return "Deleted particle";
switch (particle.type) {
case "text":
return particle.properties.content;
case "media": {
const mime = particle.properties.mime_type;
if (mime.startsWith("image/")) return "Photo";
if (mime.startsWith("video/") || mime.startsWith("audio/")) {
const transcriptText = particle.properties.transcript?.transcript;
if (transcriptText) return transcriptText;
return mime.startsWith("video/") ? "Video clip" : "Voice note";
}
return "Media";
}
case "file":
return particle.properties.filename;
case "quest":
return particle.properties.title;
case "paper":
return particle.properties.title;
default:
return particle.type;
}
}
const StreamRow = memo(function StreamRow({
particle,
networkId,
onNavigate,
isSelected,
shortcutKey,
}: {
particle: Particle & { type: "stream"; properties: StreamProperties };
networkId: string;
onNavigate: (streamId: string) => void;
isSelected?: boolean;
shortcutKey?: number;
}) {
const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath);
const user = useAuthStore((s) => s.user);
const userId = user?.id ?? "";
const network = useNetwork(networkId);
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
const hasActiveHuddle =
particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
const huddleCount = particle.huddle_active_participants?.length ?? 0;
const isDM =
particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith("human:"));
const initials = useMemo(() => {
if (isDM) {
const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`,
);
if (otherEntry) {
const otherId = otherEntry.replace("human:", "");
const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email);
}
}
if (latestChild) {
const creator = network?.humans?.find((h) => h.id === latestChild.created_by_human_id);
if (creator) return getInitials(creator.email);
}
return particle.properties.name.slice(0, 2).toUpperCase();
}, [isDM, particle.visible_to, particle.properties.name, userId, latestChild, network]);
const isUnseen = useMemo(() => {
if (!latestChild) return false;
const latestChildTimestamp = latestChild.created_at.getTime();
const userPlaybackPosition =
particle.playback_markers?.[userId]?.getTime() ?? 0;
return latestChildTimestamp > userPlaybackPosition;
}, [latestChild, particle.playback_markers, userId]);
const senderPrefix = useMemo(() => {
if (!latestChild) return null;
const isCurrentUser = latestChild.created_by_human_id === userId;
if (isDM) {
return isCurrentUser ? "You: " : null;
}
// Group stream
if (isCurrentUser) return "You: ";
const { displayName } = resolveHumanDisplay(
latestChild.created_by_human_id,
network?.humans,
);
const capitalized = displayName.charAt(0).toUpperCase() + displayName.slice(1);
return `${capitalized}: `;
}, [latestChild, userId, isDM, network]);
const subtitle = latestChild
? getMessagePreview(latestChild)
: particle.properties.name;
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
const videoThumbObjectId =
latestChild &&
!isParticleDeleted(latestChild) &&
latestChild.type === "media" &&
latestChild.properties.mime_type.startsWith("video/")
? latestChild.properties.object_id
: null;
return (
<div
role="button"
tabIndex={0}
onClick={() => onNavigate(particle.id)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onNavigate(particle.id); }}
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent",
isSelected && "bg-accent",
hasActiveHuddle && "bg-gradient-to-r from-red-500/10 to-transparent",
)}
>
{shortcutKey && (
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
{shortcutKey}
</kbd>
)}
{videoThumbObjectId ? (
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
) : (
<Avatar className={cn(isUnseen && "ring-2 ring-primary")}>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p
className={cn(
"truncate text-sm",
isUnseen
? "font-semibold text-foreground"
: "font-medium text-muted-foreground",
)}
>
{particle.properties.name}
</p>
<div className="flex shrink-0 items-center gap-1.5">
{hasActiveHuddle && (
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
<Headphones className="size-3 text-red-400" />
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
</span>
)}
{latestChild && (
<Small
className={cn(
"shrink-0",
isUnseen ? "text-primary" : "text-muted-foreground",
)}
>
<RelativeTimestamp date={latestChild.created_at} />
</Small>
)}
</div>
</div>
<div className="flex items-center gap-1">
<TypeIcon
className={cn(
"size-3.5 shrink-0",
isUnseen ? "text-foreground" : "text-muted-foreground",
)}
/>
<Small
className={cn(
"truncate",
isUnseen
? "text-foreground font-medium"
: "text-muted-foreground font-normal",
)}
>
{senderPrefix && (
<span className="text-muted-foreground">{senderPrefix}</span>
)}
{subtitle}
</Small>
</div>
</div>
{isUnseen && (
<span className="size-2 shrink-0 rounded-full bg-primary" />
)}
</div>
);
});
interface ParticleListViewProps {
streams: StreamParticle[];
networkId: string;
isLoading: boolean;
selectedIndex?: number | null;
/** When true, render a footer that invokes onLoadMore. */
canLoadMore?: boolean;
onLoadMore?: () => void;
}
/**
* List of stream particles for a container (network root, folder, etc.).
*/
export function ParticleListView({
streams,
networkId,
isLoading,
selectedIndex,
canLoadMore,
onLoadMore,
}: ParticleListViewProps) {
const navigate = useNavigate();
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
const navigateToStream = useCallback(
(streamId: string) => navigate(`/${networkId}/${streamId}`),
[navigate, networkId],
);
useEffect(() => {
if (selectedIndex !== null && selectedIndex !== undefined && selectedIndex >= 0) {
rowRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
}
}, [selectedIndex]);
if (isLoading) {
return <Progress />;
}
if (streams.length === 0) {
return (
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-2 px-4 text-center">
<Radio className="text-muted-foreground size-8" />
<p className="text-muted-foreground text-sm">
No streams here. Start a conversation using the keyboard shortcuts below.
</p>
</div>
);
}
return (
<div>
{streams.map((stream, index) => (
<StreamContextMenu key={stream.id} particle={stream} networkId={networkId}>
<div
ref={(el) => { rowRefs.current[index] = el; }}
>
<StreamRow
particle={stream}
networkId={networkId}
onNavigate={navigateToStream}
isSelected={index === selectedIndex}
shortcutKey={index < 9 ? index + 1 : undefined}
/>
{index < streams.length - 1 && <Separator className="px-4" />}
</div>
</StreamContextMenu>
))}
{canLoadMore && onLoadMore && (
<div className="flex justify-center p-3">
<Button variant="ghost" size="sm" onClick={onLoadMore}>
Load more
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,174 @@
import { useEffect, useState } from "react";
import type { Particle } from "@/api/types";
import { apiClient } from "@/api/client";
import { Skeleton } from "@/components/ui/skeleton";
import {
Video,
Mic,
ScrollText,
BookOpen,
FileIcon,
FolderIcon,
} from "lucide-react";
export function ParticlePreview({ particle }: { particle: Particle }) {
switch (particle.type) {
case "text":
return <TextPreview particle={particle} />;
case "media":
return <MediaPreview particle={particle} />;
case "quest":
return <QuestPreview particle={particle} />;
case "paper":
return <PaperPreview particle={particle} />;
case "file":
return <FilePreview particle={particle} />;
case "folder":
return <FolderPreview particle={particle} />;
default:
return <EmptyPreview />;
}
}
function TextPreview({ particle }: { particle: Extract<Particle, { type: "text" }> }) {
const truncated =
particle.properties.content.length > 30
? particle.properties.content.slice(0, 30) + "..."
: particle.properties.content;
return (
<div className="flex h-full w-full items-center justify-center p-4">
<p className="line-clamp-4 text-center text-xl leading-relaxed">
{truncated}
</p>
</div>
);
}
function MediaPreview({ particle }: { particle: Extract<Particle, { type: "media" }> }) {
const { mime_type, duration_ms } = particle.properties;
const isVideo = mime_type.startsWith("video");
const durationSec = Math.round(duration_ms / 1000);
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, "0")}`;
if (isVideo) {
return <VideoThumbnail particleId={particle.properties.object_id} duration={durationLabel} />;
}
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-black/90">
<Mic className="h-8 w-8 text-white/60" />
<span className="font-mono text-xs text-white/50">{durationLabel}</span>
</div>
);
}
function VideoThumbnail({
particleId,
duration,
}: {
particleId: string;
duration: string;
}) {
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
apiClient
.getParticleDownloadUrl(particleId)
.then((downloadUrl) => {
if (!cancelled) setUrl(downloadUrl);
})
.catch(() => {
if (!cancelled) setError(true);
});
return () => {
cancelled = true;
};
}, [particleId]);
if (error) {
return (
<div className="flex h-full w-full items-center justify-center bg-black/80">
<Video className="h-8 w-8 text-white/40" />
</div>
);
}
if (!url) {
return <Skeleton className="h-full w-full rounded-none" />;
}
return (
<div className="relative h-full w-full bg-black">
<video
src={`${url}#t=2`}
preload="metadata"
muted
playsInline
className="h-full w-full object-cover"
/>
<span className="absolute right-1.5 bottom-1.5 rounded bg-black/70 px-1.5 py-0.5 font-mono text-[10px] text-white/80">
{duration}
</span>
</div>
);
}
function QuestPreview({ particle }: { particle: Extract<Particle, { type: "quest" }> }) {
const { title, status } = particle.properties;
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
<ScrollText className="h-6 w-6 text-amber-600/70 dark:text-amber-400/70" />
<p className="line-clamp-2 text-center text-sm font-medium">
{title}
</p>
{status && (
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
{status}
</span>
)}
</div>
);
}
function PaperPreview({ particle }: { particle: Extract<Particle, { type: "paper" }> }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-blue-500/10 p-4">
<BookOpen className="h-6 w-6 text-blue-600/70 dark:text-blue-400/70" />
<p className="line-clamp-2 text-center text-sm font-medium">
{particle.properties.title}
</p>
</div>
);
}
function FilePreview({ particle }: { particle: Extract<Particle, { type: "file" }> }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-emerald-500/10 p-4">
<FileIcon className="h-6 w-6 text-emerald-600/70 dark:text-emerald-400/70" />
<p className="text-muted-foreground line-clamp-1 text-center text-xs">
{particle.properties.filename}
</p>
</div>
);
}
function FolderPreview({ particle }: { particle: Extract<Particle, { type: "folder" }> }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-orange-500/10 p-4">
<FolderIcon className="h-6 w-6 text-orange-600/70 dark:text-orange-400/70" />
<p className="text-muted-foreground line-clamp-1 text-center text-xs">
{particle.properties.name}
</p>
</div>
);
}
function EmptyPreview() {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-muted-foreground text-xs italic">No messages yet</p>
</div>
);
}
@@ -0,0 +1,79 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Lock } from "lucide-react";
import { useLiveParticle } from "@/hooks/use-particle";
import { particlePath } from "@/lib/particle-path";
import { Button } from "@/components/ui/button";
import { StreamView } from "@/features/particles/stream-view";
import { FolderView } from "@/features/particles/folder-view";
/**
* Route-level component for /:networkId/*.
* Reads params from the router, resolves the particle, and renders
* the appropriate view based on particle type.
*/
export default function ParticleViewResolver() {
const { networkId, "*": rest } = useParams();
const segments = (rest ?? "").split("/").filter(Boolean);
const path = particlePath(networkId!, segments); // path of current container particle
const { particle, isLoading, error } = useLiveParticle(path);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
);
}
if (error || !particle) {
// Errors here are almost always Firestore permission-denied — the user lost
// access to the network or to a custom-visibility particle. The React Router
// stays on the dead route, so without an explicit escape the user is stuck.
return <InaccessibleParticle />;
}
switch (particle.type) {
case "stream":
return <StreamView streamParticle={particle} path={path} />;
case "folder":
return <FolderView folderParticle={particle} path={path} />;
default:
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
{particle.type} particle: {particle.id}
</p>
</div>
);
}
}
function InaccessibleParticle() {
const navigate = useNavigate();
const queryClient = useQueryClient();
useEffect(() => {
// Refresh the networks list so the home page reflects current access.
queryClient.invalidateQueries({ queryKey: ["networks"] });
}, [queryClient]);
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<Lock className="text-muted-foreground size-8" />
<div className="flex max-w-sm flex-col gap-1">
<p className="text-sm font-medium">This particle isn't available</p>
<p className="text-muted-foreground text-xs">
It may have been deleted, or your access was removed.
</p>
</div>
<Button size="sm" onClick={() => navigate("/", { replace: true })}>
Go home
</Button>
</div>
);
}
@@ -0,0 +1,111 @@
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { HumanPresence } from "@/hooks/use-presence-positions";
const MAX_VISIBLE_AVATARS = 3;
interface PlaybackPageIndicatorProps {
total: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
presenceBySegment?: Map<number, HumanPresence[]>;
/** Set of humanIds currently online in the stream channel. */
onlineHumanIds?: Set<string>;
/** Render only avatars or only tracks. Omit to render both. */
layer?: "avatars" | "tracks";
}
export function PlaybackPageIndicator({
total,
current,
progress,
onGoTo,
presenceBySegment,
onlineHumanIds,
layer,
}: PlaybackPageIndicatorProps) {
if (total === 0) return null;
const showAvatars = layer !== "tracks";
const showTracks = layer !== "avatars";
return (
<div className="flex w-full items-end gap-px leading-none">
{Array.from({ length: total }, (_, i) => {
const presence = presenceBySegment?.get(i);
return (
<div key={i} className="flex flex-1 flex-col items-stretch">
{showAvatars && presence && presence.length > 0 && (
<SegmentPresenceAvatars presence={presence} onlineHumanIds={onlineHumanIds} />
)}
{showTracks && (
<button
onClick={(e) => {
e.stopPropagation();
onGoTo(i);
}}
className="group relative block h-3 w-full"
>
{/* Dim track */}
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" />
{/* Fill */}
<div
className="absolute left-0 bottom-0 h-[3px] bg-white/90 transition-all group-hover:h-1.5"
style={{
width:
i < current
? "100%"
: i === current
? `${progress * 100}%`
: "0%",
transition: i === current ? "width 300ms linear" : "none",
}}
/>
</button>
)}
</div>
);
})}
</div>
);
}
function SegmentPresenceAvatars({
presence,
onlineHumanIds,
}: {
presence: HumanPresence[];
onlineHumanIds?: Set<string>;
}) {
const visible = presence.slice(0, MAX_VISIBLE_AVATARS);
const overflow = presence.length - MAX_VISIBLE_AVATARS;
return (
<div className="flex items-center justify-center -space-x-1.5 pb-0.5">
{visible.map((human) => (
<Tooltip key={human.humanId}>
<TooltipTrigger asChild>
<Avatar size="xs" className={onlineHumanIds?.has(human.humanId) ? "ring-2 ring-green-500" : "ring-1 ring-black/50"}>
<AvatarFallback>
{human.emailPrefix.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{human.email}
</TooltipContent>
</Tooltip>
))}
{overflow > 0 && (
<span className="text-[10px] text-white/70 pl-1">
+{overflow}
</span>
)}
</div>
);
}
@@ -0,0 +1,183 @@
import { useState } from "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";
import type { Human } from "@/api/types";
interface ReactionBarProps {
reactions: Reactions;
currentHumanId: string;
humans?: Human[];
onToggle: (key: string) => void;
onOpenTextReaction: () => void;
}
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
function getReactorNames(humanIds: string[], humans?: Human[]): string {
return humanIds
.map((id) => resolveHumanDisplay(id, humans).displayName)
.join(", ");
}
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 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-end gap-1.5">
{/* Emoji 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="left" className="text-xs">
{getReactorNames(reactors, humans)}
</TooltipContent>
</Tooltip>
);
})}
{/* 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) => {
if (activeEmojis.includes(emoji)) return null;
return (
<button
key={emoji}
onClick={(e) => { e.stopPropagation(); handleToggle(emoji); }}
className="rounded-full px-0.5 py-1 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>
) : (
<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>
);
}
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { updateParticleProperties } from "@/lib/firestore-particles";
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
import type { Particle } from "@/api/types";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
interface RenameStreamOverlayProps {
networkId: string;
streamParticle: Particle & { type: "stream" };
onClose: () => void;
}
export function RenameStreamOverlay({
networkId,
streamParticle,
onClose,
}: RenameStreamOverlayProps) {
useSuspendPlayback(true, "rename-stream");
const [name, setName] = useState(streamParticle.properties.name);
const [saving, setSaving] = useState(false);
const trimmed = name.trim();
const canSave =
!saving &&
trimmed.length > 0 &&
trimmed !== streamParticle.properties.name;
const handleSave = useCallback(async () => {
if (!canSave) return;
setSaving(true);
try {
const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
await updateParticleProperties<"stream">(docPath, { name: trimmed });
onClose();
} finally {
setSaving(false);
}
}, [canSave, networkId, onClose, streamParticle.id, trimmed]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true });
}, [onClose]);
return createPortal(
<div className="fixed inset-0 z-[100]">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Rename stream</h2>
<span className="text-xs text-white/30">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
to close
</span>
</div>
<Input
type="text"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
}
}}
placeholder="Stream name"
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/>
<div className="mt-4 flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={!canSave}>
Save
</Button>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,169 @@
import { forwardRef, useMemo } from "react";
import { Headphones } from "lucide-react";
import { cn, getInitials } from "@/lib/utils";
import { useLiveLatestChild } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import { particlePath } from "@/lib/particle-path";
import type { Particle, StreamProperties } from "@/api/types";
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
import { ParticlePreview } from "@/features/particles/particle-preview";
import { useNetwork } from "@/hooks/use-networks";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { Small } from "@/components/ui/typography";
interface StreamCardProps {
particle: Particle & { type: "stream"; properties: StreamProperties };
networkId: string;
onClick: () => void;
isSelected?: boolean;
shortcutKey?: number;
}
export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function StreamCard({ particle, networkId, onClick, isSelected, shortcutKey }, ref) {
const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath);
const userId = useAuthStore((s) => s.user?.id) ?? "";
const network = useNetwork(networkId);
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
const hasActiveHuddle =
particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
const huddleCount = particle.huddle_active_participants?.length ?? 0;
const isDM =
particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith("human:"));
const initials = useMemo(() => {
if (isDM) {
const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`,
);
if (otherEntry) {
const otherId = otherEntry.replace("human:", "");
const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email);
}
}
if (latestChild) {
const creator = network?.humans?.find(
(h) => h.id === latestChild.created_by_human_id,
);
if (creator) return getInitials(creator.email);
}
return particle.properties.name.slice(0, 2).toUpperCase();
}, [
isDM,
particle.visible_to,
particle.properties.name,
userId,
latestChild,
network,
]);
const isUnseen = useMemo(() => {
if (!latestChild) return false;
const latestChildTimestamp = latestChild.created_at.getTime();
const userPlaybackPosition =
particle.playback_markers?.[userId]?.getTime() ?? 0;
return latestChildTimestamp > userPlaybackPosition;
}, [latestChild, particle.playback_markers, userId]);
// For media particles with a transcript, show it as an overlay on the preview
const transcript =
latestChild?.type === "media"
? latestChild.properties.transcript?.transcript
: undefined;
return (
<div
ref={ref}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onClick();
}}
className={cn(
"cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20",
isUnseen && "ring-2 ring-primary",
isSelected && "ring-2 ring-ring",
hasActiveHuddle && "ring-2 ring-red-500/70",
)}
>
{/* Preview area */}
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
{hasActiveHuddle && (
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
)}
{shortcutKey && (
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
{shortcutKey}
</kbd>
)}
{latestChild ? (
<ParticlePreview particle={latestChild} />
) : (
<div className="flex h-full w-full items-center justify-center">
<p className="text-muted-foreground text-xs italic">
No messages yet
</p>
</div>
)}
{/* Transcript overlay for media with transcripts */}
{transcript && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-2.5 pt-6 pb-2">
<p className="line-clamp-2 text-md leading-snug text-white/90">
{transcript}
</p>
</div>
)}
</div>
{/* Info bar */}
<div className="flex items-center gap-2 px-2.5 py-2">
<Avatar className={cn("size-6 shrink-0", isUnseen && "ring-2 ring-primary")}>
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
{initials}
</AvatarFallback>
</Avatar>
<Small
className={cn(
"min-w-0 truncate",
isUnseen
? "font-semibold text-foreground"
: "font-medium text-muted-foreground",
)}
>
{particle.properties.name}
</Small>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{hasActiveHuddle && (
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
<Headphones className="size-3 text-red-400" />
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
</span>
)}
{latestChild && (
<Small
className={cn(
"text-[10px]",
isUnseen ? "text-primary" : "text-muted-foreground",
)}
>
<RelativeTimestamp date={latestChild.created_at} />
</Small>
)}
{isUnseen && (
<span className="size-2 shrink-0 rounded-full bg-primary" />
)}
</div>
</div>
</div>
);
});
@@ -0,0 +1,46 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { CircleCheckBig, CircleDot } from "lucide-react";
import { updateStreamStatus } from "@/lib/firestore-particles";
import { toFirestoreDocPath, particlePath } from "@/lib/particle-path";
import type { StreamParticle } from "@/hooks/use-stream-particles";
interface StreamContextMenuProps {
particle: StreamParticle;
networkId: string;
children: React.ReactNode;
}
export function StreamContextMenu({ particle, networkId, children }: StreamContextMenuProps) {
const isOpen = particle.status === "open";
const docPath = toFirestoreDocPath(particlePath(networkId, [particle.id]));
const toggleStatus = async () => {
await updateStreamStatus(docPath, isOpen ? "closed" : "open");
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={toggleStatus}>
{isOpen ? (
<>
<CircleCheckBig className="size-4" />
Close stream
</>
) : (
<>
<CircleDot className="size-4 text-green-500" />
Open stream
</>
)}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
@@ -0,0 +1,272 @@
import { useCallback, useEffect, useMemo } from "react";
import { createPortal } from "react-dom";
import { X, UserPlus, Globe, Users, Lock } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
buildCustomVisibility,
buildNetworkVisibility,
parseVisibleTo,
} from "@/lib/stream-visibility";
import { updateParticleVisibleTo } from "@/lib/firestore-particles";
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
import { useNetwork } from "@/hooks/use-networks";
import { cn, getInitials } from "@/lib/utils";
import { resolveHumanDisplay } from "@/lib/humans";
import type { Particle } from "@/api/types";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
interface StreamMembersOverlayProps {
networkId: string;
streamParticle: Particle & { type: "stream" };
isCreator: boolean;
onClose: () => void;
}
export function StreamMembersOverlay({
networkId,
streamParticle,
isCreator,
onClose,
}: StreamMembersOverlayProps) {
useSuspendPlayback(true, "stream-members");
const network = useNetwork(networkId);
const humans = network?.humans ?? [];
const creatorId = streamParticle.created_by_human_id;
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
const docPath = useMemo(
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
[networkId, streamParticle.id],
);
const memberIds =
visibility.mode === "network"
? humans.map((h) => h.id)
: visibility.humanIds;
const memberSet = new Set(memberIds);
const availableToAdd = humans.filter((h) => !memberSet.has(h.id));
const setNetworkWide = useCallback(() => {
void updateParticleVisibleTo(docPath, buildNetworkVisibility(networkId));
}, [docPath, networkId]);
const setCustomOnlyCreator = useCallback(() => {
void updateParticleVisibleTo(docPath, buildCustomVisibility([creatorId]));
}, [docPath, creatorId]);
const removeMember = useCallback(
(id: string) => {
if (visibility.mode !== "custom") return;
if (id === creatorId) return;
const next = visibility.humanIds.filter((x) => x !== id);
if (next.length === 0) return;
void updateParticleVisibleTo(docPath, buildCustomVisibility(next));
},
[docPath, creatorId, visibility],
);
const addMember = useCallback(
(id: string) => {
if (visibility.mode !== "custom") return;
void updateParticleVisibleTo(
docPath,
buildCustomVisibility([...visibility.humanIds, id]),
);
},
[docPath, visibility],
);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true });
}, [onClose]);
return createPortal(
<div className="fixed inset-0 z-[100]">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 flex max-h-[80vh] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Members</h2>
<span className="text-xs text-white/30">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
to close
</span>
</div>
{/* Visibility */}
<section className="mb-4">
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
Visibility
</h3>
{isCreator ? (
<div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 p-1">
<VisibilityPill
active={visibility.mode === "network"}
icon={<Globe className="size-3.5" />}
label="Network-wide"
onClick={setNetworkWide}
/>
<VisibilityPill
active={visibility.mode === "custom"}
icon={<Lock className="size-3.5" />}
label="Specific people"
onClick={setCustomOnlyCreator}
/>
</div>
) : (
<div className="flex items-center gap-2 text-sm text-white/70">
{visibility.mode === "network" ? (
<>
<Globe className="size-3.5 text-white/40" />
<span>Everyone in {network?.name ?? "network"}</span>
</>
) : (
<>
<Lock className="size-3.5 text-white/40" />
<span>{memberIds.length} specific people</span>
</>
)}
</div>
)}
</section>
{/* Member list */}
<section className="flex min-h-0 flex-1 flex-col">
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
{visibility.mode === "network" ? "Has access" : "People"}{" "}
<span className="ml-1 text-white/20">{memberIds.length}</span>
</h3>
<ScrollArea className="min-h-0 flex-1">
<ul className="flex flex-col gap-0.5 pr-2">
{memberIds.map((id) => {
const display = resolveHumanDisplay(id, humans);
const isCreatorRow = id === creatorId;
const canRemove =
isCreator && visibility.mode === "custom" && !isCreatorRow;
return (
<li
key={id}
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70"
>
<Avatar size="sm">
<AvatarFallback className="text-[10px]">
{display.initials}
</AvatarFallback>
</Avatar>
<span
className={cn(
"flex-1 truncate",
!display.exists && "italic text-white/40",
)}
>
{display.displayName}
</span>
{isCreatorRow && (
<span className="text-[10px] uppercase tracking-wider text-white/30">
Creator
</span>
)}
{canRemove && (
<button
type="button"
onClick={() => removeMember(id)}
className="rounded p-1 text-white/30 opacity-0 transition-opacity hover:bg-white/10 hover:text-white/70 group-hover:opacity-100"
aria-label={`Remove ${display.displayName}`}
>
<X className="size-3.5" />
</button>
)}
</li>
);
})}
</ul>
</ScrollArea>
</section>
{/* Add */}
{isCreator && visibility.mode === "custom" && availableToAdd.length > 0 && (
<section className="mt-4 border-t border-white/5 pt-4">
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
<UserPlus className="size-3" />
Add people
</h3>
<ScrollArea className="max-h-32">
<ul className="flex flex-col gap-0.5 pr-2">
{availableToAdd.map((human) => (
<li key={human.id}>
<button
type="button"
onClick={() => addMember(human.id)}
className={cn(
"flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5",
)}
>
<Avatar size="sm">
<AvatarFallback className="text-[10px]">
{getInitials(human.email)}
</AvatarFallback>
</Avatar>
<span className="flex-1 truncate">{human.email_prefix}</span>
<UserPlus className="size-3.5 text-white/30" />
</button>
</li>
))}
</ul>
</ScrollArea>
</section>
)}
{isCreator && visibility.mode === "custom" && availableToAdd.length === 0 && (
<p className="mt-4 text-center text-xs text-white/30">
<Users className="mr-1 inline size-3" />
Everyone in the network is already a member
</p>
)}
</div>
</div>,
document.body,
);
}
function VisibilityPill({
active,
icon,
label,
onClick,
}: {
active: boolean;
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors",
active
? "bg-white/10 text-white/90"
: "text-white/50 hover:text-white/80",
)}
>
{icon}
{label}
</button>
);
}
@@ -0,0 +1,229 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useChannel } from "@/hooks/use-channel";
import { useAuthStore } from "@/stores/auth-store";
import type { ChannelMessage } from "@/lib/pusher-client";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ComposingMode = "recording" | "typing" | "screen";
export interface ComposingUser {
humanId: string;
mode: ComposingMode;
lastSeen: number;
}
interface StreamPresenceContextValue {
onlineHumanIds: Set<string>;
composingUsers: ComposingUser[];
startComposing: (mode: ComposingMode) => void;
stopComposing: () => void;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const COMPOSING_TIMEOUT_MS = 10_000;
const COMPOSING_HEARTBEAT_MS = 5_000;
const COMPOSING_CLEANUP_INTERVAL_MS = 2_000;
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const StreamPresenceContext = createContext<StreamPresenceContextValue | null>(
null,
);
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
interface StreamPresenceProviderProps {
networkId: string;
streamId: string;
children: ReactNode;
}
export function StreamPresenceProvider({
networkId,
streamId,
children,
}: StreamPresenceProviderProps) {
const channelId = `stream:${networkId}:${streamId}`;
const { presence, messages, sendMessage } = useChannel(channelId);
const currentUserId = useAuthStore((s) => s.user?.id);
// --- Online presence ---
const onlineHumanIds = useMemo(() => new Set(presence), [presence]);
// --- Composing state ---
const [composingUsers, setComposingUsers] = useState<ComposingUser[]>([]);
const composingMapRef = useRef(new Map<string, ComposingUser>());
const processedCountRef = useRef(0);
// Process new messages incrementally
useEffect(() => {
if (messages.length <= processedCountRef.current) return;
const newMessages = messages.slice(processedCountRef.current);
processedCountRef.current = messages.length;
let changed = false;
const map = composingMapRef.current;
for (const msg of newMessages) {
const payload = msg.payload as
| { type: string; mode?: string }
| undefined;
if (!payload?.type) continue;
// Skip own events
if (msg.humanId === currentUserId) continue;
if (payload.type === "composing_start" && payload.mode) {
map.set(msg.humanId, {
humanId: msg.humanId,
mode: payload.mode as ComposingMode,
lastSeen: Date.now(),
});
changed = true;
} else if (payload.type === "composing_stop") {
if (map.delete(msg.humanId)) changed = true;
}
}
if (changed) {
setComposingUsers(Array.from(map.values()));
}
}, [messages, currentUserId]);
// Also clear composing when a user leaves the channel
useEffect(() => {
const map = composingMapRef.current;
const onlineSet = new Set(presence);
let changed = false;
for (const humanId of map.keys()) {
if (!onlineSet.has(humanId)) {
map.delete(humanId);
changed = true;
}
}
if (changed) {
setComposingUsers(Array.from(map.values()));
}
}, [presence]);
// Cleanup stale composing entries
useEffect(() => {
const interval = setInterval(() => {
const map = composingMapRef.current;
const now = Date.now();
let changed = false;
for (const [humanId, entry] of map) {
if (now - entry.lastSeen > COMPOSING_TIMEOUT_MS) {
map.delete(humanId);
changed = true;
}
}
if (changed) {
setComposingUsers(Array.from(map.values()));
}
}, COMPOSING_CLEANUP_INTERVAL_MS);
return () => clearInterval(interval);
}, []);
// --- Composing broadcast ---
const heartbeatRef = useRef<ReturnType<typeof setInterval>>(undefined);
const startComposing = useCallback(
(mode: ComposingMode) => {
// Send immediately
sendMessage({ type: "composing_start", mode });
// Clear any existing heartbeat
clearInterval(heartbeatRef.current);
// Start heartbeat
heartbeatRef.current = setInterval(() => {
sendMessage({ type: "composing_start", mode });
}, COMPOSING_HEARTBEAT_MS);
},
[sendMessage],
);
const stopComposing = useCallback(() => {
clearInterval(heartbeatRef.current);
heartbeatRef.current = undefined;
sendMessage({ type: "composing_stop" });
}, [sendMessage]);
// Cleanup heartbeat on unmount
useEffect(() => {
return () => {
clearInterval(heartbeatRef.current);
};
}, []);
const value = useMemo<StreamPresenceContextValue>(
() => ({
onlineHumanIds,
composingUsers,
startComposing,
stopComposing,
}),
[onlineHumanIds, composingUsers, startComposing, stopComposing],
);
return (
<StreamPresenceContext.Provider value={value}>
{children}
</StreamPresenceContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
function useStreamPresenceContext() {
const ctx = useContext(StreamPresenceContext);
if (!ctx) {
throw new Error(
"useStreamPresence must be used within a StreamPresenceProvider",
);
}
return ctx;
}
export function useStreamPresence() {
const { onlineHumanIds } = useStreamPresenceContext();
return { onlineHumanIds };
}
export function useStreamComposing() {
const { composingUsers } = useStreamPresenceContext();
return { composingUsers };
}
export function useStreamComposingBroadcast() {
const { startComposing, stopComposing } = useStreamPresenceContext();
return { startComposing, stopComposing };
}
@@ -0,0 +1,304 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import { apiClient } from "@/api/client";
import { isParticleDeleted, type Particle } from "@/api/types";
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useNetwork } from "@/hooks/use-networks";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Settings, CircleCheckBig, CircleDot, EllipsisVertical, Pencil, Globe, Trash2 } from "lucide-react";
import { updateStreamStatus } from "@/lib/firestore-particles";
import { RenameStreamOverlay } from "@/features/particles/rename-stream-overlay";
import { DeleteParticleOverlay } from "@/features/particles/delete-particle-overlay";
import { StreamMembersOverlay } from "@/features/particles/stream-members-overlay";
import { parseVisibleTo } from "@/lib/stream-visibility";
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
import { WindowControls } from "@/components/window-controls";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { useStreamPresence } from "@/features/particles/stream-presence-context";
import { resolveHumanDisplay } from "@/lib/humans";
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
case "stream":
case "folder":
return particle.properties.name;
case "quest":
return particle.properties.title;
case "paper":
return particle.properties.title;
case "file":
return particle.properties.filename;
case "text":
return particle.properties.content.slice(0, 30);
case "media":
return particle.type;
}
}
interface TopBarProps {
networkId: string;
particle: Particle | null;
streamParticle: Particle & { type: "stream" };
}
export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
const navigate = useNavigate();
const network = useNetwork(networkId);
const userId = useAuthStore((s) => s.user?.id);
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
const [renameOpen, setRenameOpen] = useState(false);
const [membersOpen, setMembersOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const canDeleteParticle =
!!particle &&
!!userId &&
particle.created_by_human_id === userId &&
particle.type !== "stream" &&
particle.type !== "folder" &&
!isParticleDeleted(particle);
const huddleParticipants = streamParticle.huddle_active_participants ?? [];
const hasActiveHuddle = huddleParticipants.length > 0;
const handleJoinHuddle = () => {
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
window.electronWindow.openHuddle({ token, serverUrl: server_url });
});
};
return (
<div className="drag-region flex flex-row px-2 gap-1 items-center">
<WindowControls />
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto">
<BreadcrumbList>
{streamParticle && (
<>
<BreadcrumbItem className="text-xs">
<BreadcrumbPage>{getParticleDisplayName(streamParticle)}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
{particle && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs">
<BreadcrumbPage><ParticleBreadcrumbContent particle={particle} networkId={networkId} /></BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
{hasActiveHuddle && (
<button
onClick={handleJoinHuddle}
className="no-drag flex items-center gap-2 rounded-full bg-red-500/20 px-3 py-1 backdrop-blur-sm transition-colors hover:bg-red-500/30"
>
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
</span>
<AvatarGroup>
{huddleParticipants.map((humanId) => {
const display = resolveHumanDisplay(humanId, network?.humans);
return (
<Tooltip key={humanId}>
<TooltipTrigger asChild>
<Avatar size="sm">
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200">
{display.initials}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>{display.email}</TooltipContent>
</Tooltip>
);
})}
</AvatarGroup>
<span className="text-xs font-medium text-red-200">Join</span>
</button>
)}
{streamParticle.status === "closed" && (
<span className="no-drag flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-muted-foreground backdrop-blur-sm">
<CircleCheckBig className="size-3" />
Closed
</span>
)}
<MembersIndicator
networkId={networkId}
streamParticle={streamParticle}
onClick={() => setMembersOpen(true)}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
>
<EllipsisVertical className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={async () => {
const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
await updateStreamStatus(docPath, streamParticle.status === "open" ? "closed" : "open");
}}
>
{streamParticle.status === "open" ? (
<>
<CircleCheckBig className="size-4" />
Close stream
</>
) : (
<>
<CircleDot className="size-4 text-green-500" />
Open stream
</>
)}
</DropdownMenuItem>
{isCreator && (
<DropdownMenuItem onSelect={() => setRenameOpen(true)}>
<Pencil className="size-4" />
Rename stream
</DropdownMenuItem>
)}
{canDeleteParticle && (
<DropdownMenuItem
onSelect={() => setDeleteOpen(true)}
variant="destructive"
>
<Trash2 className="size-4" />
Delete particle
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => navigate("/settings")}>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{renameOpen && isCreator && (
<RenameStreamOverlay
networkId={networkId}
streamParticle={streamParticle}
onClose={() => setRenameOpen(false)}
/>
)}
{deleteOpen && canDeleteParticle && particle && userId && (
<DeleteParticleOverlay
networkId={networkId}
streamId={streamParticle.id}
particle={particle}
userId={userId}
onClose={() => setDeleteOpen(false)}
/>
)}
{membersOpen && (
<StreamMembersOverlay
networkId={networkId}
streamParticle={streamParticle}
isCreator={isCreator}
onClose={() => setMembersOpen(false)}
/>
)}
</div>
);
}
function MembersIndicator({
networkId,
streamParticle,
onClick,
}: {
networkId: string;
streamParticle: Particle & { type: "stream" };
onClick: () => void;
}) {
const network = useNetwork(networkId);
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
const humans = network?.humans ?? [];
const memberIds =
visibility.mode === "network"
? humans.map((h) => h.id)
: visibility.humanIds;
const shownMembers = memberIds
.slice(0, 3)
.map((id) => humans.find((h) => h.id === id))
.filter((h): h is NonNullable<typeof h> => !!h);
const overflow = memberIds.length - shownMembers.length;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onClick}
className="no-drag flex items-center gap-1.5 rounded-full bg-white/5 px-2 py-1 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-white/10"
>
{visibility.mode === "network" ? (
<>
<Globe className="size-3 text-white/50" />
<span>Everyone</span>
</>
) : (
<>
<AvatarGroup>
{shownMembers.map((human) => (
<Avatar key={human.id} size="sm">
<AvatarFallback className="text-[8px]">
{resolveHumanDisplay(human.id, humans).initials}
</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
{overflow > 0 && <span className="text-white/50">+{overflow}</span>}
</>
)}
</button>
</TooltipTrigger>
<TooltipContent>
{visibility.mode === "network"
? `Everyone in ${network?.name ?? "network"}`
: `${memberIds.length} ${memberIds.length === 1 ? "member" : "members"}`}
</TooltipContent>
</Tooltip>
);
}
function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) {
const network = useNetwork(networkId);
const { onlineHumanIds } = useStreamPresence();
const display = resolveHumanDisplay(particle.created_by_human_id, network?.humans);
const isOnline = particle.created_by_human_id ? onlineHumanIds.has(particle.created_by_human_id) : false;
return (
<span className="flex items-center gap-1.5">
<Avatar size="sm" className={isOnline ? "ring-2 ring-green-500" : ""}>
<AvatarFallback>
{display.initials}
</AvatarFallback>
</Avatar>
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
</span>
);
}
@@ -0,0 +1,568 @@
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 { isParticleDeleted, type Particle } from "@/api/types";
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
import { ComposeOverlay, type ComposeStep } from "@/features/compose/compose-overlay";
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view";
import { TextParticleView } from "@/features/particles/text-particle-view";
import { FallbackParticleView } from "@/features/particles/fallback-particle-view";
import { DeletedParticleView } from "@/features/particles/deleted-particle-view";
import { VideoAudioToggle } from "@/components/video-audio-toggle";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindings-overlay";
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";
import { usePresencePositions } from "@/hooks/use-presence-positions";
import { StreamPresenceProvider, useStreamPresence, useStreamComposing, useStreamComposingBroadcast, type ComposingMode } from "@/features/particles/stream-presence-context";
import { ComposingIndicator } from "@/components/composing-indicator";
import { cn } from "@/lib/utils";
import { useMount } from "react-use";
import { usePlaybackPauseStore, selectIsPaused } from "@/stores/playback-pause-store";
import { usePlaybackKeys } from "@/hooks/use-playback-keys";
import { useStreamNavigationKeys } from "@/hooks/use-stream-navigation-keys";
import { useStreamActionKeys } from "@/hooks/use-stream-action-keys";
import { c } from "vite/dist/node/types.d-aGj9QkWt";
function getReactions(particle: Particle): Record<string, string[]> | undefined {
if (isParticleDeleted(particle)) return undefined;
if (particle.type === "media" || particle.type === "text") return particle.reactions;
return undefined;
}
// --- Exit countdown hook ---
const EXIT_DELAY_MS = 5000;
const EXIT_TICK_MS = 100;
type PlaybackStatus = "idle" | "playing" | "ended";
function useExitCountdown(
status: PlaybackStatus,
disabled: boolean,
onExit: () => void,
) {
const [remainingMs, setRemainingMs] = useState<number | null>(null);
const handleExit = useEffectEvent(() => {
onExit();
});
// Start/cancel countdown based on playback status
useEffect(() => {
if (status === "ended") {
setRemainingMs(EXIT_DELAY_MS);
} else {
setRemainingMs(null);
}
}, [status]);
// Tick the countdown down (pauses when compose is active)
useEffect(() => {
if (remainingMs === null || remainingMs <= 0 || disabled) return;
const interval = setInterval(() => {
setRemainingMs((prev) => {
if (prev === null) return null;
const next = prev - EXIT_TICK_MS;
return next <= 0 ? 0 : next;
});
}, EXIT_TICK_MS);
return () => clearInterval(interval);
}, [remainingMs !== null && remainingMs > 0, disabled]);
// Navigate once countdown hits zero
useEffect(() => {
if (remainingMs !== null && remainingMs <= 0) {
handleExit();
}
}, [remainingMs]);
return remainingMs;
}
// --- Keybindings ---
const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
{
label: "Navigation",
bindings: [
{ keys: ["←", "→", "↑", "↓"], description: "Previous / next particle" },
{ keys: ["Esc"], description: "Back to network" },
],
},
{
label: "Playback",
bindings: [
{ keys: ["Space"], description: "Toggle pause" },
{ keys: ["Hold", "Space"], description: "Pause while held" },
{ keys: ["Hold", "Shift"], description: "1.5× speed" },
{ keys: ["Shift", "←", "→"], description: "Seek ±5s" },
],
},
{
label: "Compose",
bindings: [
{ keys: ["Hold", "`"], description: "Reply" },
{ keys: ["S"], description: "Screen record" },
{ keys: ["T"], description: "Text compose" },
{ keys: ["V"], description: "Toggle video / audio" },
{ keys: ["H"], description: "Join huddle" },
],
},
{
label: "Reactions",
bindings: [
{ keys: ["1-7"], description: "Toggle emoji reaction" },
{ keys: ["R"], description: "Quick text reply" },
],
},
];
// --- StreamView ---
interface StreamViewProps {
streamParticle: Particle & { type: "stream" };
path: ParticlePath;
}
export function StreamView({ path, streamParticle }: StreamViewProps) {
const { networkId } = parseParticlePath(path);
return (
<StreamPresenceProvider networkId={networkId} streamId={streamParticle.id}>
<StreamViewInner path={path} streamParticle={streamParticle} />
</StreamPresenceProvider>
);
}
function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const { networkId } = parseParticlePath(path);
const navigate = useNavigate();
useMount(() => {
window.electronAutoplay.dismiss();
});
const {
children,
currentParticle,
currentIndex,
status,
next,
prev,
goTo,
goToParticle
} = useStreamPlayback(streamParticle, path);
usePrefetchAdjacentMedia(children, currentIndex);
const authedUser = useAuthStore((s) => s.user);
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
const network = useNetwork(networkId);
const presenceBySegment = usePresencePositions(
streamParticle.playback_markers,
children,
network?.humans,
authedUser?.id,
);
// --- Stream presence (realtime via pusher) ---
const { onlineHumanIds } = useStreamPresence();
const { composingUsers } = useStreamComposing();
const { startComposing, stopComposing } = useStreamComposingBroadcast();
const mediaRef = useRef<MediaParticleHandle>(null);
const handleToggleReaction = useCallback((emoji: string) => {
if (!authedUser || !currentParticle) return;
if (isParticleDeleted(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 [composeStep, setComposeStep] = useState<ComposeStep>("idle");
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 });
useStreamNavigationKeys({
next,
prev,
currentIndex,
childrenLength: children.length,
mediaRef,
});
const handleOpenHuddle = useCallback(() => {
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
window.electronWindow.openHuddle({ token, serverUrl: server_url });
});
navigate(`/${networkId}`);
}, [networkId, streamParticle.id, navigate]);
const handleToggleRecordingMode = useCallback(() => {
setRecordingMode(recordingMode === "video" ? "audio" : "video");
}, [recordingMode, setRecordingMode]);
const handleToggleKeybindings = useCallback(() => {
setShowKeybindings((v) => !v);
}, []);
useStreamActionKeys({
onToggleReaction: handleToggleReaction,
onOpenHuddle: handleOpenHuddle,
onToggleRecordingMode: handleToggleRecordingMode,
onToggleKeybindings: handleToggleKeybindings,
onOpenTextReaction: () => setTextReactionOpen(true),
});
// Broadcast composing state to other viewers
useEffect(() => {
const stepToMode: Record<string, ComposingMode | null> = {
idle: null,
submitting: null,
recording: "recording",
typing: "typing",
reviewing: "typing",
configuring: "typing",
picking: "screen",
};
const mode = stepToMode[composeStep] ?? null;
if (mode) {
startComposing(mode);
} else {
stopComposing();
}
}, [composeStep, startComposing, stopComposing]);
// Show/hide chrome on mouse activity (YouTube-style)
const [showControls, setShowControls] = useState(true);
const idleTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleMouseActivity = useCallback(() => {
setShowControls(true);
clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(() => setShowControls(false), 3000);
}, []);
useEffect(() => () => clearTimeout(idleTimerRef.current), []);
// Always show controls when compose is active or exit countdown is visible
const controlsVisible = showControls || composeActive || status === "ended";
const handleExitNavigate = useCallback(() => {
navigate(`/${networkId}`);
}, [navigate, networkId]);
const exitRemainingMs = useExitCountdown(
status,
paused,
handleExitNavigate,
);
// Reset progress when particle changes
useEffect(() => {
setProgress(0);
}, [currentParticle?.id]);
const handleParticleCreated = useCallback((particleId: string) => {
if (currentIndex === -1) return;
// When local user is at children.length - 1, and they send a new particle,
// we want to navigate to the new particle immediately so the user is considered caught up in the stream.
// In other cases (e.g. when user is in the middle of the stream and new particles are added),
// we don't want to disrupt their current position by jumping them to the end of the stream.
// NOTE: at this point, `children` contains stale data from the time when compose was sending, so it doesn't include the new particle yet.
if (currentIndex === children.length - 1) {
goToParticle(particleId);
}
}, [children, goToParticle, currentIndex]);
if (children.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 bg-black text-white">
<p className="text-muted-foreground text-sm">
No particles in this stream yet
</p>
<StreamViewControls
showEscape
onOpenKeybindings={() => setShowKeybindings(true)}
/>
<ComposeOverlay
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
disabled={streamParticle.status === "closed"}
onParticleCreated={handleParticleCreated}
/>
</div>
);
}
// Render particle content inline
function renderParticle(particle: Particle) {
if (isParticleDeleted(particle)) {
return (
<DeletedParticleView
key={particle.id}
particle={particle}
networkId={networkId}
paused={paused}
onEnded={next}
/>
);
}
switch (particle.type) {
case "media":
return (
<MediaParticleView
ref={mediaRef}
key={particle.id}
particle={particle}
streamPath={path}
paused={paused}
onEnded={next}
onProgress={setProgress}
/>
);
case "text":
return (
<TextParticleView
key={particle.id}
particle={particle}
streamPath={path}
paused={paused}
onEnded={next}
onProgress={setProgress}
/>
);
default:
return <FallbackParticleView particle={particle} networkId={networkId} />;
}
}
return (
<div
className="relative flex h-screen flex-col overflow-hidden bg-black text-white [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
onMouseMove={handleMouseActivity}
onMouseLeave={() => setShowControls(false)}
>
{/* Top gradient safe zone */}
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" />
{/* TopBar — always visible */}
<div className="z-10 absolute left-0 right-0 pt-2">
<TopBar networkId={networkId} particle={currentParticle} streamParticle={streamParticle} />
</div>
{/* Main playback area */}
<div className="flex-1 overflow-hidden">
{currentParticle && (
<div className="relative flex h-full w-full cursor-pointer items-center justify-center">
{renderParticle(currentParticle)}
<div className="pointer-events-none absolute right-4 top-14 z-20 flex flex-col items-end gap-1.5">
{fastPlayback && (
<div className="rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm">
1.5x
</div>
)}
{paused && (
<div className="rounded-full bg-black/40 px-2.5 py-1 text-xs font-medium text-white/70 backdrop-blur-sm">
Paused
</div>
)}
</div>
</div>
)}
</div>
{/* Reaction bar — always visible */}
{currentParticle && !isParticleDeleted(currentParticle) && (
<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>
)}
{/* Composing indicator — left edge, always visible */}
<ComposingIndicator users={composingUsers} networkHumans={network?.humans} />
<ComposeOverlay
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
onStepChange={setComposeStep}
disabled={streamParticle.status === "closed"}
onParticleCreated={handleParticleCreated}
/>
{/* Bottom gradient safe zone for keyboard hints */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
{/* BottomBar */}
<BottomBar
visible={controlsVisible}
total={children.length}
current={currentIndex}
progress={progress}
onGoTo={goTo}
presenceBySegment={presenceBySegment}
onlineHumanIds={onlineHumanIds}
exitRemainingMs={exitRemainingMs}
onOpenKeybindings={() => setShowKeybindings(true)}
/>
<KeybindingsOverlay
open={showKeybindings}
onClose={() => setShowKeybindings(false)}
groups={STREAM_VIEW_KEYBINDINGS}
title="Stream View"
/>
</div>
);
}
function BottomBar({
visible,
total,
current,
progress,
onGoTo,
presenceBySegment,
onlineHumanIds,
exitRemainingMs,
onOpenKeybindings,
}: {
visible: boolean;
total: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
presenceBySegment: Map<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
onlineHumanIds: Set<string>;
exitRemainingMs: number | null;
onOpenKeybindings: () => void;
}) {
return (
<div className={cn(
"absolute inset-x-0 bottom-0 z-10 transition-all duration-300",
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2 pointer-events-none",
)}>
{/* Presence avatars — above the blurred background */}
<PlaybackPageIndicator
total={total}
current={current}
progress={progress}
onGoTo={onGoTo}
presenceBySegment={presenceBySegment}
onlineHumanIds={onlineHumanIds}
layer="avatars"
/>
{/* Blurred background container — tracks + controls */}
<div className="pb-3">
<PlaybackPageIndicator
total={total}
current={current}
progress={progress}
onGoTo={onGoTo}
layer="tracks"
/>
<div className="flex items-center justify-center px-3 pt-2 gap-2">
{exitRemainingMs !== null && (
<div className="flex justify-center">
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span>
</div>
)}
<StreamViewControls
showEscape
onOpenKeybindings={onOpenKeybindings}
/>
</div>
</div>
</div>
);
}
function StreamViewControls({
showEscape,
onOpenKeybindings,
}: {
showEscape?: boolean;
onOpenKeybindings: () => void;
}) {
return (
<div className="flex items-center gap-4 text-sm text-white/50">
{showEscape && (
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
back
</span>
)}
<VideoAudioToggle />
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{" "}
to reply
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
</kbd>{" "}
text
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
H
</kbd>{" "}
huddle
</span>
<kbd
role="button"
onClick={onOpenKeybindings}
className="cursor-pointer rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs transition-colors hover:text-white/80"
title="Show all shortcuts"
>
?
</kbd>
</div>
);
}
@@ -0,0 +1,71 @@
import { useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import type { Particle } from "@/api/types";
import {
particlePath,
parseParticlePath,
toFirestoreDocPath,
type ParticlePath,
} from "@/lib/particle-path";
import { editTextParticleContent } from "@/lib/firestore-particles";
import { TextEditor } from "@/features/compose/text-editor";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
type TextParticle = Extract<Particle, { type: "text" }>;
interface TextEditOverlayProps {
particle: TextParticle;
streamPath: ParticlePath;
onClose: () => void;
}
export function TextEditOverlay({
particle,
streamPath,
onClose,
}: TextEditOverlayProps) {
useSuspendPlayback(true, "text-edit");
const [textContent, setTextContent] = useState(particle.properties.content);
const [saving, setSaving] = useState(false);
const handleSubmit = useCallback(async () => {
if (saving) return;
const trimmed = textContent.trim();
if (!trimmed) return;
if (trimmed === particle.properties.content) {
onClose();
return;
}
setSaving(true);
try {
const { networkId, segments } = parseParticlePath(streamPath);
const docPath = toFirestoreDocPath(
particlePath(networkId, [...segments, particle.id]),
);
await editTextParticleContent(docPath, trimmed);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save");
setSaving(false);
}
}, [saving, textContent, particle.properties.content, particle.id, streamPath, onClose]);
return createPortal(
// React synthetic events bubble through the React tree (not the DOM tree),
// so clicks here would reach stream-view's click-to-navigate handler even
// though we're portaled to document.body. Stop propagation at the root.
<div className="fixed inset-0 z-[100]" onClick={(e) => e.stopPropagation()}>
<TextEditor
textContent={textContent}
onTextChange={setTextContent}
onSubmit={handleSubmit}
onCancel={onClose}
submitHint="save"
/>
</div>,
document.body,
);
}
@@ -0,0 +1,281 @@
import { useEffect, useRef, useState } from "react";
import { Pencil } from "lucide-react";
import type { Particle } from "@/api/types";
import type { ParticlePath } from "@/lib/particle-path";
import { cn } from "@/lib/utils";
import { useAllLinkMetadata, type LinkPreviewEntry } from "@/hooks/use-link-metadata";
import { extractUrls } from "@/lib/link-metadata";
import {
LinkPreviewCard,
LinkPreviewCardSkeleton,
} from "@/components/link-preview-card";
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
import { ParticleAttachments } from "@/features/particles/particle-attachments";
import { TextEditOverlay } from "@/features/particles/text-edit-overlay";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { useAuthStore } from "@/stores/auth-store";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github-dark.css";
type TextParticle = Extract<Particle, { type: "text" }>;
interface TextParticleViewProps {
particle: TextParticle;
streamPath: ParticlePath;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
// Characters per minute (~1000 cpm ≈ 200 wpm at ~5 chars/word)
const CHARS_PER_MINUTE = 1000;
const MIN_DURATION_S = 3;
const MAX_DURATION_S = 15;
const TICK_MS = 100;
const EXTRA_S_PER_LINK = 2;
const EXTRA_S_PER_ATTACHMENT = 2;
// Below this threshold: immersive centered display
const IMMERSIVE_CHAR_LIMIT = 120;
function computeReadDuration(
text: string,
linkCount: number,
attachmentCount: number,
): number {
const base = (text.length / CHARS_PER_MINUTE) * 60;
const extra = linkCount * EXTRA_S_PER_LINK + attachmentCount * EXTRA_S_PER_ATTACHMENT;
return Math.min(Math.max(base + extra, MIN_DURATION_S), MAX_DURATION_S);
}
function getImmersiveTextStyle(length: number) {
if (length < 30) return { size: "text-5xl", weight: "font-semibold" };
if (length < 70) return { size: "text-3xl", weight: "font-semibold" };
return { size: "text-2xl", weight: "font-normal" };
}
function hasMarkdownFormatting(content: string): boolean {
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(content);
}
const markdownComponents: React.ComponentProps<typeof ReactMarkdown>["components"] = {
h1: ({ children }) => <h1 className="mb-3 text-3xl font-bold text-white">{children}</h1>,
h2: ({ children }) => <h2 className="mb-2 text-2xl font-semibold text-white">{children}</h2>,
h3: ({ children }) => <h3 className="mb-2 text-xl font-semibold text-white">{children}</h3>,
h4: ({ children }) => <h4 className="mb-1 text-lg font-medium text-white">{children}</h4>,
h5: ({ children }) => <h5 className="mb-1 text-base font-medium text-white">{children}</h5>,
h6: ({ children }) => <h6 className="mb-1 text-sm font-medium text-white">{children}</h6>,
p: ({ children }) => <p className="mb-3 leading-relaxed text-white last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
em: ({ children }) => <em className="italic text-white">{children}</em>,
a: ({ href, children }) => (
<a href={href} className="text-blue-400 underline" target="_blank" rel="noreferrer">
{children}
</a>
),
code: ({ className, children, ...props }) => {
const isBlock = className?.startsWith("language-");
if (isBlock) {
return (
<code className={cn(className, "text-sm")} {...props}>
{children}
</code>
);
}
return (
<code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-sm text-white" {...props}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="mb-3 overflow-x-auto rounded-lg bg-black/40 p-4 text-sm last:mb-0">
{children}
</pre>
),
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 text-white last:mb-0">{children}</ul>,
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
li: ({ children }) => <li className="mb-1 leading-relaxed">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
{children}
</blockquote>
),
hr: () => <hr className="my-4 border-white/10" />,
};
function MarkdownContent({ content, className }: { content: string; className?: string }) {
return (
<div className={cn("break-words", className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
);
}
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
return (
<div className="flex flex-wrap gap-3">
{entries.map((entry) => (
<div key={entry.url} className="shrink-0">
{entry.isLoading ? (
<LinkPreviewCardSkeleton />
) : entry.metadata ? (
<LinkPreviewCard metadata={entry.metadata} />
) : null}
</div>
))}
</div>
);
}
export function TextParticleView({
particle,
streamPath,
paused,
onEnded,
onProgress,
}: TextParticleViewProps) {
const content = particle.properties.content;
const linkPreviews = useAllLinkMetadata(content);
const { attachments } = useParticleAttachments(streamPath, particle.id);
const urls = extractUrls(content);
const userId = useAuthStore((s) => s.user?.id);
const isCreator = !!userId && userId === particle.created_by_human_id;
const [isEditing, setIsEditing] = useState(false);
const hasLinks = urls.length > 0;
const hasAttachments = attachments.length > 0;
const hasEnrichments = hasLinks || hasAttachments;
const durationS = computeReadDuration(content, urls.length, attachments.length);
const elapsedRef = useRef(0);
// Reset elapsed when particle changes
useEffect(() => {
elapsedRef.current = 0;
}, [particle.id]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / durationS, 1);
onProgress?.(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
// Content is just bare URLs with no surrounding text
const contentTrimmed = content.trim();
const linksOnly = hasLinks && urls.every((url) => contentTrimmed.includes(url)) &&
contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, "").trim() === "";
const editButton = isCreator && !isEditing && (
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
title="Edit"
className="absolute bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 rounded-full bg-black/40 px-3 py-1.5 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white"
>
<Pencil className="size-3.5" />
Edit
</button>
);
const editedLabel = particle.properties.edited_at && (
<span className="text-xs text-white/40">
edited <RelativeTimestamp date={particle.properties.edited_at} />
</span>
);
const editOverlay = isEditing && (
<TextEditOverlay
particle={particle}
streamPath={streamPath}
onClose={() => setIsEditing(false)}
/>
);
// Mode 1: bare URLs only — show link cards centered
if (linksOnly && !hasAttachments) {
return (
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<LinkPreviews entries={linkPreviews} />
{editedLabel && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
{editedLabel}
</div>
)}
{editButton}
{editOverlay}
</div>
);
}
// Mode 2: short plain text, no enrichments — immersive centered display
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !hasMarkdownFormatting(content)) {
const style = getImmersiveTextStyle(content.length);
return (
<div className="group relative flex h-full w-full flex-col items-center justify-center gap-4 bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<p
className={cn(
"max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text",
style.size,
style.weight,
)}
>
{content}
</p>
{editedLabel}
{editButton}
{editOverlay}
</div>
);
}
// Mode 3: card layout
return (
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div
className={cn(
"flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded-2xl bg-white/10 p-6 backdrop-blur-md",
"[&::-webkit-scrollbar]:w-2",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-white/30",
"[&::-webkit-scrollbar-thumb]:hover:bg-white/50",
)}
>
<MarkdownContent content={content} className="select-text cursor-text pb-3" />
{hasLinks && <LinkPreviews entries={linkPreviews} />}
{hasAttachments && <ParticleAttachments attachments={attachments} />}
{editedLabel}
</div>
{editButton}
{editOverlay}
</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>
);
}
@@ -0,0 +1,102 @@
import { useMemo, useRef } from "react";
import type { Transcript } from "@/api/types";
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
type Word = Transcript["words"][number];
const CHUNK_SIZE = 9;
/** Split an array of words into fixed-size display chunks */
function chunkWords(words: Word[]): Word[][] {
const chunks: Word[][] = [];
for (let i = 0; i < words.length; i += CHUNK_SIZE) {
chunks.push(words.slice(i, i + CHUNK_SIZE));
}
return chunks;
}
interface TranscriptOverlayProps {
transcript: Transcript;
activeSentence: Sentence | null;
activeWordIndex: number | null;
/** Center captions vertically (e.g. for audio-only playback) */
centered?: boolean;
}
export function TranscriptOverlay({
transcript,
activeSentence,
activeWordIndex,
centered = false,
}: TranscriptOverlayProps) {
const sentenceWords = useMemo(() => {
if (!activeSentence) return [];
return transcript.words.filter(
(w) => w.start >= activeSentence.start && w.end <= activeSentence.end,
);
}, [transcript.words, activeSentence]);
const chunks = useMemo(() => chunkWords(sentenceWords), [sentenceWords]);
const activeWord =
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
// Remember the last spoken word so highlights hold during pauses
const lastSpokenWordRef = useRef<Word | null>(null);
if (activeWord) {
lastSpokenWordRef.current = activeWord;
}
const highlightWord = activeWord ?? lastSpokenWordRef.current;
const lastChunkRef = useRef<Word[] | null>(null);
// Find which chunk contains the active word, holding the last one during pauses
const activeChunk = useMemo(() => {
if (activeWord) {
for (const chunk of chunks) {
if (chunk.some((w) => w.start === activeWord.start && w.end === activeWord.end)) {
lastChunkRef.current = chunk;
return chunk;
}
}
}
// No active word (speaker pausing) — hold the last chunk
if (lastChunkRef.current && chunks.some((c) => c === lastChunkRef.current)) {
return lastChunkRef.current;
}
// Sentence changed, last chunk no longer valid — use first chunk
const fallback = chunks[0] ?? null;
lastChunkRef.current = fallback;
return fallback;
}, [chunks, activeWord]);
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
return (
<div className={centered
? "absolute inset-0 flex items-center justify-center px-6"
: "absolute bottom-15 left-0 right-0 flex justify-center px-6"
}>
<p className="rounded-lg px-5 py-3 text-2xl text-center max-w-lg">
{activeChunk.map((word, i) => {
const isSpoken =
highlightWord !== null && word.start <= highlightWord.end;
return (
<span
key={`${word.start}-${i}`}
className={
isSpoken
? "text-white font-medium transition-colors duration-150"
: "text-white/40 transition-colors duration-150"
}
>
{i > 0 ? " " : ""}
{word.word}
</span>
);
})}
</p>
</div>
);
}