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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user