From 6f6f57f0623b31cf1b9919c4ef21369ba219d463 Mon Sep 17 00:00:00 2001
From: ZhenYi <434836402@qq.com>
Date: Mon, 20 Apr 2026 15:45:47 +0800
Subject: [PATCH] feat(frontend): add push notification hooks, image
compression, and update room/chat components
---
src/app/settings/preferences.tsx | 54 ++++-
src/components/room/icon-match.tsx | 61 +++--
src/components/room/message/MessageBubble.tsx | 9 +-
.../room/message/MessageContent.tsx | 212 +++++++++++++-----
.../room/message/editor/IMEditor.tsx | 12 +-
src/contexts/room-context.tsx | 76 +++++--
src/hooks/useImageCompress.ts | 56 +++++
src/hooks/usePushNotification.ts | 170 ++++++++++++++
src/lib/room-ws-client.ts | 24 +-
src/lib/ws-protocol.ts | 3 +
src/types/browser-image-compression.d.ts | 17 ++
11 files changed, 579 insertions(+), 115 deletions(-)
create mode 100644 src/hooks/useImageCompress.ts
create mode 100644 src/hooks/usePushNotification.ts
create mode 100644 src/types/browser-image-compression.d.ts
diff --git a/src/app/settings/preferences.tsx b/src/app/settings/preferences.tsx
index 7f8e2cf..4d8456d 100644
--- a/src/app/settings/preferences.tsx
+++ b/src/app/settings/preferences.tsx
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
-import { Bell, Loader2, Mail, Moon, Package, Shield } from 'lucide-react';
+import { Bell, Loader2, Mail, Moon, Package, Shield, BellRing } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -9,11 +9,20 @@ import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { getNotificationPreferences, updateNotificationPreferences } from '@/client';
-import {getApiErrorMessage} from '@/lib/api-error';
+import { getApiErrorMessage } from '@/lib/api-error';
+import { usePushNotification } from '@/hooks/usePushNotification';
export function SettingsPreferences() {
const queryClient = useQueryClient();
const isInitialized = useRef(false);
+ const { permission: pushPermission, isSubscribed: isPushSubscribed, isLoading: isPushLoading, error: pushError, subscribe: subscribePush, unsubscribe: unsubscribePush } = usePushNotification();
+
+ // Sync push_enabled state with subscription
+ const [pushEnabled, setPushEnabled] = useState(false);
+
+ useEffect(() => {
+ setPushEnabled(isPushSubscribed);
+ }, [isPushSubscribed]);
const [emailEnabled, setEmailEnabled] = useState(true);
const [inAppEnabled, setInAppEnabled] = useState(true);
@@ -80,6 +89,7 @@ export function SettingsPreferences() {
marketing_enabled: marketingEnabled,
security_enabled: securityEnabled,
product_enabled: productEnabled,
+ push_enabled: pushEnabled,
},
});
},
@@ -146,6 +156,46 @@ export function SettingsPreferences() {
+
+
+
+ {/* Browser Push Notifications */}
+
+
+
+
+
+ Browser Push Notifications
+
+
+
+ {pushPermission === 'unsupported'
+ ? 'Your browser does not support push notifications'
+ : pushPermission === 'denied'
+ ? 'Blocked by browser. Enable in site settings.'
+ : isPushSubscribed
+ ? 'Subscribed — you will receive browser notifications'
+ : 'Receive notifications even when the tab is closed'}
+
+ {pushError &&
{pushError}
}
+
+
+ {isPushLoading ? (
+
+ ) : null}
+ {isPushLoading
+ ? 'Loading...'
+ : isPushSubscribed
+ ? 'Disable'
+ : 'Enable'}
+
+
diff --git a/src/components/room/icon-match.tsx b/src/components/room/icon-match.tsx
index 94e6802..7644327 100644
--- a/src/components/room/icon-match.tsx
+++ b/src/components/room/icon-match.tsx
@@ -1,26 +1,39 @@
-import type { ReactNode } from 'react';
+/**
+ * AI model icon using @lobehub/icons.
+ * Pass the AI model display name (e.g. "anthropic/claude-3-5-sonnet-20241022")
+ * as the `model` prop — lobehub's keyword regex matching handles the rest.
+ */
+import { memo } from 'react';
+import LobehubModelIcon from '@lobehub/icons/es/features/ModelIcon';
-/** Map model IDs to colored circles (fallback for AI sender avatars) */
-export function ModelIcon({ modelId, className }: { modelId?: string; className?: string }): ReactNode {
- const colors: Record = {
- claude: 'bg-orange-500',
- gpt: 'bg-green-600',
- gemini: 'bg-blue-600',
- deepseek: 'bg-purple-600',
- o1: 'bg-pink-600',
- o3: 'bg-pink-700',
- o4: 'bg-pink-800',
- ai: 'bg-primary',
- };
-
- const color = modelId ? (colors[modelId.toLowerCase()] ?? 'bg-primary') : 'bg-primary';
-
- return (
-
- A
-
- );
+/** Derive a readable label from the display name for the tooltip. */
+export function modelDisplayLabel(displayName?: string): string {
+ if (!displayName) return 'AI';
+ // e.g. "anthropic/claude-3-5-sonnet-20241022" → "Claude 3 5 Sonnet 20241022"
+ const last = displayName.includes('/')
+ ? displayName.split('/').pop()!
+ : displayName;
+ return last
+ .replace(/[-_]/g, ' ')
+ .replace(/\b\w/g, (c) => c.toUpperCase());
}
+
+interface ModelIconProps {
+ /** AI model display name from the backend (e.g. "anthropic/claude-3-5-sonnet"). */
+ model?: string;
+ className?: string;
+}
+
+/** Colored AI model logo icon from @lobehub/icons.
+ * Supports 250+ AI models via keyword regex matching.
+ * Falls back to a default circle when no match is found. */
+export const ModelIcon = memo(function ModelIcon({ model, className }: ModelIconProps) {
+ return (
+
+ );
+});
diff --git a/src/components/room/message/MessageBubble.tsx b/src/components/room/message/MessageBubble.tsx
index 2acd2cc..b0bc997 100644
--- a/src/components/room/message/MessageBubble.tsx
+++ b/src/components/room/message/MessageBubble.tsx
@@ -17,7 +17,7 @@ import { ModelIcon } from '../icon-match';
import { FunctionCallBadge } from '../FunctionCallBadge';
import { MessageContent } from './MessageContent';
import { ThreadIndicator } from '../RoomThreadPanel';
-import { getSenderDisplayName, getSenderModelId, getAvatarFromUiMessage, getSenderUserUid, isUserSender } from '../sender';
+import { getSenderDisplayName, getAvatarFromUiMessage, getSenderUserUid, isUserSender } from '../sender';
import { MessageReactions } from './MessageReactions';
import { ReactionPicker } from './ReactionPicker';
@@ -78,7 +78,6 @@ export const MessageBubble = memo(function MessageBubble({
const isAi = ['ai', 'system', 'tool'].includes(message.sender_type);
const isSystem = message.sender_type === 'system';
const displayName = getSenderDisplayName(message);
- const senderModelId = getSenderModelId(message);
const avatarUrl = getAvatarFromUiMessage(message);
const initial = (displayName?.charAt(0) ?? '?').toUpperCase();
const isStreaming = !!message.is_streaming;
@@ -209,7 +208,7 @@ export const MessageBubble = memo(function MessageBubble({
className="text-sm font-semibold"
style={{ background: `${senderColor}22`, color: senderColor }}
>
- {isAi ? : initial}
+ {isAi ? : initial}
@@ -318,12 +317,10 @@ export const MessageBubble = memo(function MessageBubble({
))
) : (
-
-
-
)}
{/* Streaming cursor */}
diff --git a/src/components/room/message/MessageContent.tsx b/src/components/room/message/MessageContent.tsx
index c3473f1..3d7e50f 100644
--- a/src/components/room/message/MessageContent.tsx
+++ b/src/components/room/message/MessageContent.tsx
@@ -1,9 +1,14 @@
'use client';
/**
- * Renders message content — parses @[type:id:label] mentions into styled spans.
+ * Renders message content — markdown with @[type:id:label] mentions.
+ * Mentions are protected from markdown parsing by replacing them with
+ * placeholder tokens before rendering, then restored in custom text components.
*/
+import { memo, useMemo } from 'react';
+import Markdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
import { cn } from '@/lib/utils';
interface MessageContentProps {
@@ -11,35 +16,23 @@ interface MessageContentProps {
onMentionClick?: (type: string, id: string, label: string) => void;
}
-/** Parses @[type:id:label] patterns from message content */
-function parseContent(content: string): Array<{ type: 'text' | 'mention'; text?: string; mention?: { type: string; id: string; label: string } }> {
- const parts: Array<{ type: 'text' | 'mention'; text?: string; mention?: { type: string; id: string; label: string } }> = [];
- const RE = /@\[([a-z]+):([^:\]]+):([^\]]+)\]/g;
- let lastIndex = 0;
- let match: RegExpExecArray | null;
+const MENTION_RE = /@\[([a-z]+):([^:\]]+):([^\]]+)\]/g;
- while ((match = RE.exec(content)) !== null) {
- // Text before this match
- if (match.index > lastIndex) {
- parts.push({ type: 'text', text: content.slice(lastIndex, match.index) });
- }
- parts.push({
- type: 'mention',
- mention: {
- type: match[1],
- id: match[2],
- label: match[3],
- },
- });
- lastIndex = RE.lastIndex;
- }
+interface MentionInfo {
+ type: string;
+ id: string;
+ label: string;
+}
- // Remaining text
- if (lastIndex < content.length) {
- parts.push({ type: 'text', text: content.slice(lastIndex) });
- }
-
- return parts;
+/** Replace @[type:id:label] with ◊MENTION_i◊ placeholders (◊ is unlikely in real content) */
+function extractMentions(content: string): { safeContent: string; mentions: MentionInfo[] } {
+ const mentions: MentionInfo[] = [];
+ const safeContent = content.replace(MENTION_RE, (_match, type, id, label) => {
+ const idx = mentions.length;
+ mentions.push({ type, id, label });
+ return `\u200BMENTION_${idx}\u200B`; // zero-width spaces prevent markdown parsing
+ });
+ return { safeContent, mentions };
}
function getMentionStyle(type: string): string {
@@ -52,42 +45,149 @@ function getMentionStyle(type: string): string {
}
}
-export function MessageContent({ content, onMentionClick }: MessageContentProps) {
- const parts = parseContent(content);
+/** Restore mention placeholders inside a text node into React elements */
+function restoreMentions(text: string, mentions: MentionInfo[], onMentionClick?: (type: string, id: string, label: string) => void): React.ReactNode[] {
+ const MENTION_PLACEHOLDER_RE = /\u200BMENTION_(\d+)\u200B/g;
+ const parts: React.ReactNode[] = [];
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = MENTION_PLACEHOLDER_RE.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ parts.push(text.slice(lastIndex, match.index));
+ }
+ const idx = parseInt(match[1], 10);
+ const m = mentions[idx];
+ if (m) {
+ parts.push(
+ onMentionClick?.(m.type, m.id, m.label)}
+ onKeyDown={(e) => {
+ if ((e.key === 'Enter' || e.key === ' ') && onMentionClick) {
+ e.preventDefault();
+ onMentionClick(m.type, m.id, m.label);
+ }
+ }}
+ >
+ @{m.label}
+ ,
+ );
+ }
+ lastIndex = MENTION_PLACEHOLDER_RE.lastIndex;
+ }
+
+ if (lastIndex < text.length) {
+ parts.push(text.slice(lastIndex));
+ }
+
+ return parts;
+}
+
+export const MessageContent = memo(function MessageContent({ content, onMentionClick }: MessageContentProps) {
+ const { safeContent, mentions } = useMemo(() => extractMentions(content), [content]);
return (
- {parts.map((part, i) =>
- part.type === 'text' ? (
-
{part.text}
- ) : (
-
onMentionClick?.(part.mention!.type, part.mention!.id, part.mention!.label)}
- onKeyDown={(e) => {
- if ((e.key === 'Enter' || e.key === ' ') && onMentionClick) {
- e.preventDefault();
- onMentionClick(part.mention!.type, part.mention!.id, part.mention!.label);
+ {
+ // Restore mentions in paragraph text nodes
+ if (typeof children === 'string') {
+ return {restoreMentions(children, mentions, onMentionClick)}
;
+ }
+ // Children may be an array of strings/elements
+ if (Array.isArray(children)) {
+ const restored = children.map((child) => {
+ if (typeof child === 'string') {
+ return restoreMentions(child, mentions, onMentionClick);
}
- }}
- >
- @{part.mention!.label}
-
- ),
- )}
+ return child;
+ });
+ return
{restored}
;
+ }
+ return
{children}
;
+ },
+ li: ({ children }) => {
+ if (typeof children === 'string') {
+ return
{restoreMentions(children, mentions, onMentionClick)} ;
+ }
+ if (Array.isArray(children)) {
+ const restored = children.map((child) => {
+ if (typeof child === 'string') {
+ return restoreMentions(child, mentions, onMentionClick);
+ }
+ return child;
+ });
+ return
{restored} ;
+ }
+ return
{children} ;
+ },
+ strong: ({ children }) => {
+ if (typeof children === 'string') {
+ return
{restoreMentions(children, mentions, onMentionClick)} ;
+ }
+ return
{children} ;
+ },
+ em: ({ children }) => {
+ if (typeof children === 'string') {
+ return
{restoreMentions(children, mentions, onMentionClick)} ;
+ }
+ return
{children} ;
+ },
+ code: ({ className, children, ...props }) => {
+ // Inline code — don't restore mentions inside code blocks
+ const isBlock = typeof className === 'string' && className.includes('language-');
+ if (isBlock) {
+ // Fenced code block — let the pre wrapper handle it
+ return
{children};
+ }
+ return (
+
+ {children}
+
+ );
+ },
+ pre: ({ children }) => {
+ // Preserve code blocks as-is, no mention restoration
+ return
{children} ;
+ },
+ }}
+ >
+ {safeContent}
+
);
-}
+});
\ No newline at end of file
diff --git a/src/components/room/message/editor/IMEditor.tsx b/src/components/room/message/editor/IMEditor.tsx
index dab4260..0e0dd76 100644
--- a/src/components/room/message/editor/IMEditor.tsx
+++ b/src/components/room/message/editor/IMEditor.tsx
@@ -15,6 +15,7 @@ import { Paperclip, Smile, Send, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { COMMON_EMOJIS } from '../../shared';
import { useTheme } from '@/contexts';
+import { useImageCompress } from '@/hooks/useImageCompress';
export interface IMEditorProps {
replyingTo?: { id: string; display_name?: string; content: string } | null;
@@ -243,6 +244,7 @@ export const IMEditor = forwardRef(function IMEdi
) {
const { resolvedTheme } = useTheme();
const p = resolvedTheme === 'dark' ? DARK : LIGHT;
+ const { compress } = useImageCompress();
const [showEmoji, setShowEmoji] = useState(false);
const [mentionOpen, setMentionOpen] = useState(false);
@@ -338,8 +340,14 @@ export const IMEditor = forwardRef(function IMEdi
const doUpload = async (file: File) => {
if (!editor || !onUploadFile) return;
try {
- const res = await onUploadFile(file);
- editor.chain().focus().insertContent({ type: 'file', attrs: { id: res.id, name: file.name, url: res.url, size: file.size, type: file.type, status: 'done' } }).insertContent(' ').run();
+ // Compress image before upload (only if it's an image and > 500KB)
+ let uploadFile = file;
+ if (file.type.startsWith('image/') && file.size > 500 * 1024) {
+ const result = await compress(file, { maxSizeMB: 1, maxWidthOrHeight: 1920, useWebWorker: true });
+ uploadFile = result.file;
+ }
+ const res = await onUploadFile(uploadFile);
+ editor.chain().focus().insertContent({ type: 'file', attrs: { id: res.id, name: uploadFile.name, url: res.url, size: uploadFile.size, type: uploadFile.type, status: 'done' } }).insertContent(' ').run();
} catch { /* ignore */ }
};
diff --git a/src/contexts/room-context.tsx b/src/contexts/room-context.tsx
index 4244493..ffe40fc 100644
--- a/src/contexts/room-context.tsx
+++ b/src/contexts/room-context.tsx
@@ -178,6 +178,9 @@ export function RoomProvider({
const [wsStatus, setWsStatus] = useState('idle');
const [wsError, setWsError] = useState(null);
const [wsToken, setWsToken] = useState(null);
+ // Buffer for messages received while user is in a different room (Bug 3 fix).
+ // Merged into state when the user switches to that room.
+ const pendingRoomMessagesRef = useRef>(new Map());
// Keep ref updated with latest activeRoomId
activeRoomIdRef.current = activeRoomId;
@@ -237,6 +240,19 @@ export function RoomProvider({
setMessages([]);
setIsHistoryLoaded(false);
setNextCursor(null);
+
+ // Merge any buffered messages for the new room (Bug 3 fix)
+ if (activeRoomId) {
+ const pending = pendingRoomMessagesRef.current.get(activeRoomId);
+ if (pending && pending.length > 0) {
+ pendingRoomMessagesRef.current.delete(activeRoomId);
+ setMessages((prev) => {
+ const merged = [...prev, ...pending.map(wsMessageToUiMessage)];
+ merged.sort((a, b) => a.seq - b.seq);
+ return merged;
+ });
+ }
+ }
// NOTE: intentionally NOT clearing IndexedDB — keeping it enables instant
// load when the user returns to this room without waiting for API.
}
@@ -396,6 +412,16 @@ export function RoomProvider({
const [roomAiConfigs, setRoomAiConfigs] = useState([]);
const [aiConfigsLoading, setAiConfigsLoading] = useState(false);
+ // ── Update WS token on existing client (instead of recreating client) ────────
+ useEffect(() => {
+ if (wsToken && wsClientRef.current) {
+ wsClientRef.current.setWsToken(wsToken);
+ }
+ }, [wsToken]);
+
+ // ── Create WS client ONCE on mount ──────────────────────────────────────────
+ // Recreating the client on wsToken change caused multiple invalid connections
+ // (Bug 1). Instead, create once and update the token in-place.
useEffect(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
const client = createRoomWsClient(
@@ -407,13 +433,22 @@ export function RoomProvider({
setMessages((prev) => {
const existingIdx = prev.findIndex((m) => m.id === payload.id);
if (existingIdx !== -1) {
- // Message already exists — update reactions if provided
- if (payload.reactions !== undefined) {
+ // Message already exists (e.g. created by streaming chunk) —
+ // merge server-side fields (display_name, reactions) that the
+ // chunk didn't have.
+ const existing = prev[existingIdx];
+ const needsUpdate =
+ (!existing.display_name && payload.display_name) ||
+ (payload.reactions !== undefined && existing.reactions === undefined);
+ if (needsUpdate) {
const updated = [...prev];
- updated[existingIdx] = { ...updated[existingIdx], reactions: payload.reactions };
+ updated[existingIdx] = {
+ ...existing,
+ display_name: payload.display_name ?? existing.display_name,
+ reactions: payload.reactions ?? existing.reactions,
+ };
return updated;
}
- // Duplicate of a real message — ignore
return prev;
}
// Replace optimistic message with server-confirmed one
@@ -437,9 +472,16 @@ export function RoomProvider({
}
return updated;
});
+ } else {
+ // Buffer messages for non-active rooms (Bug 3 fix).
+ // When user switches to that room, pending messages are merged.
+ pendingRoomMessagesRef.current.set(payload.room_id, [
+ ...pendingRoomMessagesRef.current.get(payload.room_id) ?? [],
+ payload,
+ ]);
}
},
- onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string }) => {
+ onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string }) => {
if (chunk.done) {
setStreamingContent((prev) => {
prev.delete(chunk.message_id);
@@ -480,6 +522,7 @@ export function RoomProvider({
room: chunk.room_id,
seq: 0,
sender_type: 'ai',
+ display_name: chunk.display_name,
content: accumulated,
display_content: accumulated,
content_type: 'text',
@@ -493,7 +536,7 @@ export function RoomProvider({
}
},
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
- if (!activeRoomIdRef.current) return;
+ if (payload.room_id !== activeRoomIdRef.current) return;
setMessages((prev) => {
const existingIdx = prev.findIndex((m) => m.id === payload.message_id);
if (existingIdx === -1) return prev;
@@ -589,26 +632,21 @@ export function RoomProvider({
setWsError(error.message);
},
},
- { wsToken: wsToken ?? undefined },
);
setWsClient(client);
wsClientRef.current = client;
- return () => {
- client.disconnect();
- wsClientRef.current = null;
- };
- }, [wsToken]);
-
- // ── Connect WS whenever a new client is created ─────────────────────────────
- // Intentionally depends on wsClient (not wsClientRef) so a new client triggers connect().
- // connect() is idempotent — no-op if already connecting/open.
- useEffect(() => {
- wsClientRef.current?.connect().catch((e) => {
+ // Connect immediately — connect() fetches its own token if needed
+ client.connect().catch((e) => {
console.error('[RoomContext] WS connect error:', e);
});
- }, [wsClient]);
+
+ return () => {
+ client.disconnect(); // Intentional disconnect on unmount — no reconnect
+ wsClientRef.current = null;
+ };
+ }, []); // ← empty deps: create once on mount
const connectWs = useCallback(async () => {
const client = wsClientRef.current;
diff --git a/src/hooks/useImageCompress.ts b/src/hooks/useImageCompress.ts
new file mode 100644
index 0000000..b001c0a
--- /dev/null
+++ b/src/hooks/useImageCompress.ts
@@ -0,0 +1,56 @@
+import { useState, useCallback } from 'react';
+import imageCompression from 'browser-image-compression';
+
+export interface ImageCompressOptions {
+ maxSizeMB?: number;
+ maxWidthOrHeight?: number;
+ useWebWorker?: boolean;
+}
+
+export interface CompressionResult {
+ file: File;
+ originalSize: number;
+ compressedSize: number;
+}
+
+/**
+ * Compresses an image file using browser-image-compression.
+ * Runs in a WebWorker by default to avoid blocking the main thread.
+ */
+export function useImageCompress() {
+ const [isCompressing, setIsCompressing] = useState(false);
+ const [error, setError] = useState(null);
+
+ const compress = useCallback(async (
+ file: File,
+ options: ImageCompressOptions = {}
+ ): Promise => {
+ setIsCompressing(true);
+ setError(null);
+
+ const defaultOptions = {
+ maxSizeMB: 1,
+ maxWidthOrHeight: 1920,
+ useWebWorker: true,
+ fileType: file.type,
+ ...options,
+ };
+
+ try {
+ const compressed = await imageCompression(file, defaultOptions);
+ setIsCompressing(false);
+ return {
+ file: compressed as File,
+ originalSize: file.size,
+ compressedSize: compressed.size,
+ };
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Compression failed';
+ setError(msg);
+ setIsCompressing(false);
+ throw e;
+ }
+ }, []);
+
+ return { compress, isCompressing, error };
+}
diff --git a/src/hooks/usePushNotification.ts b/src/hooks/usePushNotification.ts
new file mode 100644
index 0000000..fe3bc3a
--- /dev/null
+++ b/src/hooks/usePushNotification.ts
@@ -0,0 +1,170 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import axios from 'axios';
+
+export type PushPermissionState = NotificationPermission | 'unsupported';
+
+export interface PushSubscriptionInfo {
+ endpoint: string;
+ p256dh: string;
+ auth: string;
+}
+
+interface UsePushNotificationReturn {
+ permission: PushPermissionState;
+ isSubscribed: boolean;
+ isLoading: boolean;
+ error: string | null;
+ subscribe: () => Promise;
+ unsubscribe: () => Promise;
+}
+
+/**
+ * Hook for managing Web Push notification subscriptions.
+ * Handles Service Worker registration, push permission, and subscription lifecycle.
+ */
+export function usePushNotification(): UsePushNotificationReturn {
+ const [permission, setPermission] = useState('unsupported');
+ const [isSubscribed, setIsSubscribed] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Check initial state on mount
+ useEffect(() => {
+ if (!('Notification' in self) || !('serviceWorker' in navigator) || !('PushManager' in self)) {
+ setPermission('unsupported');
+ return;
+ }
+ setPermission(Notification.permission);
+
+ // Check if already subscribed
+ navigator.serviceWorker.ready.then((registration) => {
+ registration.pushManager.getSubscription().then((sub) => {
+ setIsSubscribed(!!sub);
+ }).catch(() => {
+ setIsSubscribed(false);
+ });
+ }).catch(() => {
+ setIsSubscribed(false);
+ });
+ }, []);
+
+ const subscribe = useCallback(async () => {
+ if (permission === 'unsupported') {
+ setError('Push notifications are not supported in this browser.');
+ return;
+ }
+ if (permission === 'denied') {
+ setError('Push notifications are blocked. Please enable them in your browser settings.');
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ // 1. Register / get Service Worker
+ const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
+ // If already registered, just get it
+ return navigator.serviceWorker.ready;
+ });
+
+ // 2. Request permission if not granted
+ if (Notification.permission !== 'granted') {
+ const result = await Notification.requestPermission();
+ if (result !== 'granted') {
+ setPermission(result);
+ setError('Permission denied. Cannot subscribe to push notifications.');
+ setIsLoading(false);
+ return;
+ }
+ setPermission('granted');
+ }
+
+ // 3. Get VAPID public key from server
+ const vapidResponse = await axios.get<{ data?: { public_key?: string } }>(
+ '/api/users/me/notifications/push/vapid-key'
+ );
+ const publicKey = vapidResponse.data?.data?.public_key;
+ if (!publicKey) {
+ throw new Error('VAPID public key not available from server.');
+ }
+
+ // 4. Subscribe to push
+ const subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ applicationServerKey: urlBase64ToUint8Array(publicKey) as any,
+ });
+
+ // 5. Extract subscription details
+ const raw = subscription.toJSON();
+ const pushSubscription: PushSubscriptionInfo = {
+ endpoint: raw.endpoint ?? '',
+ p256dh: raw.keys?.p256dh ?? '',
+ auth: raw.keys?.auth ?? '',
+ };
+
+ // 6. Save subscription to server via the preferences endpoint
+ // The server stores these in user_notification.push_subscription_* columns
+ await axios.patch('/api/users/me/notifications/preferences', {
+ push_subscription_endpoint: pushSubscription.endpoint,
+ push_subscription_keys_p256dh: pushSubscription.p256dh,
+ push_subscription_keys_auth: pushSubscription.auth,
+ });
+
+ setIsSubscribed(true);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'Failed to subscribe to push notifications';
+ setError(msg);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [permission]);
+
+ const unsubscribe = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ // 1. Unsubscribe from push manager
+ const registration = await navigator.serviceWorker.ready;
+ const subscription = await registration.pushManager.getSubscription();
+ if (subscription) {
+ await subscription.unsubscribe();
+ }
+
+ // 2. Clear subscription on server
+ await axios.delete('/api/users/me/notifications/push/subscription');
+
+ setIsSubscribed(false);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'Failed to unsubscribe from push notifications';
+ setError(msg);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ return { permission, isSubscribed, isLoading, error, subscribe, unsubscribe };
+}
+
+// ─── Utility ────────────────────────────────────────────────────────────────
+
+/**
+ * Convert a Base64url string to a Uint8Array (for applicationServerKey).
+ * Matches the browser's built-in urlBase64ToUint8Array behavior.
+ */
+function urlBase64ToUint8Array(base64String: string): Uint8Array {
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
+
+ const rawData = atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+}
diff --git a/src/lib/room-ws-client.ts b/src/lib/room-ws-client.ts
index 74724a6..b74ca9d 100644
--- a/src/lib/room-ws-client.ts
+++ b/src/lib/room-ws-client.ts
@@ -249,8 +249,12 @@ export class RoomWsClient {
});
}
- disconnect(): void {
- this.shouldReconnect = false;
+ disconnect(graceful = false): void {
+ // Only permanently disable reconnect on intentional disconnect (user action).
+ // Graceful disconnect (cleanup from effect swap) allows reconnect to continue.
+ if (!graceful) {
+ this.shouldReconnect = false;
+ }
this.stopHeartbeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
@@ -1011,7 +1015,10 @@ export class RoomWsClient {
break;
case 'room.reaction_updated':
case 'room_reaction_updated':
- this.callbacks.onRoomReactionUpdated?.(event.data as RoomReactionUpdatedPayload);
+ this.callbacks.onRoomReactionUpdated?.({
+ ...(event.data as Omit),
+ room_id: event.room_id ?? '',
+ });
break;
default:
// Unknown event type - ignore silently
@@ -1063,9 +1070,12 @@ export class RoomWsClient {
}
private async resubscribeAll(): Promise {
+ // Subscribe/unsubscribe are WS-only actions — request() would fall back to HTTP
+ // which maps them to POST /ws (not a real REST endpoint), causing 404 failures.
+ // Use requestWs() to ensure they go through the WebSocket.
for (const roomId of this.subscribedRooms) {
try {
- await this.request('room.subscribe', { room_id: roomId });
+ await this.requestWs('room.subscribe', { room_id: roomId });
} catch (err) {
// Resubscribe failure is non-fatal — messages still arrive via REST poll.
// Log at warn level so operators can observe patterns (e.g. auth expiry).
@@ -1074,7 +1084,7 @@ export class RoomWsClient {
}
for (const projectName of this.subscribedProjects) {
try {
- await this.request('project.subscribe', { project_name: projectName });
+ await this.requestWs('project.subscribe', { project_name: projectName });
} catch (err) {
console.warn(`[RoomWs] resubscribe project failed (will retry on next reconnect): ${projectName}`, err);
}
@@ -1089,7 +1099,9 @@ export class RoomWsClient {
// (thundering herd) after a server restart, overwhelming it.
const baseDelay = this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempt);
const cappedDelay = Math.min(baseDelay, this.reconnectMaxDelay);
- const jitter = Math.random() * cappedDelay;
+ // Ensure minimum 500ms delay to avoid hitting backend 30s connection cooldown
+ const minDelay = 500;
+ const jitter = minDelay + Math.random() * (cappedDelay - minDelay);
const delay = Math.floor(jitter);
this.reconnectAttempt++;
diff --git a/src/lib/ws-protocol.ts b/src/lib/ws-protocol.ts
index 6530503..df5554d 100644
--- a/src/lib/ws-protocol.ts
+++ b/src/lib/ws-protocol.ts
@@ -186,6 +186,8 @@ export interface AiStreamChunkPayload {
content: string;
done: boolean;
error?: string;
+ /** Human-readable AI model name for display (e.g. "Claude 3.5 Sonnet"). */
+ display_name?: string;
}
export interface RoomResponse {
@@ -283,6 +285,7 @@ export interface ReactionListData {
}
export interface RoomReactionUpdatedPayload {
+ room_id: string;
message_id: string;
reactions: ReactionItem[];
}
diff --git a/src/types/browser-image-compression.d.ts b/src/types/browser-image-compression.d.ts
new file mode 100644
index 0000000..28bc423
--- /dev/null
+++ b/src/types/browser-image-compression.d.ts
@@ -0,0 +1,17 @@
+declare module 'browser-image-compression' {
+ export interface Options {
+ maxSizeMB?: number;
+ maxWidthOrHeight?: number;
+ useWebWorker?: boolean;
+ fileType?: string;
+ initialQuality?: number;
+ alwaysKeepResolution?: boolean;
+ preserveExif?: boolean;
+ onProgress?: (progress: number) => void;
+ usePixelLength?: boolean;
+ signal?: AbortSignal;
+ }
+
+ function imageCompression(file: File | Blob, options?: Options): Promise;
+ export default imageCompression;
+}