feat(bots): unify command-card chrome with left-side semantic icons and fold WA Meta-ToS warning into the AboutCard modal

This commit is contained in:
v.lagerev 2026-05-05 19:59:24 +03:00
parent 4559a89a31
commit 19f484308e
8 changed files with 375 additions and 244 deletions

View file

@ -46,6 +46,55 @@ const RefreshIcon = () => (
</svg> </svg>
); );
// Inline SVG info icon — leads the AboutCard. Shared shape with the
// Telegram widget. WhatsApp uses a triangle warning glyph instead
// because its About modal also carries a Meta-ToS risk disclosure.
const InfoIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="10" cy="10" r="7.5" />
<path d="M10 9.2 L10 14" stroke-linecap="round" />
<circle cx="10" cy="6.4" r="0.7" fill="currentColor" stroke="none" />
</svg>
);
// Three QR finder squares + a few module dots — leads the QR-login
// card. Same shape across all three bot widgets.
const QrIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="3" y="3" width="5" height="5" rx="0.6" />
<rect x="12" y="3" width="5" height="5" rx="0.6" />
<rect x="3" y="12" width="5" height="5" rx="0.6" />
<path
d="M12 12 H13.5 M15.5 12 H17 M12 14.5 H14 M16 14.5 H17 M12 17 H13.5 M15.5 17 H17"
stroke-linecap="round"
/>
</svg>
);
// Sign-out arrow leaving an open box — leads the destructive logout
// card. Open right side conveys «out of the session». Stays muted
// inside `.command-card.danger` so the rose accent remains a single
// accent on the title.
const LogoutIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M11 3.5 H4.5 V16.5 H11" stroke-linecap="round" stroke-linejoin="round" />
<line x1="9" y1="10" x2="17" y2="10" stroke-linecap="round" />
<path d="M14 7 L17 10 L14 13" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// Two chain links joined by a horizontal bar — leads the Discord-
// only Reconnect card. Visually distinct from RefreshIcon's circular
// arrows so the user can tell «re-establish bridge link» from
// «re-fetch status» at a glance.
const LinkIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M8 13 H5.5 a3 3 0 0 1 0 -6 H8" stroke-linecap="round" />
<path d="M12 7 H14.5 a3 3 0 0 1 0 6 H12" stroke-linecap="round" />
<line x1="7" y1="10" x2="13" y2="10" stroke-linecap="round" />
</svg>
);
// Linkifier — same heuristic as TG widget. // Linkifier — same heuristic as TG widget.
const URL_RE = /https?:\/\/[^\s)]+/g; const URL_RE = /https?:\/\/[^\s)]+/g;
@ -281,6 +330,9 @@ type AboutCardProps = {
const AboutCard = ({ t, onOpen }: AboutCardProps) => ( const AboutCard = ({ t, onOpen }: AboutCardProps) => (
<button class="command-card" type="button" onClick={onOpen}> <button class="command-card" type="button" onClick={onOpen}>
<span class="command-card-lead-icon" aria-hidden="true">
<InfoIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.about.name')}</div> <div class="command-card-name">{t('card.about.name')}</div>
<div class="command-card-desc">{t('card.about.desc')}</div> <div class="command-card-desc">{t('card.about.desc')}</div>
@ -405,6 +457,9 @@ const LogoutCard = ({ t, onConfirm }: LogoutCardProps) => {
return ( return (
<button class="command-card danger" type="button" onClick={() => setConfirming(true)}> <button class="command-card danger" type="button" onClick={() => setConfirming(true)}>
<span class="command-card-lead-icon" aria-hidden="true">
<LogoutIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.logout.name')}</div> <div class="command-card-name">{t('card.logout.name')}</div>
<div class="command-card-desc">{t('card.logout.desc')}</div> <div class="command-card-desc">{t('card.logout.desc')}</div>
@ -849,6 +904,9 @@ export function App({ bootstrap, api }: Props) {
) : null} ) : null}
<div class="command-grid"> <div class="command-grid">
<button class="command-card" type="button" onClick={onClickLoginQr}> <button class="command-card" type="button" onClick={onClickLoginQr}>
<span class="command-card-lead-icon" aria-hidden="true">
<QrIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.login-qr.name')}</div> <div class="command-card-name">{t('card.login-qr.name')}</div>
<div class="command-card-desc">{t('card.login-qr.desc')}</div> <div class="command-card-desc">{t('card.login-qr.desc')}</div>
@ -863,6 +921,9 @@ export function App({ bootstrap, api }: Props) {
onClick={onClickRefresh} onClick={onClickRefresh}
disabled={refreshing} disabled={refreshing}
> >
<span class="command-card-lead-icon" aria-hidden="true">
<RefreshIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.refresh.name')}</div> <div class="command-card-name">{t('card.refresh.name')}</div>
<div class="command-card-desc"> <div class="command-card-desc">
@ -870,7 +931,7 @@ export function App({ bootstrap, api }: Props) {
</div> </div>
</div> </div>
<span class="command-card-chevron" aria-hidden="true"> <span class="command-card-chevron" aria-hidden="true">
<RefreshIcon />
</span> </span>
</button> </button>
<AboutCard t={t} onOpen={() => setAboutOpen(true)} /> <AboutCard t={t} onOpen={() => setAboutOpen(true)} />
@ -946,6 +1007,9 @@ export function App({ bootstrap, api }: Props) {
* the same command-card chrome so it visually matches Login / * the same command-card chrome so it visually matches Login /
* Logout cards. */} * Logout cards. */}
<button class="command-card" type="button" onClick={onClickReconnect}> <button class="command-card" type="button" onClick={onClickReconnect}>
<span class="command-card-lead-icon" aria-hidden="true">
<LinkIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.reconnect.name')}</div> <div class="command-card-name">{t('card.reconnect.name')}</div>
<div class="command-card-desc">{t('card.reconnect.desc')}</div> <div class="command-card-desc">{t('card.reconnect.desc')}</div>

View file

@ -263,7 +263,29 @@ body {
display: block; display: block;
} }
.command-card.refreshing .command-card-chevron svg { /* Generic leading-icon slot every command-card carries a semantic
* left-side glyph (mirror of the right-side chevron). Picks up
* `currentColor` from the parent and stays muted by default;
* `.danger` deliberately does NOT colour the lead icon so the rose
* accent stays reserved for the title. */
.command-card-lead-icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.command-card-lead-icon svg {
width: 20px;
height: 20px;
display: block;
}
/* Spin the leading refresh icon while the card is in its `refreshing`
* in-flight state. The selector targets the lead slot since the
* refresh card moved its glyph from the chevron (right) to the lead
* slot (left) for parity with every other card. */
.command-card.refreshing .command-card-lead-icon svg {
animation: command-card-spin 0.8s linear infinite; animation: command-card-spin 0.8s linear infinite;
} }
@keyframes command-card-spin { @keyframes command-card-spin {

View file

@ -67,6 +67,55 @@ const RefreshIcon = () => (
</svg> </svg>
); );
// Inline SVG info icon — leads the AboutCard. Shared shape with the
// Discord widget so «How the bot works» reads the same across the
// catalog. WhatsApp uses a triangle warning glyph instead because its
// About modal also carries a Meta-ToS risk disclosure.
const InfoIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="10" cy="10" r="7.5" />
<path d="M10 9.2 L10 14" stroke-linecap="round" />
<circle cx="10" cy="6.4" r="0.7" fill="currentColor" stroke="none" />
</svg>
);
// Smartphone outline — leads the «Войти в Telegram» card. Same shape
// as the WhatsApp pairing-code card; both flows ask for a phone-side
// number, so the icon language stays consistent.
const PhoneIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="6" y="2.5" width="8" height="15" rx="1.6" />
<line x1="8.6" y1="14.5" x2="11.4" y2="14.5" stroke-linecap="round" />
</svg>
);
// Three QR finder squares + a sprinkle of module dots — leads every
// QR-login card. The finder pattern is the strongest «this is QR»
// visual cue; no need to draw a full code.
const QrIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="3" y="3" width="5" height="5" rx="0.6" />
<rect x="12" y="3" width="5" height="5" rx="0.6" />
<rect x="3" y="12" width="5" height="5" rx="0.6" />
<path
d="M12 12 H13.5 M15.5 12 H17 M12 14.5 H14 M16 14.5 H17 M12 17 H13.5 M15.5 17 H17"
stroke-linecap="round"
/>
</svg>
);
// Sign-out arrow leaving an open box — leads the destructive logout
// card. Open right side conveys «out of the session». Stays muted
// inside `.command-card.danger` so the rose accent is reserved for
// the title only (one accent per card).
const LogoutIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M11 3.5 H4.5 V16.5 H11" stroke-linecap="round" stroke-linejoin="round" />
<line x1="9" y1="10" x2="17" y2="10" stroke-linecap="round" />
<path d="M14 7 L17 10 L14 13" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// Linkifier: matches plain http/https URLs in transcript bodies. Bot replies // Linkifier: matches plain http/https URLs in transcript bodies. Bot replies
// regularly carry matrix.to URLs (success line includes one), and our M11 // regularly carry matrix.to URLs (success line includes one), and our M11
// scaffold previously rendered them as inert text. // scaffold previously rendered them as inert text.
@ -671,6 +720,9 @@ type AboutCardProps = {
// next to the button itself, not in a separate header chrome. // next to the button itself, not in a separate header chrome.
const AboutCard = ({ t, onOpen }: AboutCardProps) => ( const AboutCard = ({ t, onOpen }: AboutCardProps) => (
<button class="command-card" type="button" onClick={onOpen}> <button class="command-card" type="button" onClick={onOpen}>
<span class="command-card-lead-icon" aria-hidden="true">
<InfoIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.about.name')}</div> <div class="command-card-name">{t('card.about.name')}</div>
<div class="command-card-desc">{t('card.about.desc')}</div> <div class="command-card-desc">{t('card.about.desc')}</div>
@ -809,6 +861,9 @@ const LogoutCard = ({ loginId, t, onConfirm }: LogoutCardProps) => {
disabled={!loginId} disabled={!loginId}
title={!loginId ? t('card.logout.gated') : undefined} title={!loginId ? t('card.logout.gated') : undefined}
> >
<span class="command-card-lead-icon" aria-hidden="true">
<LogoutIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.logout.name')}</div> <div class="command-card-name">{t('card.logout.name')}</div>
<div class="command-card-desc">{t('card.logout.desc')}</div> <div class="command-card-desc">{t('card.logout.desc')}</div>
@ -1351,6 +1406,9 @@ export function App({ bootstrap, api }: Props) {
</span> </span>
<div class="command-grid"> <div class="command-grid">
<button class="command-card" type="button" onClick={onClickLogin}> <button class="command-card" type="button" onClick={onClickLogin}>
<span class="command-card-lead-icon" aria-hidden="true">
<PhoneIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.login.name')}</div> <div class="command-card-name">{t('card.login.name')}</div>
<div class="command-card-desc">{t('card.login.desc')}</div> <div class="command-card-desc">{t('card.login.desc')}</div>
@ -1364,6 +1422,9 @@ export function App({ bootstrap, api }: Props) {
* surface it as a primary action rather than burying it in a * surface it as a primary action rather than burying it in a
* sub-menu. Same card vocabulary as login-by-phone. */} * sub-menu. Same card vocabulary as login-by-phone. */}
<button class="command-card" type="button" onClick={onClickLoginQr}> <button class="command-card" type="button" onClick={onClickLoginQr}>
<span class="command-card-lead-icon" aria-hidden="true">
<QrIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.login-qr.name')}</div> <div class="command-card-name">{t('card.login-qr.name')}</div>
<div class="command-card-desc">{t('card.login-qr.desc')}</div> <div class="command-card-desc">{t('card.login-qr.desc')}</div>
@ -1376,13 +1437,18 @@ export function App({ bootstrap, api }: Props) {
* `refreshing` class + disabled attribute drive the in-flight * `refreshing` class + disabled attribute drive the in-flight
* spinner state disabled gates :hover/:focus via :not(:disabled) * spinner state disabled gates :hover/:focus via :not(:disabled)
* and so neutralises any WebView quirks that previously made the * and so neutralises any WebView quirks that previously made the
* click read as «button stuck on grey» after tap. */} * click read as «button stuck on grey» after tap. The spinner
* targets the leading icon slot (the chevron is back to ``
* for parity with every other card). */}
<button <button
class={`command-card${refreshing ? ' refreshing' : ''}`} class={`command-card${refreshing ? ' refreshing' : ''}`}
type="button" type="button"
onClick={onClickRefresh} onClick={onClickRefresh}
disabled={refreshing} disabled={refreshing}
> >
<span class="command-card-lead-icon" aria-hidden="true">
<RefreshIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.refresh.name')}</div> <div class="command-card-name">{t('card.refresh.name')}</div>
<div class="command-card-desc"> <div class="command-card-desc">
@ -1390,7 +1456,7 @@ export function App({ bootstrap, api }: Props) {
</div> </div>
</div> </div>
<span class="command-card-chevron" aria-hidden="true"> <span class="command-card-chevron" aria-hidden="true">
<RefreshIcon />
</span> </span>
</button> </button>
<AboutCard t={t} onOpen={() => setAboutOpen(true)} /> <AboutCard t={t} onOpen={() => setAboutOpen(true)} />

View file

@ -319,11 +319,33 @@ body {
display: block; display: block;
} }
/* Spin the chevron icon while the card is in its `refreshing` in-flight /* Generic leading-icon slot every command-card carries a semantic
* state. Combined with `disabled` (which dims the card to opacity 0.5 and * left-side glyph (mirror of the right-side chevron). Picks up
* gates :hover via :not(:disabled)), the spinner is the unambiguous «I'm * `currentColor` from the parent and stays muted by default; the
* working» signal no more guessing whether the click registered. */ * `.danger` modifier on logout deliberately does NOT colour the lead
.command-card.refreshing .command-card-chevron svg { * icon so the rose accent stays reserved for the title (one accent
* per card). */
.command-card-lead-icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.command-card-lead-icon svg {
width: 20px;
height: 20px;
display: block;
}
/* Spin the leading refresh icon while the card is in its `refreshing`
* in-flight state. Combined with `disabled` (which dims the card to
* opacity 0.5 and gates :hover via :not(:disabled)), the spinner is
* the unambiguous «I'm working» signal no more guessing whether the
* click registered. The selector targets the lead slot since the
* refresh card moved its glyph from the chevron (right) to the lead
* slot (left) for parity with every other card. */
.command-card.refreshing .command-card-lead-icon svg {
animation: command-card-spin 0.8s linear infinite; animation: command-card-spin 0.8s linear infinite;
} }
@keyframes command-card-spin { @keyframes command-card-spin {

View file

@ -55,6 +55,59 @@ const RefreshIcon = () => (
</svg> </svg>
); );
// Smartphone outline — leads the «Войти по номеру» card. Shared shape
// across TG (text-login) and WhatsApp (pairing-code login) since both
// flows boil down to «вы вводите номер с телефона».
const PhoneIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="6" y="2.5" width="8" height="15" rx="1.6" />
<line x1="8.6" y1="14.5" x2="11.4" y2="14.5" stroke-linecap="round" />
</svg>
);
// Three QR finder squares + a few module dots — leads every QR-login
// card. The finder pattern is the strongest «this is QR» visual cue;
// no need to draw a full code.
const QrIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="3" y="3" width="5" height="5" rx="0.6" />
<rect x="12" y="3" width="5" height="5" rx="0.6" />
<rect x="3" y="12" width="5" height="5" rx="0.6" />
<path
d="M12 12 H13.5 M15.5 12 H17 M12 14.5 H14 M16 14.5 H17 M12 17 H13.5 M15.5 17 H17"
stroke-linecap="round"
/>
</svg>
);
// Sign-out arrow leaving an open box — leads the destructive logout
// card. Open right side conveys «out of the session». Picks up the
// rose tint via `.command-card.danger` cascade only on the title;
// the lead icon stays muted so the rose stays a single accent.
const LogoutIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M11 3.5 H4.5 V16.5 H11" stroke-linecap="round" stroke-linejoin="round" />
<line x1="9" y1="10" x2="17" y2="10" stroke-linecap="round" />
<path d="M14 7 L17 10 L14 13" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// Triangle warning glyph — leads the WhatsApp-only AboutCard (which
// carries `command-card warn` for the amber outline) and re-appears
// inside the AboutModal's risk-disclosure callout. Stroke-only so it
// picks up the amber tint via `currentColor` in either context.
const WarningIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path
d="M10 3.2 L17.5 16.5 L2.5 16.5 Z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M10 8.5 L10 12" stroke-linecap="round" />
<circle cx="10" cy="14.2" r="0.7" fill="currentColor" stroke="none" />
</svg>
);
// Linkifier — same heuristic as TG / Discord widgets. // Linkifier — same heuristic as TG / Discord widgets.
const URL_RE = /https?:\/\/[^\s)]+/g; const URL_RE = /https?:\/\/[^\s)]+/g;
@ -583,8 +636,17 @@ type AboutCardProps = {
onOpen: () => void; onOpen: () => void;
}; };
// WhatsApp-only: AboutCard carries the `warn` modifier so the amber
// border + amber-tinted name signal that the modal behind it includes
// the Meta-ToS risk disclosure. The leading icon is the triangle
// WarningIcon (not the info-circle used by TG / Discord) for the
// same reason — the hybrid card description («о работе и рисках»)
// stays believable when the icon previews the «risks» half.
const AboutCard = ({ t, onOpen }: AboutCardProps) => ( const AboutCard = ({ t, onOpen }: AboutCardProps) => (
<button class="command-card" type="button" onClick={onOpen}> <button class="command-card warn" type="button" onClick={onOpen}>
<span class="command-card-lead-icon" aria-hidden="true">
<WarningIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.about.name')}</div> <div class="command-card-name">{t('card.about.name')}</div>
<div class="command-card-desc">{t('card.about.desc')}</div> <div class="command-card-desc">{t('card.about.desc')}</div>
@ -632,11 +694,29 @@ const AboutModal = ({ t, onClose }: AboutModalProps) => {
</button> </button>
</header> </header>
<div class="about-body"> <div class="about-body">
{/* WhatsApp-specific callout: surfaces the Meta-ToS ban risk {/* WhatsApp-specific Meta-ToS account-ban risk disclosure.
* for users who open the About modal without clicking the * Lives at the top of the About modal as an amber-tinted
* dedicated warning card. Shorter than the warning modal * block same content the dedicated WarningCard used to
* just a heads-up + pointer to the full text. */} * carry on the disconnected screen, folded in here so the
<p class="about-warn-callout">{t('warning.about-callout')}</p> * About card is the single info entry point (matches the
* TG / Discord shape; the amber block carries the «risks»
* half of the hybrid card description). */}
<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.body-2')}</p>
<p>
{t('warning.tos-label')}{' '}
<a href={t('warning.tos-url')} target="_blank" rel="noreferrer noopener">
{t('warning.tos-url')}
</a>
</p>
</aside>
<p>{t('about.body-1')}</p> <p>{t('about.body-1')}</p>
<p>{t('about.body-2')}</p> <p>{t('about.body-2')}</p>
<p>{t('about.body-3')}</p> <p>{t('about.body-3')}</p>
@ -658,121 +738,6 @@ const AboutModal = ({ t, onClose }: AboutModalProps) => {
); );
}; };
// --------------------------------------------------------------------------
// Warning card + modal (WhatsApp-specific Meta-ToS risk disclosure)
// --------------------------------------------------------------------------
//
// WhatsApp is the only bot in the Vojo catalog that carries a real
// account-loss risk for the user. Meta's WhatsApp ToS forbids
// connecting an account through unofficial clients (mautrix-whatsapp
// uses the same multi-device API as WhatsApp Web — technically
// standard, but Meta may treat it as a violation). Enforcement is
// unpredictable. Users who don't understand this risk before clicking
// «Войти» can lose their primary messenger.
//
// Two surfaces:
// 1. A dedicated card on the disconnected screen above the login
// cards. Amber tone, ⚠ glyph in the title — visible even if the
// user skips the About modal.
// 2. A short callout at the top of the About modal (handled in
// AboutModal above) so users who open About also see it.
// Inline triangle warning glyph. Stroke-only, picks up `currentColor`
// from the parent so it tints with the amber accent on the card.
const WarningIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path
d="M10 3.2 L17.5 16.5 L2.5 16.5 Z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M10 8.5 L10 12" stroke-linecap="round" />
<circle cx="10" cy="14.2" r="0.7" fill="currentColor" stroke="none" />
</svg>
);
type WarningCardProps = {
t: T;
onOpen: () => void;
};
const WarningCard = ({ t, onOpen }: WarningCardProps) => (
<button class="command-card warn" type="button" onClick={onOpen}>
<span class="command-card-warn-icon" aria-hidden="true">
<WarningIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.warning.name')}</div>
<div class="command-card-desc">{t('card.warning.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
);
type WarningModalProps = {
t: T;
onClose: () => void;
};
const WarningModal = ({ t, onClose }: WarningModalProps) => {
// Same Escape + backdrop-click pattern as AboutModal — no focus-trap
// library because the surface is small. The visible header glyph
// makes the modal's purpose unambiguous.
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('warning.title')}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div class="about-panel about-panel-warn">
<header class="about-header about-header-warn">
<span class="about-header-icon" aria-hidden="true">
<WarningIcon />
</span>
<h2 class="about-title">{t('warning.title')}</h2>
<button
type="button"
class="about-close-x"
onClick={onClose}
aria-label={t('warning.aria-close')}
>
×
</button>
</header>
<div class="about-body">
<p>{t('warning.body-1')}</p>
<p>{t('warning.body-2')}</p>
<p>
{t('warning.tos-label')}{' '}
<a href={t('warning.tos-url')} target="_blank" rel="noreferrer noopener">
{t('warning.tos-url')}
</a>
</p>
</div>
<div class="about-footer">
<button type="button" class="btn-primary" onClick={onClose}>
{t('warning.close')}
</button>
</div>
</div>
</div>
);
};
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Logout card with confirm-in-place // Logout card with confirm-in-place
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@ -833,6 +798,9 @@ const LogoutCard = ({ loginId, t, onConfirm }: LogoutCardProps) => {
disabled={!loginId} disabled={!loginId}
title={!loginId ? t('card.logout.gated') : undefined} title={!loginId ? t('card.logout.gated') : undefined}
> >
<span class="command-card-lead-icon" aria-hidden="true">
<LogoutIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.logout.name')}</div> <div class="command-card-name">{t('card.logout.name')}</div>
<div class="command-card-desc">{t('card.logout.desc')}</div> <div class="command-card-desc">{t('card.logout.desc')}</div>
@ -853,7 +821,6 @@ export function App({ bootstrap, api }: Props) {
const [transcript, setTranscript] = useState<TranscriptLine[]>([]); const [transcript, setTranscript] = useState<TranscriptLine[]>([]);
const [handshakeOk, setHandshakeOk] = useState(false); const [handshakeOk, setHandshakeOk] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false);
const [warningOpen, setWarningOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const seenEventIds = useRef(new Set<string>()); const seenEventIds = useRef(new Set<string>());
const [state, dispatch] = useReducer(loginReducer, initialLoginState); const [state, dispatch] = useReducer(loginReducer, initialLoginState);
@ -1313,17 +1280,18 @@ export function App({ bootstrap, api }: Props) {
{t('status.disconnected')} {t('status.disconnected')}
</span> </span>
<div class="command-grid"> <div class="command-grid">
{/* Warning card sits FIRST. Sized like the other cards
* (no full-width spanning) so the disconnected screen
* reads as a single uniform grid; the amber tint +
* icon carry the visual emphasis instead. */}
<WarningCard t={t} onOpen={() => setWarningOpen(true)} />
{/* Login order mirrors the Telegram widget: phone-flow {/* Login order mirrors the Telegram widget: phone-flow
* («Войти по номеру») first, QR second. Both are valid * («Войти по номеру») first, QR second. Both are valid
* primary paths; phone-flow is the more familiar entry * primary paths; phone-flow is the more familiar entry
* point for users coming from Telegram or used to * point for users coming from Telegram or used to
* SMS-style codes, so it leads. */} * SMS-style codes, so it leads. The Meta-ToS risk
* disclosure now lives inside the AboutCard modal as
* an amber callout the hybrid card description tells
* the user it's there. */}
<button class="command-card" type="button" onClick={onClickLoginPairing}> <button class="command-card" type="button" onClick={onClickLoginPairing}>
<span class="command-card-lead-icon" aria-hidden="true">
<PhoneIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.login-pairing.name')}</div> <div class="command-card-name">{t('card.login-pairing.name')}</div>
<div class="command-card-desc">{t('card.login-pairing.desc')}</div> <div class="command-card-desc">{t('card.login-pairing.desc')}</div>
@ -1333,6 +1301,9 @@ export function App({ bootstrap, api }: Props) {
</span> </span>
</button> </button>
<button class="command-card" type="button" onClick={onClickLoginQr}> <button class="command-card" type="button" onClick={onClickLoginQr}>
<span class="command-card-lead-icon" aria-hidden="true">
<QrIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.login-qr.name')}</div> <div class="command-card-name">{t('card.login-qr.name')}</div>
<div class="command-card-desc">{t('card.login-qr.desc')}</div> <div class="command-card-desc">{t('card.login-qr.desc')}</div>
@ -1347,6 +1318,9 @@ export function App({ bootstrap, api }: Props) {
onClick={onClickRefresh} onClick={onClickRefresh}
disabled={refreshing} disabled={refreshing}
> >
<span class="command-card-lead-icon" aria-hidden="true">
<RefreshIcon />
</span>
<div class="command-card-body"> <div class="command-card-body">
<div class="command-card-name">{t('card.refresh.name')}</div> <div class="command-card-name">{t('card.refresh.name')}</div>
<div class="command-card-desc"> <div class="command-card-desc">
@ -1354,7 +1328,7 @@ export function App({ bootstrap, api }: Props) {
</div> </div>
</div> </div>
<span class="command-card-chevron" aria-hidden="true"> <span class="command-card-chevron" aria-hidden="true">
<RefreshIcon />
</span> </span>
</button> </button>
<AboutCard t={t} onOpen={() => setAboutOpen(true)} /> <AboutCard t={t} onOpen={() => setAboutOpen(true)} />
@ -1461,7 +1435,6 @@ export function App({ bootstrap, api }: Props) {
) : null} ) : null}
{aboutOpen ? <AboutModal t={t} onClose={() => setAboutOpen(false)} /> : null} {aboutOpen ? <AboutModal t={t} onClose={() => setAboutOpen(false)} /> : null}
{warningOpen ? <WarningModal t={t} onClose={() => setWarningOpen(false)} /> : null}
<section class="section"> <section class="section">
<div ref={transcriptRef} class="transcript" role="log" aria-live="polite"> <div ref={transcriptRef} class="transcript" role="log" aria-live="polite">

View file

@ -20,21 +20,15 @@ export const EN: Record<StringKey, string> = {
'card.refresh.name': 'Refresh status', 'card.refresh.name': 'Refresh status',
'card.refresh.desc': 'Re-check whether WhatsApp is linked', 'card.refresh.desc': 'Re-check whether WhatsApp is linked',
'card.refresh.in-flight': 'Checking…', 'card.refresh.in-flight': 'Checking…',
'card.warning.name': 'Read before linking', 'warning.title': 'Important before linking WhatsApp',
'card.warning.desc': 'Important information about risks — tap to open',
'warning.title': 'Read before linking WhatsApp',
'warning.body-1': 'warning.body-1':
'Mautrix-whatsapp connects to your account through the same linked-device mechanism as WhatsApp Web. Technically a standard API — but unlike other messengers, WhatsApps terms of service explicitly forbid connecting through third-party clients, and Meta may ban your account for it.', 'Mautrix-whatsapp connects to your account through the same linked-device mechanism as WhatsApp Web. Technically a standard API — but unlike other messengers, WhatsApps terms of service explicitly forbid connecting through third-party clients, and Meta may ban your account for it.',
'warning.body-2': 'warning.body-2':
'WhatsApp bans are regular and unpredictable — Meta does not publish criteria. For some users the bridge works for years without issue; for others the account is banned within hours of linking.', 'WhatsApp bans are regular and unpredictable — Meta does not publish criteria. For some users the bridge works for years without issue; for others the account is banned within hours of linking.',
'warning.tos-label': 'WhatsApp terms of service:', 'warning.tos-label': 'WhatsApp terms of service:',
'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service', 'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service',
'warning.close': 'Got it',
'warning.aria-close': 'Close warning',
'warning.about-callout':
'⚠ Before linking WhatsApp, read the “Read before linking” card on the bots main screen.',
'card.about.name': 'How the WhatsApp bot works', 'card.about.name': 'How the WhatsApp bot works',
'card.about.desc': 'Sign-in, safety, and source code', 'card.about.desc': 'How it works and the risks — tap to read',
'about.title': 'About the WhatsApp bot', 'about.title': 'About the WhatsApp bot',
'about.body-1': 'about.body-1':
'This bot connects WhatsApp to Vojo. After sign-in, your private chats and groups from WhatsApp will appear in Vojos chat list, and replies from the Vojo app will be sent to your contacts as normal WhatsApp messages.', 'This bot connects WhatsApp to Vojo. After sign-in, your private chats and groups from WhatsApp will appear in Vojos chat list, and replies from the Vojo app will be sent to your contacts as normal WhatsApp messages.',

View file

@ -35,40 +35,27 @@ export const RU = {
'card.refresh.name': 'Обновить статус', 'card.refresh.name': 'Обновить статус',
'card.refresh.desc': 'Перепроверить, привязан ли WhatsApp', 'card.refresh.desc': 'Перепроверить, привязан ли WhatsApp',
'card.refresh.in-flight': 'Проверяю…', 'card.refresh.in-flight': 'Проверяю…',
// --- Warning card (WhatsApp-specific) --------------------------------- // --- About panel -------------------------------------------------------
// Карточка-предупреждение ставится ТОЛЬКО для WhatsApp в каталоге // WhatsApp-only Meta-ToS risk disclosure is folded into the About
// Vojo, потому что у WhatsApp ToS прямо запрещает подключение // modal as an amber callout at the top of the body. The AboutCard
// через сторонние клиенты и Meta это активно энфорсит. Сравнения с // itself carries `command-card warn` (amber border + amber name)
// Telegram/Discord В ИНТЕРФЕЙСЕ намеренно НЕТ: Telegram user ToS // and a triangle warning glyph in the lead slot — instead of the
// (telegram.org/tos) такого ограничения вообще не упоминает, а // info-circle TG / Discord use — so the «риски» half of the hybrid
// Discord — отдельный кейс. Делать сравнение в копии = вводить // description («о работе и рисках») is visible at a glance before
// юзера в заблуждение, поэтому warning-модалка говорит ТОЛЬКО про // the user opens the modal. TG / Discord get the plain «вход,
// WhatsApp/Meta и ссылается ТОЛЬКО на Meta ToS. // безопасность, исходный код» variant because they don't carry an
// account-loss risk in the same way (Telegram user ToS doesn't
// forbid third-party clients; Discord's restriction on self-bots
// lives in developer policies, not user ToS proper). The amber
// block keeps the unique WhatsApp framing without claiming anything
// about TG / Discord by comparison.
// //
// ToS reference: https://www.whatsapp.com/legal/terms-of-service // ToS reference for the body: https://www.whatsapp.com/legal/terms-of-service
// секция «Harm To WhatsApp Or Our Users» запрещает «software or // section «Harm To WhatsApp Or Our Users» forbids «software or
// APIs that function substantially the same as our Services» и // APIs that function substantially the same as our Services» and
// «accounts for our Services through unauthorized or automated // «accounts for our Services through unauthorized or automated
// means». // means».
// Triangle glyph lives in the icon slot to the LEFT — don't repeat
// it in the title text or it doubles up («⚠ ⚠ Прочтите...»).
'card.warning.name': 'Прочтите перед подключением',
// Two-line desc: hint + explicit «click here to read» so the user
// doesn't stare at the card wondering if it's interactive (the
// amber tone helps signal it's special, but doesn't tell you it's
// a button).
'card.warning.desc': 'Важная информация о рисках — нажмите, чтобы открыть',
'warning.title': 'Важно знать до подключения WhatsApp', 'warning.title': 'Важно знать до подключения WhatsApp',
// Два информационных параграфа: что технически делает мост и
// почему это под риском, и насколько риск реален. Сравнение
// с другими мессенджерами оставлено НЕЯВНЫМ («в отличие от других
// мессенджеров») — без явного перечисления TG/Discord, потому что
// у Telegram user ToS (telegram.org/tos) запрета на сторонние
// клиенты нет вообще, у Discord ToS тоже нет (запрет self-bot'ов
// живёт у них в developer policies, не в ToS proper). Прямой
// формальный запрет в ToS есть ТОЛЬКО у WhatsApp; общая фраза
// «в отличие от других мессенджеров» подчёркивает уникальность
// WhatsApp без неточностей в адрес конкретных сервисов.
'warning.body-1': 'warning.body-1':
'Mautrix-whatsapp подключает ваш аккаунт через тот же механизм связанных устройств, что и WhatsApp Web. Технически это стандартный API — но в отличие от других мессенджеров, условия использования WhatsApp прямо запрещают подключение через сторонние клиенты, и Meta может заблокировать аккаунт за это.', 'Mautrix-whatsapp подключает ваш аккаунт через тот же механизм связанных устройств, что и WhatsApp Web. Технически это стандартный API — но в отличие от других мессенджеров, условия использования WhatsApp прямо запрещают подключение через сторонние клиенты, и Meta может заблокировать аккаунт за это.',
'warning.body-2': 'warning.body-2':
@ -79,17 +66,12 @@ export const RU = {
// src/app/features/bots/BotWidgetEmbed.ts). // src/app/features/bots/BotWidgetEmbed.ts).
'warning.tos-label': 'Условия использования WhatsApp:', 'warning.tos-label': 'Условия использования WhatsApp:',
'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service', 'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service',
'warning.close': 'Понятно',
'warning.aria-close': 'Закрыть предупреждение',
// Короткий callout в About-модале — пойнтер на отдельную карточку
// с предупреждением. Объяснение «почему» живёт в самой модалке
// warning'а; здесь — только указание куда смотреть, чтобы About
// не дублировал warning по сути.
'warning.about-callout':
'⚠ Перед подключением WhatsApp прочтите карточку «Прочтите перед подключением» на главном экране бота.',
// --- About panel -------------------------------------------------------
'card.about.name': 'Как работает WhatsApp-бот', 'card.about.name': 'Как работает WhatsApp-бот',
'card.about.desc': 'Вход, безопасность и исходный код', // Hybrid copy: tells the user the modal carries BOTH the «как
// работает» explainer AND the Meta-ToS risk disclosure. «нажмите,
// чтобы прочесть» reinforces interactivity — the amber border +
// warning triangle help but the explicit verb seals it.
'card.about.desc': 'Информация о работе и рисках — нажмите, чтобы прочесть',
'about.title': 'О боте WhatsApp', 'about.title': 'О боте WhatsApp',
'about.body-1': 'about.body-1':
'Этот бот подключает WhatsApp к Vojo. После входа личные чаты и группы из WhatsApp появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в WhatsApp.', 'Этот бот подключает WhatsApp к Vojo. После входа личные чаты и группы из WhatsApp появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в WhatsApp.',

View file

@ -319,11 +319,14 @@ body {
display: block; display: block;
} }
/* Spin the chevron icon while the card is in its `refreshing` in-flight /* Spin the leading refresh icon while the card is in its `refreshing`
* state. Combined with `disabled` (which dims the card to opacity 0.5 and * in-flight state. Combined with `disabled` (which dims the card to
* gates :hover via :not(:disabled)), the spinner is the unambiguous «I'm * opacity 0.5 and gates :hover via :not(:disabled)), the spinner is
* working» signal no more guessing whether the click registered. */ * the unambiguous «I'm working» signal no more guessing whether the
.command-card.refreshing .command-card-chevron svg { * click registered. The selector targets the leading icon slot since
* the refresh card moved its glyph from the chevron (right) to the
* lead slot (left) for parity with every other card. */
.command-card.refreshing .command-card-lead-icon svg {
animation: command-card-spin 0.8s linear infinite; animation: command-card-spin 0.8s linear infinite;
} }
@keyframes command-card-spin { @keyframes command-card-spin {
@ -424,45 +427,45 @@ body {
border-color: var(--rose); border-color: var(--rose);
} }
/* Warning card WhatsApp-specific Meta-ToS account-ban risk disclosure. /* Generic leading-icon slot every command-card carries one as a
* Sized like every other card in the grid (auto-fill column on * left-side semantic glyph (mirror of the right-side chevron). The
* desktop, full-width on mobile) visually distinguished from peers * SVG picks up `currentColor` so it tints with the card's modifier
* by amber tint + border, NOT by spanning the full row. The earlier * muted by default, amber inside `.command-card.warn`, rose-ish
* full-width version was visually overweight on a wide desktop grid. * inheritance left intentionally OFF for `.danger` (only the title
* Amber tint on every layer of chrome (border, name, background) * goes rose; the lead icon stays muted to keep one accent per card). */
* without going as loud as `.danger` this isn't destructive, it's .command-card-lead-icon {
* «read this before you act». */ flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.command-card-lead-icon svg {
width: 20px;
height: 20px;
display: block;
}
/* Warn variant WhatsApp-only, applied to the AboutCard so the amber
* border, amber-tinted name, and amber lead icon together signal that
* the modal behind this card carries a Meta-ToS risk disclosure (in
* addition to the «how it works» copy). The hybrid card description
* («о работе и рисках») depends on this visual cue to feel honest. */
.command-card.warn { .command-card.warn {
/* Slight amber tint on the surface itself so the eye can't slide
* past the card on a quick scan; the border underlines it. */
background: rgba(212, 184, 138, 0.06); background: rgba(212, 184, 138, 0.06);
border-color: var(--amber); border-color: var(--amber);
} }
.command-card.warn .command-card-name { .command-card.warn .command-card-name {
color: var(--amber); color: var(--amber);
} }
.command-card.warn .command-card-lead-icon {
color: var(--amber);
}
:root[data-input='mouse'] .command-card.warn:hover:not(:disabled) { :root[data-input='mouse'] .command-card.warn:hover:not(:disabled) {
background: rgba(212, 184, 138, 0.12); background: rgba(212, 184, 138, 0.12);
border-color: var(--amber); border-color: var(--amber);
} }
/* Icon slot to the LEFT of the title block distinct from the chevron
* which is on the right. The triangle SVG picks up `currentColor` from
* the parent's amber tint so it stays in palette without a separate
* fill rule. */
.command-card-warn-icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--amber);
}
.command-card-warn-icon svg {
width: 22px;
height: 22px;
display: block;
}
/* Inline confirm-in-place body for the destructive logout card. The button /* Inline confirm-in-place body for the destructive logout card. The button
* group lives inside the same card frame no modal, no layout shift. */ * group lives inside the same card frame no modal, no layout shift. */
.command-card-confirm { .command-card-confirm {
@ -1061,43 +1064,48 @@ body {
border-top: 1px solid var(--divider); border-top: 1px solid var(--divider);
} }
/* WhatsApp-only: Meta-ToS callout that prepends the About body. Same /* WhatsApp-only: Meta-ToS risk-disclosure callout at the top of the
* tone as `.section-warn-banner` amber tint + border, distinct * About modal body. Amber tint + border distinguish it from the plain
* shape from the body paragraphs around it so it doesn't blend in. * paragraphs that follow same visual language the dedicated warning
* card used to carry on the disconnected screen, now folded inline.
* *
* Selector specificity note: `.about-body p { color: var(--text) }` * Body text inside the callout still picks up the about-body's
* (specificity 0,1,1) wins over a bare `.about-warn-callout` rule * `.about-body p` rule (default text colour), which is intentional:
* (0,1,0), so the callout's amber `color` would silently lose. Pin * only the title is amber-tinted, the paragraphs stay readable. */
* the callout selector inside `.about-body p.about-warn-callout` .about-warn-callout {
* (0,2,1) to outscore the descendant rule. Background and border background: rgba(212, 184, 138, 0.08);
* weren't affected because no competing rule sets them on `.about-body p`. */
.about-body p.about-warn-callout {
background: rgba(212, 184, 138, 0.10);
border: 1px solid var(--amber); border: 1px solid var(--amber);
border-radius: 8px; border-radius: 8px;
padding: 10px 12px; padding: 12px 14px;
color: var(--amber); display: flex;
flex-direction: column;
gap: 10px;
/* Keep the about-body's `gap: 12px` honoured (no extra margin). */ /* Keep the about-body's `gap: 12px` honoured (no extra margin). */
} }
/* Warning modal same chrome as About modal but with an amber-tinted .about-warn-callout-head {
* header to make the dialog's purpose visible at a glance. The icon display: flex;
* sits to the left of the title; the close-X stays on the right. */ align-items: center;
.about-panel-warn { gap: 10px;
border-color: var(--amber);
} }
.about-header-warn {
background: rgba(212, 184, 138, 0.08); .about-warn-callout-icon {
}
.about-header-icon {
flex-shrink: 0; flex-shrink: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--amber); color: var(--amber);
} }
.about-header-icon svg { .about-warn-callout-icon svg {
width: 22px; width: 20px;
height: 22px; height: 20px;
display: block; display: block;
} }
.about-warn-callout-title {
font-size: 15px;
font-weight: 600;
color: var(--amber);
margin: 0;
line-height: 1.3;
}