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 */} +
+
+
+ + +
+

+ {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}

} +
+ +
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; +}