refactor: organize desktop vs. mobile into separate folders
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user