add real-time infrastructure (#137)

* setup infra for pusher service

* setup client sdk for pusher service

* fix: ping parse failure

* fix: send pong back to client

avoid disconnections every 2.5 minutes

* increase replicas

* feat: show presence and compose indicator
This commit was merged in pull request #137.
This commit is contained in:
Arjun Patel
2026-04-09 12:08:18 -07:00
committed by GitHub
parent ce368c9e6e
commit 3d8fa79657
29 changed files with 2549 additions and 11 deletions
+5 -2
View File
@@ -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) {
@@ -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<number, HumanPresence[]>;
/** Set of humanIds currently online in the stream channel. */
onlineHumanIds?: Set<string>;
/** Render only avatars or only tracks. Omit to render both. */
layer?: "avatars" | "tracks";
}
@@ -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 (
<div key={i} className="flex flex-1 flex-col items-stretch">
{showAvatars && presence && presence.length > 0 && (
<SegmentPresenceAvatars presence={presence} />
<SegmentPresenceAvatars presence={presence} onlineHumanIds={onlineHumanIds} />
)}
{showTracks && (
<button
@@ -74,8 +77,10 @@ export function PlaybackPageIndicator({
function SegmentPresenceAvatars({
presence,
onlineHumanIds,
}: {
presence: HumanPresence[];
onlineHumanIds?: Set<string>;
}) {
const visible = presence.slice(0, MAX_VISIBLE_AVATARS);
const overflow = presence.length - MAX_VISIBLE_AVATARS;
@@ -89,6 +94,9 @@ function SegmentPresenceAvatars({
<AvatarFallback>
{human.emailPrefix.slice(0, 2).toUpperCase()}
</AvatarFallback>
{onlineHumanIds?.has(human.humanId) && (
<AvatarBadge className="bg-green-500" />
)}
</Avatar>
</TooltipTrigger>
<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 };
}
+51 -4
View File
@@ -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 (
<StreamPresenceProvider networkId={networkId} streamId={streamParticle.id}>
<StreamViewInner path={path} streamParticle={streamParticle} />
</StreamPresenceProvider>
);
}
function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const { networkId } = parseParticlePath(path);
const navigate = useNavigate();
useMount(() => {
@@ -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<MediaParticleHandle>(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<ComposeStep>("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<string, ComposingMode | null> = {
idle: null,
submitting: null,
recording: "recording",
typing: "typing",
reviewing: "typing",
configuring: "typing",
picking: "screen",
};
const mode = stepToMode[composeStep] ?? null;
if (mode) {
startComposing(mode);
} else {
stopComposing();
}
}, [composeStep, startComposing, stopComposing]);
// Show/hide chrome on mouse activity (YouTube-style)
const [showControls, setShowControls] = useState(true);
const idleTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
@@ -436,10 +473,14 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
</div>
)}
{/* Composing indicator — left edge, always visible */}
<ComposingIndicator users={composingUsers} networkHumans={network?.humans} />
<ComposeOverlay
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
onStepChange={setComposeStep}
disabled={streamParticle.status === "closed"}
/>
@@ -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<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
onlineHumanIds: Set<string>;
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 (
<span className="flex
items-center gap-1.5">
<span className="flex items-center gap-1.5">
<Avatar size="sm">
<AvatarFallback>
{initials}
</AvatarFallback>
{isOnline && <AvatarBadge className="bg-green-500" />}
</Avatar>
{prefix} - <RelativeTimestamp date={particle.created_at} />
</span>