feat(bots): polish the Telegram bot widget UI and fix Android WebView sticky-hover via pointerType-based input-mode detection

This commit is contained in:
heaven 2026-05-04 18:34:51 +03:00
parent b2f3b668c5
commit e46bba2f7d
9 changed files with 514 additions and 179 deletions

View file

@ -488,6 +488,95 @@ const PasswordForm = ({ state, t, dispatch, send, sendCancel }: FormProps) => {
); );
}; };
// --------------------------------------------------------------------------
// About card + modal
// --------------------------------------------------------------------------
type AboutCardProps = {
t: T;
onOpen: () => void;
};
// Click-target sibling of the login/logout cards. Lives in the widget
// (not in the host hero) so it sits visually adjacent to the action it
// explains — a user evaluating «should I sign in?» reads the about copy
// next to the button itself, not in a separate header chrome.
const AboutCard = ({ t, onOpen }: AboutCardProps) => (
<button class="command-card" type="button" onClick={onOpen}>
<div class="command-card-body">
<div class="command-card-name">{t('card.about.name')}</div>
<div class="command-card-desc">{t('card.about.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
);
type AboutModalProps = {
t: T;
onClose: () => void;
};
const AboutModal = ({ t, onClose }: AboutModalProps) => {
// ESC closes — matches the standard «modal as transient overlay» pattern
// and keeps a fast escape hatch for keyboard users without focus-trap
// machinery (the widget is a small surface; trap-style focus management
// would be heavier than the panel deserves).
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) => {
// Backdrop click closes; a click that started inside the panel
// bubbles up here too — guard by comparing currentTarget.
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 with confirm-in-place // Logout card with confirm-in-place
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@ -571,6 +660,14 @@ export function App({ bootstrap, api }: Props) {
const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme); const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme);
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);
// True while a `list-logins` probe is in flight from a refresh-card click.
// Drives the button's `disabled` + spinner state so the user gets explicit
// «I'm working» feedback during the round-trip — the previous design left
// the click unacknowledged (the only signal was a transcript line below
// the fold), and the bare card surface combined with various WebView
// hover/focus quirks read as «button stuck on grey».
const [refreshing, setRefreshing] = useState(false);
const apiRef = useRef<WidgetApi | null>(api); const apiRef = useRef<WidgetApi | null>(api);
const seenEventIds = useRef(new Set<string>()); const seenEventIds = useRef(new Set<string>());
const [state, dispatch] = useReducer(loginReducer, initialLoginState); const [state, dispatch] = useReducer(loginReducer, initialLoginState);
@ -839,11 +936,28 @@ export function App({ bootstrap, api }: Props) {
} }
}, [sendBare]); }, [sendBare]);
const onClickRefresh = useCallback(() => { const onClickRefresh = useCallback(async () => {
sendBare('list-logins').catch(() => { if (refreshing) return;
setRefreshing(true);
const start = Date.now();
try {
await sendBare('list-logins');
} catch {
/* transcript carries the failure */ /* transcript carries the failure */
}
// 500 ms minimum visible loading state. `sendBare` typically resolves
// in <100 ms when the transport is healthy — without a min-hold the
// browser may not even paint a single frame of the disabled state, so
// the click goes visually unacknowledged. The transcript line is the
// only other signal and it sits below the fold on phone viewports.
const elapsed = Date.now() - start;
if (elapsed < 500) {
await new Promise<void>((resolve) => {
window.setTimeout(resolve, 500 - elapsed);
}); });
}, [sendBare]); }
setRefreshing(false);
}, [refreshing, sendBare]);
// Optimistic logging_out + recovery on send failure: refire list-logins // Optimistic logging_out + recovery on send failure: refire list-logins
// so the reducer recalibrates from the bridge's truth instead of leaving // so the reducer recalibrates from the bridge's truth instead of leaving
@ -876,10 +990,10 @@ export function App({ bootstrap, api }: Props) {
<div class="app"> <div class="app">
{/* Hero is OWNED BY THE HOST (BotShell + BotShellHero). The widget no {/* Hero is OWNED BY THE HOST (BotShell + BotShellHero). The widget no
* longer renders an avatar/name/handle/description block the host * longer renders an avatar/name/handle/description block the host
* panel above the iframe carries that information, with the * panel above the iframe carries that information plus the
* «Настроить» dropdown that controls show-chat / mark-read / * three-dots menu (show-chat / mark-read / notifications /
* notifications / leave-room. The widget body starts with the * leave-room). «О боте» lives HERE in the widget body so it sits
* action-relevant section for the current state. */} * adjacent to the login/logout actions it explains. */}
{handshakeOk && state.kind === 'unknown' ? ( {handshakeOk && state.kind === 'unknown' ? (
<section class="section"> <section class="section">
@ -901,15 +1015,14 @@ export function App({ bootstrap, api }: Props) {
{handshakeOk && state.kind === 'disconnected' ? ( {handshakeOk && state.kind === 'disconnected' ? (
<section class="section"> <section class="section">
{/* Status pill replaces the section header the pill itself {/* Status pill describes state («Telegram не привязан»), command
* carries the section's identity («Войдите в Telegram» says * cards below carry the actions. Earlier copy made the pill an
* what this surface is for and what state we're in, so a * imperative («Войдите в Telegram»), which duplicated the login
* separate «Подключение» label was redundant). */} * card's title sitting directly beneath it. */}
<span class="section-status disconnected" role="status"> <span class="section-status disconnected" role="status">
<span class="dot" /> <span class="dot" />
{t('status.disconnected')} {t('status.disconnected')}
</span> </span>
<div class="connect-row">
<div class="command-grid"> <div class="command-grid">
<button class="command-card" type="button" onClick={onClickLogin}> <button class="command-card" type="button" onClick={onClickLogin}>
<div class="command-card-body"> <div class="command-card-body">
@ -920,17 +1033,29 @@ export function App({ bootstrap, api }: Props) {
</span> </span>
</button> </button>
</div> {/* Refresh as a peer card to login (same size + style). The
* `refreshing` class + disabled attribute drive the in-flight
* spinner state disabled gates :hover/:focus via :not(:disabled)
* and so neutralises any WebView quirks that previously made the
* click read as «button stuck on grey» after tap. */}
<button <button
class={`command-card${refreshing ? ' refreshing' : ''}`}
type="button" type="button"
class="refresh-button"
onClick={onClickRefresh} onClick={onClickRefresh}
aria-label={t('card.refresh.aria')} disabled={refreshing}
> >
<RefreshIcon /> <div class="command-card-body">
</button> <div class="command-card-name">{t('card.refresh.name')}</div>
<div class="command-card-desc">
{refreshing ? t('card.refresh.in-flight') : t('card.refresh.desc')}
</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
<RefreshIcon />
</span>
</button>
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
</div> </div>
<p class="hint">{t('landing.hint')}</p>
</section> </section>
) : null} ) : null}
@ -998,13 +1123,15 @@ export function App({ bootstrap, api }: Props) {
</button> </button>
</div> </div>
)} )}
<p class="hint">{t('auth-card.code.privacy-hint-history')}</p>
<div class="command-grid"> <div class="command-grid">
<LogoutCard loginId={state.loginId} t={t} onConfirm={onConfirmLogout} /> <LogoutCard loginId={state.loginId} t={t} onConfirm={onConfirmLogout} />
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
</div> </div>
</section> </section>
) : null} ) : null}
{aboutOpen ? <AboutModal t={t} onClose={() => setAboutOpen(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">
{transcript.length === 0 ? ( {transcript.length === 0 ? (

View file

@ -5,15 +5,32 @@ import type { StringKey } from './ru';
export const EN: Record<StringKey, string> = { export const EN: Record<StringKey, string> = {
'status.unknown': 'Checking status…', 'status.unknown': 'Checking status…',
'status.disconnected': 'Sign in to Telegram', 'status.disconnected': 'Telegram not linked',
'status.connected': 'Telegram linked', 'status.connected': 'Telegram linked',
'status.connected-as': 'Telegram linked as {handle}', 'status.connected-as': 'Telegram linked as {handle}',
'status.logging-out': 'Signing out…', 'status.logging-out': 'Signing out…',
'card.login.name': '/login', 'card.login.name': 'Sign in to Telegram',
'card.login.desc': 'By phone number', 'card.login.desc': 'By phone number, with an SMS code',
'card.refresh.aria': 'Refresh status', 'card.refresh.aria': 'Refresh status',
'card.refresh.label': 'Refresh status', 'card.refresh.label': 'Refresh status',
'landing.hint': 'The bot replies in this chat — forms appear below.', 'card.refresh.name': 'Refresh status',
'card.refresh.desc': 'Re-check whether Telegram is linked',
'card.refresh.in-flight': 'Checking…',
'card.about.name': 'How the Telegram bot works',
'card.about.desc': 'Sign-in, safety, and source code',
'about.title': 'About the Telegram bot',
'about.body-1':
'This bot connects Telegram to Vojo. After sign-in, your private chats and groups from Telegram will appear in Vojos chat list, and replies from the Vojo app will be sent to your contacts as normal Telegram messages.',
'about.body-2':
'Sign-in uses your phone number and the code from Telegram, just like signing in on a new device. If you have two-step verification enabled, Telegram will also ask for your cloud password.',
'about.body-3':
'The connection runs through the open-source mautrix-telegram bridge. It creates a Telegram session on the Vojo server and uses it to connect Telegram with your Vojo account: receive messages from Telegram and send your replies back.',
'about.github-label': 'The bridge source code is public on GitHub:',
'about.github-url': 'https://github.com/mautrix/telegram',
'about.body-4':
'You can revoke access at any time — either with the “Sign out of Telegram” button here, or inside Telegram itself under Settings → Devices.',
'about.close': 'Close',
'about.aria-close': 'Close “About this bot”',
'auth-card.phone.title': 'Phone login', 'auth-card.phone.title': 'Phone login',
'auth-card.phone.label': 'Phone number', 'auth-card.phone.label': 'Phone number',
'auth-card.phone.placeholder': '+15551234567', 'auth-card.phone.placeholder': '+15551234567',
@ -26,8 +43,6 @@ export const EN: Record<StringKey, string> = {
'auth-card.code.submit': 'Confirm', 'auth-card.code.submit': 'Confirm',
'auth-card.code.privacy-hint': 'auth-card.code.privacy-hint':
'The Telegram code is visible in the room history — you can clear it manually.', 'The Telegram code is visible in the room history — you can clear it manually.',
'auth-card.code.privacy-hint-history':
'The code you entered is still in the room history — clear it manually if you want.',
'auth-card.password.title': 'Telegram cloud password', 'auth-card.password.title': 'Telegram cloud password',
'auth-card.password.hint': 'auth-card.password.hint':
'Your account has two-factor authentication enabled. Enter your Telegram cloud password — this is not your Vojo password.', 'Your account has two-factor authentication enabled. Enter your Telegram cloud password — this is not your Vojo password.',
@ -50,8 +65,8 @@ export const EN: Record<StringKey, string> = {
'The bot does not recognise this command — check the prefix in config.json.', 'The bot does not recognise this command — check the prefix in config.json.',
'auth-error.start-failed': 'Failed to start login: {reason}', 'auth-error.start-failed': 'Failed to start login: {reason}',
'auth-error.prepare-failed': 'Failed to prepare login: {reason}', 'auth-error.prepare-failed': 'Failed to prepare login: {reason}',
'card.logout.name': '/logout', 'card.logout.name': 'Sign out of Telegram',
'card.logout.desc': 'Sign out of Telegram', 'card.logout.desc': 'End the session for this account',
'card.logout.confirm-prompt': 'Sign out for real?', 'card.logout.confirm-prompt': 'Sign out for real?',
'card.logout.confirm-yes': 'Sign out', 'card.logout.confirm-yes': 'Sign out',
'card.logout.confirm-no': 'Cancel', 'card.logout.confirm-no': 'Cancel',

View file

@ -13,19 +13,48 @@
export const RU = { export const RU = {
// --- Inline section status --------------------------------------------- // --- Inline section status ---------------------------------------------
// Status pill mirrors the connected pill («Telegram привязан»). Earlier
// copy used «Войдите в Telegram», which read as a duplicate of the login
// card sitting directly below — the pill should describe state, the
// card should carry the action.
'status.unknown': 'Проверка статуса…', 'status.unknown': 'Проверка статуса…',
'status.disconnected': 'Войдите в Telegram', 'status.disconnected': 'Telegram не привязан',
'status.connected': 'Telegram привязан', 'status.connected': 'Telegram привязан',
'status.connected-as': 'Telegram привязан как {handle}', 'status.connected-as': 'Telegram привязан как {handle}',
'status.logging-out': 'Завершение сеанса…', 'status.logging-out': 'Завершение сеанса…',
// --- Section headers --------------------------------------------------- // --- Section headers ---------------------------------------------------
'card.login.name': '/login', // Human-readable name; bridgev2's `!tg login` is sent under the hood, but
// surfacing «/login» on the button makes the UI read like a CLI.
'card.login.name': 'Войти в Telegram',
// Card desc is descriptive (noun-style), not a third call-to-action — the // Card desc is descriptive (noun-style), not a third call-to-action — the
// section status «Войдите в Telegram» already carries the imperative. // section status carries state, the card carries action + how-to.
'card.login.desc': 'По номеру телефона', 'card.login.desc': 'По номеру телефона, через SMS-код',
'card.refresh.aria': 'Обновить статус', 'card.refresh.aria': 'Обновить статус',
'card.refresh.label': 'Обновить статус', 'card.refresh.label': 'Обновить статус',
'landing.hint': 'Бот ответит в этом чате — формы появятся ниже.', // Refresh-as-card variant for the disconnected state where it sits in
// the same `command-grid` as login. Same vocabulary as login card.
'card.refresh.name': 'Обновить статус',
'card.refresh.desc': 'Перепроверить, привязан ли Telegram',
// Shown in the desc slot while a refresh request is in flight (button
// also goes :disabled + spinning icon). Without this the click has no
// visible acknowledgement until the bot replies.
'card.refresh.in-flight': 'Проверяю…',
// --- About panel -------------------------------------------------------
'card.about.name': 'Как работает Telegram-бот',
'card.about.desc': 'Вход, безопасность и исходный код',
'about.title': 'О боте Telegram',
'about.body-1':
'Этот бот подключает Telegram к Vojo. После входа личные чаты и группы из Telegram появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в Telegram.',
'about.body-2':
'Для входа нужен номер телефона и код из Telegram — как при входе на новом устройстве. Если у вас включена двухэтапная проверка, Telegram дополнительно попросит облачный пароль.',
'about.body-3':
'Подключение работает через open-source мост mautrix-telegram. Он создаёт Telegram-сессию на сервере Vojo и использует её для связи Telegram с вашим аккаунтом Vojo: получает сообщения из Telegram и отправляет ваши ответы обратно.',
'about.github-label': 'Исходный код моста открыт на GitHub:',
'about.github-url': 'https://github.com/mautrix/telegram',
'about.body-4':
'Отозвать доступ можно в любой момент — кнопкой «Выйти из Telegram» здесь, либо в самом Telegram через «Настройки → Устройства».',
'about.close': 'Закрыть',
'about.aria-close': 'Закрыть «О боте»',
// --- Phone form -------------------------------------------------------- // --- Phone form --------------------------------------------------------
'auth-card.phone.title': 'Вход по номеру', 'auth-card.phone.title': 'Вход по номеру',
'auth-card.phone.label': 'Номер телефона', 'auth-card.phone.label': 'Номер телефона',
@ -39,8 +68,6 @@ export const RU = {
'auth-card.code.placeholder': '123456', 'auth-card.code.placeholder': '123456',
'auth-card.code.submit': 'Подтвердить', 'auth-card.code.submit': 'Подтвердить',
'auth-card.code.privacy-hint': 'Telegram-код виден в истории комнаты — можно очистить вручную.', 'auth-card.code.privacy-hint': 'Telegram-код виден в истории комнаты — можно очистить вручную.',
'auth-card.code.privacy-hint-history':
'Введённый код остался в истории комнаты — при желании очистите вручную.',
// --- 2FA password form ------------------------------------------------- // --- 2FA password form -------------------------------------------------
'auth-card.password.title': 'Облачный пароль Telegram', 'auth-card.password.title': 'Облачный пароль Telegram',
'auth-card.password.hint': 'auth-card.password.hint':
@ -67,8 +94,10 @@ export const RU = {
'auth-error.start-failed': 'Не удалось начать вход: {reason}', 'auth-error.start-failed': 'Не удалось начать вход: {reason}',
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}', 'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
// --- Logout ------------------------------------------------------------ // --- Logout ------------------------------------------------------------
'card.logout.name': '/logout', // Same readability rationale as `card.login.name` — the bridgev2 command
'card.logout.desc': 'Выйти из Telegram', // name belongs in the wire payload, not on the button.
'card.logout.name': 'Выйти из Telegram',
'card.logout.desc': 'Завершить сеанс на этом аккаунте',
'card.logout.confirm-prompt': 'Точно выйти?', 'card.logout.confirm-prompt': 'Точно выйти?',
'card.logout.confirm-yes': 'Выйти', 'card.logout.confirm-yes': 'Выйти',
'card.logout.confirm-no': 'Отмена', 'card.logout.confirm-no': 'Отмена',

View file

@ -5,6 +5,33 @@ import { createT } from './i18n';
import { WidgetApi, buildCapabilities } from './widget-api'; import { WidgetApi, buildCapabilities } from './widget-api';
import './styles.css'; import './styles.css';
// Input-mode detector. Capacitor's Android Chromium WebView reports
// `(hover: hover)` and `(any-pointer: fine)` as TRUE on a pure-touch
// device — verified via on-device console.log of `matchMedia(...).matches`.
// That makes media-query gating of `:hover` styles unreliable: the rule
// fires on touch and then sticks (Chromium WebView synthesises `:hover` on
// the focused element after a tap and never clears it until the next
// interaction elsewhere). The visible symptom is a card that «greys out
// after tap and only un-greys when you tap a different button».
//
// Real input is determined from the actual `pointerdown.pointerType` at
// runtime. The first pointerdown after load is authoritative; CSS gates
// hover styling via `:root[data-input="mouse"]`. The initial guess based
// on `(any-pointer: coarse)` covers the pre-first-pointerdown frame so
// the first paint on a touch device doesn't briefly show the mouse-mode
// hover affordances if the user immediately taps a card.
const setInputMode = (mode: 'touch' | 'mouse'): void => {
document.documentElement.dataset.input = mode;
};
setInputMode(window.matchMedia('(any-pointer: coarse)').matches ? 'touch' : 'mouse');
window.addEventListener(
'pointerdown',
(event) => {
setInputMode(event.pointerType === 'mouse' ? 'mouse' : 'touch');
},
{ passive: true, capture: true }
);
const root = document.getElementById('app'); const root = document.getElementById('app');
if (!root) { if (!root) {
throw new Error('#app root element missing — index.html out of sync'); throw new Error('#app root element missing — index.html out of sync');

View file

@ -45,6 +45,11 @@
* { * {
box-sizing: border-box; box-sizing: border-box;
/* Kills the translucent grey overlay iOS/Android WebViews paint on top
* of any tapped element. On the wide refresh card this overlay was
* read as «button stuck on grey» the underlying state was correct,
* the WebView's tap-highlight was not. Web browsers ignore this. */
-webkit-tap-highlight-color: transparent;
} }
html, html,
@ -71,7 +76,7 @@ body {
margin: 0 auto; margin: 0 auto;
} }
/* The hero (avatar + name + handle + description + Настроить dropdown) is /* The hero (avatar + name + handle + description + three-dots menu) is
* OWNED BY THE HOST, not the widget see src/app/features/bots/BotShell.tsx. * OWNED BY THE HOST, not the widget see src/app/features/bots/BotShell.tsx.
* Removing the widget-side hero collapses the duplicate header that used to * Removing the widget-side hero collapses the duplicate header that used to
* sit between the host's BotShellHero (which the user actually sees) and * sit between the host's BotShellHero (which the user actually sees) and
@ -115,8 +120,8 @@ body {
* sections (disconnected / connected / unknown / logging_out) the * sections (disconnected / connected / unknown / logging_out) the
* pill itself carries the section's identity, so a separate * pill itself carries the section's identity, so a separate
* `.section-label` would just duplicate the meaning. Same dark-bg * `.section-label` would just duplicate the meaning. Same dark-bg
* vocabulary (--bg2 / divider border) as .refresh-button and the host * vocabulary (--bg2 / divider border) as `.recovery-action` and the
* hero settings button. */ * host hero's «О боте» chip. */
.section-status { .section-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -194,7 +199,7 @@ body {
cursor: pointer; cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s; transition: background 0.12s, color 0.12s, border-color 0.12s;
} }
.recovery-action:hover:not(:disabled) { :root[data-input='mouse'] .recovery-action:hover:not(:disabled) {
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
border-color: var(--hairline); border-color: var(--hairline);
@ -211,6 +216,21 @@ body {
/* ── Command card (action card with name + desc + chevron) ──────── */ /* ── Command card (action card with name + desc + chevron) ──────── */
.command-card { .command-card {
/* The widget runs in an iframe, so it does NOT inherit the host's
* `button { -webkit-appearance: button }` rule (src/index.css:112). The
* browser default for <button> on WebKit is `auto`, which on iOS/Android
* Capacitor WebView resolves to native button rendering the WebView
* draws its own focus/active overlay ON TOP of our explicit background.
* That overlay was the «button greys out and doesn't snap back» bug:
* after tap, the WebView holds the native focus paint until focus moves
* elsewhere. Setting appearance:none strips the native paint and makes
* our CSS the sole source of truth, matching what the host does for
* inputs (src/index.css:122-124). On the OLD 70px icon-only refresh
* chip the native overlay had nowhere to render visibly; on a wide
* command-card it was very visible. Web browsers ignore appearance for
* <button> already, so this only matters on native WebViews. */
-webkit-appearance: none;
appearance: none;
background: var(--bg2); background: var(--bg2);
border: 1px solid var(--divider); border: 1px solid var(--divider);
border-radius: 10px; border-radius: 10px;
@ -225,11 +245,32 @@ body {
transition: border-color 0.12s, background 0.12s; transition: border-color 0.12s, background 0.12s;
} }
.command-card:hover:not(:disabled) { /* Hover scoped to mouse-mode sessions only. Capacitor Android WebView
* reports `(hover: hover)` as TRUE on a pure-touch device (verified
* via on-device console.log), so a media-query gate doesn't work the
* rule would apply, then the WebView would synthesise `:hover` on the
* focused element after tap and leave it stuck until the user tapped
* elsewhere (visible symptom: card greys after tap, only un-greys on
* tapping a different button). `[data-input]` is set in main.tsx from
* the actual `pointerdown.pointerType`, which the WebView reports
* truthfully even when its media queries lie. */
:root[data-input='mouse'] .command-card:hover:not(:disabled) {
background: var(--surface); background: var(--surface);
border-color: var(--hairline); border-color: var(--hairline);
} }
.command-card:focus {
outline: none;
}
/* Keyboard focus ring same data-input gate. On touch sessions there's
* no keyboard navigation to support and the ring would also stick (focus
* stays on the tapped button until something else takes it). */
:root[data-input='mouse'] .command-card:focus-visible {
outline: 2px solid var(--fleet);
outline-offset: 2px;
}
.command-card:disabled { .command-card:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@ -241,10 +282,13 @@ body {
} }
.command-card-name { .command-card-name {
/* Sans-serif + on-surface text color the previous monospace + fleet-soft
* styling read like a `/login` CLI label. With «Войти в Telegram» as the
* actual name (no slash, no command-line mimicry), the row should look
* like a primary action title, not a code token. */
font-size: 15px; font-size: 15px;
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace; color: var(--text);
color: var(--fleet-soft); font-weight: 600;
font-weight: 500;
margin-bottom: 3px; margin-bottom: 3px;
} }
@ -259,6 +303,33 @@ body {
font-size: 18px; font-size: 18px;
flex-shrink: 0; flex-shrink: 0;
line-height: 1; line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* When the chevron slot carries an icon (e.g. refresh card uses
* `<RefreshIcon />` instead of ``), size the SVG explicitly the icon
* has no intrinsic width and would expand to 300×150 (SVG default) inside
* a flex-shrink:0 container. 18px keeps it visually equivalent to the
* `` glyph used by the other cards. */
.command-card-chevron svg {
width: 18px;
height: 18px;
display: block;
}
/* Spin the chevron 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. */
.command-card.refreshing .command-card-chevron svg {
animation: command-card-spin 0.8s linear infinite;
}
@keyframes command-card-spin {
to {
transform: rotate(360deg);
}
} }
/* ── Transcript ──────────────────────────────────────────────────── */ /* ── Transcript ──────────────────────────────────────────────────── */
@ -344,6 +415,8 @@ body {
font-style: italic; font-style: italic;
} }
/* Destructive card keeps the red name to mark «Выйти из Telegram» as a
* destructive action, distinguishing it from the primary login card. */
.command-card.danger .command-card-name { .command-card.danger .command-card-name {
color: var(--rose); color: var(--rose);
} }
@ -371,7 +444,6 @@ body {
.command-card-confirm-yes, .command-card-confirm-yes,
.command-card-confirm-no, .command-card-confirm-no,
.refresh-button,
.btn-primary, .btn-primary,
.btn-text, .btn-text,
.btn-icon { .btn-icon {
@ -404,62 +476,6 @@ body {
cursor: not-allowed; cursor: not-allowed;
} }
/* ── Refresh button ──────────────────────────────────────────────── */
.refresh-button {
/* Square side = .command-card's content height: padding 14*2 = 28,
* name lh ~18, name margin-bottom 3, desc lh 19, border 2 = 70px.
* Hard-coded because flex-stretch + aspect-ratio:1 doesn't reliably
* propagate across browsers when neither axis is explicitly sized
* (the icon's intrinsic 18×18 wins in the cross-axis-from-aspect
* resolution). 70px keeps the chip flush with the login card's
* top and bottom edges. */
width: 70px;
height: 70px;
border-radius: 10px;
background: var(--bg2);
border: 1px solid var(--divider);
color: var(--muted);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.refresh-button:hover:not(:disabled) {
background: var(--surface);
border-color: var(--hairline);
color: var(--text);
}
.refresh-button:active:not(:disabled) {
background: var(--surface2);
}
.refresh-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.refresh-button svg {
width: 26px;
height: 26px;
display: block;
}
/* Connect-row holds the `.command-grid` (flex-grows; the grid auto-fill
* caps the login card at one column-width so it doesn't stretch across
* the full row) and the square refresh button beside it. flex-wrap is
* defensive for sub-360px viewports. */
.connect-row {
display: flex;
align-items: stretch;
gap: 10px;
flex-wrap: wrap;
}
.connect-row > .command-grid {
flex: 1;
min-width: 0;
}
.command-grid { .command-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@ -629,9 +645,9 @@ body {
width: 100%; width: 100%;
} }
/* Compact .command-card on mobile so its height matches the shrunk /* Compact .command-card on mobile preserves the «two-row title +
* refresh button below; preserves the «two-row title + chevron» * chevron» structure but trims padding so a single login/logout card
* structure. */ * doesn't dominate a phone-height viewport. */
.command-card { .command-card {
padding: 12px 14px; padding: 12px 14px;
border-radius: 8px; border-radius: 8px;
@ -645,24 +661,9 @@ body {
line-height: 17px; line-height: 17px;
} }
/* Allow the grid to shrink below its 280px desktop floor so it doesn't
* push the refresh button onto its own wrap line at sub-360px. */
.command-grid { .command-grid {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
/* Refresh side compact card height (padding 12*2 + name 17 + margin
* 2 + desc 17 + border 2 = 62px). Keeps the chip flush with the
* card's top and bottom edges on mobile. */
.refresh-button {
width: 62px;
height: 62px;
border-radius: 8px;
}
.refresh-button svg {
width: 22px;
height: 22px;
}
} }
/* ── Linkified transcript bodies ─────────────────────────────────── */ /* ── Linkified transcript bodies ─────────────────────────────────── */
@ -712,3 +713,112 @@ body {
font-size: 12px; font-size: 12px;
color: var(--text); color: var(--text);
} }
/* ── About modal ─────────────────────────────────────────────────── */
/* Lightweight modal fixed inside the widget iframe, not crossing into
* the host. Backdrop click + Escape close; no focus-trap library (the
* widget is a small surface a heavier mechanism would be overkill). */
.about-overlay {
position: fixed;
inset: 0;
background: rgba(13, 14, 17, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
/* Animate in so the panel doesn't feel like a hard pop matches the
* reassuring tone of the body copy itself. */
animation: about-fade 0.15s ease-out;
}
@keyframes about-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.about-panel {
background: var(--bg);
border: 1px solid var(--hairline);
border-radius: 14px;
width: 100%;
max-width: 520px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.about-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--divider);
}
.about-title {
flex: 1;
font-size: 17px;
font-weight: 600;
color: var(--text);
margin: 0;
line-height: 1.3;
}
.about-close-x {
background: transparent;
border: none;
color: var(--muted);
width: 32px;
height: 32px;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font: inherit;
font-size: 24px;
line-height: 1;
transition: background 0.12s, color 0.12s;
}
.about-close-x:hover {
background: var(--surface);
color: var(--text);
}
.about-body {
padding: 16px 18px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.about-body p {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--text);
}
.about-body a {
color: var(--fleet-soft);
text-decoration: underline;
overflow-wrap: anywhere;
}
.about-body a:hover {
color: var(--text);
}
.about-footer {
padding: 12px 18px 16px;
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--divider);
}

View file

@ -895,9 +895,12 @@
"show_chat": "Show chat", "show_chat": "Show chat",
"show_widget": "Show robot", "show_widget": "Show robot",
"retry_widget": "Retry robot", "retry_widget": "Retry robot",
"settings_label": "Settings", "more_options": "More",
"description": { "description": {
"telegram": "Matrix↔Telegram bridge. Sign in by phone number to sync your Telegram chats." "telegram": "Connect Telegram to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal Telegram messages."
},
"description_short": {
"telegram": "Telegram chat connection"
}, },
"unknown_title": "Robot not found", "unknown_title": "Robot not found",
"unknown_description": "This robot is not in the Vojo catalog." "unknown_description": "This robot is not in the Vojo catalog."

View file

@ -899,9 +899,12 @@
"show_chat": "Показать чат", "show_chat": "Показать чат",
"show_widget": "Показать робота", "show_widget": "Показать робота",
"retry_widget": "Повторить", "retry_widget": "Повторить",
"settings_label": "Настроить", "more_options": "Ещё",
"description": { "description": {
"telegram": "Мост Matrix↔Telegram. Войдите по номеру, чтобы синхронизировать чаты с Telegram." "telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения."
},
"description_short": {
"telegram": "Подключение чатов Telegram"
}, },
"unknown_title": "Робот не найден", "unknown_title": "Робот не найден",
"unknown_description": "Этого робота нет в каталоге Vojo." "unknown_description": "Этого робота нет в каталоге Vojo."

View file

@ -189,11 +189,10 @@ export const HeroName = style([
}, },
]); ]);
// Handle and description are hidden on mobile to keep the hero band at // Handle is desktop-only — mxid is rarely useful on phone where you can't
// chat-header height. The bot's identity is already conveyed by the // easily copy it anyway, and the chat-header-style mobile band only has
// avatar + name + the room route itself; the operator-config description // room for one row of metadata under the name (the short description,
// is supplemental and the mxid is rarely useful on phone where you // not the mxid).
// can't easily copy it anyway.
export const HeroHandle = style([ export const HeroHandle = style([
DefaultReset, DefaultReset,
{ {
@ -211,6 +210,10 @@ export const HeroHandle = style([
}, },
]); ]);
// Desktop description: full sentence(s), wraps freely up to 560px. Hidden
// on mobile in favor of the single-line `HeroDescriptionShort` below — the
// long copy doesn't fit in a chat-header band without pushing the avatar
// row out of vertical alignment.
export const HeroDescription = style([ export const HeroDescription = style([
DefaultReset, DefaultReset,
{ {
@ -229,41 +232,34 @@ export const HeroDescription = style([
}, },
]); ]);
// Trailing "Настроить" button — overrides the mockup's transparent spec // Mobile-only one-liner description — sits directly under the name in the
// with a dark filled chip. Sits on top of the hero's #181a20 // chat-header-style band. Truncated with ellipsis so a long short-desc
// (SurfaceVariant.Container) surface; Background.Container resolves to // from /config.json doesn't break the header height. Desktop hides this
// #0d0e11 and reads as a darker chip — the visual partner of the widget- // since the full-length `HeroDescription` carries the same content with
// side status pill that sits directly below it on the right edge. // more room.
export const HeroSettingsButton = style([ export const HeroDescriptionShort = style([
DefaultReset, DefaultReset,
{ {
background: color.Background.Container, display: 'none',
color: color.Surface.OnContainer,
border: `1px solid ${color.Background.ContainerLine}`,
borderRadius: toRem(8),
padding: `${toRem(10)} ${toRem(18)}`,
fontSize: toRem(14),
fontWeight: 500,
cursor: 'pointer',
flexShrink: 0,
transition: 'background 0.12s ease, border-color 0.12s ease, color 0.12s ease',
selectors: {
'&:hover:not(:disabled)': {
background: color.Background.ContainerHover,
borderColor: color.Background.ContainerActive,
},
'&[aria-pressed="true"]': {
background: color.Background.ContainerActive,
borderColor: color.Background.ContainerActive,
},
},
'@media': { '@media': {
'(max-width: 600px)': { '(max-width: 600px)': {
padding: `${toRem(6)} ${toRem(12)}`, display: 'block',
fontSize: toRem(13), fontSize: toRem(12),
fontWeight: 400, lineHeight: toRem(16),
color: color.SurfaceVariant.OnContainer,
opacity: 0.75,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
marginTop: toRem(2),
}, },
}, },
}, },
]); ]);
// «О боте» button moved INSIDE the widget body (next to login/refresh/
// logout cards). The hero only keeps the standard three-dots IconButton
// as a trailing action — its styling comes from folds, no host-side CSS
// needed.

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { Room } from 'matrix-js-sdk'; import type { Room } from 'matrix-js-sdk';
import { Icon, IconButton, Icons, PopOut, RectCords } from 'folds'; import { Icon, IconButton, Icons, PopOut, RectCords, Tooltip, TooltipProvider, Text } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { BotPreset } from './catalog'; import type { BotPreset } from './catalog';
@ -59,6 +59,12 @@ export function BotShellHero({ preset, room }: BotShellHeroProps) {
const description = t(`Bots.description.${preset.id}`, { const description = t(`Bots.description.${preset.id}`, {
defaultValue: preset.description ?? '', defaultValue: preset.description ?? '',
}); });
// Mobile-only one-liner. Falls back to the long description when no
// short variant is configured — better to truncate the long one than
// render nothing in the chat-header band.
const descriptionShort = t(`Bots.description_short.${preset.id}`, {
defaultValue: description,
});
const initial = heroInitial(preset); const initial = heroInitial(preset);
return ( return (
@ -91,16 +97,35 @@ export function BotShellHero({ preset, room }: BotShellHeroProps) {
<span className={css.HeroHandle}>{preset.mxid}</span> <span className={css.HeroHandle}>{preset.mxid}</span>
</div> </div>
{description ? <p className={css.HeroDescription}>{description}</p> : null} {description ? <p className={css.HeroDescription}>{description}</p> : null}
{descriptionShort ? <p className={css.HeroDescriptionShort}>{descriptionShort}</p> : null}
</div> </div>
<button {/* «О боте» lives INSIDE the widget body now (next to login/refresh/
type="button" * logout cards) see apps/widget-telegram/src/App.tsx. The hero
className={css.HeroSettingsButton} * keeps only the standard three-dots menu trigger so its trailing
* actions match the rest of the chat headers in the project. */}
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>{t('Bots.more_options')}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={handleOpenMenu} onClick={handleOpenMenu}
aria-pressed={!!menuAnchor} aria-pressed={!!menuAnchor}
aria-label={t('Bots.more_options')}
> >
{t('Bots.settings_label')} <Icon src={Icons.VerticalDots} filled={!!menuAnchor} />
</button> </IconButton>
)}
</TooltipProvider>
</div> </div>
<PopOut <PopOut