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

@@ -6,6 +6,7 @@ import { ChannelSidebar } from "@/components/ChannelSidebar";
import { MessageItem } from "@/components/MessageItem";
import { ComposeBox } from "@/components/ComposeBox";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { useChannelSocket } from "@/hooks/useChannelSocket";
export default function App() {
const [channels, setChannels] = useState<Channel[]>([]);
@@ -34,10 +35,21 @@ export default function App() {
setMessages(msgs);
}
} catch {
// Silently ignore fetch errors during polling
// Silently ignore fetch errors
}
}, []);
// WebSocket: append new messages in real-time
const handleWsMessage = useCallback((msg: Message) => {
setMessages((prev) => {
// Deduplicate — the sender also fetches after posting
if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg];
});
}, []);
useChannelSocket(activeChannelId, handleWsMessage);
useEffect(() => { loadChannels(); }, [loadChannels]);
useEffect(() => {
@@ -47,6 +59,7 @@ export default function App() {
loadMessages();
}, [activeChannelId, loadMessages]);
// Auto-scroll only on new messages
useEffect(() => {
if (messages.length > prevMsgCountRef.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@@ -54,11 +67,6 @@ export default function App() {
prevMsgCountRef.current = messages.length;
}, [messages]);
useEffect(() => {
const interval = setInterval(loadMessages, 3000);
return () => clearInterval(interval);
}, [loadMessages]);
const messagesById = new Map(messages.map((m) => [m.id, m]));
const activeChannel = channels.find((c) => c.id === activeChannelId);
@@ -76,15 +84,12 @@ export default function App() {
return (
<div className="flex h-full">
{/* Desktop sidebar */}
<div className="hidden md:block">
{sidebar}
</div>
<div className="flex-1 flex flex-col min-w-0">
{/* Channel header */}
<div className="px-3 py-2 md:px-4 border-b border-border flex items-center gap-2">
{/* Mobile: Sheet trigger */}
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger className="md:hidden p-1 h-8 w-8 text-muted-foreground hover:text-foreground rounded-sm">
=
@@ -112,7 +117,6 @@ export default function App() {
)}
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{messages.length === 0 && activeChannelId && (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">

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]);
}