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
b2f3b668c5
commit
e46bba2f7d
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
|
// 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 */
|
||||||
});
|
}
|
||||||
}, [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
|
// 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,36 +1015,47 @@ 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">
|
<div class="command-card-name">{t('card.login.name')}</div>
|
||||||
<div class="command-card-name">{t('card.login.name')}</div>
|
<div class="command-card-desc">{t('card.login.desc')}</div>
|
||||||
<div class="command-card-desc">{t('card.login.desc')}</div>
|
</div>
|
||||||
</div>
|
<span class="command-card-chevron" aria-hidden="true">
|
||||||
<span class="command-card-chevron" aria-hidden="true">
|
›
|
||||||
›
|
</span>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="refresh-button"
|
|
||||||
onClick={onClickRefresh}
|
|
||||||
aria-label={t('card.refresh.aria')}
|
|
||||||
>
|
|
||||||
<RefreshIcon />
|
|
||||||
</button>
|
</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>
|
</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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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 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.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',
|
||||||
|
|
|
||||||
|
|
@ -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': 'Отмена',
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
onClick={handleOpenMenu}
|
* actions match the rest of the chat headers in the project. */}
|
||||||
aria-pressed={!!menuAnchor}
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>{t('Bots.more_options')}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t('Bots.settings_label')}
|
{(triggerRef) => (
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<PopOut
|
<PopOut
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue