feat(frontend): add push notification hooks, image compression, and update room/chat components

This commit is contained in:
ZhenYi 2026-04-20 15:45:47 +08:00
parent 8316fe926f
commit 6f6f57f062
11 changed files with 579 additions and 115 deletions

View File

@ -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() {
</div>
<Switch id="in-app-enabled" checked={inAppEnabled} onCheckedChange={setInAppEnabled} />
</div>
<Separator />
{/* Browser Push Notifications */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<BellRing className="h-4 w-4 text-muted-foreground" />
<Label htmlFor="push-enabled" className="cursor-pointer">
Browser Push Notifications
</Label>
</div>
<p className="text-sm text-muted-foreground">
{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'}
</p>
{pushError && <p className="text-sm text-destructive">{pushError}</p>}
</div>
<Button
id="push-enabled"
size="sm"
variant={isPushSubscribed ? 'destructive' : 'default'}
disabled={isPushLoading || pushPermission === 'unsupported' || pushPermission === 'denied'}
onClick={isPushSubscribed ? unsubscribePush : subscribePush}
>
{isPushLoading ? (
<Loader2 className="h-4 w-4 animate-spin mr-1" />
) : null}
{isPushLoading
? 'Loading...'
: isPushSubscribed
? 'Disable'
: 'Enable'}
</Button>
</div>
</CardContent>
</Card>

View File

@ -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<string, string> = {
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 (
<span
className={`inline-block h-5 w-5 rounded-full ${color} flex items-center justify-center ${className ?? ''}`}
title={modelId}
>
<span className="text-[10px] font-bold text-white">A</span>
</span>
);
/** 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 (
<LobehubModelIcon
model={model}
type="avatar"
size={20}
className={className}
/>
);
});

View File

@ -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 ? <ModelIcon modelId={senderModelId} /> : initial}
{isAi ? <ModelIcon model={displayName} /> : initial}
</AvatarFallback>
</Avatar>
</button>
@ -318,12 +317,10 @@ export const MessageBubble = memo(function MessageBubble({
</div>
))
) : (
<div className="whitespace-pre-wrap break-words">
<MessageContent
<MessageContent
content={displayContent}
onMentionClick={handleMentionClick}
/>
</div>
)}
{/* Streaming cursor */}

View File

@ -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(
<span
key={`mention-${idx}`}
role={onMentionClick ? 'button' : undefined}
tabIndex={onMentionClick ? 0 : undefined}
className={cn(
'inline-flex items-center gap-0.5 rounded px-1 py-0.5 font-medium text-xs mx-0.5',
getMentionStyle(m.type),
)}
onClick={() => 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}
</span>,
);
}
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 (
<div
className={cn(
'text-sm text-foreground',
'max-w-full min-w-0 break-words whitespace-pre-wrap',
'text-[15px] text-foreground',
'max-w-full min-w-0 break-words',
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs',
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
'[&_p]:whitespace-pre-wrap [&_p]:leading-[1.4] [&_p]:my-1',
'[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-1',
'[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-1',
'[&_li]:my-0.5',
'[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:my-1',
'[&_h1]:text-xl [&_h1]:font-semibold [&_h1]:my-2',
'[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:my-2',
'[&_h3]:text-base [&_h3]:font-semibold [&_h3]:my-1.5',
'[&_strong]:font-semibold',
'[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2',
'[&_hr]:border-foreground/20 [&_hr]:my-2',
'[&_table]:w-full [&_table]:border-collapse [&_table]:rounded-md [&_table]:border [&_table]:border-foreground/20 [&_table]:my-2',
'[&_th]:border [&_th]:border-foreground/20 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-bold',
'[&_td]:border [&_td]:border-foreground/20 [&_td]:px-2 [&_td]:py-1 [&_td]:text-left',
'[&_tr]:border-t [&_tr]:even:bg-muted',
)}
>
{parts.map((part, i) =>
part.type === 'text' ? (
<span key={i}>{part.text}</span>
) : (
<span
key={i}
role={onMentionClick ? 'button' : undefined}
tabIndex={onMentionClick ? 0 : undefined}
className={cn(
'inline-flex items-center gap-0.5 rounded px-1 py-0.5 font-medium text-xs mx-0.5',
getMentionStyle(part.mention!.type),
)}
onClick={() => 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);
<Markdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => {
// Restore mentions in paragraph text nodes
if (typeof children === 'string') {
return <p>{restoreMentions(children, mentions, onMentionClick)}</p>;
}
// 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);
}
}}
>
<span>@{part.mention!.label}</span>
</span>
),
)}
return child;
});
return <p>{restored}</p>;
}
return <p>{children}</p>;
},
li: ({ children }) => {
if (typeof children === 'string') {
return <li>{restoreMentions(children, mentions, onMentionClick)}</li>;
}
if (Array.isArray(children)) {
const restored = children.map((child) => {
if (typeof child === 'string') {
return restoreMentions(child, mentions, onMentionClick);
}
return child;
});
return <li>{restored}</li>;
}
return <li>{children}</li>;
},
strong: ({ children }) => {
if (typeof children === 'string') {
return <strong>{restoreMentions(children, mentions, onMentionClick)}</strong>;
}
return <strong>{children}</strong>;
},
em: ({ children }) => {
if (typeof children === 'string') {
return <em>{restoreMentions(children, mentions, onMentionClick)}</em>;
}
return <em>{children}</em>;
},
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 <code className={className} {...props}>{children}</code>;
}
return (
<code
className="font-mono rounded bg-muted px-1 py-0.5 text-xs"
{...props}
>
{children}
</code>
);
},
pre: ({ children }) => {
// Preserve code blocks as-is, no mention restoration
return <pre className="rounded-md bg-muted p-3 overflow-x-auto">{children}</pre>;
},
}}
>
{safeContent}
</Markdown>
</div>
);
}
});

View File

@ -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<IMEditorHandle, IMEditorProps>(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<IMEditorHandle, IMEditorProps>(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 */ }
};

View File

@ -178,6 +178,9 @@ export function RoomProvider({
const [wsStatus, setWsStatus] = useState<RoomWsStatus>('idle');
const [wsError, setWsError] = useState<string | null>(null);
const [wsToken, setWsToken] = useState<string | null>(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<Map<string, RoomMessagePayload[]>>(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<RoomAiConfig[]>([]);
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;

View File

@ -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<string | null>(null);
const compress = useCallback(async (
file: File,
options: ImageCompressOptions = {}
): Promise<CompressionResult> => {
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 };
}

View File

@ -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<void>;
unsubscribe: () => Promise<void>;
}
/**
* Hook for managing Web Push notification subscriptions.
* Handles Service Worker registration, push permission, and subscription lifecycle.
*/
export function usePushNotification(): UsePushNotificationReturn {
const [permission, setPermission] = useState<PushPermissionState>('unsupported');
const [isSubscribed, setIsSubscribed] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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;
}

View File

@ -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<RoomReactionUpdatedPayload, 'room_id'>),
room_id: event.room_id ?? '',
});
break;
default:
// Unknown event type - ignore silently
@ -1063,9 +1070,12 @@ export class RoomWsClient {
}
private async resubscribeAll(): Promise<void> {
// 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<SubscribeData>('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<SubscribeData>('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++;

View File

@ -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[];
}

View File

@ -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<Blob>;
export default imageCompression;
}