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:
parent
0263d2d6da
commit
7f9f6b8e4f
9 changed files with 514 additions and 179 deletions
|
|
@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
|
@ -571,6 +660,14 @@ export function App({ bootstrap, api }: Props) {
|
|||
const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme);
|
||||
const [transcript, setTranscript] = useState<TranscriptLine[]>([]);
|
||||
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 seenEventIds = useRef(new Set<string>());
|
||||
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<void>((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) {
|
|||
<div class="app">
|
||||
{/* 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' ? (
|
||||
<section class="section">
|
||||
|
|
@ -901,36 +1015,47 @@ export function App({ bootstrap, api }: Props) {
|
|||
|
||||
{handshakeOk && state.kind === 'disconnected' ? (
|
||||
<section class="section">
|
||||
{/* 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. */}
|
||||
<span class="section-status disconnected" role="status">
|
||||
<span class="dot" />
|
||||
{t('status.disconnected')}
|
||||
</span>
|
||||
<div class="connect-row">
|
||||
<div class="command-grid">
|
||||
<button class="command-card" type="button" onClick={onClickLogin}>
|
||||
<div class="command-card-body">
|
||||
<div class="command-card-name">{t('card.login.name')}</div>
|
||||
<div class="command-card-desc">{t('card.login.desc')}</div>
|
||||
</div>
|
||||
<span class="command-card-chevron" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="refresh-button"
|
||||
onClick={onClickRefresh}
|
||||
aria-label={t('card.refresh.aria')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
<div class="command-grid">
|
||||
<button class="command-card" type="button" onClick={onClickLogin}>
|
||||
<div class="command-card-body">
|
||||
<div class="command-card-name">{t('card.login.name')}</div>
|
||||
<div class="command-card-desc">{t('card.login.desc')}</div>
|
||||
</div>
|
||||
<span class="command-card-chevron" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
</button>
|
||||
{/* 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
|
||||
class={`command-card${refreshing ? ' refreshing' : ''}`}
|
||||
type="button"
|
||||
onClick={onClickRefresh}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<div class="command-card-body">
|
||||
<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>
|
||||
<p class="hint">{t('landing.hint')}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
|
|
@ -998,13 +1123,15 @@ export function App({ bootstrap, api }: Props) {
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p class="hint">{t('auth-card.code.privacy-hint-history')}</p>
|
||||
<div class="command-grid">
|
||||
<LogoutCard loginId={state.loginId} t={t} onConfirm={onConfirmLogout} />
|
||||
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{aboutOpen ? <AboutModal t={t} onClose={() => setAboutOpen(false)} /> : null}
|
||||
|
||||
<section class="section">
|
||||
<div ref={transcriptRef} class="transcript" role="log" aria-live="polite">
|
||||
{transcript.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -5,15 +5,32 @@ import type { StringKey } from './ru';
|
|||
|
||||
export const EN: Record<StringKey, string> = {
|
||||
'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<StringKey, string> = {
|
|||
'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<StringKey, string> = {
|
|||
'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',
|
||||
|
|
|
|||
|
|
@ -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': 'Отмена',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 <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);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 10px;
|
||||
|
|
@ -225,11 +245,32 @@ body {
|
|||
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);
|
||||
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 {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
@ -241,10 +282,13 @@ body {
|
|||
}
|
||||
|
||||
.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-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
|
||||
color: var(--fleet-soft);
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +303,33 @@ body {
|
|||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
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 ──────────────────────────────────────────────────── */
|
||||
|
|
@ -344,6 +415,8 @@ body {
|
|||
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 {
|
||||
color: var(--rose);
|
||||
}
|
||||
|
|
@ -371,7 +444,6 @@ body {
|
|||
|
||||
.command-card-confirm-yes,
|
||||
.command-card-confirm-no,
|
||||
.refresh-button,
|
||||
.btn-primary,
|
||||
.btn-text,
|
||||
.btn-icon {
|
||||
|
|
@ -404,62 +476,6 @@ body {
|
|||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
|
|
@ -629,9 +645,9 @@ body {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
/* Compact .command-card on mobile so its height matches the shrunk
|
||||
* refresh button below; preserves the «two-row title + chevron»
|
||||
* structure. */
|
||||
/* Compact .command-card on mobile — preserves the «two-row title +
|
||||
* chevron» structure but trims padding so a single login/logout card
|
||||
* doesn't dominate a phone-height viewport. */
|
||||
.command-card {
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
|
|
@ -645,24 +661,9 @@ body {
|
|||
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 {
|
||||
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 ─────────────────────────────────── */
|
||||
|
|
@ -712,3 +713,112 @@ body {
|
|||
font-size: 12px;
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -895,9 +895,12 @@
|
|||
"show_chat": "Show chat",
|
||||
"show_widget": "Show robot",
|
||||
"retry_widget": "Retry robot",
|
||||
"settings_label": "Settings",
|
||||
"more_options": "More",
|
||||
"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_description": "This robot is not in the Vojo catalog."
|
||||
|
|
|
|||
|
|
@ -899,9 +899,12 @@
|
|||
"show_chat": "Показать чат",
|
||||
"show_widget": "Показать робота",
|
||||
"retry_widget": "Повторить",
|
||||
"settings_label": "Настроить",
|
||||
"more_options": "Ещё",
|
||||
"description": {
|
||||
"telegram": "Мост Matrix↔Telegram. Войдите по номеру, чтобы синхронизировать чаты с Telegram."
|
||||
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения."
|
||||
},
|
||||
"description_short": {
|
||||
"telegram": "Подключение чатов Telegram"
|
||||
},
|
||||
"unknown_title": "Робот не найден",
|
||||
"unknown_description": "Этого робота нет в каталоге Vojo."
|
||||
|
|
|
|||
|
|
@ -189,11 +189,10 @@ export const HeroName = style([
|
|||
},
|
||||
]);
|
||||
|
||||
// Handle and description are hidden on mobile to keep the hero band at
|
||||
// chat-header height. The bot's identity is already conveyed by the
|
||||
// avatar + name + the room route itself; the operator-config description
|
||||
// is supplemental and the mxid is rarely useful on phone where you
|
||||
// can't easily copy it anyway.
|
||||
// Handle is desktop-only — mxid is rarely useful on phone where you can't
|
||||
// easily copy it anyway, and the chat-header-style mobile band only has
|
||||
// room for one row of metadata under the name (the short description,
|
||||
// not the mxid).
|
||||
export const HeroHandle = style([
|
||||
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([
|
||||
DefaultReset,
|
||||
{
|
||||
|
|
@ -229,41 +232,34 @@ export const HeroDescription = style([
|
|||
},
|
||||
]);
|
||||
|
||||
// Trailing "Настроить" button — overrides the mockup's transparent spec
|
||||
// with a dark filled chip. Sits on top of the hero's #181a20
|
||||
// (SurfaceVariant.Container) surface; Background.Container resolves to
|
||||
// #0d0e11 and reads as a darker chip — the visual partner of the widget-
|
||||
// side status pill that sits directly below it on the right edge.
|
||||
export const HeroSettingsButton = style([
|
||||
// Mobile-only one-liner description — sits directly under the name in the
|
||||
// chat-header-style band. Truncated with ellipsis so a long short-desc
|
||||
// from /config.json doesn't break the header height. Desktop hides this
|
||||
// since the full-length `HeroDescription` carries the same content with
|
||||
// more room.
|
||||
export const HeroDescriptionShort = style([
|
||||
DefaultReset,
|
||||
{
|
||||
background: color.Background.Container,
|
||||
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,
|
||||
},
|
||||
},
|
||||
display: 'none',
|
||||
|
||||
'@media': {
|
||||
'(max-width: 600px)': {
|
||||
padding: `${toRem(6)} ${toRem(12)}`,
|
||||
fontSize: toRem(13),
|
||||
fontWeight: 400,
|
||||
display: 'block',
|
||||
fontSize: toRem(12),
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import type { BotPreset } from './catalog';
|
||||
|
|
@ -59,6 +59,12 @@ export function BotShellHero({ preset, room }: BotShellHeroProps) {
|
|||
const description = t(`Bots.description.${preset.id}`, {
|
||||
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);
|
||||
|
||||
return (
|
||||
|
|
@ -91,16 +97,35 @@ export function BotShellHero({ preset, room }: BotShellHeroProps) {
|
|||
<span className={css.HeroHandle}>{preset.mxid}</span>
|
||||
</div>
|
||||
{description ? <p className={css.HeroDescription}>{description}</p> : null}
|
||||
{descriptionShort ? <p className={css.HeroDescriptionShort}>{descriptionShort}</p> : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={css.HeroSettingsButton}
|
||||
onClick={handleOpenMenu}
|
||||
aria-pressed={!!menuAnchor}
|
||||
{/* «О боте» lives INSIDE the widget body now (next to login/refresh/
|
||||
* logout cards) — see apps/widget-telegram/src/App.tsx. The hero
|
||||
* 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>
|
||||
}
|
||||
>
|
||||
{t('Bots.settings_label')}
|
||||
</button>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={handleOpenMenu}
|
||||
aria-pressed={!!menuAnchor}
|
||||
aria-label={t('Bots.more_options')}
|
||||
>
|
||||
<Icon src={Icons.VerticalDots} filled={!!menuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<PopOut
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue