From e46bba2f7db5467c26959a36ca878c7e419aa36d Mon Sep 17 00:00:00 2001 From: heaven Date: Mon, 4 May 2026 18:34:51 +0300 Subject: [PATCH] feat(bots): polish the Telegram bot widget UI and fix Android WebView sticky-hover via pointerType-based input-mode detection --- apps/widget-telegram/src/App.tsx | 193 ++++++++++++++--- apps/widget-telegram/src/i18n/en.ts | 31 ++- apps/widget-telegram/src/i18n/ru.ts | 47 ++++- apps/widget-telegram/src/main.tsx | 27 +++ apps/widget-telegram/src/styles.css | 276 +++++++++++++++++-------- public/locales/en.json | 7 +- public/locales/ru.json | 7 +- src/app/features/bots/BotShell.css.ts | 64 +++--- src/app/features/bots/BotShellHero.tsx | 41 +++- 9 files changed, 514 insertions(+), 179 deletions(-) diff --git a/apps/widget-telegram/src/App.tsx b/apps/widget-telegram/src/App.tsx index 66c87658..d7a21188 100644 --- a/apps/widget-telegram/src/App.tsx +++ b/apps/widget-telegram/src/App.tsx @@ -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) => ( + +); + +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 ( + + ); +}; + // -------------------------------------------------------------------------- // 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 [transcript, setTranscript] = useState([]); 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(api); const seenEventIds = useRef(new Set()); const [state, dispatch] = useReducer(loginReducer, initialLoginState); @@ -839,11 +936,28 @@ export function App({ bootstrap, api }: Props) { } }, [sendBare]); - const onClickRefresh = useCallback(() => { - sendBare('list-logins').catch(() => { + const onClickRefresh = useCallback(async () => { + if (refreshing) return; + setRefreshing(true); + const start = Date.now(); + try { + await sendBare('list-logins'); + } catch { /* transcript carries the failure */ - }); - }, [sendBare]); + } + // 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((resolve) => { + window.setTimeout(resolve, 500 - elapsed); + }); + } + setRefreshing(false); + }, [refreshing, sendBare]); // Optimistic logging_out + recovery on send failure: refire list-logins // so the reducer recalibrates from the bridge's truth instead of leaving @@ -876,10 +990,10 @@ export function App({ bootstrap, api }: Props) {
{/* Hero is OWNED BY THE HOST (BotShell + BotShellHero). The widget no * longer renders an avatar/name/handle/description block — the host - * panel above the iframe carries that information, with the - * «Настроить» dropdown that controls show-chat / mark-read / - * notifications / leave-room. The widget body starts with the - * action-relevant section for the current state. */} + * panel above the iframe carries that information plus the + * three-dots menu (show-chat / mark-read / notifications / + * leave-room). «О боте» lives HERE in the widget body so it sits + * adjacent to the login/logout actions it explains. */} {handshakeOk && state.kind === 'unknown' ? (
@@ -901,36 +1015,47 @@ export function App({ bootstrap, api }: Props) { {handshakeOk && state.kind === 'disconnected' ? (
- {/* Status pill replaces the section header — the pill itself - * carries the section's identity («Войдите в Telegram» says - * what this surface is for and what state we're in, so a - * separate «Подключение» label was redundant). */} + {/* Status pill describes state («Telegram не привязан»), command + * cards below carry the actions. Earlier copy made the pill an + * imperative («Войдите в Telegram»), which duplicated the login + * card's title sitting directly beneath it. */} {t('status.disconnected')} -
-
- -
- + {/* 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. */} + + setAboutOpen(true)} />
-

{t('landing.hint')}

) : null} @@ -998,13 +1123,15 @@ export function App({ bootstrap, api }: Props) {
)} -

{t('auth-card.code.privacy-hint-history')}

+ setAboutOpen(true)} />
) : null} + {aboutOpen ? setAboutOpen(false)} /> : null} +
{transcript.length === 0 ? ( diff --git a/apps/widget-telegram/src/i18n/en.ts b/apps/widget-telegram/src/i18n/en.ts index aa259049..c95396dd 100644 --- a/apps/widget-telegram/src/i18n/en.ts +++ b/apps/widget-telegram/src/i18n/en.ts @@ -5,15 +5,32 @@ import type { StringKey } from './ru'; export const EN: Record = { 'status.unknown': 'Checking status…', - 'status.disconnected': 'Sign in to Telegram', + 'status.disconnected': 'Telegram not linked', 'status.connected': 'Telegram linked', 'status.connected-as': 'Telegram linked as {handle}', 'status.logging-out': 'Signing out…', - 'card.login.name': '/login', - 'card.login.desc': 'By phone number', + 'card.login.name': 'Sign in to Telegram', + 'card.login.desc': 'By phone number, with an SMS code', 'card.refresh.aria': '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 Vojo’s 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.label': 'Phone number', 'auth-card.phone.placeholder': '+15551234567', @@ -26,8 +43,6 @@ export const EN: Record = { 'auth-card.code.submit': 'Confirm', 'auth-card.code.privacy-hint': '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.hint': '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 = { 'The bot does not recognise this command — check the prefix in config.json.', 'auth-error.start-failed': 'Failed to start login: {reason}', 'auth-error.prepare-failed': 'Failed to prepare login: {reason}', - 'card.logout.name': '/logout', - 'card.logout.desc': 'Sign out of Telegram', + 'card.logout.name': 'Sign out of Telegram', + 'card.logout.desc': 'End the session for this account', 'card.logout.confirm-prompt': 'Sign out for real?', 'card.logout.confirm-yes': 'Sign out', 'card.logout.confirm-no': 'Cancel', diff --git a/apps/widget-telegram/src/i18n/ru.ts b/apps/widget-telegram/src/i18n/ru.ts index 82d860fd..b2b9ea92 100644 --- a/apps/widget-telegram/src/i18n/ru.ts +++ b/apps/widget-telegram/src/i18n/ru.ts @@ -13,19 +13,48 @@ export const RU = { // --- 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.disconnected': 'Войдите в Telegram', + 'status.disconnected': 'Telegram не привязан', 'status.connected': 'Telegram привязан', 'status.connected-as': 'Telegram привязан как {handle}', 'status.logging-out': 'Завершение сеанса…', // --- 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 - // section status «Войдите в Telegram» already carries the imperative. - 'card.login.desc': 'По номеру телефона', + // section status carries state, the card carries action + how-to. + 'card.login.desc': 'По номеру телефона, через SMS-код', 'card.refresh.aria': 'Обновить статус', '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 -------------------------------------------------------- 'auth-card.phone.title': 'Вход по номеру', 'auth-card.phone.label': 'Номер телефона', @@ -39,8 +68,6 @@ export const RU = { 'auth-card.code.placeholder': '123456', 'auth-card.code.submit': 'Подтвердить', 'auth-card.code.privacy-hint': 'Telegram-код виден в истории комнаты — можно очистить вручную.', - 'auth-card.code.privacy-hint-history': - 'Введённый код остался в истории комнаты — при желании очистите вручную.', // --- 2FA password form ------------------------------------------------- 'auth-card.password.title': 'Облачный пароль Telegram', 'auth-card.password.hint': @@ -67,8 +94,10 @@ export const RU = { 'auth-error.start-failed': 'Не удалось начать вход: {reason}', 'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}', // --- Logout ------------------------------------------------------------ - 'card.logout.name': '/logout', - 'card.logout.desc': 'Выйти из Telegram', + // Same readability rationale as `card.login.name` — the bridgev2 command + // name belongs in the wire payload, not on the button. + 'card.logout.name': 'Выйти из Telegram', + 'card.logout.desc': 'Завершить сеанс на этом аккаунте', 'card.logout.confirm-prompt': 'Точно выйти?', 'card.logout.confirm-yes': 'Выйти', 'card.logout.confirm-no': 'Отмена', diff --git a/apps/widget-telegram/src/main.tsx b/apps/widget-telegram/src/main.tsx index a597d5fa..953bf766 100644 --- a/apps/widget-telegram/src/main.tsx +++ b/apps/widget-telegram/src/main.tsx @@ -5,6 +5,33 @@ import { createT } from './i18n'; import { WidgetApi, buildCapabilities } from './widget-api'; 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'); if (!root) { throw new Error('#app root element missing — index.html out of sync'); diff --git a/apps/widget-telegram/src/styles.css b/apps/widget-telegram/src/styles.css index c75317e7..4e57b782 100644 --- a/apps/widget-telegram/src/styles.css +++ b/apps/widget-telegram/src/styles.css @@ -45,6 +45,11 @@ * { 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, @@ -71,7 +76,7 @@ body { 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. * Removing the widget-side hero collapses the duplicate header that used to * sit between the host's BotShellHero (which the user actually sees) and @@ -115,8 +120,8 @@ body { * sections (disconnected / connected / unknown / logging_out) — the * pill itself carries the section's identity, so a separate * `.section-label` would just duplicate the meaning. Same dark-bg - * vocabulary (--bg2 / divider border) as .refresh-button and the host - * hero settings button. */ + * vocabulary (--bg2 / divider border) as `.recovery-action` and the + * host hero's «О боте» chip. */ .section-status { display: inline-flex; align-items: center; @@ -194,7 +199,7 @@ body { cursor: pointer; 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); color: var(--text); border-color: var(--hairline); @@ -211,6 +216,21 @@ body { /* ── Command card (action card with name + desc + chevron) ──────── */ .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
{description ?

{description}

: null} + {descriptionShort ?

{descriptionShort}

: null} - + {(triggerRef) => ( + + + + )} +