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:
@@ -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">
|
||||
|
||||
61
ui/colony/src/hooks/useChannelSocket.ts
Normal file
61
ui/colony/src/hooks/useChannelSocket.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user