S5: WebSocket real-time — per-channel broadcast, auto-reconnect

Backend:
- AppState with per-channel broadcast::Sender map
- WS handler: auth via first message, keepalive pings, broadcast forwarding
- post_message broadcasts WsEvent::Message to all subscribers

Frontend:
- useChannelSocket hook: connects, auths, appends messages, auto-reconnects
- Removed 3s polling — WebSocket is primary, initial load via REST
- Deduplication on WS messages (sender also fetches after post)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 20:29:07 +02:00
parent 9303641daf
commit 17cca7b077
9 changed files with 270 additions and 35 deletions

View File

@@ -0,0 +1,61 @@
import { useEffect, useRef, useCallback } from "react";
import type { Message } from "@/types/Message";
import { getCurrentUsername } from "@/api";
interface WsMessageEvent {
event: "message";
data: Message;
}
interface WsConnectedEvent {
event: "connected";
}
type WsEvent = WsMessageEvent | WsConnectedEvent;
export function useChannelSocket(
channelId: string | null,
onMessage: (msg: Message) => void,
) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
const connect = useCallback(() => {
if (!channelId) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host;
const ws = new WebSocket(`${protocol}//${host}/ws/${channelId}`);
ws.onopen = () => {
// Send auth message
ws.send(JSON.stringify({
type: "auth",
user: getCurrentUsername(),
}));
};
ws.onmessage = (e) => {
const event: WsEvent = JSON.parse(e.data);
if (event.event === "message") {
onMessage(event.data);
}
};
ws.onclose = () => {
// Reconnect after 3s
reconnectTimer.current = setTimeout(connect, 3000);
};
wsRef.current = ws;
}, [channelId, onMessage]);
useEffect(() => {
connect();
return () => {
clearTimeout(reconnectTimer.current);
wsRef.current?.close();
wsRef.current = null;
};
}, [connect]);
}