From 22df172dc626621d8ae6afbac83bec40e3aa860c Mon Sep 17 00:00:00 2001 From: Xavier-Trump <2933243959@qq.com> Date: Wed, 27 May 2026 23:36:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20IM=20=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E2=80=94=E2=80=94=E5=A5=BD=E5=8F=8B/?= =?UTF-8?q?=E9=80=9A=E7=9F=A5/=E6=92=A4=E5=9B=9E/=E5=B7=B2=E8=AF=BB/?= =?UTF-8?q?=E7=BE=A4=E8=81=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 dev/trump 提取(原 PR #194 #200,commit beb97b8, 42c71b8)。 useIMChat: +645 lines — friend requests, notifications, recall, read receipts, group/private session creation, interface gap detection IMView: +196 lines — action panels, loading/error/empty states IMContactList: +151 lines — group creation, Hub contact picker IMMessageView: +52 lines — recall button, read status IMMessageInput: async callback support Co-Authored-By: Xavier-Trump <2933243959@qq.com> --- .../src/components/IM/IMContactList.tsx | 245 ++++- .../src/components/IM/IMMessageInput.tsx | 7 +- .../src/components/IM/IMMessageView.tsx | 62 +- app/desktop/src/hooks/useIMChat.ts | 886 +++++++++++++++++- app/desktop/src/views/IMView.module.css | 130 +++ app/desktop/src/views/IMView.tsx | 237 ++++- 6 files changed, 1444 insertions(+), 123 deletions(-) diff --git a/app/desktop/src/components/IM/IMContactList.tsx b/app/desktop/src/components/IM/IMContactList.tsx index d97fd266f..abb3d7fa6 100644 --- a/app/desktop/src/components/IM/IMContactList.tsx +++ b/app/desktop/src/components/IM/IMContactList.tsx @@ -1,12 +1,19 @@ import { useState, useMemo, useCallback, memo } from 'react'; -import { Plus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { MessageCircle, Plus, UserPlus, Users } from 'lucide-react'; +import type { ContactInfo } from '@/api/hubClient'; import type { IMContact } from './types'; import styles from './IMContactList.module.css'; +type ComposeMode = 'contact' | 'private' | 'group'; + interface IMContactListProps { contacts: IMContact[]; + hubContacts?: ContactInfo[]; onSelect?: (contact: IMContact) => void; - onAdd?: (name: string) => void; + onAddContact?: (userId: string) => boolean | void | Promise; + onCreatePrivateSession?: (userId: string) => boolean | void | Promise; + onCreateGroupSession?: (name: string, memberIds: string[]) => boolean | void | Promise; selectedId?: string; } @@ -23,13 +30,39 @@ function avatarClass(type: string): string { const IMContactList = memo(function IMContactList({ contacts, + hubContacts = [], onSelect, - onAdd, + onAddContact, + onCreatePrivateSession, + onCreateGroupSession, selectedId, }: IMContactListProps) { const [search, setSearch] = useState(''); - const [showAdd, setShowAdd] = useState(false); - const [addName, setAddName] = useState(''); + const [showCompose, setShowCompose] = useState(false); + const [composeMode, setComposeMode] = useState('contact'); + const [targetUserId, setTargetUserId] = useState(''); + const [groupName, setGroupName] = useState(''); + const [groupMembers, setGroupMembers] = useState([]); + const [submitting, setSubmitting] = useState(false); + const { t } = useTranslation(); + const label = useCallback( + (key: string, fallback: string) => { + const translated = t(key); + return translated === key ? fallback : translated; + }, + [t], + ); + const statusHintLabel = useCallback( + (contact: IMContact) => { + if (!contact.statusHint) return contact.lastSeen; + const translated = t(contact.statusHint, contact.statusHintParams); + if (translated !== contact.statusHint) return translated; + if (contact.statusHintParams?.seq) return `${contact.statusHint} ${contact.statusHintParams.seq}`; + return contact.statusHint; + }, + [t], + ); + const canCompose = Boolean(onAddContact || onCreatePrivateSession || onCreateGroupSession); const filtered = useMemo(() => { if (!search.trim()) return contacts; @@ -37,49 +70,171 @@ const IMContactList = memo(function IMContactList({ return contacts.filter((c) => c.name.toLowerCase().includes(lower)); }, [contacts, search]); - const handleAdd = useCallback(() => { - const trimmed = addName.trim(); - if (!trimmed) return; - onAdd?.(trimmed); - setAddName(''); - setShowAdd(false); - }, [addName, onAdd]); + const handleSubmit = useCallback(async () => { + if (submitting) return; + setSubmitting(true); + try { + let accepted: boolean | void = false; + if (composeMode === 'group') { + const memberIds = groupMembers.map((id) => id.trim()).filter(Boolean); + if (groupName.trim() && memberIds.length > 0) { + accepted = await onCreateGroupSession?.(groupName.trim(), memberIds); + } + } else if (targetUserId.trim()) { + accepted = composeMode === 'private' + ? await onCreatePrivateSession?.(targetUserId.trim()) + : await onAddContact?.(targetUserId.trim()); + } + + if (accepted === false) return; + setTargetUserId(''); + setGroupName(''); + setGroupMembers([]); + setShowCompose(false); + } finally { + setSubmitting(false); + } + }, [ + composeMode, + groupMembers, + groupName, + onAddContact, + onCreateGroupSession, + onCreatePrivateSession, + submitting, + targetUserId, + ]); const handleAddKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === 'Enter') handleAdd(); - if (e.key === 'Escape') setShowAdd(false); + if (e.key === 'Enter') void handleSubmit(); + if (e.key === 'Escape') setShowCompose(false); }, - [handleAdd], + [handleSubmit], ); + const toggleGroupMember = useCallback((userId: string) => { + setGroupMembers((prev) => + prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId], + ); + }, []); + return (
- Contacts - + {label('im.contact.title', 'Contacts')} + {canCompose && ( + + )}
- {showAdd && ( -
- setAddName(e.target.value)} - onKeyDown={handleAddKeyDown} - placeholder="Contact name..." - autoFocus - aria-label="Contact name" - /> - + )} + {onCreatePrivateSession && ( + + )} + {onCreateGroupSession && ( + + )} +
+ + {composeMode === 'group' ? ( + <> + setGroupName(e.target.value)} + onKeyDown={handleAddKeyDown} + placeholder={label('im.contact.groupNamePlaceholder', 'Group name...')} + autoFocus + aria-label={label('im.contact.groupName', 'Group name')} + /> +
+ {hubContacts.length === 0 ? ( + {label('im.contact.noHubContacts', 'No Hub contacts available')} + ) : ( + hubContacts.map((contact) => ( + + )) + )} +
+ + ) : ( + <> + {composeMode === 'private' && hubContacts.length > 0 && ( + + )} + setTargetUserId(e.target.value)} + onKeyDown={handleAddKeyDown} + placeholder={label('im.contact.hubUserIdPlaceholder', 'Hub user ID...')} + autoFocus + aria-label={label('im.contact.hubUserId', 'Hub user ID')} + /> + + )} + +
)} @@ -89,15 +244,15 @@ const IMContactList = memo(function IMContactList({ className={styles.searchInput} value={search} onChange={(e) => setSearch(e.target.value)} - placeholder="Search contacts..." - aria-label="Search contacts" + placeholder={label('im.contact.search', 'Search contacts...')} + aria-label={label('im.contact.searchLabel', 'Search contacts')} /> -
+
{filtered.length === 0 ? (
- {search ? 'No contacts match your search' : 'No contacts yet'} + {search ? label('im.contact.noSearchResults', 'No contacts match your search') : label('im.contact.noContacts', 'No contacts yet')}
) : ( filtered.map((contact) => ( @@ -115,16 +270,16 @@ const IMContactList = memo(function IMContactList({
{contact.name}
{contact.type} - {contact.authority ? ` · ${contact.authority}` : ''} - {contact.lastSeen ? ` · ${contact.lastSeen}` : ''} + {contact.authority ? ` | ${contact.authority}` : ''} + {statusHintLabel(contact) ? ` | ${statusHintLabel(contact)}` : ''}
)) diff --git a/app/desktop/src/components/IM/IMMessageInput.tsx b/app/desktop/src/components/IM/IMMessageInput.tsx index 514088646..05dc0f560 100644 --- a/app/desktop/src/components/IM/IMMessageInput.tsx +++ b/app/desktop/src/components/IM/IMMessageInput.tsx @@ -5,7 +5,7 @@ import styles from './IMMessageInput.module.css'; const MAX_CHARS = 2000; interface IMMessageInputProps { - onSend: (content: string) => void; + onSend: (content: string) => boolean | void | Promise; disabled?: boolean; placeholder?: string; } @@ -18,10 +18,11 @@ const IMMessageInput = memo(function IMMessageInput({ const [value, setValue] = useState(''); const textareaRef = useRef(null); - const handleSend = useCallback(() => { + const handleSend = useCallback(async () => { const trimmed = value.trim(); if (!trimmed || disabled) return; - onSend(trimmed); + const accepted = await onSend(trimmed); + if (accepted === false) return; setValue(''); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; diff --git a/app/desktop/src/components/IM/IMMessageView.tsx b/app/desktop/src/components/IM/IMMessageView.tsx index 54adec63d..c031412dc 100644 --- a/app/desktop/src/components/IM/IMMessageView.tsx +++ b/app/desktop/src/components/IM/IMMessageView.tsx @@ -1,4 +1,6 @@ import { useRef, useEffect, memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RotateCcw } from 'lucide-react'; import MarkdownRenderer from '@/components/MarkdownRenderer'; import type { IMMessage } from './types'; import styles from './IMMessageView.module.css'; @@ -6,6 +8,9 @@ import styles from './IMMessageView.module.css'; interface IMMessageViewProps { messages: IMMessage[]; currentUserId?: string; + canRecall?: boolean; + recallingMessageIds?: Record; + onRecallMessage?: (message: IMMessage) => void | Promise; } function formatTime(timestamp: string): string { @@ -67,10 +72,21 @@ function SenderAvatar({ const IMMessageBubble = memo(function IMMessageBubble({ message, isOwn, + canRecall, + recalling, + onRecallMessage, }: { message: IMMessage; isOwn: boolean; + canRecall: boolean; + recalling: boolean; + onRecallMessage?: (message: IMMessage) => void | Promise; }) { + const { t } = useTranslation(); + const label = (key: string, fallback: string, vars?: Record) => { + const translated = t(key, vars); + return translated === key ? fallback : translated; + }; const isRecalled = message.content === '[Message recalled]'; return ( @@ -101,10 +117,37 @@ const IMMessageBubble = memo(function IMMessageBubble({
- {/* Timestamp */} - +
+ + {message.recalled || isRecalled ? ( + {label('im.message.recalledStatus', 'Recalled on Hub')} + ) : message.read ? ( + + {label('im.message.readStatus', `Read by ${message.readBy ?? 'Hub member'}`, { + reader: message.readBy ?? label('im.message.unknownReader', 'Hub member'), + })} + + ) : isOwn ? ( + {label('im.message.sentStatus', 'Sent through Hub')} + ) : null} + {message.actionError ? ( + {message.actionError} + ) : null} + {isOwn && message.authority === 'hub' && !message.recalled && !isRecalled ? ( + + ) : null} +
); }); @@ -112,6 +155,9 @@ const IMMessageBubble = memo(function IMMessageBubble({ const IMMessageView = memo(function IMMessageView({ messages, currentUserId, + canRecall = false, + recallingMessageIds = {}, + onRecallMessage, }: IMMessageViewProps) { const bottomRef = useRef(null); @@ -141,7 +187,13 @@ const IMMessageView = memo(function IMMessageView({ key={msg.id} className={isOwn ? styles.userRow : styles.agentRow} > - + ); })} diff --git a/app/desktop/src/hooks/useIMChat.ts b/app/desktop/src/hooks/useIMChat.ts index 579588c78..842ba11bf 100644 --- a/app/desktop/src/hooks/useIMChat.ts +++ b/app/desktop/src/hooks/useIMChat.ts @@ -1,100 +1,872 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; -import type { HubWSHandle } from '@/api/hubWS'; +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { + createHubClient, + type ContactInfo, + type FriendRequestInfo, + type HubClient, + type HubNotification, + type MessageResponse, + type Session, +} from '@/api/hubClient'; +import { createHubWS, type HubWSHandle } from '@/api/hubWS'; import type { HubMessage } from '@/api/hubEvents'; import { HUB_EVENTS } from '@shared/hubEvents'; +import { getAccessToken } from '@/hooks/useAuth'; import { useHubStore } from '@/stores/hubStore'; import { useToastStore } from '@/stores/toastStore'; import type { IMMessage, IMContact, AuthorityType } from '@/components/IM/types'; -function hubMessageToIMMessage(msg: HubMessage, authority: AuthorityType = 'hub'): IMMessage { - return { +type IMStatus = 'idle' | 'loading' | 'ready' | 'error'; +type IMMessageWithHubState = IMMessage & { + seqId?: number; +}; +type IMActionStatus = 'pending' | 'error'; +type IMActionState = Record; + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function isCallable(client: HubClient, method: TMethod): boolean { + return typeof client[method] === 'function'; +} + +function errorMessage(err: unknown): string { + return err instanceof Error && err.message ? err.message : 'Hub action failed'; +} + +function readTextContent(content: string): string { + try { + const parsed = JSON.parse(content) as unknown; + if (parsed && typeof parsed === 'object' && typeof (parsed as Record).text === 'string') { + return (parsed as Record).text; + } + } catch { + // Plain text or non-JSON rich payloads should pass through unchanged. + } + return content; +} + +function sessionIdOf(session: Session): string { + return session.session_id ?? session.id ?? ''; +} + +function makeClientMessageId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `desktop-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function hubMessageToIMMessage( + msg: HubMessage | MessageResponse, + authority: AuthorityType = 'hub', +): IMMessage { + const record = msg as unknown as Record; + const message: IMMessageWithHubState = { id: msg.id, sessionId: msg.session_id, + clientMsgId: readString(record.client_msg_id), senderId: msg.sender_id, senderName: msg.sender_id, senderType: msg.sender_type === 'agent' ? 'agent' : 'user', authority, - content: msg.recalled ? '[Message recalled]' : msg.content, - timestamp: msg.created_at, + content: msg.recalled ? '[Message recalled]' : readTextContent(msg.content), + timestamp: msg.created_at ?? new Date().toISOString(), replyToId: msg.reply_to_message_id, + recalled: msg.recalled, + seqId: readNumber(record.seq_id), + read: Boolean(record.read), + readAt: readString(record.read_at), + }; + return message; +} + +function sessionToContact(session: Session, contactsByUserId: Map): IMContact { + const sessionId = sessionIdOf(session); + const memberUserIds = session.members + ?.filter((member) => member.member_type === 'user') + .map((member) => member.member_id) ?? []; + const firstKnownMember = memberUserIds + .map((id) => contactsByUserId.get(id)) + .find(Boolean); + const lastMessage = session.last_message as Record | undefined; + const fallbackName = session.type === 'group' ? 'Group chat' : 'Direct chat'; + + return { + id: sessionId, + name: + session.name ?? + firstKnownMember?.remark ?? + firstKnownMember?.nickname ?? + firstKnownMember?.username ?? + fallbackName, + type: session.type === 'group' ? 'group' : 'user', + authority: 'hub', + online: firstKnownMember?.online ?? false, + avatar: firstKnownMember?.avatar_url, + lastSeen: + readString(lastMessage?.content) ?? + session.last_message_at ?? + session.updated_at ?? + session.created_at, + statusHint: + session.archived + ? 'im.session.archived' + : session.unread_count && session.unread_count > 0 + ? 'im.session.unread' + : undefined, + statusHintParams: + session.unread_count && session.unread_count > 0 + ? { count: session.unread_count } + : undefined, + unreadCount: session.unread_count ?? 0, }; } +function mergeMessages(existing: IMMessage[], incoming: IMMessage[]): IMMessage[] { + const byKey = new Map(); + for (const message of existing) { + byKey.set(message.clientMsgId ?? message.id, message); + byKey.set(message.id, message); + } + for (const message of incoming) { + const key = message.clientMsgId ?? message.id; + const previous = byKey.get(message.id) ?? byKey.get(key); + const merged = previous ? { ...previous, ...message } : message; + byKey.set(key, merged); + byKey.set(message.id, merged); + } + + const unique = Array.from(new Set(byKey.values())); + unique.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + return unique; +} + +function getPayloadSession(payload: unknown): Session | null { + if (!payload || typeof payload !== 'object') return null; + const record = payload as Record; + const nested = record.session; + if (nested && typeof nested === 'object') return nested as Session; + if (typeof record.id === 'string') return record as unknown as Session; + if (typeof record.session_id === 'string') { + return { + id: record.session_id, + type: readString(record.type) ?? 'private', + name: readString(record.name), + owner_user_id: readString(record.owner_user_id) ?? '', + updated_at: readString(record.updated_at), + created_at: readString(record.created_at), + }; + } + return null; +} + interface UseIMChatOptions { - hubWS: HubWSHandle | null; + hubClient?: HubClient | null; + hubWS?: HubWSHandle | null; } -export function useIMChat({ hubWS }: UseIMChatOptions) { +export interface SendIMMessageResult { + ok: boolean; +} + +export interface HubIMActionResult { + ok: boolean; + reason?: 'unauthenticated' | 'interface-gap' | 'failed' | 'invalid'; + error?: string; +} + +export function useIMChat({ hubClient, hubWS }: UseIMChatOptions = {}) { + const defaultHubClient = useMemo(() => createHubClient({ getToken: getAccessToken }), []); + const client = hubClient ?? defaultHubClient; + const [ownedHubWS, setOwnedHubWS] = useState(null); + const activeHubWS = hubWS ?? ownedHubWS; const [messages, setMessages] = useState>(new Map()); const [contacts, setContacts] = useState([]); + const [hubContacts, setHubContacts] = useState([]); + const [friendRequests, setFriendRequests] = useState([]); + const [notifications, setNotifications] = useState([]); + const [actionState, setActionState] = useState({}); + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(null); const authenticated = useHubStore((s) => s.authenticated); + const userId = useHubStore((s) => s.userId); const addToast = useToastStore((s) => s.addToast); - const hubWSRef = useRef(hubWS); - hubWSRef.current = hubWS; + const contactsByUserIdRef = useRef>(new Map()); + + const actionCapabilities = useMemo( + () => ({ + friendRequests: + isCallable(client, 'acceptFriendRequest') && + isCallable(client, 'rejectFriendRequest'), + notifications: + isCallable(client, 'markNotificationRead') && + isCallable(client, 'readAllNotifications'), + sessionRead: isCallable(client, 'markRead'), + recallMessage: isCallable(client, 'recallMessage'), + }), + [client], + ); + + const markActionPending = useCallback((key: string) => { + setActionState((prev) => ({ ...prev, [key]: { status: 'pending' } })); + }, []); + + const markActionError = useCallback((key: string, message: string) => { + setActionState((prev) => ({ ...prev, [key]: { status: 'error', error: message } })); + }, []); + + const clearAction = useCallback((key: string) => { + setActionState((prev) => { + if (!prev[key]) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + }, []); - // Wire Hub WS message.new events useEffect(() => { - if (!hubWS || !authenticated) return; + if (hubWS || !authenticated || !getAccessToken()) { + setOwnedHubWS(null); + return; + } - const unsub = hubWS.on(HUB_EVENTS.MESSAGE_NEW, (rawPayload: unknown) => { + const handle = createHubWS({ getToken: getAccessToken }); + setOwnedHubWS(handle); + handle.connect(); + return () => { + handle.close(); + setOwnedHubWS(null); + }; + }, [hubWS, authenticated]); + + const upsertContact = useCallback((contact: IMContact) => { + setContacts((prev) => { + const idx = prev.findIndex((c) => c.id === contact.id); + if (idx >= 0) { + const next = [...prev]; + next[idx] = { ...next[idx], ...contact }; + return next; + } + return [...prev, contact]; + }); + }, []); + + const removeContact = useCallback((contactId: string) => { + setContacts((prev) => prev.filter((c) => c.id !== contactId)); + }, []); + + const refreshSessions = useCallback(async () => { + if (!authenticated) { + setStatus('idle'); + setError(null); + setContacts([]); + setHubContacts([]); + setFriendRequests([]); + setNotifications([]); + setActionState({}); + setMessages(new Map()); + return; + } + + setStatus('loading'); + setError(null); + try { + const [contactSnapshot, sessionSnapshot, friendRequestSnapshot, notificationSnapshot] = await Promise.all([ + client.listContacts(), + client.listSessions(), + client.listFriendRequests(), + client.listNotifications({ limit: 20 }), + ]); + const contactsByUserId = new Map(contactSnapshot.map((contact) => [contact.user_id, contact])); + contactsByUserIdRef.current = contactsByUserId; + setHubContacts(contactSnapshot); + setFriendRequests(friendRequestSnapshot); + setNotifications(notificationSnapshot); + setContacts(sessionSnapshot.map((session) => sessionToContact(session, contactsByUserId))); + setStatus('ready'); + } catch (err) { + setContacts([]); + setHubContacts([]); + setFriendRequests([]); + setNotifications([]); + setStatus('error'); + setError('im.state.unavailable'); + addToast({ type: 'error', message: 'Failed to load Hub sessions' }); + console.error('Failed to load IM sessions:', err); + } + }, [addToast, authenticated, client]); + + useEffect(() => { + void refreshSessions(); + }, [refreshSessions]); + + const loadSessionMessages = useCallback( + async (sessionId: string) => { + if (!authenticated) return; + try { + const snapshot = await client.getMessages(sessionId, { limit: 50 }); + const incoming = snapshot.map((msg) => hubMessageToIMMessage(msg)); + setMessages((prev) => { + const next = new Map(prev); + next.set(sessionId, mergeMessages(next.get(sessionId) ?? [], incoming)); + return next; + }); + } catch (err) { + addToast({ type: 'error', message: 'Failed to load Hub messages' }); + console.error('Failed to load IM messages:', err); + } + }, + [addToast, authenticated, client], + ); + + useEffect(() => { + if (!activeHubWS || !authenticated) return; + + const unsubMessageNew = activeHubWS.on(HUB_EVENTS.MESSAGE_NEW, (rawPayload: unknown) => { const msg = rawPayload as HubMessage; if (!msg?.id || !msg?.session_id) return; const imMsg = hubMessageToIMMessage(msg); setMessages((prev) => { const next = new Map(prev); - const sessionMessages = [...(next.get(msg.session_id) ?? [])]; - // Deduplicate by id - if (sessionMessages.some((m) => m.id === imMsg.id)) return prev; - sessionMessages.push(imMsg); - next.set(msg.session_id, sessionMessages); + next.set(msg.session_id, mergeMessages(next.get(msg.session_id) ?? [], [imMsg])); + return next; + }); + }); + + const unsubRecall = activeHubWS.on(HUB_EVENTS.MESSAGE_RECALL, (rawPayload: unknown) => { + const payload = rawPayload as Record; + const sessionId = readString(payload.session_id); + const messageId = readString(payload.message_id) ?? readString(payload.id); + if (!sessionId || !messageId) return; + setMessages((prev) => { + const next = new Map(prev); + next.set( + sessionId, + (next.get(sessionId) ?? []).map((message) => + message.id === messageId + ? { ...message, recalled: true, content: '[Message recalled]' } + : message, + ), + ); return next; }); + setContacts((prev) => + prev.map((contact) => + contact.id === sessionId + ? { ...contact, statusHint: 'im.session.messageRecalled' } + : contact, + ), + ); + }); + + const upsertSessionFromPayload = (payload: unknown) => { + const session = getPayloadSession(payload); + if (session && sessionIdOf(session)) { + upsertContact({ + ...sessionToContact(session, contactsByUserIdRef.current), + statusHint: 'im.session.updated', + }); + } + }; + + const unsubSessionCreated = activeHubWS.on(HUB_EVENTS.SESSION_CREATED, upsertSessionFromPayload); + const unsubSessionUpdated = activeHubWS.on(HUB_EVENTS.SESSION_INFO_UPDATED, upsertSessionFromPayload); + const unsubSessionDissolved = activeHubWS.on(HUB_EVENTS.SESSION_DISSOLVED, (rawPayload: unknown) => { + const payload = rawPayload as Record; + const sessionId = readString(payload.session_id) ?? readString(payload.id); + if (!sessionId) return; + setContacts((prev) => + prev.map((contact) => + contact.id === sessionId + ? { + ...contact, + dissolved: true, + online: false, + lastSeen: 'Dissolved', + statusHint: 'im.session.dissolved', + } + : contact, + ), + ); + }); + + const unsubRead = activeHubWS.on(HUB_EVENTS.MESSAGE_READ, (rawPayload: unknown) => { + const payload = rawPayload as Record; + const sessionId = readString(payload.session_id); + const readerId = readString(payload.user_id); + const lastReadSeq = readNumber(payload.last_read_seq); + const readAt = readString(payload.read_at) ?? new Date().toISOString(); + if (!sessionId || !readerId || lastReadSeq === undefined) return; + + setMessages((prev) => { + const current = prev.get(sessionId); + if (!current) return prev; + const next = new Map(prev); + next.set( + sessionId, + current.map((message) => { + const hubMessage = message as IMMessageWithHubState; + if ( + hubMessage.seqId === undefined || + hubMessage.seqId > lastReadSeq || + hubMessage.senderId === readerId + ) { + return message; + } + return { + ...hubMessage, + read: true, + readBy: readerId, + readAt, + } as IMMessage; + }), + ); + return next; + }); + setContacts((prev) => + prev.map((contact) => + contact.id === sessionId + ? { + ...contact, + statusHint: 'im.session.readThrough', + statusHintParams: { seq: lastReadSeq }, + } + : contact, + ), + ); }); return () => { - unsub(); + unsubMessageNew(); + unsubRecall(); + unsubSessionCreated(); + unsubSessionUpdated(); + unsubSessionDissolved(); + unsubRead(); }; - }, [hubWS, authenticated]); + }, [activeHubWS, authenticated, upsertContact]); - // Send a message through Hub WS const sendMessage = useCallback( - (sessionId: string, content: string) => { - const ws = hubWSRef.current; - if (!ws || !authenticated) { - addToast({ type: 'error', message: 'Not connected to Hub' }); - return; + async (sessionId: string, content: string): Promise => { + if (!authenticated) { + addToast({ type: 'error', message: 'Connect to Hub to send messages' }); + return { ok: false }; + } + const session = contacts.find((contact) => contact.id === sessionId); + if (session?.dissolved) { + addToast({ type: 'error', message: 'This Hub session is no longer available' }); + return { ok: false }; + } + + const clientMsgId = makeClientMessageId(); + try { + const response = await client.sendMessage(sessionId, { + client_msg_id: clientMsgId, + content_type: 'text', + content, + }); + const confirmed: IMMessageWithHubState = { + id: response.message_id, + sessionId, + clientMsgId, + senderId: userId ?? 'me', + senderName: userId ?? 'Me', + senderType: 'user', + authority: 'hub', + content, + timestamp: response.created_at, + recalled: false, + seqId: response.seq_id, + read: false, + }; + setMessages((prev) => { + const next = new Map(prev); + next.set(sessionId, mergeMessages(next.get(sessionId) ?? [], [confirmed])); + return next; + }); + return { ok: true }; + } catch (err) { + addToast({ type: 'error', message: 'Failed to send Hub message' }); + console.error('Failed to send IM message:', err); + return { ok: false }; } - ws.send('message.send', { session_id: sessionId, content }); }, - [authenticated, addToast], + [addToast, authenticated, client, contacts, userId], ); - // Get messages for a specific session - const getSessionMessages = useCallback( - (sessionId: string): IMMessage[] => messages.get(sessionId) ?? [], - [messages], + const acceptFriendRequest = useCallback( + async (requestId: string): Promise => { + if (!authenticated) { + addToast({ type: 'error', message: 'Connect to Hub to accept contact requests' }); + return { ok: false, reason: 'unauthenticated' }; + } + if (!actionCapabilities.friendRequests) { + const message = 'Hub friend request action interface is not available'; + markActionError(`friend:${requestId}:accept`, message); + return { ok: false, reason: 'interface-gap', error: message }; + } + + const key = `friend:${requestId}:accept`; + markActionPending(key); + try { + await client.acceptFriendRequest(requestId); + clearAction(key); + setFriendRequests((prev) => prev.filter((request) => request.request_id !== requestId)); + addToast({ type: 'success', message: 'Friend request accepted' }); + void refreshSessions(); + return { ok: true }; + } catch (err) { + const message = errorMessage(err); + markActionError(key, message); + addToast({ type: 'error', message: 'Failed to accept friend request' }); + console.error('Failed to accept friend request:', err); + return { ok: false, reason: 'failed', error: message }; + } + }, + [ + actionCapabilities.friendRequests, + addToast, + authenticated, + clearAction, + client, + markActionError, + markActionPending, + refreshSessions, + ], ); - // Add or update a contact - const upsertContact = useCallback((contact: IMContact) => { - setContacts((prev) => { - const idx = prev.findIndex((c) => c.id === contact.id); - if (idx >= 0) { - const next = [...prev]; - next[idx] = { ...next[idx], ...contact }; - return next; + const rejectFriendRequest = useCallback( + async (requestId: string): Promise => { + if (!authenticated) { + addToast({ type: 'error', message: 'Connect to Hub to reject contact requests' }); + return { ok: false, reason: 'unauthenticated' }; + } + if (!actionCapabilities.friendRequests) { + const message = 'Hub friend request action interface is not available'; + markActionError(`friend:${requestId}:reject`, message); + return { ok: false, reason: 'interface-gap', error: message }; } - return [...prev, contact]; - }); - }, []); - // Remove a contact - const removeContact = useCallback((contactId: string) => { - setContacts((prev) => prev.filter((c) => c.id !== contactId)); - }, []); + const key = `friend:${requestId}:reject`; + markActionPending(key); + try { + await client.rejectFriendRequest(requestId); + clearAction(key); + setFriendRequests((prev) => prev.filter((request) => request.request_id !== requestId)); + addToast({ type: 'success', message: 'Friend request rejected' }); + return { ok: true }; + } catch (err) { + const message = errorMessage(err); + markActionError(key, message); + addToast({ type: 'error', message: 'Failed to reject friend request' }); + console.error('Failed to reject friend request:', err); + return { ok: false, reason: 'failed', error: message }; + } + }, + [ + actionCapabilities.friendRequests, + addToast, + authenticated, + clearAction, + client, + markActionError, + markActionPending, + ], + ); + + const markNotificationRead = useCallback( + async (notificationId: string): Promise => { + if (!authenticated) { + addToast({ type: 'error', message: 'Connect to Hub to mark notifications read' }); + return { ok: false, reason: 'unauthenticated' }; + } + if (!actionCapabilities.notifications) { + const message = 'Hub notification read interface is not available'; + markActionError(`notification:${notificationId}:read`, message); + return { ok: false, reason: 'interface-gap', error: message }; + } + + const key = `notification:${notificationId}:read`; + markActionPending(key); + try { + await client.markNotificationRead(notificationId); + clearAction(key); + setNotifications((prev) => + prev.map((notification) => + notification.id === notificationId ? { ...notification, read: true } : notification, + ), + ); + return { ok: true }; + } catch (err) { + const message = errorMessage(err); + markActionError(key, message); + addToast({ type: 'error', message: 'Failed to mark notification read' }); + console.error('Failed to mark notification read:', err); + return { ok: false, reason: 'failed', error: message }; + } + }, + [ + actionCapabilities.notifications, + addToast, + authenticated, + clearAction, + client, + markActionError, + markActionPending, + ], + ); + + const readAllNotifications = useCallback(async (): Promise => { + if (!authenticated) { + addToast({ type: 'error', message: 'Connect to Hub to mark notifications read' }); + return { ok: false, reason: 'unauthenticated' }; + } + if (!actionCapabilities.notifications) { + const message = 'Hub notification read-all interface is not available'; + markActionError('notification:all:read', message); + return { ok: false, reason: 'interface-gap', error: message }; + } + + const key = 'notification:all:read'; + markActionPending(key); + try { + await client.readAllNotifications(); + clearAction(key); + setNotifications((prev) => prev.map((notification) => ({ ...notification, read: true }))); + return { ok: true }; + } catch (err) { + const message = errorMessage(err); + markActionError(key, message); + addToast({ type: 'error', message: 'Failed to mark all notifications read' }); + console.error('Failed to mark all notifications read:', err); + return { ok: false, reason: 'failed', error: message }; + } + }, [ + actionCapabilities.notifications, + addToast, + authenticated, + clearAction, + client, + markActionError, + markActionPending, + ]); + + const markSessionRead = useCallback( + async (sessionId: string): Promise => { + if (!authenticated) { + addToast({ type: 'error', message: 'Connect to Hub to mark sessions read' }); + return { ok: false, reason: 'unauthenticated' }; + } + if (!actionCapabilities.sessionRead) { + const message = 'Hub session read interface is not available'; + markActionError(`session:${sessionId}:read`, message); + return { ok: false, reason: 'interface-gap', error: message }; + } + + const current = messages.get(sessionId) ?? []; + const lastReadSeq = current.reduce((max, message) => { + const seqId = (message as IMMessageWithHubState).seqId; + return seqId !== undefined && seqId > max ? seqId : max; + }, 0); + if (lastReadSeq <= 0) { + const message = 'No Hub message sequence is loaded for this session'; + markActionError(`session:${sessionId}:read`, message); + return { ok: false, reason: 'invalid', error: message }; + } + + const key = `session:${sessionId}:read`; + markActionPending(key); + try { + await client.markRead(sessionId, lastReadSeq); + clearAction(key); + setContacts((prev) => + prev.map((contact) => + contact.id === sessionId + ? { + ...contact, + unreadCount: 0, + statusHint: 'im.session.markedRead', + statusHintParams: { seq: lastReadSeq }, + } + : contact, + ), + ); + return { ok: true }; + } catch (err) { + const message = errorMessage(err); + markActionError(key, message); + addToast({ type: 'error', message: 'Failed to mark Hub session read' }); + console.error('Failed to mark Hub session read:', err); + return { ok: false, reason: 'failed', error: message }; + } + }, + [ + actionCapabilities.sessionRead, + addToast, + authenticated, + clearAction, + client, + markActionError, + markActionPending, + messages, + ], + ); + + const recallMessage = useCallback( + async (message: IMMessage): Promise => { + if (!authenticated) { + addToast({ type: 'error', message: 'Connect to Hub to recall messages' }); + return { ok: false, reason: 'unauthenticated' }; + } + if (!actionCapabilities.recallMessage) { + const error = 'Hub message recall interface is not available'; + markActionError(`message:${message.id}:recall`, error); + return { ok: false, reason: 'interface-gap', error }; + } + if (message.recalled) return { ok: true }; + + const key = `message:${message.id}:recall`; + markActionPending(key); + try { + await client.recallMessage(message.id); + clearAction(key); + setMessages((prev) => { + const next = new Map(prev); + next.set( + message.sessionId, + (next.get(message.sessionId) ?? []).map((item) => + item.id === message.id + ? { + ...item, + recalled: true, + content: '[Message recalled]', + actionError: undefined, + } + : item, + ), + ); + return next; + }); + return { ok: true }; + } catch (err) { + const messageText = errorMessage(err); + markActionError(key, messageText); + setMessages((prev) => { + const next = new Map(prev); + next.set( + message.sessionId, + (next.get(message.sessionId) ?? []).map((item) => + item.id === message.id ? { ...item, actionError: messageText } : item, + ), + ); + return next; + }); + addToast({ type: 'error', message: 'Failed to recall Hub message' }); + console.error('Failed to recall IM message:', err); + return { ok: false, reason: 'failed', error: messageText }; + } + }, + [ + actionCapabilities.recallMessage, + addToast, + authenticated, + clearAction, + client, + markActionError, + markActionPending, + ], + ); + + const addContact = useCallback( + async (targetUserId: string): Promise => { + const trimmed = targetUserId.trim(); + if (!trimmed) return { ok: false }; + if (!authenticated) { + addToast({ type: 'error', message: 'Connect to Hub to add contacts' }); + return { ok: false }; + } + + try { + await client.searchUser(trimmed); + await client.sendFriendRequest(trimmed); + addToast({ type: 'success', message: 'Friend request sent' }); + await refreshSessions(); + return { ok: true }; + } catch (err) { + addToast({ type: 'error', message: 'Failed to add Hub contact' }); + console.error('Failed to add Hub contact:', err); + return { ok: false }; + } + }, + [addToast, authenticated, client, refreshSessions], + ); + + const createPrivateSession = useCallback( + async (targetUserId: string): Promise => { + const trimmed = targetUserId.trim(); + if (!trimmed) return { ok: false }; + if (!authenticated) { + addToast({ type: 'error', message: 'Connect to Hub to create chats' }); + return { ok: false }; + } + + try { + const session = await client.createPrivateSession({ target_user_id: trimmed }); + upsertContact(sessionToContact(session, contactsByUserIdRef.current)); + await refreshSessions(); + addToast({ type: 'success', message: 'Direct chat created' }); + return { ok: true }; + } catch (err) { + addToast({ type: 'error', message: 'Failed to create Hub direct chat' }); + console.error('Failed to create Hub direct chat:', err); + return { ok: false }; + } + }, + [addToast, authenticated, client, refreshSessions, upsertContact], + ); + + const createGroupSession = useCallback( + async (name: string, memberIds: string[]): Promise => { + const trimmedName = name.trim(); + const trimmedMemberIds = Array.from( + new Set(memberIds.map((id) => id.trim()).filter(Boolean)), + ); + if (!trimmedName || trimmedMemberIds.length === 0) return { ok: false }; + if (!authenticated) { + addToast({ type: 'error', message: 'Connect to Hub to create groups' }); + return { ok: false }; + } + + try { + const session = await client.createGroupSession({ + name: trimmedName, + member_ids: trimmedMemberIds, + }); + upsertContact(sessionToContact(session, contactsByUserIdRef.current)); + await refreshSessions(); + addToast({ type: 'success', message: 'Group chat created' }); + return { ok: true }; + } catch (err) { + addToast({ type: 'error', message: 'Failed to create Hub group chat' }); + console.error('Failed to create Hub group chat:', err); + return { ok: false }; + } + }, + [addToast, authenticated, client, refreshSessions, upsertContact], + ); + + const getSessionMessages = useCallback( + (sessionId: string): IMMessage[] => messages.get(sessionId) ?? [], + [messages], + ); - // Search contacts by name const searchContacts = useCallback( (query: string): IMContact[] => { if (!query.trim()) return contacts; @@ -107,10 +879,28 @@ export function useIMChat({ hubWS }: UseIMChatOptions) { return { messages, contacts, + hubContacts, + friendRequests, + notifications, + actionState, + actionCapabilities, + status, + error, sendMessage, getSessionMessages, + loadSessionMessages, + refreshSessions, upsertContact, removeContact, searchContacts, + addContact, + createPrivateSession, + createGroupSession, + acceptFriendRequest, + rejectFriendRequest, + markNotificationRead, + readAllNotifications, + markSessionRead, + recallMessage, } as const; } diff --git a/app/desktop/src/views/IMView.module.css b/app/desktop/src/views/IMView.module.css index 7af4e1c53..d17a89a10 100644 --- a/app/desktop/src/views/IMView.module.css +++ b/app/desktop/src/views/IMView.module.css @@ -18,9 +18,129 @@ min-width: 0; } +.actionPanel { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + background: var(--surface-alt); + flex-shrink: 0; +} + +.actionPanelHeader, +.actionPanelFooter { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.actionPanelFooter { + justify-content: flex-end; +} + +.actionQueues { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 8px; +} + +.queueItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + padding: 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); +} + +.queueText { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + font-size: 12px; +} + +.queueText strong, +.queueText span, +.queueText em { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.queueText span { + color: var(--muted); +} + +.queueText em, +.inlineError { + color: var(--destructive); + font-style: normal; +} + +.queueActions { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.iconAction, +.textAction { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + background: var(--surface); + color: var(--foreground); + cursor: pointer; +} + +.iconAction { + width: 28px; + height: 28px; + border-radius: 6px; +} + +.textAction { + gap: 6px; + min-height: 28px; + padding: 0 10px; + border-radius: 6px; + font-size: 12px; + white-space: nowrap; +} + +.iconAction:hover:not(:disabled), +.textAction:hover:not(:disabled) { + background: var(--surface-raised); +} + +.iconAction:disabled, +.textAction:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.interfaceGap { + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--muted); + font-size: 11px; + text-transform: uppercase; +} + .chatHeader { display: flex; align-items: center; + justify-content: space-between; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--border); @@ -28,10 +148,20 @@ flex-shrink: 0; } +.chatHeading { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + .chatTitle { font-weight: 600; font-size: 14px; color: var(--foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .chatType { diff --git a/app/desktop/src/views/IMView.tsx b/app/desktop/src/views/IMView.tsx index e5760017c..b8d3bf4a8 100644 --- a/app/desktop/src/views/IMView.tsx +++ b/app/desktop/src/views/IMView.tsx @@ -1,18 +1,80 @@ import { useState, useMemo, useCallback } from 'react'; -import { MessageCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Check, CheckCheck, MessageCircle, X } from 'lucide-react'; import type { IMContact, IMMessage } from '@/components/IM/types'; import IMContactList from '@/components/IM/IMContactList'; import IMMessageView from '@/components/IM/IMMessageView'; import IMMessageInput from '@/components/IM/IMMessageInput'; import { useIMChat } from '@/hooks/useIMChat'; import { useHubStore } from '@/stores/hubStore'; +import type { FriendRequestInfo, HubClient, HubNotification } from '@/api/hubClient'; import type { HubWSHandle } from '@/api/hubWS'; import type { ViewProps } from '@/config/viewRegistry'; import styles from './IMView.module.css'; -export default function IMView({ hubWS: hubWsProp }: ViewProps) { - const hubWS = (hubWsProp ?? null) as HubWSHandle | null; - const { getSessionMessages, contacts, sendMessage, upsertContact } = useIMChat({ +type ActionState = Record; + +function contactName(request: FriendRequestInfo): string { + return request.nickname || request.username || request.user_id; +} + +function notificationTitle(notification: HubNotification): string { + try { + const parsed = JSON.parse(notification.payload) as unknown; + if (parsed && typeof parsed === 'object') { + const record = parsed as Record; + if (typeof record.title === 'string' && record.title) return record.title; + if (typeof record.body === 'string' && record.body) return record.body; + if (typeof record.message === 'string' && record.message) return record.message; + } + } catch { + if (notification.payload) return notification.payload; + } + return notification.type || notification.id; +} + +function actionError(actionState: ActionState, keys: string[]): string | undefined { + return keys.map((key) => actionState[key]?.error).find(Boolean); +} + +function actionPending(actionState: ActionState, keys: string[]): boolean { + return keys.some((key) => actionState[key]?.status === 'pending'); +} + +export default function IMView(props: ViewProps) { + const { t } = useTranslation(); + const label = useCallback( + (key: string, fallback: string, vars?: Record) => { + const translated = t(key, vars); + return translated === key ? fallback : translated; + }, + [t], + ); + const hubWS = (props.hubWS ?? null) as HubWSHandle | null; + const hubClient = (props.hubClient ?? null) as HubClient | null; + const { + getSessionMessages, + loadSessionMessages, + contacts, + hubContacts, + friendRequests, + notifications, + actionState, + actionCapabilities, + sendMessage, + addContact, + createPrivateSession, + createGroupSession, + acceptFriendRequest, + rejectFriendRequest, + markNotificationRead, + readAllNotifications, + markSessionRead, + recallMessage, + status, + error, + } = useIMChat({ + hubClient, hubWS, }); const userId = useHubStore((s) => s.userId); @@ -28,23 +90,22 @@ export default function IMView({ hubWS: hubWsProp }: ViewProps) { const handleSelectContact = useCallback((contact: IMContact) => { setActiveSessionId(contact.id); - }, []); + void loadSessionMessages(contact.id); + }, [loadSessionMessages]); const handleSend = useCallback( - (content: string) => { - if (!activeSessionId) return; - sendMessage(activeSessionId, content); + async (content: string) => { + if (!activeSessionId) return false; + const result = await sendMessage(activeSessionId, content); + return result?.ok !== false; }, [activeSessionId, sendMessage], ); - const handleAddContact = useCallback( - (name: string) => { - const id = `contact-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; - upsertContact({ id, name, type: 'user', online: false }); - }, - [upsertContact], - ); + const unreadNotifications = notifications.filter((notification) => !notification.read); + const sessionReadError = activeSessionId + ? actionState[`session:${activeSessionId}:read`]?.error + : undefined; // Not authenticated: show a prompt to connect if (!authenticated) { @@ -53,7 +114,7 @@ export default function IMView({ hubWS: hubWsProp }: ViewProps) {
); @@ -64,35 +125,167 @@ export default function IMView({ hubWS: hubWsProp }: ViewProps) {
(await addContact(userId)).ok} + onCreatePrivateSession={async (userId) => (await createPrivateSession(userId)).ok} + onCreateGroupSession={async (name, memberIds) => (await createGroupSession(name, memberIds)).ok} />
- {activeContact ? ( +
+
+ {label('im.snapshot.contactRequests', `${friendRequests.length} contact requests`, { count: friendRequests.length })} + {label('im.snapshot.notifications', `${unreadNotifications.length} unread notifications`, { count: unreadNotifications.length })} + {!actionCapabilities.friendRequests || !actionCapabilities.notifications ? ( + {label('im.action.interfaceGap', 'Interface gap')} + ) : null} +
+ + {friendRequests.length > 0 || notifications.length > 0 ? ( +
+ {friendRequests.slice(0, 3).map((request) => { + const pending = actionPending(actionState, [ + `friend:${request.request_id}:accept`, + `friend:${request.request_id}:reject`, + ]); + const errorText = actionError(actionState, [ + `friend:${request.request_id}:accept`, + `friend:${request.request_id}:reject`, + ]); + return ( +
+
+ {contactName(request)} + {request.message || label('im.request.noMessage', 'No request message')} + {errorText ? {errorText} : null} +
+
+ + +
+
+ ); + })} + + {notifications.slice(0, 3).map((notification) => { + const pending = actionPending(actionState, [`notification:${notification.id}:read`]); + const errorText = actionState[`notification:${notification.id}:read`]?.error; + return ( +
+
+ {notificationTitle(notification)} + {notification.read ? label('im.notification.read', 'Read') : label('im.notification.unread', 'Unread')} + {errorText ? {errorText} : null} +
+ +
+ ); + })} +
+ ) : null} + + {notifications.length > 0 ? ( +
+ {actionState['notification:all:read']?.error ? ( + {actionState['notification:all:read']?.error} + ) : null} + +
+ ) : null} +
+ {status === 'loading' ? ( +
+ {label('im.state.loadingSessions', 'Loading Hub sessions...')} +
+ ) : status === 'error' ? ( +
+ {error ? label(error, 'Hub messages are unavailable.') : label('im.state.unavailable', 'Hub messages are unavailable.')} +
+ ) : contacts.length === 0 ? ( +
+ {label('im.state.noConversations', 'No Hub conversations yet')} +
+ ) : activeContact ? ( <>
- {activeContact.name} - {activeContact.type} +
+ {activeContact.name} + {activeContact.type} + {sessionReadError ? {sessionReadError} : null} +
+
key.startsWith('message:') && key.endsWith(':recall') && value.status === 'pending') + .map(([key]) => [key.split(':')[1], true]), + )} + onRecallMessage={(message: IMMessage) => recallMessage(message)} />
) : (
- Select a contact to start messaging + {label('im.state.selectContact', 'Select a contact to start messaging')}
)}