// 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 (
{
if (e.target === e.currentTarget) onClose();
}}
>
);
};
// --- 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 (
{/* 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). */}