feat: show presence and compose indicator
This commit is contained in:
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="z-100 absolute left-2 top-1/2 z-20 flex -translate-y-1/2 flex-col gap-1.5 animate-in fade-in duration-200"
|
||||||
|
style={{ writingMode: "vertical-rl" }}
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={u.humanId}
|
||||||
|
className="flex rotate-180 items-center gap-1.5 rounded-full bg-white/10 px-2 py-1 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<span className="flex gap-0.5">
|
||||||
|
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:0ms]" />
|
||||||
|
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:150ms]" />
|
||||||
|
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:300ms]" />
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-nowrap text-[10px] text-white/50">
|
||||||
|
{name} {modeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import { createImageThumbnail } from "@/lib/image-thumbnail";
|
|||||||
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
|
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
|
||||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
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";
|
type RecordingSource = "media" | "screen";
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ interface ComposeOverlayProps {
|
|||||||
// Optional target path for reply mode. If not provided, compose creates a new stream.
|
// Optional target path for reply mode. If not provided, compose creates a new stream.
|
||||||
targetPath?: ParticlePath;
|
targetPath?: ParticlePath;
|
||||||
onActiveChange?: (active: boolean) => void;
|
onActiveChange?: (active: boolean) => void;
|
||||||
|
onStepChange?: (step: ComposeStep) => void;
|
||||||
onParticleCreated?: (particleId: string) => void;
|
onParticleCreated?: (particleId: string) => void;
|
||||||
/** When true, composing is blocked (e.g. stream is closed). */
|
/** When true, composing is blocked (e.g. stream is closed). */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -42,6 +43,7 @@ export function ComposeOverlay({
|
|||||||
networkId,
|
networkId,
|
||||||
targetPath,
|
targetPath,
|
||||||
onActiveChange,
|
onActiveChange,
|
||||||
|
onStepChange,
|
||||||
onParticleCreated,
|
onParticleCreated,
|
||||||
disabled,
|
disabled,
|
||||||
}: ComposeOverlayProps) {
|
}: ComposeOverlayProps) {
|
||||||
@@ -77,7 +79,8 @@ export function ComposeOverlay({
|
|||||||
// Notify parent when active state changes
|
// Notify parent when active state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onActiveChange?.(step !== "idle");
|
onActiveChange?.(step !== "idle");
|
||||||
}, [step, onActiveChange]);
|
onStepChange?.(step);
|
||||||
|
}, [step, onActiveChange, onStepChange]);
|
||||||
|
|
||||||
const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => {
|
const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => {
|
||||||
for (const a of items) {
|
for (const a of items) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarBadge, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -14,6 +14,8 @@ interface PlaybackPageIndicatorProps {
|
|||||||
progress: number;
|
progress: number;
|
||||||
onGoTo: (index: number) => void;
|
onGoTo: (index: number) => void;
|
||||||
presenceBySegment?: Map<number, HumanPresence[]>;
|
presenceBySegment?: Map<number, HumanPresence[]>;
|
||||||
|
/** Set of humanIds currently online in the stream channel. */
|
||||||
|
onlineHumanIds?: Set<string>;
|
||||||
/** Render only avatars or only tracks. Omit to render both. */
|
/** Render only avatars or only tracks. Omit to render both. */
|
||||||
layer?: "avatars" | "tracks";
|
layer?: "avatars" | "tracks";
|
||||||
}
|
}
|
||||||
@@ -24,6 +26,7 @@ export function PlaybackPageIndicator({
|
|||||||
progress,
|
progress,
|
||||||
onGoTo,
|
onGoTo,
|
||||||
presenceBySegment,
|
presenceBySegment,
|
||||||
|
onlineHumanIds,
|
||||||
layer,
|
layer,
|
||||||
}: PlaybackPageIndicatorProps) {
|
}: PlaybackPageIndicatorProps) {
|
||||||
if (total === 0) return null;
|
if (total === 0) return null;
|
||||||
@@ -38,7 +41,7 @@ export function PlaybackPageIndicator({
|
|||||||
return (
|
return (
|
||||||
<div key={i} className="flex flex-1 flex-col items-stretch">
|
<div key={i} className="flex flex-1 flex-col items-stretch">
|
||||||
{showAvatars && presence && presence.length > 0 && (
|
{showAvatars && presence && presence.length > 0 && (
|
||||||
<SegmentPresenceAvatars presence={presence} />
|
<SegmentPresenceAvatars presence={presence} onlineHumanIds={onlineHumanIds} />
|
||||||
)}
|
)}
|
||||||
{showTracks && (
|
{showTracks && (
|
||||||
<button
|
<button
|
||||||
@@ -74,8 +77,10 @@ export function PlaybackPageIndicator({
|
|||||||
|
|
||||||
function SegmentPresenceAvatars({
|
function SegmentPresenceAvatars({
|
||||||
presence,
|
presence,
|
||||||
|
onlineHumanIds,
|
||||||
}: {
|
}: {
|
||||||
presence: HumanPresence[];
|
presence: HumanPresence[];
|
||||||
|
onlineHumanIds?: Set<string>;
|
||||||
}) {
|
}) {
|
||||||
const visible = presence.slice(0, MAX_VISIBLE_AVATARS);
|
const visible = presence.slice(0, MAX_VISIBLE_AVATARS);
|
||||||
const overflow = presence.length - MAX_VISIBLE_AVATARS;
|
const overflow = presence.length - MAX_VISIBLE_AVATARS;
|
||||||
@@ -89,6 +94,9 @@ function SegmentPresenceAvatars({
|
|||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{human.emailPrefix.slice(0, 2).toUpperCase()}
|
{human.emailPrefix.slice(0, 2).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
|
{onlineHumanIds?.has(human.humanId) && (
|
||||||
|
<AvatarBadge className="bg-green-500" />
|
||||||
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" className="text-xs">
|
<TooltipContent side="top" className="text-xs">
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { useChannel } from "@/hooks/use-channel";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import type { ChannelMessage } from "@/lib/pusher-client";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ComposingMode = "recording" | "typing" | "screen";
|
||||||
|
|
||||||
|
export interface ComposingUser {
|
||||||
|
humanId: string;
|
||||||
|
mode: ComposingMode;
|
||||||
|
lastSeen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StreamPresenceContextValue {
|
||||||
|
onlineHumanIds: Set<string>;
|
||||||
|
composingUsers: ComposingUser[];
|
||||||
|
startComposing: (mode: ComposingMode) => void;
|
||||||
|
stopComposing: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const COMPOSING_TIMEOUT_MS = 10_000;
|
||||||
|
const COMPOSING_HEARTBEAT_MS = 5_000;
|
||||||
|
const COMPOSING_CLEANUP_INTERVAL_MS = 2_000;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const StreamPresenceContext = createContext<StreamPresenceContextValue | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface StreamPresenceProviderProps {
|
||||||
|
networkId: string;
|
||||||
|
streamId: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StreamPresenceProvider({
|
||||||
|
networkId,
|
||||||
|
streamId,
|
||||||
|
children,
|
||||||
|
}: StreamPresenceProviderProps) {
|
||||||
|
const channelId = `stream:${networkId}:${streamId}`;
|
||||||
|
const { presence, messages, sendMessage } = useChannel(channelId);
|
||||||
|
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||||
|
|
||||||
|
// --- Online presence ---
|
||||||
|
const onlineHumanIds = useMemo(() => new Set(presence), [presence]);
|
||||||
|
|
||||||
|
// --- Composing state ---
|
||||||
|
const [composingUsers, setComposingUsers] = useState<ComposingUser[]>([]);
|
||||||
|
const composingMapRef = useRef(new Map<string, ComposingUser>());
|
||||||
|
const processedCountRef = useRef(0);
|
||||||
|
|
||||||
|
// Process new messages incrementally
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length <= processedCountRef.current) return;
|
||||||
|
|
||||||
|
const newMessages = messages.slice(processedCountRef.current);
|
||||||
|
processedCountRef.current = messages.length;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const map = composingMapRef.current;
|
||||||
|
|
||||||
|
for (const msg of newMessages) {
|
||||||
|
const payload = msg.payload as
|
||||||
|
| { type: string; mode?: string }
|
||||||
|
| undefined;
|
||||||
|
if (!payload?.type) continue;
|
||||||
|
|
||||||
|
// Skip own events
|
||||||
|
if (msg.humanId === currentUserId) continue;
|
||||||
|
|
||||||
|
if (payload.type === "composing_start" && payload.mode) {
|
||||||
|
map.set(msg.humanId, {
|
||||||
|
humanId: msg.humanId,
|
||||||
|
mode: payload.mode as ComposingMode,
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
} else if (payload.type === "composing_stop") {
|
||||||
|
if (map.delete(msg.humanId)) changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
setComposingUsers(Array.from(map.values()));
|
||||||
|
}
|
||||||
|
}, [messages, currentUserId]);
|
||||||
|
|
||||||
|
// Also clear composing when a user leaves the channel
|
||||||
|
useEffect(() => {
|
||||||
|
const map = composingMapRef.current;
|
||||||
|
const onlineSet = new Set(presence);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const humanId of map.keys()) {
|
||||||
|
if (!onlineSet.has(humanId)) {
|
||||||
|
map.delete(humanId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
setComposingUsers(Array.from(map.values()));
|
||||||
|
}
|
||||||
|
}, [presence]);
|
||||||
|
|
||||||
|
// Cleanup stale composing entries
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const map = composingMapRef.current;
|
||||||
|
const now = Date.now();
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const [humanId, entry] of map) {
|
||||||
|
if (now - entry.lastSeen > COMPOSING_TIMEOUT_MS) {
|
||||||
|
map.delete(humanId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
setComposingUsers(Array.from(map.values()));
|
||||||
|
}
|
||||||
|
}, COMPOSING_CLEANUP_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- Composing broadcast ---
|
||||||
|
const heartbeatRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||||
|
|
||||||
|
const startComposing = useCallback(
|
||||||
|
(mode: ComposingMode) => {
|
||||||
|
// Send immediately
|
||||||
|
sendMessage({ type: "composing_start", mode });
|
||||||
|
|
||||||
|
// Clear any existing heartbeat
|
||||||
|
clearInterval(heartbeatRef.current);
|
||||||
|
|
||||||
|
// Start heartbeat
|
||||||
|
heartbeatRef.current = setInterval(() => {
|
||||||
|
sendMessage({ type: "composing_start", mode });
|
||||||
|
}, COMPOSING_HEARTBEAT_MS);
|
||||||
|
},
|
||||||
|
[sendMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopComposing = useCallback(() => {
|
||||||
|
clearInterval(heartbeatRef.current);
|
||||||
|
heartbeatRef.current = undefined;
|
||||||
|
sendMessage({ type: "composing_stop" });
|
||||||
|
}, [sendMessage]);
|
||||||
|
|
||||||
|
// Cleanup heartbeat on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearInterval(heartbeatRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<StreamPresenceContextValue>(
|
||||||
|
() => ({
|
||||||
|
onlineHumanIds,
|
||||||
|
composingUsers,
|
||||||
|
startComposing,
|
||||||
|
stopComposing,
|
||||||
|
}),
|
||||||
|
[onlineHumanIds, composingUsers, startComposing, stopComposing],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StreamPresenceContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</StreamPresenceContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hooks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function useStreamPresenceContext() {
|
||||||
|
const ctx = useContext(StreamPresenceContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useStreamPresence must be used within a StreamPresenceProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStreamPresence() {
|
||||||
|
const { onlineHumanIds } = useStreamPresenceContext();
|
||||||
|
return { onlineHumanIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStreamComposing() {
|
||||||
|
const { composingUsers } = useStreamPresenceContext();
|
||||||
|
return { composingUsers };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStreamComposingBroadcast() {
|
||||||
|
const { startComposing, stopComposing } = useStreamPresenceContext();
|
||||||
|
return { startComposing, stopComposing };
|
||||||
|
}
|
||||||
@@ -4,12 +4,12 @@ import { useAuthStore } from "@/stores/auth-store";
|
|||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
import { type Particle, REACTION_EMOJIS } from "@/api/types";
|
import { type Particle, REACTION_EMOJIS } from "@/api/types";
|
||||||
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
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 { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
|
||||||
import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view";
|
import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view";
|
||||||
import { TextParticleView } from "@/features/particles/text-particle-view";
|
import { TextParticleView } from "@/features/particles/text-particle-view";
|
||||||
import { FallbackParticleView } from "@/features/particles/fallback-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 { VideoAudioToggle } from "@/components/video-audio-toggle";
|
||||||
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
||||||
import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindings-overlay";
|
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 { useStreamPlayback } from "@/hooks/use-stream-playback";
|
||||||
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
|
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
|
||||||
import { usePresencePositions } from "@/hooks/use-presence-positions";
|
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 { cn, getInitials } from "@/lib/utils";
|
||||||
import { useMount } from "react-use";
|
import { useMount } from "react-use";
|
||||||
|
|
||||||
@@ -153,6 +155,16 @@ interface StreamViewProps {
|
|||||||
|
|
||||||
export function StreamView({ path, streamParticle }: StreamViewProps) {
|
export function StreamView({ path, streamParticle }: StreamViewProps) {
|
||||||
const { networkId } = parseParticlePath(path);
|
const { networkId } = parseParticlePath(path);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StreamPresenceProvider networkId={networkId} streamId={streamParticle.id}>
|
||||||
|
<StreamViewInner path={path} streamParticle={streamParticle} />
|
||||||
|
</StreamPresenceProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||||
|
const { networkId } = parseParticlePath(path);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useMount(() => {
|
useMount(() => {
|
||||||
@@ -185,6 +197,11 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
authedUser?.id,
|
authedUser?.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Stream presence (realtime via pusher) ---
|
||||||
|
const { onlineHumanIds } = useStreamPresence();
|
||||||
|
const { composingUsers } = useStreamComposing();
|
||||||
|
const { startComposing, stopComposing } = useStreamComposingBroadcast();
|
||||||
|
|
||||||
const mediaRef = useRef<MediaParticleHandle>(null);
|
const mediaRef = useRef<MediaParticleHandle>(null);
|
||||||
|
|
||||||
const handleToggleReaction = useCallback((emoji: string) => {
|
const handleToggleReaction = useCallback((emoji: string) => {
|
||||||
@@ -201,10 +218,30 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
}, [authedUser, currentParticle]);
|
}, [authedUser, currentParticle]);
|
||||||
|
|
||||||
const [composeActive, setComposeActive] = useState(false);
|
const [composeActive, setComposeActive] = useState(false);
|
||||||
|
const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [fastPlayback, setFastPlayback] = useState(false);
|
const [fastPlayback, setFastPlayback] = useState(false);
|
||||||
const [showKeybindings, setShowKeybindings] = useState(false);
|
const [showKeybindings, setShowKeybindings] = useState(false);
|
||||||
|
|
||||||
|
// Broadcast composing state to other viewers
|
||||||
|
useEffect(() => {
|
||||||
|
const stepToMode: Record<string, ComposingMode | null> = {
|
||||||
|
idle: null,
|
||||||
|
submitting: null,
|
||||||
|
recording: "recording",
|
||||||
|
typing: "typing",
|
||||||
|
reviewing: "typing",
|
||||||
|
configuring: "typing",
|
||||||
|
picking: "screen",
|
||||||
|
};
|
||||||
|
const mode = stepToMode[composeStep] ?? null;
|
||||||
|
if (mode) {
|
||||||
|
startComposing(mode);
|
||||||
|
} else {
|
||||||
|
stopComposing();
|
||||||
|
}
|
||||||
|
}, [composeStep, startComposing, stopComposing]);
|
||||||
|
|
||||||
// Show/hide chrome on mouse activity (YouTube-style)
|
// Show/hide chrome on mouse activity (YouTube-style)
|
||||||
const [showControls, setShowControls] = useState(true);
|
const [showControls, setShowControls] = useState(true);
|
||||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const idleTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
@@ -436,10 +473,14 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Composing indicator — left edge, always visible */}
|
||||||
|
<ComposingIndicator users={composingUsers} networkHumans={network?.humans} />
|
||||||
|
|
||||||
<ComposeOverlay
|
<ComposeOverlay
|
||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
targetPath={path}
|
targetPath={path}
|
||||||
onActiveChange={setComposeActive}
|
onActiveChange={setComposeActive}
|
||||||
|
onStepChange={setComposeStep}
|
||||||
disabled={streamParticle.status === "closed"}
|
disabled={streamParticle.status === "closed"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -454,6 +495,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
progress={progress}
|
progress={progress}
|
||||||
onGoTo={goTo}
|
onGoTo={goTo}
|
||||||
presenceBySegment={presenceBySegment}
|
presenceBySegment={presenceBySegment}
|
||||||
|
onlineHumanIds={onlineHumanIds}
|
||||||
exitRemainingMs={exitRemainingMs}
|
exitRemainingMs={exitRemainingMs}
|
||||||
onOpenKeybindings={() => setShowKeybindings(true)}
|
onOpenKeybindings={() => setShowKeybindings(true)}
|
||||||
/>
|
/>
|
||||||
@@ -475,6 +517,7 @@ function BottomBar({
|
|||||||
progress,
|
progress,
|
||||||
onGoTo,
|
onGoTo,
|
||||||
presenceBySegment,
|
presenceBySegment,
|
||||||
|
onlineHumanIds,
|
||||||
exitRemainingMs,
|
exitRemainingMs,
|
||||||
onOpenKeybindings,
|
onOpenKeybindings,
|
||||||
}: {
|
}: {
|
||||||
@@ -484,6 +527,7 @@ function BottomBar({
|
|||||||
progress: number;
|
progress: number;
|
||||||
onGoTo: (index: number) => void;
|
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;
|
exitRemainingMs: number | null;
|
||||||
onOpenKeybindings: () => void;
|
onOpenKeybindings: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -499,6 +543,7 @@ function BottomBar({
|
|||||||
progress={progress}
|
progress={progress}
|
||||||
onGoTo={onGoTo}
|
onGoTo={onGoTo}
|
||||||
presenceBySegment={presenceBySegment}
|
presenceBySegment={presenceBySegment}
|
||||||
|
onlineHumanIds={onlineHumanIds}
|
||||||
layer="avatars"
|
layer="avatars"
|
||||||
/>
|
/>
|
||||||
{/* Blurred background container — tracks + controls */}
|
{/* Blurred background container — tracks + controls */}
|
||||||
@@ -711,17 +756,19 @@ function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
|
|||||||
|
|
||||||
function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) {
|
function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) {
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
|
const { onlineHumanIds } = useStreamPresence();
|
||||||
const creator = network?.humans?.find((h) => h.id === particle.created_by_human_id);
|
const creator = network?.humans?.find((h) => h.id === particle.created_by_human_id);
|
||||||
const prefix = creator?.email_prefix ?? particle.created_by_human_id;
|
const prefix = creator?.email_prefix ?? particle.created_by_human_id;
|
||||||
const initials = prefix.slice(0, 2).toUpperCase();
|
const initials = prefix.slice(0, 2).toUpperCase();
|
||||||
|
const isOnline = particle.created_by_human_id ? onlineHumanIds.has(particle.created_by_human_id) : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex
|
<span className="flex items-center gap-1.5">
|
||||||
items-center gap-1.5">
|
|
||||||
<Avatar size="sm">
|
<Avatar size="sm">
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{initials}
|
{initials}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
|
{isOnline && <AvatarBadge className="bg-green-500" />}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{prefix} - <RelativeTimestamp date={particle.created_at} />
|
{prefix} - <RelativeTimestamp date={particle.created_at} />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user