// 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 ( ); }; // --- Logout card (confirm-in-place) --------------------------------------------- type LogoutCardProps = { t: T; onConfirm: () => Promise }; 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 (
{t('card.logout.confirm-prompt')}
); } return ( } 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; 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 , 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(undefined); const [selfResolvedMxc, setSelfResolvedMxc] = useState(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 (
{t('status.connected-as', { handle: loginHandle(login) })} {view === 'contacts' ? ( <> ) : null}
{stateBad ? ( {t('account.state-bad', { reason: login.state?.message || login.state?.error || stateEvent, })} ) : null} {view === 'account' ? (
{/* 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). */} } name={t('card.contacts.name')} desc={t('card.contacts.desc')} onClick={() => setView('contacts')} /> } name={t('card.about.name')} desc={t('card.about.desc')} onClick={onOpenAbout} /> {/* Destructive action sits last, after every constructive one. */}
) : ( setSelfContactMxc(mxc)} reloadToken={contactsReloadToken} onLoadingChange={setContactsLoading} onOpenRoom={(roomId) => api.openMatrixRoom(roomId)} onError={(message) => onNotice({ tone: 'error', text: message })} /> )}
); }; // --- Main (provisioning configured) ---------------------------------------------- type MainProps = { api: WidgetApi; client: ProvisioningClient; t: T; handshakeOk: boolean; }; const Main = ({ api, client, t, handshakeOk }: MainProps) => { const [phase, setPhase] = useState({ kind: 'boot' }); const [notice, setNotice] = useState(null); const [aboutOpen, setAboutOpen] = useState(false); const phaseRef = useRef(phase); phaseRef.current = phase; const refresh = useCallback( async (opts: { quiet?: boolean } = {}): Promise => { 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 (
); } if (flow.ui.kind === 'qr') { return (
); } if (flow.ui.kind === 'starting') { return (
{flow.ui.flow === 'qr' ? t('auth-card.qr.preparing') : t('status.checking')}
); } return (
{t('status.disconnected')}
} name={t('card.login.name')} desc={t('card.login.desc')} onClick={() => flow.start('phone')} /> } name={t('card.login-qr.name')} desc={t('card.login-qr.desc')} onClick={() => flow.start('qr')} /> } name={t('card.refresh.name')} desc={refreshingCard ? t('card.refresh.in-flight') : t('card.refresh.desc')} onClick={() => void refreshCard()} disabled={refreshingCard} spinning={refreshingCard} /> } name={t('card.about.name')} desc={t('card.about.desc')} onClick={() => setAboutOpen(true)} />
); }; return (
{notice ? (
setNotice(null)} > {notice.text}
) : null} {phase.kind === 'boot' ? (
{handshakeOk ? t('status.checking') : t('boot.connecting')}
) : null} {phase.kind === 'error' ? (
{phase.message}
) : null} {phase.kind === 'disconnected' ? renderDisconnected() : null} {phase.kind === 'connected' ? ( setAboutOpen(true)} /> ) : null} {aboutOpen ? setAboutOpen(false)} /> : null}
); }; // --- 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 `` — 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 (
{t('config.missing.title')} {t('config.missing.body')}
); } return
; }