608 lines
20 KiB
TypeScript
608 lines
20 KiB
TypeScript
// Widget shell. Three surfaces driven by GET /v3/whoami:
|
||
//
|
||
// boot → handshake + first whoami round-trip
|
||
// disconnected → login action cards + the active login-flow form
|
||
// connected → the contacts list (single main surface); profile and
|
||
// session actions live in the Account modal behind the
|
||
// icon button next to the status pill
|
||
//
|
||
// The bridge is driven exclusively over its provisioning HTTP API
|
||
// (provisioning.ts) authenticated with MSC1960 OpenID credentials from the
|
||
// host. There is no transcript pane and no bot text-command parsing — every
|
||
// action reports its status inline, next to the control that fired it.
|
||
//
|
||
// Visual canon: «Боты» mockup at docs/design/new-direct-messages-design/
|
||
// project/stream-v2-dawn.jsx (BotsDesktop) — Dawn palette, fleet-violet
|
||
// accent, friendly product cards, no terminal styling.
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||
import type { WidgetBootstrap } from './bootstrap';
|
||
import { WidgetApi } from './widget-api';
|
||
import { ProvisioningClient, type WhoamiLogin } from './provisioning';
|
||
import { describeApiError } from './errors';
|
||
import { createT, type T } from './i18n';
|
||
import { CodeForm, PasswordForm, PhoneForm, QrPanel, useLoginFlow } from './login';
|
||
import { Contacts } from './contacts';
|
||
import { useFirstAvatar } from './avatars';
|
||
import {
|
||
Avatar,
|
||
BackIcon,
|
||
CommandCard,
|
||
InfoIcon,
|
||
LogoutIcon,
|
||
Notice,
|
||
PhoneIcon,
|
||
QrIcon,
|
||
RefreshIcon,
|
||
Spinner,
|
||
StatusPill,
|
||
UserIcon,
|
||
} from './ui';
|
||
|
||
type Props = {
|
||
bootstrap: WidgetBootstrap;
|
||
// Constructed in main.tsx BEFORE the first render so its postMessage
|
||
// listener is attached before the host's ClientWidgetApi fires the
|
||
// capabilities request on iframe `load`.
|
||
api: WidgetApi;
|
||
};
|
||
|
||
type Phase =
|
||
| { kind: 'boot' }
|
||
| { kind: 'error'; message: string }
|
||
| { kind: 'disconnected' }
|
||
| { kind: 'connected'; login: WhoamiLogin };
|
||
|
||
type NoticeState = { tone: 'error' | 'info'; text: string } | null;
|
||
|
||
const loginHandle = (login: WhoamiLogin): string => {
|
||
const profile = login.profile ?? {};
|
||
if (profile.username) return `@${profile.username}`;
|
||
if (login.name) return login.name;
|
||
if (profile.phone) return profile.phone.startsWith('+') ? profile.phone : `+${profile.phone}`;
|
||
return login.id;
|
||
};
|
||
|
||
// --- About card + modal -------------------------------------------------------
|
||
|
||
type AboutModalProps = { t: T; onClose: () => void };
|
||
|
||
const AboutModal = ({ t, onClose }: AboutModalProps) => {
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose();
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [onClose]);
|
||
|
||
return (
|
||
<div
|
||
class="about-overlay"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={t('about.title')}
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) onClose();
|
||
}}
|
||
>
|
||
<div class="about-panel">
|
||
<header class="about-header">
|
||
<h2 class="about-title">{t('about.title')}</h2>
|
||
<button
|
||
type="button"
|
||
class="about-close-x"
|
||
onClick={onClose}
|
||
aria-label={t('about.aria-close')}
|
||
>
|
||
×
|
||
</button>
|
||
</header>
|
||
<div class="about-body">
|
||
<p>{t('about.body-1')}</p>
|
||
<p>{t('about.body-2')}</p>
|
||
<p>{t('about.body-3')}</p>
|
||
<p>
|
||
{t('about.github-label')}{' '}
|
||
<a href={t('about.github-url')} target="_blank" rel="noreferrer">
|
||
{t('about.github-url')}
|
||
</a>
|
||
</p>
|
||
<p>{t('about.body-4')}</p>
|
||
</div>
|
||
<div class="about-footer">
|
||
<button type="button" class="btn-primary" onClick={onClose}>
|
||
{t('about.close')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --- Logout card (confirm-in-place) ---------------------------------------------
|
||
|
||
type LogoutCardProps = { t: T; onConfirm: () => Promise<void> };
|
||
|
||
const LogoutCard = ({ t, onConfirm }: LogoutCardProps) => {
|
||
const [confirming, setConfirming] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
// Sync double-submit guard for the microtask window between click and the
|
||
// disabled state landing in the DOM.
|
||
const inFlight = useRef(false);
|
||
|
||
if (confirming) {
|
||
return (
|
||
<div class="command-card danger">
|
||
<div class="command-card-confirm">
|
||
<span class="command-card-confirm-prompt">{t('card.logout.confirm-prompt')}</span>
|
||
<button
|
||
type="button"
|
||
class="command-card-confirm-yes"
|
||
disabled={submitting}
|
||
onClick={async () => {
|
||
if (inFlight.current) return;
|
||
inFlight.current = true;
|
||
setSubmitting(true);
|
||
try {
|
||
await onConfirm();
|
||
} finally {
|
||
inFlight.current = false;
|
||
setSubmitting(false);
|
||
setConfirming(false);
|
||
}
|
||
}}
|
||
>
|
||
{submitting ? <Spinner /> : null}
|
||
{t('card.logout.confirm-yes')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="command-card-confirm-no"
|
||
disabled={submitting}
|
||
onClick={() => setConfirming(false)}
|
||
>
|
||
{t('card.logout.confirm-no')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<CommandCard
|
||
icon={<LogoutIcon />}
|
||
name={t('card.logout.name')}
|
||
desc={t('card.logout.desc')}
|
||
onClick={() => setConfirming(true)}
|
||
danger
|
||
/>
|
||
);
|
||
};
|
||
|
||
// --- Connected view -------------------------------------------------------------
|
||
// The account surface IS the main connected screen (profile card + action
|
||
// cards, telegram-bot style). «Контакты» is one of the cards — it swaps the
|
||
// surface for the contacts list (search + rows) with a back affordance.
|
||
|
||
type ConnectedViewProps = {
|
||
login: WhoamiLogin;
|
||
client: ProvisioningClient;
|
||
api: WidgetApi;
|
||
t: T;
|
||
onNotice: (notice: NoticeState) => void;
|
||
onLogout: () => Promise<void>;
|
||
onOpenAbout: () => void;
|
||
};
|
||
|
||
const ConnectedView = ({
|
||
login,
|
||
client,
|
||
api,
|
||
t,
|
||
onNotice,
|
||
onLogout,
|
||
onOpenAbout,
|
||
}: ConnectedViewProps) => {
|
||
const [view, setView] = useState<'account' | 'contacts'>('account');
|
||
// Header-row refresh control for the contacts list: bumping the token
|
||
// re-fetches inside <Contacts>, which reports its in-flight state back so
|
||
// the button can spin/disable.
|
||
const [contactsReloadToken, setContactsReloadToken] = useState(0);
|
||
const [contactsLoading, setContactsLoading] = useState(false);
|
||
|
||
const profile = login.profile ?? {};
|
||
const stateEvent = login.state?.state_event ?? '';
|
||
// Transitional states (connecting/backfilling/starting) are healthy — only
|
||
// genuinely broken sessions (BAD_CREDENTIALS, UNKNOWN_ERROR, …) get the
|
||
// warning strip with the suggest-relink copy.
|
||
const stateBad = !['', 'CONNECTED', 'CONNECTING', 'BACKFILLING', 'STARTING'].includes(stateEvent);
|
||
|
||
// Own avatar comes from three sources of decreasing trust, and the first
|
||
// one can be EMPTY or point at dead media (whoami's profile.avatar is only
|
||
// filled once the bridge has met our own ghost), so the loader walks the
|
||
// candidate list and shows the first that actually downloads:
|
||
// 1. whoami profile.avatar;
|
||
// 2. our own entry in the Telegram address book (reported up by Contacts
|
||
// once the list loads — the same pipeline as every other avatar);
|
||
// 3. a one-shot resolve of our own Telegram id.
|
||
const [selfContactMxc, setSelfContactMxc] = useState<string | undefined>(undefined);
|
||
const [selfResolvedMxc, setSelfResolvedMxc] = useState<string | undefined>(undefined);
|
||
useEffect(() => {
|
||
let alive = true;
|
||
client
|
||
.resolveIdentifier(login.id)
|
||
.then((self) => {
|
||
if (alive && self?.avatar_url) setSelfResolvedMxc(self.avatar_url);
|
||
})
|
||
.catch(() => undefined);
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [client, login.id]);
|
||
const avatarSrc = useFirstAvatar(api, [profile.avatar, selfContactMxc, selfResolvedMxc]);
|
||
|
||
// Telegram's RemoteName prefers the username over the display name
|
||
// (connector userToRemoteProfile), so for the headline we go the other way
|
||
// round — the human name first — and drop the @username line when it would
|
||
// just repeat the headline.
|
||
const headline = profile.name || login.name || loginHandle(login);
|
||
const username = profile.username ?? '';
|
||
const showUsername =
|
||
username !== '' && headline.replace(/^@/, '').toLowerCase() !== username.toLowerCase();
|
||
|
||
return (
|
||
<section class="section">
|
||
<div class="section-recovery-row">
|
||
<StatusPill tone="connected">
|
||
{t('status.connected-as', { handle: loginHandle(login) })}
|
||
</StatusPill>
|
||
{view === 'contacts' ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
class={`icon-btn${contactsLoading ? ' spinning' : ''}`}
|
||
title={t('contacts.refresh')}
|
||
aria-label={t('contacts.refresh')}
|
||
onClick={() => setContactsReloadToken((token) => token + 1)}
|
||
disabled={contactsLoading}
|
||
>
|
||
<RefreshIcon />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="icon-btn icon-btn-back"
|
||
title={t('contacts.back')}
|
||
aria-label={t('contacts.back')}
|
||
onClick={() => setView('account')}
|
||
>
|
||
<BackIcon />
|
||
</button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
|
||
{stateBad ? (
|
||
<Notice tone="warn">
|
||
{t('account.state-bad', {
|
||
reason: login.state?.message || login.state?.error || stateEvent,
|
||
})}
|
||
</Notice>
|
||
) : null}
|
||
|
||
{view === 'account' ? (
|
||
<div class="command-grid">
|
||
{/* The profile card lives INSIDE the grid so it gets the same
|
||
* width/height budget as the action cards (a full-bleed block
|
||
* reads as a banner on wide web viewports). */}
|
||
<div class="account-card">
|
||
<Avatar name={headline} colorKey={login.id} size={48} src={avatarSrc} />
|
||
<div class="account-main">
|
||
<div class="account-name">{headline}</div>
|
||
<div class="account-handles">
|
||
{showUsername ? <span>@{username}</span> : null}
|
||
{profile.phone ? (
|
||
<span>{profile.phone.startsWith('+') ? profile.phone : `+${profile.phone}`}</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<CommandCard
|
||
icon={<UserIcon />}
|
||
name={t('card.contacts.name')}
|
||
desc={t('card.contacts.desc')}
|
||
onClick={() => setView('contacts')}
|
||
/>
|
||
<CommandCard
|
||
icon={<InfoIcon />}
|
||
name={t('card.about.name')}
|
||
desc={t('card.about.desc')}
|
||
onClick={onOpenAbout}
|
||
/>
|
||
{/* Destructive action sits last, after every constructive one. */}
|
||
<LogoutCard t={t} onConfirm={onLogout} />
|
||
</div>
|
||
) : (
|
||
<Contacts
|
||
client={client}
|
||
api={api}
|
||
t={t}
|
||
selfId={login.id}
|
||
onSelfAvatar={(mxc) => setSelfContactMxc(mxc)}
|
||
reloadToken={contactsReloadToken}
|
||
onLoadingChange={setContactsLoading}
|
||
onOpenRoom={(roomId) => api.openMatrixRoom(roomId)}
|
||
onError={(message) => onNotice({ tone: 'error', text: message })}
|
||
/>
|
||
)}
|
||
</section>
|
||
);
|
||
};
|
||
|
||
// --- Main (provisioning configured) ----------------------------------------------
|
||
|
||
type MainProps = {
|
||
api: WidgetApi;
|
||
client: ProvisioningClient;
|
||
t: T;
|
||
handshakeOk: boolean;
|
||
};
|
||
|
||
const Main = ({ api, client, t, handshakeOk }: MainProps) => {
|
||
const [phase, setPhase] = useState<Phase>({ kind: 'boot' });
|
||
const [notice, setNotice] = useState<NoticeState>(null);
|
||
const [aboutOpen, setAboutOpen] = useState(false);
|
||
const phaseRef = useRef(phase);
|
||
phaseRef.current = phase;
|
||
|
||
const refresh = useCallback(
|
||
async (opts: { quiet?: boolean } = {}): Promise<void> => {
|
||
try {
|
||
const whoami = await client.whoami();
|
||
const login = whoami.logins?.[0];
|
||
setPhase(login ? { kind: 'connected', login } : { kind: 'disconnected' });
|
||
} catch (err) {
|
||
// Quiet refreshes (visibility change, post-action recheck) keep the
|
||
// last good surface; only the boot path degrades to the error screen.
|
||
if (opts.quiet && phaseRef.current.kind !== 'boot') return;
|
||
setPhase({ kind: 'error', message: describeApiError(err, t) });
|
||
}
|
||
},
|
||
[client, t]
|
||
);
|
||
|
||
const flow = useLoginFlow(client, t, {
|
||
onComplete: () => {
|
||
setNotice({ tone: 'info', text: t('notice.login-success') });
|
||
void refresh();
|
||
},
|
||
onError: (message) => setNotice({ tone: 'error', text: message }),
|
||
});
|
||
const flowUiKind = flow.ui.kind;
|
||
|
||
// First whoami once the widget-api handshake completes.
|
||
useEffect(() => {
|
||
if (handshakeOk) void refresh();
|
||
}, [handshakeOk, refresh]);
|
||
|
||
// Re-check on tab return — bridge state can change from another device
|
||
// (e.g. session revoked in Telegram's Settings → Devices). Never during an
|
||
// active login flow: stomping the form mid-entry would lose user input.
|
||
useEffect(() => {
|
||
const onVisible = () => {
|
||
if (document.visibilityState !== 'visible') return;
|
||
if (flowUiKind !== 'idle') return;
|
||
if (phaseRef.current.kind === 'boot') return;
|
||
void refresh({ quiet: true });
|
||
};
|
||
document.addEventListener('visibilitychange', onVisible);
|
||
return () => document.removeEventListener('visibilitychange', onVisible);
|
||
}, [refresh, flowUiKind]);
|
||
|
||
// Success notices fade on their own; errors stay until dismissed.
|
||
useEffect(() => {
|
||
if (!notice || notice.tone !== 'info') return undefined;
|
||
const timer = window.setTimeout(() => setNotice(null), 6000);
|
||
return () => window.clearTimeout(timer);
|
||
}, [notice]);
|
||
|
||
const logout = useCallback(async () => {
|
||
if (phaseRef.current.kind !== 'connected') return;
|
||
try {
|
||
await client.logout(phaseRef.current.login.id);
|
||
setNotice({ tone: 'info', text: t('notice.logged-out') });
|
||
} catch (err) {
|
||
setNotice({ tone: 'error', text: describeApiError(err, t) });
|
||
}
|
||
await refresh();
|
||
}, [client, refresh, t]);
|
||
|
||
const [refreshingCard, setRefreshingCard] = useState(false);
|
||
const refreshCard = useCallback(async () => {
|
||
if (refreshingCard) return;
|
||
setRefreshingCard(true);
|
||
const start = Date.now();
|
||
await refresh();
|
||
const elapsed = Date.now() - start;
|
||
window.setTimeout(() => setRefreshingCard(false), Math.max(0, 500 - elapsed));
|
||
}, [refresh, refreshingCard]);
|
||
|
||
const renderDisconnected = () => {
|
||
if (flow.ui.kind === 'form') {
|
||
const FormComponent =
|
||
flow.ui.form === 'phone' ? PhoneForm : flow.ui.form === 'code' ? CodeForm : PasswordForm;
|
||
return (
|
||
<section class="section">
|
||
<FormComponent flow={flow} t={t} />
|
||
</section>
|
||
);
|
||
}
|
||
if (flow.ui.kind === 'qr') {
|
||
return (
|
||
<section class="section">
|
||
<QrPanel url={flow.ui.url} t={t} onCancel={flow.cancel} />
|
||
</section>
|
||
);
|
||
}
|
||
if (flow.ui.kind === 'starting') {
|
||
return (
|
||
<section class="section">
|
||
<div class="section-recovery-row">
|
||
<StatusPill tone="checking">
|
||
{flow.ui.flow === 'qr' ? t('auth-card.qr.preparing') : t('status.checking')}
|
||
</StatusPill>
|
||
<button type="button" class="recovery-action" onClick={flow.cancel}>
|
||
{t('auth-card.cancel')}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
return (
|
||
<section class="section">
|
||
<StatusPill tone="disconnected">{t('status.disconnected')}</StatusPill>
|
||
<div class="command-grid">
|
||
<CommandCard
|
||
icon={<PhoneIcon />}
|
||
name={t('card.login.name')}
|
||
desc={t('card.login.desc')}
|
||
onClick={() => flow.start('phone')}
|
||
/>
|
||
<CommandCard
|
||
icon={<QrIcon />}
|
||
name={t('card.login-qr.name')}
|
||
desc={t('card.login-qr.desc')}
|
||
onClick={() => flow.start('qr')}
|
||
/>
|
||
<CommandCard
|
||
icon={<RefreshIcon />}
|
||
name={t('card.refresh.name')}
|
||
desc={refreshingCard ? t('card.refresh.in-flight') : t('card.refresh.desc')}
|
||
onClick={() => void refreshCard()}
|
||
disabled={refreshingCard}
|
||
spinning={refreshingCard}
|
||
/>
|
||
<CommandCard
|
||
icon={<InfoIcon />}
|
||
name={t('card.about.name')}
|
||
desc={t('card.about.desc')}
|
||
onClick={() => setAboutOpen(true)}
|
||
/>
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div class="app">
|
||
{notice ? (
|
||
<div class="notice-slot">
|
||
<Notice
|
||
tone={notice.tone === 'info' ? 'info' : 'error'}
|
||
onDismiss={() => setNotice(null)}
|
||
>
|
||
{notice.text}
|
||
</Notice>
|
||
</div>
|
||
) : null}
|
||
|
||
{phase.kind === 'boot' ? (
|
||
<section class="section">
|
||
<StatusPill tone="checking">
|
||
{handshakeOk ? t('status.checking') : t('boot.connecting')}
|
||
</StatusPill>
|
||
</section>
|
||
) : null}
|
||
|
||
{phase.kind === 'error' ? (
|
||
<section class="section">
|
||
<div class="section-recovery-row">
|
||
<StatusPill tone="checking">{phase.message}</StatusPill>
|
||
<button type="button" class="recovery-action" onClick={() => void refresh()}>
|
||
<RefreshIcon />
|
||
{t('error.retry')}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{phase.kind === 'disconnected' ? renderDisconnected() : null}
|
||
|
||
{phase.kind === 'connected' ? (
|
||
<ConnectedView
|
||
login={phase.login}
|
||
client={client}
|
||
api={api}
|
||
t={t}
|
||
onNotice={setNotice}
|
||
onLogout={logout}
|
||
onOpenAbout={() => setAboutOpen(true)}
|
||
/>
|
||
) : null}
|
||
|
||
{aboutOpen ? <AboutModal t={t} onClose={() => setAboutOpen(false)} /> : null}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --- App root --------------------------------------------------------------------
|
||
|
||
export function App({ bootstrap, api }: Props) {
|
||
const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme);
|
||
const [handshakeOk, setHandshakeOk] = useState(false);
|
||
const t = useMemo(() => createT(bootstrap.clientLanguage), [bootstrap.clientLanguage]);
|
||
|
||
useEffect(() => {
|
||
document.documentElement.dataset.theme = theme;
|
||
}, [theme]);
|
||
|
||
useEffect(() => {
|
||
api.on('ready', () => setHandshakeOk(true));
|
||
api.on('themeChange', (name) => setTheme(name));
|
||
return () => api.dispose();
|
||
// `api` is a module-level singleton owned by main.tsx; the effect
|
||
// intentionally runs once at mount.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Capture-phase interceptor for `<a target="_blank">` — cross-origin
|
||
// iframes inside Capacitor's Android WebView silently drop those clicks,
|
||
// so we route them through the host's openExternalUrl side-channel.
|
||
// Modifier-clicks pass through for desktop new-tab behavior.
|
||
useEffect(() => {
|
||
const onClick = (e: MouseEvent) => {
|
||
const anchor = (e.target as HTMLElement | null)?.closest?.(
|
||
'a[target="_blank"]'
|
||
) as HTMLAnchorElement | null;
|
||
if (!anchor?.href) return;
|
||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||
e.preventDefault();
|
||
api.openExternalUrl(anchor.href);
|
||
};
|
||
document.addEventListener('click', onClick, true);
|
||
return () => document.removeEventListener('click', onClick, true);
|
||
}, [api]);
|
||
|
||
const client = useMemo(
|
||
() =>
|
||
bootstrap.provisioningUrl
|
||
? new ProvisioningClient(bootstrap.provisioningUrl, bootstrap.userId, () =>
|
||
api.getOpenIdCredentials()
|
||
)
|
||
: null,
|
||
[api, bootstrap.provisioningUrl, bootstrap.userId]
|
||
);
|
||
|
||
if (!client) {
|
||
return (
|
||
<div class="app">
|
||
<div class="error-banner">
|
||
<strong>{t('config.missing.title')}</strong>
|
||
{t('config.missing.body')}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return <Main api={api} client={client} t={t} handshakeOk={handshakeOk} />;
|
||
}
|