import { useCallback, useEffect, useRef, useState } from 'react'; import type { ReactElement, SyntheticEvent } from 'react'; import type { Conversation, Message, MediaRef, PublicUser, User } from '@altricade/core'; import { heartbeat } from '@altricade/core/api'; import { SessionProvider, useSession } from '../entities/session'; import { AuthForm } from '../features/auth'; import { RealtimeProvider } from '../features/realtime'; import { useConversations, ConversationSidebar } from '../features/conversations'; import { useFolders } from '../features/folders'; import { ContactsPanel } from '../features/contacts'; import { ChatView } from '../features/messaging'; import { ProfilePanel } from '../features/profile'; import { SettingsPanel } from '../features/settings'; import { NotificationsProvider, useNotifications, unregisterWebPush } from '../features/notifications'; import { apiConfig } from '../shared/api'; import { PlusIcon, CloseIcon, UsersIcon, UserIcon, ChatsIcon, SettingsIcon, ChevronLeftIcon, } from '../shared/ui'; type RailView = 'chats' | 'contacts' | 'compose' | 'settings'; const RAIL_TITLES: Record, string> = { contacts: 'Contacts', compose: 'New group', settings: 'Settings', }; const mediaLabel = (media: MediaRef | null): string => { if (media === null) { return 'New message'; } switch (media.kind) { case 'image': return 'Photo'; case 'video': return 'Video'; case 'video_note': return 'Video message'; case 'voice': return 'Voice message'; default: return 'File'; } }; const GroupComposer = ({ onCreate, }: { onCreate: (title: string, members: string[]) => Promise; }): ReactElement => { const [title, setTitle] = useState(''); const [members, setMembers] = useState(''); const [error, setError] = useState(null); const submit = async (event: SyntheticEvent): Promise => { event.preventDefault(); const trimmed = title.trim(); if (trimmed === '') { return; } const list = members .split(',') .map((name) => name.trim()) .filter((name) => name.length > 0); setError(null); try { await onCreate(trimmed, list); setTitle(''); setMembers(''); } catch { setError('Could not create group'); } }; return (

New group

void submit(event)}> { setTitle(event.target.value); }} /> { setMembers(event.target.value); }} />
{error !== null ?

{error}

: null}
); }; const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): ReactElement => { const me: PublicUser = { id: user.id, username: user.username, displayName: user.displayName, avatarUrl: user.avatarUrl, }; const { notify, setOpener } = useNotifications(); const [current, setCurrent] = useState(null); const [railView, setRailView] = useState('chats'); const [activeFolderId, setActiveFolderId] = useState(null); const [profileUser, setProfileUser] = useState(null); const convRef = useRef([]); const folders = useFolders(user.id); // Toast (or OS notification when hidden) for messages arriving in other chats. const onIncoming = useCallback( (message: Message): void => { const conversation = convRef.current.find((item) => item.id === message.conversationId); const isGroup = conversation?.type === 'group'; const text = message.content.length > 0 ? message.content : mediaLabel(message.media); notify({ conversationId: message.conversationId, title: isGroup ? (conversation.title ?? 'Group') : message.sender.displayName, body: isGroup ? `${message.sender.displayName}: ${text}` : text, }); }, [notify], ); const { conversations, onlineMap, startDirect, createGroupChat, clearChat, deleteChat } = useConversations(user.id, current?.id ?? null, onIncoming); convRef.current = conversations; const openConversation = useCallback((conversation: Conversation): void => { setCurrent(conversation); setRailView('chats'); setProfileUser(null); }, []); // The DM shared with the profiled user, when one exists (drives shared media). const profileConversation = profileUser === null ? null : (conversations.find( (item) => item.type === 'direct' && item.peer !== null && item.peer.id === profileUser.id, ) ?? null); // If the open chat disappears from the list (deleted for me, possibly on // another device), close it. useEffect(() => { if (current !== null && !conversations.some((c) => c.id === current.id)) { setCurrent(null); } }, [conversations, current]); // If the active folder was deleted (any device), fall back to All. useEffect(() => { if (activeFolderId !== null && !folders.folders.some((f) => f.id === activeFolderId)) { setActiveFolderId(null); } }, [folders.folders, activeFolderId]); // Let notification taps (in-app toast or SW message) open the right chat. useEffect(() => { setOpener((conversationId) => { const conversation = convRef.current.find((item) => item.id === conversationId); if (conversation !== undefined) { openConversation(conversation); } }); }, [setOpener, openConversation]); // Deep-link: /?conversation= (from a push opened in a fresh tab). useEffect(() => { const params = new URLSearchParams(window.location.search); const target = params.get('conversation'); if (target === null || conversations.length === 0) { return; } const conversation = conversations.find((item) => item.id === target); if (conversation !== undefined) { setCurrent(conversation); window.history.replaceState({}, '', window.location.pathname); } }, [conversations]); // Keep-alive heartbeat so our own last-seen stays fresh while connected. useEffect(() => { const interval = setInterval(() => { void heartbeat(apiConfig); }, 45000); return () => { clearInterval(interval); }; }, []); const totalUnread = conversations.reduce((sum, item) => sum + item.unreadCount, 0); const startDirectAndOpen = async (username: string): Promise => { openConversation(await startDirect(username)); }; const createGroupAndOpen = async (title: string, members: string[]): Promise => { openConversation(await createGroupChat(title, members)); }; const shellClass = [ 'app-shell', current !== null ? 'has-chat' : '', profileUser !== null ? 'has-profile' : '', ] .filter((token) => token !== '') .join(' '); return (
{current !== null ? ( { if (target.id !== user.id) { setProfileUser(target); } }} onBack={() => { setCurrent(null); }} /> ) : (

Select a conversation

Search for someone or start a new chat to begin messaging.

)} {profileUser !== null ? ( { setProfileUser(null); }} onMessage={(username) => { void startDirectAndOpen(username); }} /> ) : null}
); }; const Shell = (): ReactElement => { const { status, user, logout } = useSession(); if (status === 'loading') { return (
); } if (user === null) { return ; } return ( { // Detach this browser's push subscription before the token clears. void (async () => { await unregisterWebPush(); await logout(); })(); }} /> ); }; export const App = (): ReactElement => ( );