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:
+6
-1
@@ -14,6 +14,7 @@ import ParticleViewResolver from "@/features/particles/particle-view-resolver";
|
||||
import Layout from "@/features/layout";
|
||||
import NetworkSettingsPage from "@/features/network-settings";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { PusherProvider } from "@/lib/pusher-provider";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -37,7 +38,11 @@ const App = () => {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
return <AuthenticatedApp />;
|
||||
return (
|
||||
<PusherProvider>
|
||||
<AuthenticatedApp />
|
||||
</PusherProvider>
|
||||
);
|
||||
};
|
||||
|
||||
function AutoplayNavigationListener() {
|
||||
|
||||
@@ -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 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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { usePusherClient } from "@/lib/pusher-provider";
|
||||
import type { ChannelMessage } from "@/lib/pusher-client";
|
||||
|
||||
interface UseChannelResult {
|
||||
/** Current set of humanIds present in the channel */
|
||||
presence: string[];
|
||||
/** Messages received on this channel (since the hook mounted) */
|
||||
messages: ChannelMessage[];
|
||||
/** Send a message to the channel */
|
||||
sendMessage: (payload: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a pusher channel. Manages presence tracking and message delivery.
|
||||
* Subscribes on mount, unsubscribes on unmount.
|
||||
*
|
||||
* @param channelId - The channel to subscribe to, or null to skip.
|
||||
*/
|
||||
export function useChannel(channelId: string | null): UseChannelResult {
|
||||
const client = usePusherClient();
|
||||
const [presence, setPresence] = useState<string[]>([]);
|
||||
const [messages, setMessages] = useState<ChannelMessage[]>([]);
|
||||
|
||||
// Keep a ref to avoid re-subscribing when sendMessage changes
|
||||
const clientRef = useRef(client);
|
||||
const channelRef = useRef(channelId);
|
||||
clientRef.current = client;
|
||||
channelRef.current = channelId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !channelId) {
|
||||
setPresence([]);
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
client.subscribe(channelId);
|
||||
|
||||
const onSubscribed = (msg: { presence?: string[] }) => {
|
||||
setPresence(msg.presence ?? []);
|
||||
};
|
||||
|
||||
const onJoin = (msg: { humanId?: string }) => {
|
||||
if (msg.humanId) {
|
||||
setPresence((prev) =>
|
||||
prev.includes(msg.humanId!) ? prev : [...prev, msg.humanId!],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onLeave = (msg: { humanId?: string }) => {
|
||||
if (msg.humanId) {
|
||||
setPresence((prev) => prev.filter((id) => id !== msg.humanId));
|
||||
}
|
||||
};
|
||||
|
||||
const onMessage = (msg: { humanId?: string; payload?: unknown }) => {
|
||||
if (msg.humanId) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ humanId: msg.humanId!, payload: msg.payload },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
client.on(channelId, "subscribed", onSubscribed);
|
||||
client.on(channelId, "join", onJoin);
|
||||
client.on(channelId, "leave", onLeave);
|
||||
client.on(channelId, "message", onMessage);
|
||||
|
||||
return () => {
|
||||
client.off(channelId, "subscribed", onSubscribed);
|
||||
client.off(channelId, "join", onJoin);
|
||||
client.off(channelId, "leave", onLeave);
|
||||
client.off(channelId, "message", onMessage);
|
||||
client.unsubscribe(channelId);
|
||||
};
|
||||
}, [client, channelId]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (clientRef.current && channelRef.current) {
|
||||
clientRef.current.sendMessage(channelRef.current, payload);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { presence, messages, sendMessage };
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* PusherClient manages a WebSocket connection to the pusher service.
|
||||
* Handles authentication, reconnection with exponential backoff,
|
||||
* channel subscriptions, and event dispatching.
|
||||
*/
|
||||
|
||||
export type ConnectionState =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting";
|
||||
|
||||
export interface ChannelMessage {
|
||||
humanId: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
// Server → Client message shape
|
||||
interface ServerMessage {
|
||||
type: "subscribed" | "join" | "leave" | "message" | "error";
|
||||
channel?: string;
|
||||
humanId?: string;
|
||||
presence?: string[];
|
||||
payload?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type ChannelEventType = "subscribed" | "join" | "leave" | "message";
|
||||
type ChannelEventCallback = (msg: ServerMessage) => void;
|
||||
|
||||
interface PusherClientConfig {
|
||||
url: string;
|
||||
getToken: () => string | null;
|
||||
}
|
||||
|
||||
const INITIAL_RECONNECT_DELAY = 1000;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
const PING_INTERVAL = 20000; // 20s — keeps alive through GKE gateway timeout
|
||||
|
||||
export class PusherClient {
|
||||
private config: PusherClientConfig;
|
||||
private ws: WebSocket | null = null;
|
||||
private state: ConnectionState = "disconnected";
|
||||
private stateListeners = new Set<(state: ConnectionState) => void>();
|
||||
|
||||
// Channel event listeners: channelId → eventType → callbacks
|
||||
private listeners = new Map<
|
||||
string,
|
||||
Map<ChannelEventType, Set<ChannelEventCallback>>
|
||||
>();
|
||||
|
||||
// Active subscriptions for re-subscribe on reconnect
|
||||
private activeSubscriptions = new Set<string>();
|
||||
|
||||
// Reconnection state
|
||||
private reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = false;
|
||||
|
||||
// Keep-alive ping
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(config: PusherClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
get connectionState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.ws) return;
|
||||
|
||||
const token = this.config.getToken();
|
||||
if (!token) {
|
||||
console.warn("[pusher] no token available, cannot connect");
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldReconnect = true;
|
||||
this.setState(
|
||||
this.state === "reconnecting" ? "reconnecting" : "connecting",
|
||||
);
|
||||
|
||||
const url = `${this.config.url}?token=${encodeURIComponent(token)}`;
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.setState("connected");
|
||||
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
this.startPing();
|
||||
this.resubscribeAll();
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.cleanup();
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (event) => {
|
||||
console.warn("[pusher] websocket error", event);
|
||||
// onclose will fire after onerror, so reconnection is handled there
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this.handleMessage(event.data as string);
|
||||
};
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false;
|
||||
this.clearReconnectTimer();
|
||||
this.cleanup();
|
||||
this.activeSubscriptions.clear();
|
||||
this.setState("disconnected");
|
||||
}
|
||||
|
||||
subscribe(channelId: string): void {
|
||||
this.activeSubscriptions.add(channelId);
|
||||
this.send({ type: "subscribe", channel: channelId });
|
||||
}
|
||||
|
||||
unsubscribe(channelId: string): void {
|
||||
this.activeSubscriptions.delete(channelId);
|
||||
this.send({ type: "unsubscribe", channel: channelId });
|
||||
}
|
||||
|
||||
sendMessage(channelId: string, payload: unknown): void {
|
||||
this.send({ type: "message", channel: channelId, payload });
|
||||
}
|
||||
|
||||
on(
|
||||
channelId: string,
|
||||
event: ChannelEventType,
|
||||
callback: ChannelEventCallback,
|
||||
): void {
|
||||
if (!this.listeners.has(channelId)) {
|
||||
this.listeners.set(channelId, new Map());
|
||||
}
|
||||
const channelListeners = this.listeners.get(channelId)!;
|
||||
if (!channelListeners.has(event)) {
|
||||
channelListeners.set(event, new Set());
|
||||
}
|
||||
channelListeners.get(event)!.add(callback);
|
||||
}
|
||||
|
||||
off(
|
||||
channelId: string,
|
||||
event: ChannelEventType,
|
||||
callback: ChannelEventCallback,
|
||||
): void {
|
||||
const channelListeners = this.listeners.get(channelId);
|
||||
if (!channelListeners) return;
|
||||
const eventListeners = channelListeners.get(event);
|
||||
if (!eventListeners) return;
|
||||
eventListeners.delete(callback);
|
||||
|
||||
// Cleanup empty maps
|
||||
if (eventListeners.size === 0) channelListeners.delete(event);
|
||||
if (channelListeners.size === 0) this.listeners.delete(channelId);
|
||||
}
|
||||
|
||||
onStateChange(callback: (state: ConnectionState) => void): () => void {
|
||||
this.stateListeners.add(callback);
|
||||
return () => this.stateListeners.delete(callback);
|
||||
}
|
||||
|
||||
// --- Private ---
|
||||
|
||||
private send(msg: { type: string; channel?: string; payload?: unknown }): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(data: string): void {
|
||||
// Ignore keep-alive pong responses
|
||||
if (data === "pong") return;
|
||||
|
||||
let msg: ServerMessage;
|
||||
try {
|
||||
msg = JSON.parse(data);
|
||||
} catch {
|
||||
console.warn("[pusher] failed to parse message", data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "error") {
|
||||
console.warn("[pusher] server error:", msg.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msg.channel) return;
|
||||
|
||||
const channelListeners = this.listeners.get(msg.channel);
|
||||
if (!channelListeners) return;
|
||||
|
||||
const eventListeners = channelListeners.get(msg.type as ChannelEventType);
|
||||
if (!eventListeners) return;
|
||||
|
||||
for (const cb of eventListeners) {
|
||||
try {
|
||||
cb(msg);
|
||||
} catch (err) {
|
||||
console.error("[pusher] listener error", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resubscribeAll(): void {
|
||||
for (const channelId of this.activeSubscriptions) {
|
||||
this.send({ type: "subscribe", channel: channelId });
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.setState("reconnecting");
|
||||
|
||||
// Exponential backoff with jitter
|
||||
const jitter = Math.random() * 0.5 + 0.75; // 0.75 - 1.25x
|
||||
const delay = Math.min(
|
||||
this.reconnectDelay * jitter,
|
||||
MAX_RECONNECT_DELAY,
|
||||
);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectDelay = Math.min(
|
||||
this.reconnectDelay * 2,
|
||||
MAX_RECONNECT_DELAY,
|
||||
);
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
this.stopPing();
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.onmessage = null;
|
||||
if (
|
||||
this.ws.readyState === WebSocket.OPEN ||
|
||||
this.ws.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
this.ws.close();
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startPing(): void {
|
||||
this.stopPing();
|
||||
this.pingTimer = setInterval(() => {
|
||||
// Send an empty message as a keep-alive
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send("ping");
|
||||
}
|
||||
}, PING_INTERVAL);
|
||||
}
|
||||
|
||||
private stopPing(): void {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState): void {
|
||||
if (this.state === state) return;
|
||||
this.state = state;
|
||||
for (const cb of this.stateListeners) {
|
||||
cb(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { PusherClient, type ConnectionState } from "./pusher-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
const PusherContext = createContext<PusherClient | null>(null);
|
||||
const PusherStateContext = createContext<ConnectionState>("disconnected");
|
||||
|
||||
// TODO: make this configurable per environment
|
||||
const PUSHER_URL = "wss://pusher.dev.flowy.live/ws";
|
||||
|
||||
export function PusherProvider({ children }: { children: ReactNode }) {
|
||||
const token = useSessionStore((s) => s.token);
|
||||
const clientRef = useRef<PusherClient | null>(null);
|
||||
const [connectionState, setConnectionState] =
|
||||
useState<ConnectionState>("disconnected");
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
// Disconnect if token is cleared (logout)
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
clientRef.current = null;
|
||||
setConnectionState("disconnected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new PusherClient({
|
||||
url: PUSHER_URL,
|
||||
getToken: () => useSessionStore.getState().token,
|
||||
});
|
||||
|
||||
clientRef.current = client;
|
||||
|
||||
const unsubscribeState = client.onStateChange((state) => {
|
||||
setConnectionState(state);
|
||||
});
|
||||
|
||||
client.connect();
|
||||
|
||||
return () => {
|
||||
unsubscribeState();
|
||||
client.disconnect();
|
||||
clientRef.current = null;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<PusherContext.Provider value={clientRef.current}>
|
||||
<PusherStateContext.Provider value={connectionState}>
|
||||
{children}
|
||||
</PusherStateContext.Provider>
|
||||
</PusherContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the PusherClient instance, or null if not connected.
|
||||
*/
|
||||
export function usePusherClient(): PusherClient | null {
|
||||
return useContext(PusherContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current WebSocket connection state.
|
||||
*/
|
||||
export function usePusherConnectionState(): ConnectionState {
|
||||
return useContext(PusherStateContext);
|
||||
}
|
||||
Reference in New Issue
Block a user