168 lines
4.7 KiB
TypeScript
168 lines
4.7 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import type { ReactElement } from 'react';
|
|
import type { Conversation, PublicUser, User } from '@altricade/core';
|
|
import { getPresence, heartbeat } from '@altricade/core/api';
|
|
import { SessionProvider, useSession } from '../entities/session';
|
|
import { AuthForm } from '../features/auth';
|
|
import { RealtimeProvider, useRealtime } from '../features/realtime';
|
|
import { useConversations, ConversationSidebar } from '../features/conversations';
|
|
import { ContactsPanel } from '../features/contacts';
|
|
import { ChatView } from '../features/messaging';
|
|
import { apiConfig } from '../shared/api';
|
|
import { useTheme } from '../shared/theme';
|
|
import type { ThemePreference } from '../shared/theme';
|
|
|
|
const PREFERENCES: readonly ThemePreference[] = ['light', 'dark', 'system'];
|
|
|
|
const ThemeSwitch = (): ReactElement => {
|
|
const { preference, setPreference } = useTheme();
|
|
return (
|
|
<div className="theme-switch">
|
|
{PREFERENCES.map((option) => (
|
|
<button
|
|
key={option}
|
|
type="button"
|
|
aria-pressed={preference === option}
|
|
onClick={() => {
|
|
setPreference(option);
|
|
}}
|
|
>
|
|
{option}
|
|
</button>
|
|
))}
|
|
</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 { state } = useRealtime();
|
|
const { conversations, startDirect, createGroupChat } = useConversations(user.id);
|
|
const [current, setCurrent] = useState<Conversation | null>(null);
|
|
const [onlineMap, setOnlineMap] = useState<Record<string, boolean>>({});
|
|
|
|
const peerKey = conversations
|
|
.flatMap((conversation) => (conversation.peer === null ? [] : [conversation.peer.id]))
|
|
.join(',');
|
|
|
|
// Poll presence for the people we have DMs with.
|
|
useEffect(() => {
|
|
const ids = peerKey.split(',').filter((id) => id.length > 0);
|
|
if (ids.length === 0) {
|
|
return undefined;
|
|
}
|
|
let cancelled = false;
|
|
const poll = async (): Promise<void> => {
|
|
try {
|
|
const presences = await getPresence(apiConfig, ids);
|
|
if (!cancelled) {
|
|
const map: Record<string, boolean> = {};
|
|
for (const presence of presences) {
|
|
map[presence.userId] = presence.online;
|
|
}
|
|
setOnlineMap(map);
|
|
}
|
|
} catch {
|
|
// presence is best-effort
|
|
}
|
|
};
|
|
void poll();
|
|
const interval = setInterval(() => {
|
|
void poll();
|
|
}, 20000);
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(interval);
|
|
};
|
|
}, [peerKey]);
|
|
|
|
// Keep-alive heartbeat so our own last-seen stays fresh while connected.
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
void heartbeat(apiConfig);
|
|
}, 45000);
|
|
return () => {
|
|
clearInterval(interval);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="layout">
|
|
<header className="topbar">
|
|
<span>
|
|
<strong>@{user.username}</strong> · socket: {state}
|
|
</span>
|
|
<span className="topbar-actions">
|
|
<ThemeSwitch />
|
|
<button type="button" onClick={onLogout}>
|
|
Log out
|
|
</button>
|
|
</span>
|
|
</header>
|
|
<div className="workspace">
|
|
<div className="sidebar-column">
|
|
<ConversationSidebar
|
|
conversations={conversations}
|
|
currentId={current?.id ?? null}
|
|
onlineMap={onlineMap}
|
|
onSelect={setCurrent}
|
|
onStartDirect={async (username) => {
|
|
setCurrent(await startDirect(username));
|
|
}}
|
|
onCreateGroup={async (title, members) => {
|
|
setCurrent(await createGroupChat(title, members));
|
|
}}
|
|
/>
|
|
<ContactsPanel
|
|
onStartDirect={async (username) => {
|
|
setCurrent(await startDirect(username));
|
|
}}
|
|
/>
|
|
</div>
|
|
{current !== null ? (
|
|
<ChatView conversation={current} me={me} />
|
|
) : (
|
|
<section className="chat chat-empty">
|
|
<p>Search for a user or pick a contact to start chatting.</p>
|
|
</section>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Shell = (): ReactElement => {
|
|
const { status, user, logout } = useSession();
|
|
|
|
if (status === 'loading') {
|
|
return (
|
|
<main className="app">
|
|
<p>Loading…</p>
|
|
</main>
|
|
);
|
|
}
|
|
if (user === null) {
|
|
return <AuthForm />;
|
|
}
|
|
return (
|
|
<RealtimeProvider>
|
|
<Dashboard
|
|
user={user}
|
|
onLogout={() => {
|
|
void logout();
|
|
}}
|
|
/>
|
|
</RealtimeProvider>
|
|
);
|
|
};
|
|
|
|
export const App = (): ReactElement => (
|
|
<SessionProvider>
|
|
<Shell />
|
|
</SessionProvider>
|
|
);
|