vojo/apps/widget-telegram/src/App.tsx

608 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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} />;
}