lucide-react (1.56.0) is the one icon set; it hides icons from screen readers by default and only the icons used are bundled (+6 kB gzipped). It replaces the ✕ ☀ ☾ ↑ ↓ ▶ ✓ ← characters and the hand-drawn menu and phone icons. The admin sidebar's 21 pages are grouped under Call routing, People, Features, History and System, each with an icon; the current page gets a left bar. The My Phone tabs have icons and a blue active state. Home is a greeting and cards (new shared Panel): My status, Missed calls with a call-back button, New voicemails, with friendly empty states. Softphone: a status pill (dot and word), call controls as icon-over-label buttons that are toggles with aria-pressed (Mute, Hold, Keypad, Transfer, red Hang up), and a round keypad with the letters under the digits.
428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Settings, People, and the softphone.
|
|
import {
|
|
BookUser,
|
|
House,
|
|
Info,
|
|
LogOut,
|
|
Menu,
|
|
Phone,
|
|
PhoneMissed,
|
|
Settings,
|
|
Shield,
|
|
Users,
|
|
Voicemail,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useEffect, useState, type ReactNode } from 'react';
|
|
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
|
|
import type { AuthUser } from '@selfphoneng/shared';
|
|
import { meApi, signOut } from '../../api/client.ts';
|
|
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
|
|
import { Alert, Brand, Button, Panel } from '../../components/ui.tsx';
|
|
import { subscribeLive, useLive } from '../../live/store.ts';
|
|
import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx';
|
|
import { PresencePanel } from '../../softphone/PresencePanel.tsx';
|
|
import { VoicemailPage, callerOf, isNew, mmss } from './VoicemailPage.tsx';
|
|
import { MyCallsPage, otherParty, useCallBack } from './MyCallsPage.tsx';
|
|
import { fmtNumber } from '../admin/CallsPage.tsx';
|
|
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
|
|
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
|
|
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
|
|
|
|
const link = 'text-link underline';
|
|
|
|
export function MePage({ user }: { user: AuthUser }) {
|
|
const qc = useQueryClient();
|
|
const nav = useNavigate();
|
|
const me = user.extension?.number ?? null;
|
|
useEffect(subscribeLive, []);
|
|
const voicemailSeq = useLive((s) => s.voicemailSeq);
|
|
useEffect(() => {
|
|
if (voicemailSeq) void qc.invalidateQueries({ queryKey: ['me', 'voicemail'] });
|
|
}, [voicemailSeq, qc]);
|
|
// *78/*79 or *72 from the desk phone: refresh my settings when my presence changes.
|
|
const myDnd = useLive((s) => (me ? s.extensions[me]?.dnd : undefined));
|
|
useEffect(() => {
|
|
void qc.invalidateQueries({ queryKey: ['me', 'settings'] });
|
|
}, [myDnd, qc]);
|
|
const toasts = useLive((s) => s.toasts);
|
|
const offers = useContactShares().data?.length ?? 0;
|
|
|
|
const [menu, setMenu] = useState(false);
|
|
const close = () => setMenu(false);
|
|
async function logout() {
|
|
await signOut(); // may leave for the OpenID provider's sign-out page (12 §3.5)
|
|
qc.clear();
|
|
nav('/login', { replace: true });
|
|
}
|
|
|
|
const tab = ({ isActive }: { isActive: boolean }) =>
|
|
`inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium pointer-coarse:py-3 ${isActive ? 'bg-primary-soft text-primary-ink' : 'hover:bg-subtle'}`;
|
|
const icon = 'size-4 shrink-0';
|
|
const row = tab({ isActive: false });
|
|
return (
|
|
<main className="mx-auto flex min-h-dvh max-w-6xl flex-col gap-6 p-4 sm:p-8">
|
|
<header className="flex items-center justify-between gap-2">
|
|
<h1 className="text-xl">
|
|
<Brand />
|
|
</h1>
|
|
<div className="hidden items-center gap-2 md:flex">
|
|
{user.role === 'admin' && (
|
|
<Link className={`${link} text-sm`} to="/admin">
|
|
Admin
|
|
</Link>
|
|
)}
|
|
<Link className={`${link} text-sm`} to="/about">
|
|
About
|
|
</Link>
|
|
<ThemeToggle />
|
|
<Button variant="ghost" onClick={logout}>
|
|
Sign out
|
|
</Button>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
className="relative md:hidden"
|
|
aria-expanded={menu}
|
|
aria-controls="me-nav"
|
|
aria-label={offers > 0 ? `Menu, ${offers} shared with you` : 'Menu'}
|
|
onClick={() => setMenu((o) => !o)}
|
|
>
|
|
<Menu className="size-5" />
|
|
{offers > 0 && (
|
|
<span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary" />
|
|
)}
|
|
</Button>
|
|
</header>
|
|
{/* On phones the tabs and the header links fold into the menu button. */}
|
|
<nav
|
|
id="me-nav"
|
|
aria-label="My phone"
|
|
className={`${menu ? 'flex' : 'hidden'} flex-col gap-1 border-b border-line pb-2 md:flex md:flex-row`}
|
|
>
|
|
<NavLink to="/me" end className={tab} onClick={close}>
|
|
<House className={icon} />
|
|
Home
|
|
</NavLink>
|
|
<NavLink to="/me/calls" className={tab} onClick={close}>
|
|
<Phone className={icon} />
|
|
Calls
|
|
</NavLink>
|
|
<NavLink to="/me/voicemail" className={tab} onClick={close}>
|
|
<Voicemail className={icon} />
|
|
Voicemail
|
|
</NavLink>
|
|
<NavLink to="/me/contacts" className={tab} onClick={close}>
|
|
<BookUser className={icon} />
|
|
Contacts
|
|
{offers > 0 && (
|
|
<span className="rounded-full bg-primary px-1.5 text-xs text-white">
|
|
<span className="sr-only">, shared with you: </span>
|
|
{offers}
|
|
</span>
|
|
)}
|
|
</NavLink>
|
|
<NavLink to="/me/settings" className={tab} onClick={close}>
|
|
<Settings className={icon} />
|
|
Settings
|
|
</NavLink>
|
|
{/* People is a right-hand column on wide screens, a tab on small ones (05 §4.1). */}
|
|
<NavLink to="/me/people" className={(s) => `${tab(s)} lg:hidden`} onClick={close}>
|
|
<Users className={icon} />
|
|
People
|
|
</NavLink>
|
|
<div className="mt-1 flex flex-col gap-1 border-t border-line pt-2 md:hidden">
|
|
{user.role === 'admin' && (
|
|
<Link className={row} to="/admin">
|
|
<Shield className={icon} />
|
|
Admin
|
|
</Link>
|
|
)}
|
|
<Link className={row} to="/about">
|
|
<Info className={icon} />
|
|
About
|
|
</Link>
|
|
<div className="flex items-center justify-between pl-3 text-sm font-medium">
|
|
Theme
|
|
<ThemeToggle />
|
|
</div>
|
|
<button type="button" className={`${row} text-left`} onClick={logout}>
|
|
<LogOut className={icon} />
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
{toasts.map((t) => (
|
|
<div
|
|
key={t.id}
|
|
role="alert"
|
|
className="flex items-start gap-2 rounded-md bg-amber-100 p-3 text-sm text-amber-900 dark:bg-amber-900 dark:text-amber-100"
|
|
>
|
|
<span className="flex-1">{t.message}</span>
|
|
<Button
|
|
variant="ghost"
|
|
className="px-2 py-0"
|
|
aria-label="Dismiss"
|
|
onClick={() =>
|
|
useLive.setState((s) => ({ toasts: s.toasts.filter((x) => x.id !== t.id) }))
|
|
}
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<div className="grid flex-1 gap-8 lg:grid-cols-[1fr_20rem]">
|
|
<div className="flex min-w-0 flex-col gap-6">
|
|
<Routes>
|
|
<Route index element={<Home user={user} />} />
|
|
<Route path="calls" element={<MyCallsPage />} />
|
|
<Route path="voicemail" element={<VoicemailPage />} />
|
|
<Route path="contacts" element={<ContactsPage />} />
|
|
<Route path="settings" element={<MeSettingsPage />} />
|
|
<Route path="people" element={<PresencePanel me={me} />} />
|
|
</Routes>
|
|
<SoftphoneDock />
|
|
</div>
|
|
<aside className="hidden lg:block">
|
|
<PresencePanel me={me} />
|
|
</aside>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
const seeAll = 'text-sm font-medium text-link hover:underline';
|
|
const when = (iso: string) =>
|
|
new Date(iso).toLocaleString(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
});
|
|
|
|
/** One line in a Home card: a round icon, two lines of text, and an optional button. */
|
|
function Row({
|
|
icon,
|
|
title,
|
|
detail,
|
|
children,
|
|
}: {
|
|
icon: ReactNode;
|
|
title: string;
|
|
detail: string;
|
|
children?: ReactNode;
|
|
}) {
|
|
return (
|
|
<li className="flex items-center gap-3 py-2">
|
|
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-subtle">{icon}</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-medium">{title}</span>
|
|
<span className="block text-xs text-muted">{detail}</span>
|
|
</span>
|
|
{children}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function Home({ user }: { user: AuthUser }) {
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<p className="text-2xl font-semibold">Hi {user.displayName}</p>
|
|
<SharedNotice />
|
|
{user.extension && <StatusCard ext={user.extension.number} />}
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{user.extension && <MissedCalls />}
|
|
<NewVoicemails />
|
|
</div>
|
|
{user.extension && <ParkedOnHome />}
|
|
<p className="text-sm">
|
|
<Link className={link} to="/change-password">
|
|
Change password
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NewVoicemails() {
|
|
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
|
|
const fresh = list.data?.filter(isNew) ?? [];
|
|
const nameOf = useContactName();
|
|
return (
|
|
<Panel
|
|
title="New voicemails"
|
|
action={
|
|
list.data &&
|
|
list.data.length > 0 && (
|
|
<Link className={seeAll} to="/me/voicemail">
|
|
Open voicemail
|
|
</Link>
|
|
)
|
|
}
|
|
>
|
|
{list.error && <Alert>{list.error.message}</Alert>}
|
|
{list.data && fresh.length === 0 && (
|
|
<p className="text-sm text-muted">You're all caught up.</p>
|
|
)}
|
|
{fresh.length > 0 && (
|
|
<ul className="-my-2 divide-y divide-line">
|
|
{fresh.slice(0, 5).map((m) => (
|
|
<Row
|
|
key={m.id}
|
|
icon={<Voicemail className="size-4 text-link" />}
|
|
title={nameOf(m.callerNumber) ?? callerOf(m)}
|
|
detail={`${when(m.receivedAt)} · ${mmss(m.durationS)}`}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
/** "Dan shared 2 contacts with you": they wait in Contacts for Accept or Deny (11 §2.1). */
|
|
function SharedNotice() {
|
|
const offers = useContactShares().data ?? [];
|
|
if (!offers.length) return null;
|
|
const from = [...new Set(offers.map((o) => o.from?.name ?? 'Someone'))].join(', ');
|
|
return (
|
|
<p role="status" className="rounded-md bg-primary-soft p-3 text-sm">
|
|
{from} shared {offers.length} contact{offers.length === 1 ? '' : 's'} with you.{' '}
|
|
<Link className={link} to="/me/contacts">
|
|
Accept or deny
|
|
</Link>
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/** Do not disturb and forwarding at a glance (05 §4 Home). */
|
|
function StatusCard({ ext }: { ext: string }) {
|
|
const me = useMe();
|
|
if (!me.data) return null;
|
|
const fwd = [
|
|
me.data.cfu && `all calls go to ${showTarget(me.data.cfu)}`,
|
|
me.data.cfb && `busy → ${showTarget(me.data.cfb)}`,
|
|
me.data.cfna && `no answer → ${showTarget(me.data.cfna)}`,
|
|
].filter(Boolean);
|
|
return (
|
|
<Panel
|
|
title="My status"
|
|
action={
|
|
<Link className={seeAll} to="/me/settings">
|
|
Change
|
|
</Link>
|
|
}
|
|
>
|
|
<DndSwitch me={me.data} />
|
|
<QueueLogins ext={ext} />
|
|
<p className="text-sm text-muted">
|
|
{fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}
|
|
</p>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
/** One switch per queue I'm an agent of: on = logged in (not paused), like *45<queue>. */
|
|
function QueueLogins({ ext }: { ext: string }) {
|
|
const queues = Object.values(useLive((s) => s.queues)).sort((a, b) =>
|
|
a.number.localeCompare(b.number),
|
|
);
|
|
const toggle = useMutation({
|
|
mutationFn: ({ id, paused }: { id: string; paused: boolean }) =>
|
|
meApi.setQueuePaused(id, paused),
|
|
});
|
|
return (
|
|
<>
|
|
{queues.map((q) => {
|
|
const mine = q.members.find((m) => m.ext === ext);
|
|
if (!mine) return null;
|
|
return (
|
|
<label key={q.number} className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
role="switch"
|
|
checked={!mine.paused}
|
|
disabled={toggle.isPending}
|
|
onChange={(e) => toggle.mutate({ id: q.id, paused: !e.target.checked })}
|
|
/>
|
|
Taking calls from queue {q.name} ({q.number})
|
|
<span className="text-xs text-muted">(also *45{q.number} from your phone)</span>
|
|
</label>
|
|
);
|
|
})}
|
|
{toggle.error && <Alert>{toggle.error.message}</Alert>}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** Parked calls with a Pick up button: my phone (or softphone) dials the slot. */
|
|
function ParkedOnHome() {
|
|
const { call, note } = useCallBack();
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<ParkedCalls onPickUp={call} />
|
|
{note && (
|
|
<p role="status" className="text-sm text-muted">
|
|
{note}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Missed calls from the last page of my history (05 §4 Home). */
|
|
function MissedCalls() {
|
|
const calls = useQuery({ queryKey: ['me', 'calls', 'home'], queryFn: () => meApi.calls() });
|
|
const { call, note } = useCallBack();
|
|
const missed = calls.data?.items.filter((c) => c.missedByMe).slice(0, 5) ?? [];
|
|
const nameOf = useContactName();
|
|
return (
|
|
<Panel
|
|
title="Missed calls"
|
|
action={
|
|
calls.data &&
|
|
calls.data.items.length > 0 && (
|
|
<Link className={seeAll} to="/me/calls">
|
|
All my calls
|
|
</Link>
|
|
)
|
|
}
|
|
>
|
|
{calls.data && missed.length === 0 && <p className="text-sm text-muted">No missed calls.</p>}
|
|
{note && (
|
|
<p role="status" className="text-sm text-muted">
|
|
{note}
|
|
</p>
|
|
)}
|
|
{missed.length > 0 && (
|
|
<ul className="-my-2 divide-y divide-line">
|
|
{missed.map((c) => {
|
|
const o = otherParty(c);
|
|
return (
|
|
<Row
|
|
key={c.id}
|
|
icon={<PhoneMissed className="size-4 text-red-600 dark:text-red-400" />}
|
|
title={nameOf(o.number) ?? o.name ?? fmtNumber(o.number)}
|
|
detail={when(c.startedAt)}
|
|
>
|
|
{o.number && (
|
|
<Button
|
|
variant="ghost"
|
|
className="text-link"
|
|
onClick={() => call(o.number!)}
|
|
aria-label={`Call back ${fmtNumber(o.number)}`}
|
|
>
|
|
<Phone className="size-4" />
|
|
</Button>
|
|
)}
|
|
</Row>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</Panel>
|
|
);
|
|
}
|