Messenger/packages/web/src/app/App.tsx
Заид Омар Медхат | Zaid Omar Medhat cf432bd803 Telegram-style UX: folders, deletion modes, profile, settings, mobile nav
Data model / backend (migration 1720000000007):
- video_note as a first-class MediaRef kind — round video messages are
  explicit, no filename heuristics; mime-validated, own push label.
- Delete for me (message_hidden tombstones) alongside delete-for-everyone;
  group owners can moderate-delete any message in their groups.
- Clear history / delete chat per user (cleared_up_to_seq + hidden_at on
  conversation_members); deleted chats return on new activity.
- Manual chat folders + per-folder pins (chat_folders, chat_folder_items,
  chat_pins; folderId null = "All" tab). Every mutation returns and
  broadcasts a full snapshot on the personal channel (folders.update),
  syncing devices. New events: message.hidden, conversation.cleared,
  conversation.hidden.
- Shared-media listing: GET /conversations/:id/media?tab=media|files|voice.

Web:
- Right-click context menus (shared ContextMenu/ConfirmDialog): messages get
  a reactions row + copy/edit/delete; chats get pin/unpin per folder scope,
  folder membership, clear, delete; folder tabs get rename/delete.
- Telegram-style editing in the composer (banner + prefill), hover toolbar
  removed; delete dialog offers for-me / for-everyone per permissions.
- Folder tabs in the rail with per-folder pinned-first ordering.
- Profile panel (avatar, name, last seen, username, Message button) with
  shared media tabs; back arrow on mobile.
- Settings page (avatar, display name edit, theme, log out); contacts as a
  separate page; Telegram-style mobile bottom nav (Contacts|Chats|Settings)
  with total-unread badge.
- Chat scroll: opens at newest (ResizeObserver keeps bottom pinned while
  media loads), per-chat position memory, jump-to-newest button.
- Responsive single-pane layout under 900px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
2026-07-11 01:53:34 +05:00

452 lines
14 KiB
TypeScript

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<Exclude<RailView, 'chats'>, 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<void>;
}): ReactElement => {
const [title, setTitle] = useState('');
const [members, setMembers] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async (event: SyntheticEvent): Promise<void> => {
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 (
<div className="compose-block">
<p className="rail-section-title">New group</p>
<form className="stack-form" onSubmit={(event) => void submit(event)}>
<input
className="field"
placeholder="Group name"
value={title}
onChange={(event) => {
setTitle(event.target.value);
}}
/>
<input
className="field"
placeholder="Members (comma-separated usernames)"
value={members}
onChange={(event) => {
setMembers(event.target.value);
}}
/>
<button type="submit" className="btn-primary btn-sm">
<UsersIcon size={17} />
Create group
</button>
</form>
{error !== null ? <p className="form-error">{error}</p> : null}
</div>
);
};
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<Conversation | null>(null);
const [railView, setRailView] = useState<RailView>('chats');
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
const [profileUser, setProfileUser] = useState<PublicUser | null>(null);
const convRef = useRef<Conversation[]>([]);
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=<id> (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<void> => {
openConversation(await startDirect(username));
};
const createGroupAndOpen = async (title: string, members: string[]): Promise<void> => {
openConversation(await createGroupChat(title, members));
};
const shellClass = [
'app-shell',
current !== null ? 'has-chat' : '',
profileUser !== null ? 'has-profile' : '',
]
.filter((token) => token !== '')
.join(' ');
return (
<div className={shellClass}>
<aside className="rail">
<div className="rail-header">
{railView === 'chats' ? (
<span className="rail-brand">
<span className="rail-mark" aria-hidden="true">
A
</span>
<span className="rail-title">Altricade</span>
</span>
) : (
<span className="rail-brand">
<button
type="button"
className="icon-btn"
aria-label="Back to chats"
onClick={() => {
setRailView('chats');
}}
>
<ChevronLeftIcon />
</button>
<span className="rail-title">{RAIL_TITLES[railView]}</span>
</span>
)}
<div className="rail-header-actions">
{railView === 'chats' ? (
<>
<button
type="button"
className="icon-btn rail-desktop-action"
aria-label="Contacts"
title="Contacts"
onClick={() => {
setRailView('contacts');
}}
>
<UsersIcon />
</button>
<button
type="button"
className="icon-btn rail-desktop-action"
aria-label="Settings"
title="Settings"
onClick={() => {
setRailView('settings');
}}
>
<SettingsIcon />
</button>
<button
type="button"
className="icon-btn"
aria-label="New group"
title="New group"
onClick={() => {
setRailView('compose');
}}
>
<PlusIcon />
</button>
</>
) : (
<button
type="button"
className="icon-btn"
aria-label="Close"
onClick={() => {
setRailView('chats');
}}
>
<CloseIcon />
</button>
)}
</div>
</div>
{railView === 'chats' ? (
<ConversationSidebar
conversations={conversations}
currentId={current?.id ?? null}
onlineMap={onlineMap}
folders={folders}
activeFolderId={activeFolderId}
onSelectFolder={setActiveFolderId}
onSelect={openConversation}
onStartDirect={startDirectAndOpen}
onClearChat={clearChat}
onDeleteChat={deleteChat}
/>
) : railView === 'contacts' ? (
<div className="rail-scroll compose">
<ContactsPanel onStartDirect={startDirectAndOpen} />
</div>
) : railView === 'compose' ? (
<div className="rail-scroll compose">
<GroupComposer onCreate={createGroupAndOpen} />
</div>
) : (
<div className="rail-scroll">
<SettingsPanel onLogout={onLogout} />
</div>
)}
{/* Mobile bottom navigation (Telegram-style): Contacts | Chats | Settings */}
<nav className="rail-nav" aria-label="Main">
<button
type="button"
className="rail-nav-btn"
aria-pressed={railView === 'contacts'}
aria-label="Contacts"
onClick={() => {
setRailView('contacts');
}}
>
<UserIcon size={24} />
</button>
<button
type="button"
className="rail-nav-btn"
aria-pressed={railView === 'chats'}
aria-label="Chats"
onClick={() => {
setRailView('chats');
}}
>
<span className="rail-nav-icon">
<ChatsIcon size={24} />
{totalUnread > 0 ? <span className="unread-badge rail-nav-badge">{totalUnread}</span> : null}
</span>
</button>
<button
type="button"
className="rail-nav-btn"
aria-pressed={railView === 'settings'}
aria-label="Settings"
onClick={() => {
setRailView('settings');
}}
>
<SettingsIcon size={24} />
</button>
</nav>
</aside>
{current !== null ? (
<ChatView
conversation={current}
me={me}
onlineMap={onlineMap}
onOpenProfile={(target) => {
if (target.id !== user.id) {
setProfileUser(target);
}
}}
onBack={() => {
setCurrent(null);
}}
/>
) : (
<section className="chat-pane chat-empty">
<div className="chat-empty-inner">
<span className="chat-empty-mark" aria-hidden="true">
A
</span>
<h2>Select a conversation</h2>
<p className="muted">Search for someone or start a new chat to begin messaging.</p>
</div>
</section>
)}
{profileUser !== null ? (
<ProfilePanel
user={profileUser}
conversation={profileConversation}
online={onlineMap[profileUser.id] === true}
onClose={() => {
setProfileUser(null);
}}
onMessage={(username) => {
void startDirectAndOpen(username);
}}
/>
) : null}
</div>
);
};
const Shell = (): ReactElement => {
const { status, user, logout } = useSession();
if (status === 'loading') {
return (
<main className="splash">
<span className="splash-mark" aria-hidden="true">
A
</span>
</main>
);
}
if (user === null) {
return <AuthForm />;
}
return (
<RealtimeProvider>
<NotificationsProvider>
<Dashboard
user={user}
onLogout={() => {
// Detach this browser's push subscription before the token clears.
void (async () => {
await unregisterWebPush();
await logout();
})();
}}
/>
</NotificationsProvider>
</RealtimeProvider>
);
};
export const App = (): ReactElement => (
<SessionProvider>
<Shell />
</SessionProvider>
);