feat(frontend): add push notification hooks, image compression, and update room/chat components
This commit is contained in:
parent
8316fe926f
commit
6f6f57f062
@ -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';
|
||||
@ -10,10 +10,19 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { getNotificationPreferences, updateNotificationPreferences } from '@/client';
|
||||
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>
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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
|
||||
content={displayContent}
|
||||
onMentionClick={handleMentionClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming cursor */}
|
||||
|
||||
@ -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) });
|
||||
interface MentionInfo {
|
||||
type: string;
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
parts.push({
|
||||
type: 'mention',
|
||||
mention: {
|
||||
type: match[1],
|
||||
id: match[2],
|
||||
label: match[3],
|
||||
},
|
||||
|
||||
/** 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
|
||||
});
|
||||
lastIndex = RE.lastIndex;
|
||||
}
|
||||
|
||||
// Remaining text
|
||||
if (lastIndex < content.length) {
|
||||
parts.push({ type: 'text', text: content.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return parts;
|
||||
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;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm text-foreground',
|
||||
'max-w-full min-w-0 break-words whitespace-pre-wrap',
|
||||
'[&_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',
|
||||
)}
|
||||
>
|
||||
{parts.map((part, i) =>
|
||||
part.type === 'text' ? (
|
||||
<span key={i}>{part.text}</span>
|
||||
) : (
|
||||
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={i}
|
||||
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(part.mention!.type),
|
||||
getMentionStyle(m.type),
|
||||
)}
|
||||
onClick={() => onMentionClick?.(part.mention!.type, part.mention!.id, part.mention!.label)}
|
||||
onClick={() => onMentionClick?.(m.type, m.id, m.label)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && onMentionClick) {
|
||||
e.preventDefault();
|
||||
onMentionClick(part.mention!.type, part.mention!.id, part.mention!.label);
|
||||
onMentionClick(m.type, m.id, m.label);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>@{part.mention!.label}</span>
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
@{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-[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',
|
||||
)}
|
||||
>
|
||||
<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);
|
||||
}
|
||||
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>
|
||||
);
|
||||
});
|
||||
@ -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 */ }
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
56
src/hooks/useImageCompress.ts
Normal file
56
src/hooks/useImageCompress.ts
Normal 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 };
|
||||
}
|
||||
170
src/hooks/usePushNotification.ts
Normal file
170
src/hooks/usePushNotification.ts
Normal 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;
|
||||
}
|
||||
@ -249,8 +249,12 @@ export class RoomWsClient {
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
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++;
|
||||
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
17
src/types/browser-image-compression.d.ts
vendored
Normal file
17
src/types/browser-image-compression.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user