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
452 lines
14 KiB
TypeScript
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>
|
|
);
|