infra: add linting and formatting for js projects (#230)

* wip

* wip

* wip

* format

* wip(mobile): lint and format

* cleanup

* nits

* nits

* idiomatic react

* nit

* format all root files
This commit was merged in pull request #230.
This commit is contained in:
Arjun Patel
2026-06-02 07:44:24 -07:00
committed by GitHub
parent 2fe562ce2b
commit a8a0b7db1b
258 changed files with 7822 additions and 5195 deletions
@@ -1,10 +1,10 @@
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";
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;
@@ -21,7 +21,7 @@ export function DeleteParticleOverlay({
userId,
onClose,
}: DeleteParticleOverlayProps) {
useSuspendPlayback(true, "delete-particle");
useSuspendPlayback(true, 'delete-particle');
const [deleting, setDeleting] = useState(false);
@@ -33,10 +33,11 @@ export function DeleteParticleOverlay({
particlePath(networkId, [streamId, particle.id]),
);
await softDeleteParticle(docPath, userId);
toast.success("Particle deleted");
toast.success('Particle deleted');
onClose();
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to delete particle";
const message =
e instanceof Error ? e.message : 'Failed to delete particle';
toast.error(message);
setDeleting(false);
}
@@ -1,8 +1,8 @@
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";
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.
@@ -23,7 +23,9 @@ export function DeletedParticleView({
}: DeletedParticleViewProps) {
const network = useNetwork(networkId);
const deleterId =
"deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined;
'deleted_by_human_id' in particle
? particle.deleted_by_human_id
: undefined;
const deleter = deleterId
? resolveHumanDisplay(deleterId, network?.humans)
: null;
@@ -1,19 +1,24 @@
import type { Particle } from "@/api/types";
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";
} 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" },
quest: { icon: ScrollTextIcon, label: 'Quest' },
paper: { icon: BookOpenIcon, label: 'Paper' },
file: { icon: FileIcon, label: 'File' },
};
interface FallbackParticleViewProps {
@@ -21,9 +26,15 @@ interface FallbackParticleViewProps {
networkId: string;
}
export function FallbackParticleView({ particle, networkId }: FallbackParticleViewProps) {
export function FallbackParticleView({
particle,
networkId,
}: FallbackParticleViewProps) {
const network = useNetwork(networkId);
const creator = resolveHumanDisplay(particle.created_by_human_id, network?.humans);
const creator = resolveHumanDisplay(
particle.created_by_human_id,
network?.humans,
);
const meta = TYPE_META[particle.type] ?? {
icon: HelpCircleIcon,
label: particle.type,
@@ -31,13 +42,13 @@ export function FallbackParticleView({ particle, networkId }: FallbackParticleVi
const Icon = meta.icon;
const title = (() => {
switch (particle.type) {
case "quest":
case 'quest':
return particle.properties.title;
case "paper":
case 'paper':
return particle.properties.title;
case "file":
case 'file':
return particle.properties.filename;
case "folder":
case 'folder':
return particle.properties.name;
default:
return null;
@@ -1,7 +1,6 @@
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";
import { Particle } from '@/api/types';
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
import { ComposeOverlay } from '@/features/compose/compose-overlay';
interface FolderViewProps {
folderParticle: Particle;
@@ -9,7 +8,6 @@ interface FolderViewProps {
}
export function FolderView({ path, folderParticle }: FolderViewProps) {
const { children, error, isLoading } = useLiveParticleChildren(path);
const { networkId } = parseParticlePath(path);
return (
@@ -1,16 +1,22 @@
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";
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" }>;
type MediaParticle = Extract<Particle, { type: 'media' }>;
export interface MediaParticleHandle {
/** Seek by delta. Returns true if seeked, false if at boundary (should navigate). */
@@ -26,50 +32,55 @@ interface MediaParticleViewProps {
onProgress?: (ratio: number) => void;
}
export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleViewProps>(function MediaParticleView({
particle,
streamPath,
paused,
onEnded,
onProgress,
}, ref) {
// Prefer the worker-produced iOS-playable variant when present so desktop
// and mobile read the same canonical asset. Falls back to the original.
// Pin the choice for the lifetime of this particle: if a transcoded variant
// arrives via Firestore mid-playback, swapping the <video> src would restart
// playback from 0. Keep whatever we picked first; the original plays fine in
// Electron, and the transcoded variant will be picked up on the next view.
const pickedSourceRef = useRef<{ id: string; objectId: string; mime: string } | null>(null);
if (pickedSourceRef.current?.id !== particle.id) {
pickedSourceRef.current = {
id: particle.id,
objectId: particle.properties.transcoded_object_id ?? particle.properties.object_id,
mime: particle.properties.transcoded_mime_type ?? particle.properties.mime_type,
};
}
const activeObjectId = pickedSourceRef.current.objectId;
const activeMime = pickedSourceRef.current.mime;
export const MediaParticleView = forwardRef<
MediaParticleHandle,
MediaParticleViewProps
>(function MediaParticleView(
{ particle, streamPath, paused, onEnded, onProgress },
ref,
) {
// Prefer the worker-produced iOS-playable variant when present so desktop and
// mobile read the same canonical asset, falling back to the original. Pinned
// on mount (the parent keys this component by particle.id, so a new particle
// remounts and re-picks): if a transcoded variant arrives via Firestore for
// the same particle, swapping the <video> src would restart playback from 0.
const [pickedSource] = useState(() => ({
objectId:
particle.properties.transcoded_object_id ?? particle.properties.object_id,
mime:
particle.properties.transcoded_mime_type ?? particle.properties.mime_type,
}));
const activeObjectId = pickedSource.objectId;
const activeMime = pickedSource.mime;
const { data: url, error } = useDownloadUrl(activeObjectId);
const { attachments } = useParticleAttachments(streamPath, particle.id);
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const isAudio = activeMime?.startsWith("audio/");
const isAudio = activeMime?.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]);
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;
@@ -91,7 +102,7 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
} 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 });
console.warn('Playback failed', { particleId: particle.id });
});
}
}, [paused, isAudio, particle.id]);
@@ -108,14 +119,17 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
return <Skeleton className="h-full w-full rounded-none" />;
}
const handleTimeUpdate = (e: React.SyntheticEvent<HTMLAudioElement | HTMLVideoElement>) => {
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;
const effectiveDuration =
Number.isFinite(duration) && duration > 0
? duration
: particle.properties.duration_ms / 1000;
if (effectiveDuration > 0) onProgress?.(time / effectiveDuration);
};
@@ -1,22 +1,22 @@
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 { 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";
import { platform } from "@/lib/platform";
} from '@/features/attachments/attachment-lightbox';
import { platform } from '@/lib/platform';
type FileParticle = Extract<Particle, { type: "file" }>;
type FileParticle = Extract<Particle, { type: 'file' }>;
interface ParticleAttachmentsProps {
attachments: FileParticle[];
variant?: "inline" | "compact";
variant?: 'inline' | 'compact';
}
function formatFileSize(bytes: number): string {
@@ -31,7 +31,7 @@ function particleToItem(p: FileParticle): AttachmentItem {
filename: p.properties.filename,
mimeType: p.properties.mime_type,
sizeBytes: p.properties.size_bytes,
source: { kind: "remote", objectId: p.properties.object_id },
source: { kind: 'remote', objectId: p.properties.object_id },
};
}
@@ -44,7 +44,7 @@ function openParticle(
url: string | undefined,
onPreview: (index: number) => void,
) {
if (getAttachmentHandler(particle.properties.mime_type) === "lightbox") {
if (getAttachmentHandler(particle.properties.mime_type) === 'lightbox') {
onPreview(index);
} else if (url) {
platform.link.openExternal(url);
@@ -58,7 +58,9 @@ function ImageAttachment({
particle: FileParticle;
onPreview: () => void;
}) {
const { data: url, isLoading } = useDownloadUrl(particle.properties.object_id);
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" />;
@@ -165,7 +167,7 @@ function CompactAttachmentItem({
index: number;
onPreview: (index: number) => void;
}) {
const isImage = particle.properties.mime_type.startsWith("image/");
const isImage = particle.properties.mime_type.startsWith('image/');
const { data: url } = useDownloadUrl(particle.properties.object_id);
return (
@@ -200,13 +202,19 @@ function CompactAttachmentItem({
);
}
export function ParticleAttachments({ attachments, variant = "inline" }: ParticleAttachmentsProps) {
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.filter(
(a) => getAttachmentHandler(a.properties.mime_type) === 'lightbox',
),
[attachments],
);
const items = useMemo(() => previewable.map(particleToItem), [previewable]);
@@ -228,7 +236,7 @@ export function ParticleAttachments({ attachments, variant = "inline" }: Particl
/>
);
if (variant === "compact") {
if (variant === 'compact') {
return (
<>
<div className="flex max-w-48 flex-col gap-1">
@@ -251,7 +259,8 @@ export function ParticleAttachments({ attachments, variant = "inline" }: Particl
<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/");
const isImage =
attachment.properties.mime_type.startsWith('image/');
return isImage ? (
<ImageAttachment
key={attachment.id}
@@ -1,5 +1,12 @@
import { useMemo, useRef, useEffect, useCallback, memo } from "react";
import { useNavigate } from "react-router-dom";
import {
useMemo,
useRef,
useEffect,
useCallback,
memo,
createElement,
} from 'react';
import { useNavigate } from 'react-router-dom';
import {
Radio,
MessageSquare,
@@ -12,25 +19,28 @@ import {
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";
} 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 { 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,
@@ -43,8 +53,8 @@ function VideoThumbnail({
return (
<div
className={cn(
"size-8 shrink-0 overflow-hidden rounded-md bg-muted",
isUnseen && "ring-2 ring-primary",
'size-8 shrink-0 overflow-hidden rounded-md bg-muted',
isUnseen && 'ring-2 ring-primary',
)}
>
{url && (
@@ -64,20 +74,20 @@ function VideoThumbnail({
function getParticleTypeIcon(particle: Particle): LucideIcon {
if (isParticleDeleted(particle)) return Trash2;
switch (particle.type) {
case "text":
case 'text':
return MessageSquare;
case "media": {
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;
if (mime.startsWith('video/')) return Video;
if (mime.startsWith('audio/')) return Mic;
if (mime.startsWith('image/')) return Image;
return Video;
}
case "file":
case 'file':
return FileText;
case "quest":
case 'quest':
return CircleCheck;
case "paper":
case 'paper':
return StickyNote;
default:
return Radio;
@@ -85,25 +95,25 @@ function getParticleTypeIcon(particle: Particle): LucideIcon {
}
function getMessagePreview(particle: Particle): string {
if (isParticleDeleted(particle)) return "Deleted particle";
if (isParticleDeleted(particle)) return 'Deleted particle';
switch (particle.type) {
case "text":
case 'text':
return particle.properties.content;
case "media": {
case 'media': {
const mime = particle.properties.mime_type;
if (mime.startsWith("image/")) return "Photo";
if (mime.startsWith("video/") || mime.startsWith("audio/")) {
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 mime.startsWith('video/') ? 'Video clip' : 'Voice note';
}
return "Media";
return 'Media';
}
case "file":
case 'file':
return particle.properties.filename;
case "quest":
case 'quest':
return particle.properties.title;
case "paper":
case 'paper':
return particle.properties.title;
default:
return particle.type;
@@ -117,7 +127,7 @@ const StreamRow = memo(function StreamRow({
isSelected,
shortcutKey,
}: {
particle: Particle & { type: "stream"; properties: StreamProperties };
particle: Particle & { type: 'stream'; properties: StreamProperties };
networkId: string;
onNavigate: (streamId: string) => void;
isSelected?: boolean;
@@ -126,18 +136,19 @@ const StreamRow = memo(function StreamRow({
const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath);
const user = useAuthStore((s) => s.user);
const userId = user?.id ?? "";
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;
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:"));
particle.visible_to.every((v) => v.startsWith('human:'));
const initials = useMemo(() => {
if (isDM) {
@@ -145,19 +156,28 @@ const StreamRow = memo(function StreamRow({
(v) => v !== `human:${userId}`,
);
if (otherEntry) {
const otherId = otherEntry.replace("human:", "");
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);
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]);
}, [
isDM,
particle.visible_to,
particle.properties.name,
userId,
latestChild,
network,
]);
const isUnseen = useMemo(() => {
if (!latestChild) return false;
@@ -171,15 +191,16 @@ const StreamRow = memo(function StreamRow({
if (!latestChild) return null;
const isCurrentUser = latestChild.created_by_human_id === userId;
if (isDM) {
return isCurrentUser ? "You: " : null;
return isCurrentUser ? 'You: ' : null;
}
// Group stream
if (isCurrentUser) return "You: ";
if (isCurrentUser) return 'You: ';
const { displayName } = resolveHumanDisplay(
latestChild.created_by_human_id,
network?.humans,
);
const capitalized = displayName.charAt(0).toUpperCase() + displayName.slice(1);
const capitalized =
displayName.charAt(0).toUpperCase() + displayName.slice(1);
return `${capitalized}: `;
}, [latestChild, userId, isDM, network]);
@@ -187,13 +208,15 @@ const StreamRow = memo(function StreamRow({
? getMessagePreview(latestChild)
: particle.properties.name;
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
// Rendered via createElement below: a call-result used directly as a JSX tag
// is flagged as a dynamically-created component.
const typeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
const videoThumbObjectId =
latestChild &&
!isParticleDeleted(latestChild) &&
latestChild.type === "media" &&
latestChild.properties.mime_type.startsWith("video/")
latestChild.type === 'media' &&
latestChild.properties.mime_type.startsWith('video/')
? latestChild.properties.object_id
: null;
@@ -202,11 +225,13 @@ const StreamRow = memo(function StreamRow({
role="button"
tabIndex={0}
onClick={() => onNavigate(particle.id)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") 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",
'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 && (
@@ -217,7 +242,7 @@ const StreamRow = memo(function StreamRow({
{videoThumbObjectId ? (
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
) : (
<Avatar className={cn(isUnseen && "ring-2 ring-primary")}>
<Avatar className={cn(isUnseen && 'ring-2 ring-primary')}>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
@@ -227,10 +252,10 @@ const StreamRow = memo(function StreamRow({
<div className="flex items-center justify-between gap-2">
<p
className={cn(
"truncate text-sm",
'truncate text-sm',
isUnseen
? "font-semibold text-foreground"
: "font-medium text-muted-foreground",
? 'font-semibold text-foreground'
: 'font-medium text-muted-foreground',
)}
>
{particle.properties.name}
@@ -239,14 +264,16 @@ const StreamRow = memo(function StreamRow({
{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 className="text-[10px] font-medium text-red-400">
{huddleCount}
</span>
</span>
)}
{latestChild && (
<Small
className={cn(
"shrink-0",
isUnseen ? "text-primary" : "text-muted-foreground",
'shrink-0',
isUnseen ? 'text-primary' : 'text-muted-foreground',
)}
>
<RelativeTimestamp date={latestChild.created_at} />
@@ -255,18 +282,18 @@ const StreamRow = memo(function StreamRow({
</div>
</div>
<div className="flex items-center gap-1">
<TypeIcon
className={cn(
"size-3.5 shrink-0",
isUnseen ? "text-foreground" : "text-muted-foreground",
)}
/>
{createElement(typeIcon, {
className: cn(
'size-3.5 shrink-0',
isUnseen ? 'text-foreground' : 'text-muted-foreground',
),
})}
<Small
className={cn(
"truncate",
'truncate',
isUnseen
? "text-foreground font-medium"
: "text-muted-foreground font-normal",
? 'text-foreground font-medium'
: 'text-muted-foreground font-normal',
)}
>
{senderPrefix && (
@@ -276,9 +303,7 @@ const StreamRow = memo(function StreamRow({
</Small>
</div>
</div>
{isUnseen && (
<span className="size-2 shrink-0 rounded-full bg-primary" />
)}
{isUnseen && <span className="size-2 shrink-0 rounded-full bg-primary" />}
</div>
);
});
@@ -313,8 +338,12 @@ export function ParticleListView({
);
useEffect(() => {
if (selectedIndex !== null && selectedIndex !== undefined && selectedIndex >= 0) {
rowRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
if (
selectedIndex !== null &&
selectedIndex !== undefined &&
selectedIndex >= 0
) {
rowRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' });
}
}, [selectedIndex]);
@@ -327,7 +356,8 @@ export function ParticleListView({
<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.
No streams here. Start a conversation using the keyboard shortcuts
below.
</p>
</div>
);
@@ -336,9 +366,15 @@ export function ParticleListView({
return (
<div>
{streams.map((stream, index) => (
<StreamContextMenu key={stream.id} particle={stream} networkId={networkId}>
<StreamContextMenu
key={stream.id}
particle={stream}
networkId={networkId}
>
<div
ref={(el) => { rowRefs.current[index] = el; }}
ref={(el) => {
rowRefs.current[index] = el;
}}
>
<StreamRow
particle={stream}
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import type { Particle } from "@/api/types";
import { apiClient } from "@/api/client";
import { Skeleton } from "@/components/ui/skeleton";
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,
@@ -9,31 +9,35 @@ import {
BookOpen,
FileIcon,
FolderIcon,
} from "lucide-react";
} from 'lucide-react';
export function ParticlePreview({ particle }: { particle: Particle }) {
switch (particle.type) {
case "text":
case 'text':
return <TextPreview particle={particle} />;
case "media":
case 'media':
return <MediaPreview particle={particle} />;
case "quest":
case 'quest':
return <QuestPreview particle={particle} />;
case "paper":
case 'paper':
return <PaperPreview particle={particle} />;
case "file":
case 'file':
return <FilePreview particle={particle} />;
case "folder":
case 'folder':
return <FolderPreview particle={particle} />;
default:
return <EmptyPreview />;
}
}
function TextPreview({ particle }: { particle: Extract<Particle, { type: "text" }> }) {
function TextPreview({
particle,
}: {
particle: Extract<Particle, { type: 'text' }>;
}) {
const truncated =
particle.properties.content.length > 30
? particle.properties.content.slice(0, 30) + "..."
? particle.properties.content.slice(0, 30) + '...'
: particle.properties.content;
return (
<div className="flex h-full w-full items-center justify-center p-4">
@@ -44,14 +48,23 @@ function TextPreview({ particle }: { particle: Extract<Particle, { type: "text"
);
}
function MediaPreview({ particle }: { particle: Extract<Particle, { type: "media" }> }) {
function MediaPreview({
particle,
}: {
particle: Extract<Particle, { type: 'media' }>;
}) {
const { mime_type, duration_ms } = particle.properties;
const isVideo = mime_type.startsWith("video");
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")}`;
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, '0')}`;
if (isVideo) {
return <VideoThumbnail particleId={particle.properties.object_id} duration={durationLabel} />;
return (
<VideoThumbnail
particleId={particle.properties.object_id}
duration={durationLabel}
/>
);
}
return (
@@ -115,14 +128,16 @@ function VideoThumbnail({
);
}
function QuestPreview({ particle }: { particle: Extract<Particle, { type: "quest" }> }) {
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>
<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}
@@ -132,7 +147,11 @@ function QuestPreview({ particle }: { particle: Extract<Particle, { type: "quest
);
}
function PaperPreview({ particle }: { particle: Extract<Particle, { type: "paper" }> }) {
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" />
@@ -143,7 +162,11 @@ function PaperPreview({ particle }: { particle: Extract<Particle, { type: "paper
);
}
function FilePreview({ particle }: { particle: Extract<Particle, { type: "file" }> }) {
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" />
@@ -154,7 +177,11 @@ function FilePreview({ particle }: { particle: Extract<Particle, { type: "file"
);
}
function FolderPreview({ particle }: { particle: Extract<Particle, { type: "folder" }> }) {
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" />
@@ -1,14 +1,13 @@
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";
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/*.
@@ -16,9 +15,11 @@ import { FolderView } from "@/features/particles/folder-view";
* 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 { networkId, '*': rest } = useParams();
if (!networkId)
throw new Error('ParticleViewResolver requires a :networkId route param');
const segments = (rest ?? '').split('/').filter(Boolean);
const path = particlePath(networkId, segments); // path of current container particle
const { particle, isLoading, error } = useLiveParticle(path);
@@ -38,9 +39,9 @@ export default function ParticleViewResolver() {
}
switch (particle.type) {
case "stream":
case 'stream':
return <StreamView streamParticle={particle} path={path} />;
case "folder":
case 'folder':
return <FolderView folderParticle={particle} path={path} />;
default:
return (
@@ -59,7 +60,7 @@ function InaccessibleParticle() {
useEffect(() => {
// Refresh the networks list so the home page reflects current access.
queryClient.invalidateQueries({ queryKey: ["networks"] });
queryClient.invalidateQueries({ queryKey: ['networks'] });
}, [queryClient]);
return (
@@ -71,7 +72,7 @@ function InaccessibleParticle() {
It may have been deleted, or your access was removed.
</p>
</div>
<Button size="sm" onClick={() => navigate("/", { replace: true })}>
<Button size="sm" onClick={() => navigate('/', { replace: true })}>
Go home
</Button>
</div>
@@ -1,10 +1,10 @@
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { HumanPresence } from "@/hooks/use-presence-positions";
} from '@/components/ui/tooltip';
import type { HumanPresence } from '@/hooks/use-presence-positions';
const MAX_VISIBLE_AVATARS = 3;
const PAGE_SIZE = 10;
@@ -18,7 +18,7 @@ interface PlaybackPageIndicatorProps {
/** 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";
layer?: 'avatars' | 'tracks';
}
export function PlaybackPageIndicator({
@@ -32,8 +32,8 @@ export function PlaybackPageIndicator({
}: PlaybackPageIndicatorProps) {
if (total === 0) return null;
const showAvatars = layer !== "tracks";
const showTracks = layer !== "avatars";
const showAvatars = layer !== 'tracks';
const showTracks = layer !== 'avatars';
const paginated = total > PAGE_SIZE;
const safeCurrent = current < 0 ? 0 : current;
@@ -84,11 +84,12 @@ export function PlaybackPageIndicator({
style={{
width:
i < current
? "100%"
? '100%'
: i === current
? `${progress * 100}%`
: "0%",
transition: i === current ? "width 300ms linear" : "none",
: '0%',
transition:
i === current ? 'width 300ms linear' : 'none',
}}
/>
</button>
@@ -160,7 +161,14 @@ function SegmentPresenceAvatars({
{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"}>
<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>
@@ -172,9 +180,7 @@ function SegmentPresenceAvatars({
</Tooltip>
))}
{overflow > 0 && (
<span className="text-[10px] text-white/70 pl-1">
+{overflow}
</span>
<span className="text-[10px] text-white/70 pl-1">+{overflow}</span>
)}
</div>
);
@@ -1,11 +1,14 @@
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";
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, type Human } from '@/api/types';
import { cn } from '@/lib/utils';
import { resolveHumanDisplay } from '@/lib/humans';
interface ReactionBarProps {
reactions: Reactions;
@@ -20,7 +23,7 @@ const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
function getReactorNames(humanIds: string[], humans?: Human[]): string {
return humanIds
.map((id) => resolveHumanDisplay(id, humans).displayName)
.join(", ");
.join(', ');
}
function getReactorList(
@@ -61,18 +64,21 @@ export function ReactionBar({
<div className="flex flex-col items-end gap-1.5">
{/* Emoji reaction pills */}
{activeEmojis.map((emoji) => {
const reactors = reactions![emoji];
const reactors = reactions?.[emoji] ?? [];
const isMine = reactors.includes(currentHumanId);
return (
<Tooltip key={emoji}>
<TooltipTrigger asChild>
<button
onClick={(e) => { e.stopPropagation(); handleToggle(emoji); }}
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",
'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",
? 'bg-white/20 ring-1 ring-white/40'
: 'bg-black/40 hover:bg-black/50',
)}
>
<span className="text-sm">{emoji}</span>
@@ -88,7 +94,7 @@ export function ReactionBar({
{/* Text reaction pills */}
{activeTextReactions.map((text) => {
const reactors = reactions![text];
const reactors = reactions?.[text] ?? [];
const isMine = reactors.includes(currentHumanId);
const firstReactor = resolveHumanDisplay(reactors[0], humans);
const reactorList = getReactorList(reactors, humans, currentHumanId);
@@ -96,12 +102,15 @@ export function ReactionBar({
<Tooltip key={text}>
<TooltipTrigger asChild>
<button
onClick={(e) => { e.stopPropagation(); handleToggle(text); }}
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",
'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",
? 'bg-white/20 ring-1 ring-white/40'
: 'bg-black/40 hover:bg-black/50',
)}
>
<Avatar size="xs" className="shrink-0">
@@ -111,22 +120,30 @@ export function ReactionBar({
</Avatar>
<span className="truncate text-white/90">{text}</span>
{reactors.length > 1 && (
<span className="shrink-0 text-white/60">{reactors.length}</span>
<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">
<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")}>
<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"}
{isMine ? 'Click to remove' : 'Click to add yours'}
</div>
</TooltipContent>
</Tooltip>
@@ -141,7 +158,10 @@ export function ReactionBar({
return (
<button
key={emoji}
onClick={(e) => { e.stopPropagation(); handleToggle(emoji); }}
onClick={(e) => {
e.stopPropagation();
handleToggle(emoji);
}}
className="rounded-full px-0.5 py-1 text-sm transition-colors hover:bg-white/15"
>
{emoji}
@@ -149,7 +169,10 @@ export function ReactionBar({
);
})}
<button
onClick={(e) => { e.stopPropagation(); setExpanded(false); }}
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" />
@@ -160,18 +183,27 @@ export function ReactionBar({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => { e.stopPropagation(); onOpenTextReaction(); }}
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>
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); }}
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" />
@@ -1,15 +1,15 @@
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";
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" };
streamParticle: Particle & { type: 'stream' };
onClose: () => void;
}
@@ -18,23 +18,23 @@ export function RenameStreamOverlay({
streamParticle,
onClose,
}: RenameStreamOverlayProps) {
useSuspendPlayback(true, "rename-stream");
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;
!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 });
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamParticle.id]),
);
await updateParticleProperties<'stream'>(docPath, { name: trimmed });
onClose();
} finally {
setSaving(false);
@@ -43,14 +43,15 @@ export function RenameStreamOverlay({
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true });
window.addEventListener('keydown', handler, { capture: true });
return () =>
window.removeEventListener('keydown', handler, { capture: true });
}, [onClose]);
return createPortal(
@@ -65,7 +66,7 @@ export function RenameStreamOverlay({
<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>{" "}
</kbd>{' '}
to close
</span>
</div>
@@ -77,7 +78,7 @@ export function RenameStreamOverlay({
onChange={(e) => setName(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
}
+154 -144
View File
@@ -1,169 +1,179 @@
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";
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 };
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);
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);
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 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 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);
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);
}
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,
]);
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 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;
// 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" />
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',
)}
{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">
>
{/* Preview area */}
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
{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>
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
)}
{latestChild && (
<Small
className={cn(
"text-[10px]",
isUnseen ? "text-primary" : "text-muted-foreground",
)}
>
<RelativeTimestamp date={latestChild.created_at} />
</Small>
{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>
)}
{isUnseen && (
<span className="size-2 shrink-0 rounded-full bg-primary" />
{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>
</div>
);
});
);
},
);
@@ -3,11 +3,11 @@ import {
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";
} 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;
@@ -15,12 +15,16 @@ interface StreamContextMenuProps {
children: React.ReactNode;
}
export function StreamContextMenu({ particle, networkId, children }: StreamContextMenuProps) {
const isOpen = particle.status === "open";
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");
await updateStreamStatus(docPath, isOpen ? 'closed' : 'open');
};
return (
@@ -1,24 +1,24 @@
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 { 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";
} 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" };
streamParticle: Particle & { type: 'stream' };
isCreator: boolean;
onClose: () => void;
}
@@ -29,12 +29,15 @@ export function StreamMembersOverlay({
isCreator,
onClose,
}: StreamMembersOverlayProps) {
useSuspendPlayback(true, "stream-members");
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 visibility = useMemo(
() => parseVisibleTo(streamParticle.visible_to, networkId),
[streamParticle.visible_to, networkId],
);
const docPath = useMemo(
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
@@ -42,7 +45,7 @@ export function StreamMembersOverlay({
);
const memberIds =
visibility.mode === "network"
visibility.mode === 'network'
? humans.map((h) => h.id)
: visibility.humanIds;
const memberSet = new Set(memberIds);
@@ -58,7 +61,7 @@ export function StreamMembersOverlay({
const removeMember = useCallback(
(id: string) => {
if (visibility.mode !== "custom") return;
if (visibility.mode !== 'custom') return;
if (id === creatorId) return;
const next = visibility.humanIds.filter((x) => x !== id);
if (next.length === 0) return;
@@ -69,7 +72,7 @@ export function StreamMembersOverlay({
const addMember = useCallback(
(id: string) => {
if (visibility.mode !== "custom") return;
if (visibility.mode !== 'custom') return;
void updateParticleVisibleTo(
docPath,
buildCustomVisibility([...visibility.humanIds, id]),
@@ -80,14 +83,15 @@ export function StreamMembersOverlay({
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true });
window.addEventListener('keydown', handler, { capture: true });
return () =>
window.removeEventListener('keydown', handler, { capture: true });
}, [onClose]);
return createPortal(
@@ -103,7 +107,7 @@ export function StreamMembersOverlay({
<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>{" "}
</kbd>{' '}
to close
</span>
</div>
@@ -116,13 +120,13 @@ export function StreamMembersOverlay({
{isCreator ? (
<div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 p-1">
<VisibilityPill
active={visibility.mode === "network"}
active={visibility.mode === 'network'}
icon={<Globe className="size-3.5" />}
label="Network-wide"
onClick={setNetworkWide}
/>
<VisibilityPill
active={visibility.mode === "custom"}
active={visibility.mode === 'custom'}
icon={<Lock className="size-3.5" />}
label="Specific people"
onClick={setCustomOnlyCreator}
@@ -130,10 +134,10 @@ export function StreamMembersOverlay({
</div>
) : (
<div className="flex items-center gap-2 text-sm text-white/70">
{visibility.mode === "network" ? (
{visibility.mode === 'network' ? (
<>
<Globe className="size-3.5 text-white/40" />
<span>Everyone in {network?.name ?? "network"}</span>
<span>Everyone in {network?.name ?? 'network'}</span>
</>
) : (
<>
@@ -148,7 +152,7 @@ export function StreamMembersOverlay({
{/* 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"}{" "}
{visibility.mode === 'network' ? 'Has access' : 'People'}{' '}
<span className="ml-1 text-white/20">{memberIds.length}</span>
</h3>
<ScrollArea className="min-h-0 flex-1">
@@ -157,7 +161,7 @@ export function StreamMembersOverlay({
const display = resolveHumanDisplay(id, humans);
const isCreatorRow = id === creatorId;
const canRemove =
isCreator && visibility.mode === "custom" && !isCreatorRow;
isCreator && visibility.mode === 'custom' && !isCreatorRow;
return (
<li
key={id}
@@ -170,8 +174,8 @@ export function StreamMembersOverlay({
</Avatar>
<span
className={cn(
"flex-1 truncate",
!display.exists && "italic text-white/40",
'flex-1 truncate',
!display.exists && 'italic text-white/40',
)}
>
{display.displayName}
@@ -199,44 +203,50 @@ export function StreamMembersOverlay({
</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 && (
<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>
)}
{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,
@@ -259,10 +269,10 @@ function VisibilityPill({
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",
'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",
? 'bg-white/10 text-white/90'
: 'text-white/50 hover:text-white/80',
)}
>
{icon}
@@ -7,16 +7,15 @@ import {
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";
} from 'react';
import { useChannel } from '@/hooks/use-channel';
import { useAuthStore } from '@/stores/auth-store';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ComposingMode = "recording" | "typing" | "screen";
export type ComposingMode = 'recording' | 'typing' | 'screen';
export interface ComposingUser {
humanId: string;
@@ -93,14 +92,14 @@ export function StreamPresenceProvider({
// Skip own events
if (msg.humanId === currentUserId) continue;
if (payload.type === "composing_start" && payload.mode) {
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") {
} else if (payload.type === 'composing_stop') {
if (map.delete(msg.humanId)) changed = true;
}
}
@@ -156,14 +155,14 @@ export function StreamPresenceProvider({
const startComposing = useCallback(
(mode: ComposingMode) => {
// Send immediately
sendMessage({ type: "composing_start", mode });
sendMessage({ type: 'composing_start', mode });
// Clear any existing heartbeat
clearInterval(heartbeatRef.current);
// Start heartbeat
heartbeatRef.current = setInterval(() => {
sendMessage({ type: "composing_start", mode });
sendMessage({ type: 'composing_start', mode });
}, COMPOSING_HEARTBEAT_MS);
},
[sendMessage],
@@ -172,7 +171,7 @@ export function StreamPresenceProvider({
const stopComposing = useCallback(() => {
clearInterval(heartbeatRef.current);
heartbeatRef.current = undefined;
sendMessage({ type: "composing_stop" });
sendMessage({ type: 'composing_stop' });
}, [sendMessage]);
// Cleanup heartbeat on unmount
@@ -207,7 +206,7 @@ function useStreamPresenceContext() {
const ctx = useContext(StreamPresenceContext);
if (!ctx) {
throw new Error(
"useStreamPresence must be used within a StreamPresenceProvider",
'useStreamPresence must be used within a StreamPresenceProvider',
);
}
return ctx;
@@ -1,47 +1,65 @@
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 { 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";
import { platform } from "@/lib/platform";
import { requireDesktop } from "@/lib/platform/desktop-only";
} 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';
import { platform } from '@/lib/platform';
import { requireDesktop } from '@/lib/platform/desktop-only';
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
case "stream":
case "folder":
case 'stream':
case 'folder':
return particle.properties.name;
case "quest":
case 'quest':
return particle.properties.title;
case "paper":
case 'paper':
return particle.properties.title;
case "file":
case 'file':
return particle.properties.filename;
case "text":
case 'text':
return particle.properties.content.slice(0, 30);
case "media":
case 'media':
return particle.type;
}
}
@@ -49,7 +67,7 @@ function getParticleDisplayName(particle: Particle): string {
interface TopBarProps {
networkId: string;
particle: Particle | null;
streamParticle: Particle & { type: "stream" };
streamParticle: Particle & { type: 'stream' };
}
export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
@@ -65,18 +83,20 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
!!particle &&
!!userId &&
particle.created_by_human_id === userId &&
particle.type !== "stream" &&
particle.type !== "folder" &&
particle.type !== 'stream' &&
particle.type !== 'folder' &&
!isParticleDeleted(particle);
const huddleParticipants = streamParticle.huddle_active_participants ?? [];
const hasActiveHuddle = huddleParticipants.length > 0;
const handleJoinHuddle = () => {
if (!requireDesktop("Huddle")) return;
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
platform.huddle.open({ token, serverUrl: server_url });
});
if (!requireDesktop('Huddle')) return;
apiClient
.getLivekitToken(networkId, streamParticle.id)
.then(({ token, server_url }) => {
platform.huddle.open({ token, serverUrl: server_url });
});
};
return (
@@ -88,7 +108,9 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
{streamParticle && (
<>
<BreadcrumbItem className="text-xs">
<BreadcrumbPage>{getParticleDisplayName(streamParticle)}</BreadcrumbPage>
<BreadcrumbPage>
{getParticleDisplayName(streamParticle)}
</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
@@ -97,7 +119,12 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
<>
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs">
<BreadcrumbPage><ParticleBreadcrumbContent particle={particle} networkId={networkId} /></BreadcrumbPage>
<BreadcrumbPage>
<ParticleBreadcrumbContent
particle={particle}
networkId={networkId}
/>
</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
@@ -134,7 +161,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
</button>
)}
{streamParticle.status === "closed" && (
{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
@@ -160,11 +187,16 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={async () => {
const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
await updateStreamStatus(docPath, streamParticle.status === "open" ? "closed" : "open");
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamParticle.id]),
);
await updateStreamStatus(
docPath,
streamParticle.status === 'open' ? 'closed' : 'open',
);
}}
>
{streamParticle.status === "open" ? (
{streamParticle.status === 'open' ? (
<>
<CircleCheckBig className="size-4" />
Close stream
@@ -191,7 +223,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
Delete particle
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => navigate("/settings")}>
<DropdownMenuItem onSelect={() => navigate('/settings')}>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
@@ -234,7 +266,7 @@ function MembersIndicator({
onClick,
}: {
networkId: string;
streamParticle: Particle & { type: "stream" };
streamParticle: Particle & { type: 'stream' };
onClick: () => void;
}) {
const network = useNetwork(networkId);
@@ -242,7 +274,7 @@ function MembersIndicator({
const humans = network?.humans ?? [];
const memberIds =
visibility.mode === "network"
visibility.mode === 'network'
? humans.map((h) => h.id)
: visibility.humanIds;
const shownMembers = memberIds
@@ -258,7 +290,7 @@ function MembersIndicator({
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" ? (
{visibility.mode === 'network' ? (
<>
<Globe className="size-3 text-white/50" />
<span>Everyone</span>
@@ -274,32 +306,43 @@ function MembersIndicator({
</Avatar>
))}
</AvatarGroup>
{overflow > 0 && <span className="text-white/50">+{overflow}</span>}
{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"}`}
{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 }) {
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;
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 size="sm" className={isOnline ? 'ring-2 ring-green-500' : ''}>
<AvatarFallback>{display.initials}</AvatarFallback>
</Avatar>
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
</span>
+196 -131
View File
@@ -1,41 +1,73 @@
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 { useComposeIntentStore } from "@/stores/compose-intent-store";
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 { platform } from "@/lib/platform";
import { requireDesktop } from "@/lib/platform/desktop-only";
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 { useComposeIntentStore } from '@/stores/compose-intent-store';
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 { platform } from '@/lib/platform';
import { requireDesktop } from '@/lib/platform/desktop-only';
function getReactions(particle: Particle): Record<string, string[]> | undefined {
function getReactions(
particle: Particle,
): Record<string, string[]> | undefined {
if (isParticleDeleted(particle)) return undefined;
if (particle.type === "media" || particle.type === "text") return particle.reactions;
if (particle.type === 'media' || particle.type === 'text')
return particle.reactions;
return undefined;
}
@@ -44,31 +76,33 @@ function getReactions(particle: Particle): Record<string, string[]> | undefined
const EXIT_DELAY_MS = 5000;
const EXIT_TICK_MS = 100;
type PlaybackStatus = "idle" | "playing" | "ended";
type PlaybackStatus = 'idle' | 'playing' | 'ended';
function useExitCountdown(
status: PlaybackStatus,
disabled: boolean,
onExit: () => void,
) {
const [remainingMs, setRemainingMs] = useState<number | null>(null);
const [remainingMs, setRemainingMs] = useState<number | null>(
status === 'ended' ? EXIT_DELAY_MS : null,
);
const [prevStatus, setPrevStatus] = useState(status);
const handleExit = useEffectEvent(() => {
onExit();
});
// Start/cancel countdown based on playback status
useEffect(() => {
if (status === "ended") {
setRemainingMs(EXIT_DELAY_MS);
} else {
setRemainingMs(null);
}
}, [status]);
// Start the countdown when playback ends; cancel it otherwise.
if (status !== prevStatus) {
setPrevStatus(status);
setRemainingMs(status === 'ended' ? EXIT_DELAY_MS : null);
}
const isCountingDown = remainingMs !== null && remainingMs > 0;
// Tick the countdown down (pauses when compose is active)
useEffect(() => {
if (remainingMs === null || remainingMs <= 0 || disabled) return;
if (!isCountingDown || disabled) return;
const interval = setInterval(() => {
setRemainingMs((prev) => {
@@ -79,7 +113,7 @@ function useExitCountdown(
}, EXIT_TICK_MS);
return () => clearInterval(interval);
}, [remainingMs !== null && remainingMs > 0, disabled]);
}, [isCountingDown, disabled]);
// Navigate once countdown hits zero
useEffect(() => {
@@ -95,36 +129,36 @@ function useExitCountdown(
const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
{
label: "Navigation",
label: 'Navigation',
bindings: [
{ keys: ["←", "→", "↑", "↓"], description: "Previous / next particle" },
{ keys: ["Esc"], description: "Back to network" },
{ keys: ['←', '→', '↑', '↓'], description: 'Previous / next particle' },
{ keys: ['Esc'], description: 'Back to network' },
],
},
{
label: "Playback",
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" },
{ 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",
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" },
{ 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",
label: 'Reactions',
bindings: [
{ keys: ["1-7"], description: "Toggle emoji reaction" },
{ keys: ["R"], description: "Quick text reply" },
{ keys: ['1-7'], description: 'Toggle emoji reaction' },
{ keys: ['R'], description: 'Quick text reply' },
],
},
];
@@ -132,7 +166,7 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
// --- StreamView ---
interface StreamViewProps {
streamParticle: Particle & { type: "stream" };
streamParticle: Particle & { type: 'stream' };
path: ParticlePath;
}
@@ -162,7 +196,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
next,
prev,
goTo,
goToParticle
goToParticle,
} = useStreamPlayback(streamParticle, path);
usePrefetchAdjacentMedia(children, currentIndex);
@@ -185,30 +219,43 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const mediaRef = useRef<MediaParticleHandle>(null);
const handleToggleReaction = useCallback((emoji: string) => {
if (!authedUser || !currentParticle) return;
if (isParticleDeleted(currentParticle)) return;
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 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 reactions = getReactions(currentParticle);
toggleParticleReaction(
currentParticleDocPath,
emoji,
authedUser.id,
reactions,
);
},
[authedUser, currentParticle, networkId, streamParticle.id],
);
const [composeActive, setComposeActive] = useState(false);
const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
const [composeStep, setComposeStep] = useState<ComposeStep>('idle');
const paused = usePlaybackPauseStore(selectIsPaused);
const [progress, setProgress] = useState(0);
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
const [showKeybindings, setShowKeybindings] = useState(false);
const [textReactionOpen, setTextReactionOpen] = useState(false);
const handleSubmitTextReaction = useCallback((text: string) => {
handleToggleReaction(text);
}, [handleToggleReaction]);
const handleSubmitTextReaction = useCallback(
(text: string) => {
handleToggleReaction(text);
},
[handleToggleReaction],
);
const { fastPlayback } = usePlaybackKeys({ mediaRef });
@@ -221,15 +268,17 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
});
const handleOpenHuddle = useCallback(() => {
if (!requireDesktop("Huddle")) return;
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
platform.huddle.open({ token, serverUrl: server_url });
});
if (!requireDesktop('Huddle')) return;
apiClient
.getLivekitToken(networkId, streamParticle.id)
.then(({ token, server_url }) => {
platform.huddle.open({ token, serverUrl: server_url });
});
navigate(`/${networkId}`);
}, [networkId, streamParticle.id, navigate]);
const handleToggleRecordingMode = useCallback(() => {
setRecordingMode(recordingMode === "video" ? "audio" : "video");
setRecordingMode(recordingMode === 'video' ? 'audio' : 'video');
}, [recordingMode, setRecordingMode]);
const handleToggleKeybindings = useCallback(() => {
@@ -249,11 +298,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const stepToMode: Record<string, ComposingMode | null> = {
idle: null,
submitting: null,
recording: "recording",
typing: "typing",
reviewing: "typing",
configuring: "typing",
picking: "screen",
recording: 'recording',
typing: 'typing',
reviewing: 'typing',
configuring: 'typing',
picking: 'screen',
};
const mode = stepToMode[composeStep] ?? null;
if (mode) {
@@ -274,35 +323,35 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
useEffect(() => () => clearTimeout(idleTimerRef.current), []);
// Always show controls when compose is active or exit countdown is visible
const controlsVisible = showControls || composeActive || status === "ended";
const controlsVisible = showControls || composeActive || status === 'ended';
const handleExitNavigate = useCallback(() => {
navigate(`/${networkId}`);
}, [navigate, networkId]);
const exitRemainingMs = useExitCountdown(
status,
paused,
handleExitNavigate,
);
const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate);
// Reset progress when particle changes
useEffect(() => {
// Reset progress when the particle changes.
if (currentParticle?.id !== prevParticleId) {
setPrevParticleId(currentParticle?.id);
setProgress(0);
}, [currentParticle?.id]);
}
const handleParticleCreated = useCallback((particleId: string) => {
if (currentIndex === -1) return;
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]);
// 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 (
@@ -318,7 +367,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
disabled={streamParticle.status === "closed"}
disabled={streamParticle.status === 'closed'}
onParticleCreated={handleParticleCreated}
/>
</div>
@@ -339,7 +388,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
);
}
switch (particle.type) {
case "media":
case 'media':
return (
<MediaParticleView
ref={mediaRef}
@@ -351,7 +400,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
onProgress={setProgress}
/>
);
case "text":
case 'text':
return (
<TextParticleView
key={particle.id}
@@ -363,7 +412,9 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
/>
);
default:
return <FallbackParticleView particle={particle} networkId={networkId} />;
return (
<FallbackParticleView particle={particle} networkId={networkId} />
);
}
}
@@ -378,7 +429,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
{/* TopBar — always visible */}
<div className="z-10 absolute left-0 right-0 pt-2">
<TopBar networkId={networkId} particle={currentParticle} streamParticle={streamParticle} />
<TopBar
networkId={networkId}
particle={currentParticle}
streamParticle={streamParticle}
/>
</div>
{/* Main playback area */}
@@ -408,7 +463,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
<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 ?? ""}
currentHumanId={authedUser?.id ?? ''}
humans={network?.humans}
onToggle={handleToggleReaction}
onOpenTextReaction={() => setTextReactionOpen(true)}
@@ -422,14 +477,17 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
)}
{/* Composing indicator — left edge, always visible */}
<ComposingIndicator users={composingUsers} networkHumans={network?.humans} />
<ComposingIndicator
users={composingUsers}
networkHumans={network?.humans}
/>
<ComposeOverlay
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
onStepChange={setComposeStep}
disabled={streamParticle.status === "closed"}
disabled={streamParticle.status === 'closed'}
onParticleCreated={handleParticleCreated}
/>
@@ -475,16 +533,23 @@ function BottomBar({
current: number;
progress: number;
onGoTo: (index: number) => void;
presenceBySegment: Map<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
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",
)}>
<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}
@@ -536,37 +601,37 @@ function StreamViewControls({
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
</kbd>{' '}
back
</span>
)}
<VideoAudioToggle />
<button
type="button"
onClick={() => requestIntent("record")}
onClick={() => requestIntent('record')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Reply with a recording (or hold `)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{" "}
</kbd>{' '}
to reply
</button>
<button
type="button"
onClick={() => requestIntent("text")}
onClick={() => requestIntent('text')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Reply with text (or press T)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
</kbd>{" "}
</kbd>{' '}
text
</button>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
H
</kbd>{" "}
</kbd>{' '}
huddle
</span>
<kbd
@@ -1,18 +1,18 @@
import { useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import type { Particle } from "@/api/types";
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";
} 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" }>;
type TextParticle = Extract<Particle, { type: 'text' }>;
interface TextEditOverlayProps {
particle: TextParticle;
@@ -25,7 +25,7 @@ export function TextEditOverlay({
streamPath,
onClose,
}: TextEditOverlayProps) {
useSuspendPlayback(true, "text-edit");
useSuspendPlayback(true, 'text-edit');
const [textContent, setTextContent] = useState(particle.properties.content);
const [saving, setSaving] = useState(false);
@@ -48,10 +48,17 @@ export function TextEditOverlay({
await editTextParticleContent(docPath, trimmed);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save");
toast.error(err instanceof Error ? err.message : 'Failed to save');
setSaving(false);
}
}, [saving, textContent, particle.properties.content, particle.id, streamPath, onClose]);
}, [
saving,
textContent,
particle.properties.content,
particle.id,
streamPath,
onClose,
]);
return createPortal(
// React synthetic events bubble through the React tree (not the DOM tree),
@@ -1,22 +1,25 @@
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 { 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 { MarkdownEditor } from "@/features/compose/markdown-editor";
} 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 { MarkdownEditor } from '@/features/compose/markdown-editor';
type TextParticle = Extract<Particle, { type: "text" }>;
type TextParticle = Extract<Particle, { type: 'text' }>;
interface TextParticleViewProps {
particle: TextParticle;
@@ -43,18 +46,21 @@ function computeReadDuration(
attachmentCount: number,
): number {
const base = (text.length / CHARS_PER_MINUTE) * 60;
const extra = linkCount * EXTRA_S_PER_LINK + attachmentCount * EXTRA_S_PER_ATTACHMENT;
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" };
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);
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(
content,
);
}
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
@@ -93,7 +99,11 @@ export function TextParticleView({
const hasAttachments = attachments.length > 0;
const hasEnrichments = hasLinks || hasAttachments;
const durationS = computeReadDuration(content, urls.length, attachments.length);
const durationS = computeReadDuration(
content,
urls.length,
attachments.length,
);
const elapsedRef = useRef(0);
// Reset elapsed when particle changes
@@ -120,8 +130,10 @@ export function TextParticleView({
// 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 linksOnly =
hasLinks &&
urls.every((url) => contentTrimmed.includes(url)) &&
contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, '').trim() === '';
const editButton = isCreator && !isEditing && (
<button
@@ -170,13 +182,17 @@ export function TextParticleView({
}
// Mode 2: short plain text, no enrichments — immersive centered display
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !hasMarkdownFormatting(content)) {
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",
'max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text',
style.size,
style.weight,
)}
@@ -195,16 +211,21 @@ export function TextParticleView({
<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 bg-white/10 p-6 backdrop-blur-md",
"[&::-webkit-scrollbar]:w-2",
"[&::-webkit-scrollbar]:p-2",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-white/30",
"[&::-webkit-scrollbar-thumb]:hover:bg-white/50",
'flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded bg-white/10 p-6 backdrop-blur-md',
'[&::-webkit-scrollbar]:w-2',
'[&::-webkit-scrollbar]:p-2',
'[&::-webkit-scrollbar-track]:bg-transparent',
'[&::-webkit-scrollbar-thumb]:rounded-full',
'[&::-webkit-scrollbar-thumb]:bg-white/30',
'[&::-webkit-scrollbar-thumb]:hover:bg-white/50',
)}
>
<MarkdownEditor key={content} value={content} readOnly className="select-text pb-3" />
<MarkdownEditor
key={content}
value={content}
readOnly
className="select-text pb-3"
/>
{hasLinks && <LinkPreviews entries={linkPreviews} />}
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { Send } from "lucide-react";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
import { sanitizeReactionText } from "@/lib/firestore-particles";
import { cn } from "@/lib/utils";
import { useEffect, useRef, useState } from 'react';
import { Send } from 'lucide-react';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { sanitizeReactionText } from '@/lib/firestore-particles';
import { cn } from '@/lib/utils';
const MAX_LENGTH = 40;
@@ -12,15 +12,28 @@ interface TextReactionInputProps {
onClose: () => void;
}
export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInputProps) {
const [value, setValue] = useState("");
export function TextReactionInput({
open,
onSubmit,
onClose,
}: TextReactionInputProps) {
const [value, setValue] = useState('');
const [prevOpen, setPrevOpen] = useState(open);
const inputRef = useRef<HTMLInputElement>(null);
useSuspendPlayback(open, "text-reaction");
useSuspendPlayback(open, 'text-reaction');
if (open !== prevOpen) {
setPrevOpen(open);
// NOTE: perform side effects here when opening
if (open) {
setValue('')
}
}
useEffect(() => {
if (!open) return;
setValue("");
const id = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(id);
}, [open]);
@@ -34,6 +47,7 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
const handleSubmit = () => {
if (!canSubmit) return;
onSubmit(trimmed);
setValue('');
onClose();
};
@@ -50,10 +64,10 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
}
onBlur={onClose}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
} else if (e.key === "Escape") {
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
@@ -64,8 +78,8 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
/>
<span
className={cn(
"min-w-[1.5ch] text-right text-[10px] tabular-nums",
remaining <= 8 ? "text-amber-300/80" : "text-white/30",
'min-w-[1.5ch] text-right text-[10px] tabular-nums',
remaining <= 8 ? 'text-amber-300/80' : 'text-white/30',
)}
>
{remaining}
@@ -75,10 +89,10 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
"ml-1 flex size-6 items-center justify-center rounded-full transition-colors",
'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",
? 'bg-white/20 text-white hover:bg-white/30'
: 'text-white/30',
)}
aria-label="Send reaction"
>
@@ -1,8 +1,8 @@
import { useMemo, useRef } from "react";
import type { Transcript } from "@/api/types";
import { useMemo, useState } from 'react';
import type { Transcript } from '@/api/types';
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
type Word = Transcript["words"][number];
type Sentence = Transcript['paragraphs'][number]['sentences'][number];
type Word = Transcript['words'][number];
const CHUNK_SIZE = 9;
@@ -41,42 +41,50 @@ export function TranscriptOverlay({
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;
// Remember the last spoken word so highlights hold during pauses.
const [lastSpokenWord, setLastSpokenWord] = useState<Word | null>(null);
if (activeWord && activeWord !== lastSpokenWord) {
setLastSpokenWord(activeWord);
}
const highlightWord = activeWord ?? lastSpokenWordRef.current;
const highlightWord = activeWord ?? lastSpokenWord;
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;
// The chunk currently being spoken (null during a pause or if not found).
const spokenChunk = useMemo(() => {
if (!activeWord) return null;
return (
chunks.find((chunk) =>
chunk.some(
(w) => w.start === activeWord.start && w.end === activeWord.end,
),
) ?? null
);
}, [chunks, activeWord]);
// Resolve which chunk to display: the spoken one, else hold the last one while
// it's still part of the current sentence, else fall back to the first chunk.
const [lastChunk, setLastChunk] = useState<Word[] | null>(null);
let activeChunk: Word[] | null;
if (spokenChunk) {
activeChunk = spokenChunk;
} else if (lastChunk && chunks.includes(lastChunk)) {
activeChunk = lastChunk;
} else {
activeChunk = chunks[0] ?? null;
}
if (activeChunk !== lastChunk) {
setLastChunk(activeChunk);
}
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"
}>
<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 =
@@ -87,11 +95,11 @@ export function TranscriptOverlay({
key={`${word.start}-${i}`}
className={
isSpoken
? "text-white font-medium transition-colors duration-150"
: "text-white/40 transition-colors duration-150"
? 'text-white font-medium transition-colors duration-150'
: 'text-white/40 transition-colors duration-150'
}
>
{i > 0 ? " " : ""}
{i > 0 ? ' ' : ''}
{word.word}
</span>
);