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 && (
;
}) {
const visible = presence.slice(0, MAX_VISIBLE_AVATARS);
const overflow = presence.length - MAX_VISIBLE_AVATARS;
@@ -89,6 +94,9 @@ function SegmentPresenceAvatars({
{human.emailPrefix.slice(0, 2).toUpperCase()}
+ {onlineHumanIds?.has(human.humanId) && (
+
+ )}
diff --git a/js/src/features/particles/stream-presence-context.tsx b/js/src/features/particles/stream-presence-context.tsx
new file mode 100644
index 0000000..ae8c5f3
--- /dev/null
+++ b/js/src/features/particles/stream-presence-context.tsx
@@ -0,0 +1,229 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { useChannel } from "@/hooks/use-channel";
+import { useAuthStore } from "@/stores/auth-store";
+import type { ChannelMessage } from "@/lib/pusher-client";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export type ComposingMode = "recording" | "typing" | "screen";
+
+export interface ComposingUser {
+ humanId: string;
+ mode: ComposingMode;
+ lastSeen: number;
+}
+
+interface StreamPresenceContextValue {
+ onlineHumanIds: Set;
+ composingUsers: ComposingUser[];
+ startComposing: (mode: ComposingMode) => void;
+ stopComposing: () => void;
+}
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+const COMPOSING_TIMEOUT_MS = 10_000;
+const COMPOSING_HEARTBEAT_MS = 5_000;
+const COMPOSING_CLEANUP_INTERVAL_MS = 2_000;
+
+// ---------------------------------------------------------------------------
+// Context
+// ---------------------------------------------------------------------------
+
+const StreamPresenceContext = createContext(
+ null,
+);
+
+// ---------------------------------------------------------------------------
+// Provider
+// ---------------------------------------------------------------------------
+
+interface StreamPresenceProviderProps {
+ networkId: string;
+ streamId: string;
+ children: ReactNode;
+}
+
+export function StreamPresenceProvider({
+ networkId,
+ streamId,
+ children,
+}: StreamPresenceProviderProps) {
+ const channelId = `stream:${networkId}:${streamId}`;
+ const { presence, messages, sendMessage } = useChannel(channelId);
+ const currentUserId = useAuthStore((s) => s.user?.id);
+
+ // --- Online presence ---
+ const onlineHumanIds = useMemo(() => new Set(presence), [presence]);
+
+ // --- Composing state ---
+ const [composingUsers, setComposingUsers] = useState([]);
+ const composingMapRef = useRef(new Map());
+ const processedCountRef = useRef(0);
+
+ // Process new messages incrementally
+ useEffect(() => {
+ if (messages.length <= processedCountRef.current) return;
+
+ const newMessages = messages.slice(processedCountRef.current);
+ processedCountRef.current = messages.length;
+
+ let changed = false;
+ const map = composingMapRef.current;
+
+ for (const msg of newMessages) {
+ const payload = msg.payload as
+ | { type: string; mode?: string }
+ | undefined;
+ if (!payload?.type) continue;
+
+ // Skip own events
+ if (msg.humanId === currentUserId) continue;
+
+ if (payload.type === "composing_start" && payload.mode) {
+ map.set(msg.humanId, {
+ humanId: msg.humanId,
+ mode: payload.mode as ComposingMode,
+ lastSeen: Date.now(),
+ });
+ changed = true;
+ } else if (payload.type === "composing_stop") {
+ if (map.delete(msg.humanId)) changed = true;
+ }
+ }
+
+ if (changed) {
+ setComposingUsers(Array.from(map.values()));
+ }
+ }, [messages, currentUserId]);
+
+ // Also clear composing when a user leaves the channel
+ useEffect(() => {
+ const map = composingMapRef.current;
+ const onlineSet = new Set(presence);
+ let changed = false;
+
+ for (const humanId of map.keys()) {
+ if (!onlineSet.has(humanId)) {
+ map.delete(humanId);
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ setComposingUsers(Array.from(map.values()));
+ }
+ }, [presence]);
+
+ // Cleanup stale composing entries
+ useEffect(() => {
+ const interval = setInterval(() => {
+ const map = composingMapRef.current;
+ const now = Date.now();
+ let changed = false;
+
+ for (const [humanId, entry] of map) {
+ if (now - entry.lastSeen > COMPOSING_TIMEOUT_MS) {
+ map.delete(humanId);
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ setComposingUsers(Array.from(map.values()));
+ }
+ }, COMPOSING_CLEANUP_INTERVAL_MS);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ // --- Composing broadcast ---
+ const heartbeatRef = useRef>(undefined);
+
+ const startComposing = useCallback(
+ (mode: ComposingMode) => {
+ // Send immediately
+ sendMessage({ type: "composing_start", mode });
+
+ // Clear any existing heartbeat
+ clearInterval(heartbeatRef.current);
+
+ // Start heartbeat
+ heartbeatRef.current = setInterval(() => {
+ sendMessage({ type: "composing_start", mode });
+ }, COMPOSING_HEARTBEAT_MS);
+ },
+ [sendMessage],
+ );
+
+ const stopComposing = useCallback(() => {
+ clearInterval(heartbeatRef.current);
+ heartbeatRef.current = undefined;
+ sendMessage({ type: "composing_stop" });
+ }, [sendMessage]);
+
+ // Cleanup heartbeat on unmount
+ useEffect(() => {
+ return () => {
+ clearInterval(heartbeatRef.current);
+ };
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ onlineHumanIds,
+ composingUsers,
+ startComposing,
+ stopComposing,
+ }),
+ [onlineHumanIds, composingUsers, startComposing, stopComposing],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Hooks
+// ---------------------------------------------------------------------------
+
+function useStreamPresenceContext() {
+ const ctx = useContext(StreamPresenceContext);
+ if (!ctx) {
+ throw new Error(
+ "useStreamPresence must be used within a StreamPresenceProvider",
+ );
+ }
+ return ctx;
+}
+
+export function useStreamPresence() {
+ const { onlineHumanIds } = useStreamPresenceContext();
+ return { onlineHumanIds };
+}
+
+export function useStreamComposing() {
+ const { composingUsers } = useStreamPresenceContext();
+ return { composingUsers };
+}
+
+export function useStreamComposingBroadcast() {
+ const { startComposing, stopComposing } = useStreamPresenceContext();
+ return { startComposing, stopComposing };
+}
diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx
index 02347cc..54f21c0 100644
--- a/js/src/features/particles/stream-view.tsx
+++ b/js/src/features/particles/stream-view.tsx
@@ -4,12 +4,12 @@ import { useAuthStore } from "@/stores/auth-store";
import { apiClient } from "@/api/client";
import { type Particle, REACTION_EMOJIS } from "@/api/types";
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
-import { ComposeOverlay } from "@/features/compose/compose-overlay";
+import { ComposeOverlay, type ComposeStep } from "@/features/compose/compose-overlay";
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view";
import { TextParticleView } from "@/features/particles/text-particle-view";
import { FallbackParticleView } from "@/features/particles/fallback-particle-view";
-import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
+import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { VideoAudioToggle } from "@/components/video-audio-toggle";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindings-overlay";
@@ -31,6 +31,8 @@ import { RelativeTimestamp } from "@/components/relative-timestamp";
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, getInitials } from "@/lib/utils";
import { useMount } from "react-use";
@@ -153,6 +155,16 @@ interface StreamViewProps {
export function StreamView({ path, streamParticle }: StreamViewProps) {
const { networkId } = parseParticlePath(path);
+
+ return (
+
+
+
+ );
+}
+
+function StreamViewInner({ path, streamParticle }: StreamViewProps) {
+ const { networkId } = parseParticlePath(path);
const navigate = useNavigate();
useMount(() => {
@@ -185,6 +197,11 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
authedUser?.id,
);
+ // --- Stream presence (realtime via pusher) ---
+ const { onlineHumanIds } = useStreamPresence();
+ const { composingUsers } = useStreamComposing();
+ const { startComposing, stopComposing } = useStreamComposingBroadcast();
+
const mediaRef = useRef(null);
const handleToggleReaction = useCallback((emoji: string) => {
@@ -201,10 +218,30 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
}, [authedUser, currentParticle]);
const [composeActive, setComposeActive] = useState(false);
+ const [composeStep, setComposeStep] = useState("idle");
const [progress, setProgress] = useState(0);
const [fastPlayback, setFastPlayback] = useState(false);
const [showKeybindings, setShowKeybindings] = useState(false);
+ // Broadcast composing state to other viewers
+ useEffect(() => {
+ const stepToMode: Record = {
+ idle: null,
+ submitting: null,
+ recording: "recording",
+ typing: "typing",
+ reviewing: "typing",
+ configuring: "typing",
+ picking: "screen",
+ };
+ const mode = stepToMode[composeStep] ?? null;
+ if (mode) {
+ startComposing(mode);
+ } else {
+ stopComposing();
+ }
+ }, [composeStep, startComposing, stopComposing]);
+
// Show/hide chrome on mouse activity (YouTube-style)
const [showControls, setShowControls] = useState(true);
const idleTimerRef = useRef>(undefined);
@@ -436,10 +473,14 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
)}
+ {/* 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} -