From 76a5f914640062a172e89da80585f5b9c9a19d67 Mon Sep 17 00:00:00 2001 From: talksik Date: Thu, 9 Apr 2026 12:02:52 -0700 Subject: [PATCH] feat: show presence and compose indicator --- js/src/components/composing-indicator.tsx | 47 ++++ js/src/features/compose/compose-overlay.tsx | 7 +- .../particles/playback-page-indicator.tsx | 12 +- .../particles/stream-presence-context.tsx | 229 ++++++++++++++++++ js/src/features/particles/stream-view.tsx | 55 ++++- 5 files changed, 342 insertions(+), 8 deletions(-) create mode 100644 js/src/components/composing-indicator.tsx create mode 100644 js/src/features/particles/stream-presence-context.tsx diff --git a/js/src/components/composing-indicator.tsx b/js/src/components/composing-indicator.tsx new file mode 100644 index 0000000..8c22b51 --- /dev/null +++ b/js/src/components/composing-indicator.tsx @@ -0,0 +1,47 @@ +import type { Human } from "@/api/types"; +import type { ComposingUser } from "@/features/particles/stream-presence-context"; + +interface ComposingIndicatorProps { + users: ComposingUser[]; + networkHumans?: Human[]; +} + +/** + * Composing indicators pinned to the left edge, text running bottom-to-top + * via writing-mode so it hugs the edge without transform math issues. + */ +export function ComposingIndicator({ + users, + networkHumans, +}: ComposingIndicatorProps) { + if (users.length === 0) return null; + + return ( +
+ {users.map((u) => { + const human = networkHumans?.find((h) => h.id === u.humanId); + const name = human?.email_prefix ?? u.humanId; + const modeLabel = u.mode === "typing" ? "typing" : "recording"; + + return ( +
+ + + + + + + {name} {modeLabel} + +
+ ); + })} +
+ ); +} diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx index f2cb065..018affc 100644 --- a/js/src/features/compose/compose-overlay.tsx +++ b/js/src/features/compose/compose-overlay.tsx @@ -17,7 +17,7 @@ import { createImageThumbnail } from "@/lib/image-thumbnail"; import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants"; import type { PendingAttachment } from "@/features/compose/attachment-strip"; -type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting"; +export type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting"; type RecordingSource = "media" | "screen"; @@ -26,6 +26,7 @@ interface ComposeOverlayProps { // Optional target path for reply mode. If not provided, compose creates a new stream. targetPath?: ParticlePath; onActiveChange?: (active: boolean) => void; + onStepChange?: (step: ComposeStep) => void; onParticleCreated?: (particleId: string) => void; /** When true, composing is blocked (e.g. stream is closed). */ disabled?: boolean; @@ -42,6 +43,7 @@ export function ComposeOverlay({ networkId, targetPath, onActiveChange, + onStepChange, onParticleCreated, disabled, }: ComposeOverlayProps) { @@ -77,7 +79,8 @@ export function ComposeOverlay({ // Notify parent when active state changes useEffect(() => { onActiveChange?.(step !== "idle"); - }, [step, onActiveChange]); + onStepChange?.(step); + }, [step, onActiveChange, onStepChange]); const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => { for (const a of items) { diff --git a/js/src/features/particles/playback-page-indicator.tsx b/js/src/features/particles/playback-page-indicator.tsx index 10cfa27..d7d1bee 100644 --- a/js/src/features/particles/playback-page-indicator.tsx +++ b/js/src/features/particles/playback-page-indicator.tsx @@ -1,4 +1,4 @@ -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Avatar, AvatarBadge, AvatarFallback } from "@/components/ui/avatar"; import { Tooltip, TooltipContent, @@ -14,6 +14,8 @@ interface PlaybackPageIndicatorProps { progress: number; onGoTo: (index: number) => void; presenceBySegment?: Map; + /** Set of humanIds currently online in the stream channel. */ + onlineHumanIds?: Set; /** Render only avatars or only tracks. Omit to render both. */ layer?: "avatars" | "tracks"; } @@ -24,6 +26,7 @@ export function PlaybackPageIndicator({ progress, onGoTo, presenceBySegment, + onlineHumanIds, layer, }: PlaybackPageIndicatorProps) { if (total === 0) return null; @@ -38,7 +41,7 @@ export function PlaybackPageIndicator({ return (
{showAvatars && presence && presence.length > 0 && ( - + )} {showTracks && (
)} + {/* Composing indicator — left edge, always visible */} + + @@ -454,6 +495,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { progress={progress} onGoTo={goTo} presenceBySegment={presenceBySegment} + onlineHumanIds={onlineHumanIds} exitRemainingMs={exitRemainingMs} onOpenKeybindings={() => setShowKeybindings(true)} /> @@ -475,6 +517,7 @@ function BottomBar({ progress, onGoTo, presenceBySegment, + onlineHumanIds, exitRemainingMs, onOpenKeybindings, }: { @@ -484,6 +527,7 @@ function BottomBar({ progress: number; onGoTo: (index: number) => void; presenceBySegment: Map; + onlineHumanIds: Set; exitRemainingMs: number | null; onOpenKeybindings: () => void; }) { @@ -499,6 +543,7 @@ function BottomBar({ progress={progress} onGoTo={onGoTo} presenceBySegment={presenceBySegment} + onlineHumanIds={onlineHumanIds} layer="avatars" /> {/* Blurred background container — tracks + controls */} @@ -711,17 +756,19 @@ function NetworkBreadcrumbContent({ networkId }: { networkId: string }) { function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) { const network = useNetwork(networkId); + const { onlineHumanIds } = useStreamPresence(); const creator = network?.humans?.find((h) => h.id === particle.created_by_human_id); const prefix = creator?.email_prefix ?? particle.created_by_human_id; const initials = prefix.slice(0, 2).toUpperCase(); + const isOnline = particle.created_by_human_id ? onlineHumanIds.has(particle.created_by_human_id) : false; return ( - + {initials} + {isOnline && } {prefix} -