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

648 lines
22 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. WhatsApp-only
// deviation: the About card and modal carry the Meta-ToS risk disclosure
// (amber warn styling + triangle icon).
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 { CodePanel, PhoneForm, QrPanel, useLoginFlow } from './login';
import { Contacts } from './contacts';
import { useFirstAvatar } from './avatars';
import {
Avatar,
BackIcon,
CommandCard,
LogoutIcon,
Notice,
PhoneIcon,
QrIcon,
RefreshIcon,
Spinner,
StatusPill,
UserIcon,
WarningIcon,
} 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 ?? {};
// WhatsApp has no usernames — RemoteProfile carries phone (+ prefixed by
// the connector) and sometimes a business name.
if (profile.phone) return profile.phone.startsWith('+') ? profile.phone : `+${profile.phone}`;
if (login.name) return login.name;
return login.id;
};
// --- About card + modal -------------------------------------------------------
// WhatsApp-specific: the About surface doubles as the Meta-ToS risk
// disclosure. The card carries the amber warn styling + triangle icon so the
// «риски» half of the hybrid description is visible before opening; the
// modal leads with the amber callout. This copy is a legal requirement —
// keep it when restyling.
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">
<aside class="about-warn-callout" aria-label={t('warning.title')}>
<header class="about-warn-callout-head">
<span class="about-warn-callout-icon" aria-hidden="true">
<WarningIcon />
</span>
<h3 class="about-warn-callout-title">{t('warning.title')}</h3>
</header>
<p>{t('warning.body-1')}</p>
<p>
{t('warning.tos-label')}{' '}
<a href={t('warning.tos-url')} target="_blank" rel="noreferrer">
{t('warning.tos-url')}
</a>
</p>
</aside>
<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). «Контакты» 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.
// TRANSIENT_DISCONNECT is WhatsApp-specific «phone offline / keepalive
// lost» (connector phoneping.go) — it usually self-heals, so it gets its
// own copy WITHOUT the relink advice. Only genuinely broken sessions
// (BAD_CREDENTIALS, UNKNOWN_ERROR, …) get the suggest-relink strip.
const stateTransient = stateEvent === 'TRANSIENT_DISCONNECT';
const stateBad =
!stateTransient &&
!['', '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 WhatsApp contact store (reported up by
// Contacts once the list loads — the same pipeline as every other
// avatar);
// 3. a one-shot resolve of our own phone number.
const [selfContactMxc, setSelfContactMxc] = useState<string | undefined>(undefined);
const [selfResolvedMxc, setSelfResolvedMxc] = useState<string | undefined>(undefined);
useEffect(() => {
let alive = true;
client
// login.id is BARE phone digits (waid MakeUserLoginID) — the connector
// resolves phones only in international `+digits` form (whatsmeow
// IsOnWhatsApp contract; startchat.go normalizes JIDs the same way).
.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]);
// The connector's RemoteProfile carries the business name when there is
// one (login.go userToRemoteProfile equivalent) — prefer it for the
// headline and keep the phone as the secondary line when it would not
// just repeat the headline.
const headline = profile.name || login.name || loginHandle(login);
const phoneLine = profile.phone
? profile.phone.startsWith('+')
? profile.phone
: `+${profile.phone}`
: '';
const showPhone = phoneLine !== '' && phoneLine !== headline;
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>
{stateTransient ? <Notice tone="warn">{t('account.state-transient')}</Notice> : null}
{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">{showPhone ? <span>{phoneLine}</span> : null}</div>
</div>
</div>
<CommandCard
icon={<UserIcon />}
name={t('card.contacts.name')}
desc={t('card.contacts.desc')}
onClick={() => setView('contacts')}
/>
<CommandCard
icon={<WarningIcon />}
name={t('card.about.name')}
desc={t('card.about.desc')}
onClick={onOpenAbout}
warn
/>
{/* 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. the bridge device unlinked in WhatsApp's Settings → Linked
// 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') {
return (
<section class="section">
<PhoneForm 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 === 'code') {
return (
<section class="section">
<CodePanel code={flow.ui.code} 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={<WarningIcon />}
name={t('card.about.name')}
desc={t('card.about.desc')}
onClick={() => setAboutOpen(true)}
warn
/>
</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} />;
}