From 946420db938b47f97f68eb58433fe5976144e6b8 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 2 Jul 2026 17:16:05 +0200 Subject: [PATCH] fix(game): own export chooser modal; PNG option outside in-app webviews only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram's native showPopup delivers its callback with no user activation, so navigator.share (TG iOS) and clipboard writes (TG Android GCG copy) silently fail from it — the chooser is now always the app's own modal, keeping the button click's gesture alive for the delivery APIs. The data:URL preview modal is dropped: the Android TG/VK long-press menu mangles data: URLs (dead download, black-screen open, base64 clipboard garbage), so a binary PNG has no working client-side route in those webviews at all. The image option is withheld there until the server-rendered signed-URL delivery (Telegram downloadFile / VKWebAppDownloadFile) lands; the plain web and mobile browsers keep it. On-device findings by the owner on the test contour (TG iOS, TG Android, VK Android). --- docs/FUNCTIONAL.md | 16 +++---- docs/FUNCTIONAL_ru.md | 16 +++---- docs/UI_DESIGN.md | 18 ++++---- ui/e2e/export.spec.ts | 49 ++++++++++------------ ui/src/game/Game.svelte | 90 ++++++++++------------------------------ ui/src/lib/i18n/en.ts | 4 -- ui/src/lib/i18n/ru.ts | 4 -- ui/src/lib/share.test.ts | 26 +++--------- ui/src/lib/share.ts | 74 +++++++-------------------------- 9 files changed, 95 insertions(+), 202 deletions(-) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 0e510bb..989ff51 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -322,8 +322,9 @@ Finished games are archived in a dictionary-independent form and exportable in **two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of the final position; the export is offered **only once a game is finished**, and never for an honest-AI practice game (a live game's export would leak the move -journal; an AI game is throwaway). The format chooser is native where the platform -has one (Telegram's popup), the app's own modal elsewhere. +journal; an AI game is throwaway). The format chooser is always the app's **own +modal** — deliberately not Telegram's native popup, whose callback runs without +user activation and silently breaks the share/clipboard delivery it leads to. The **image** is rendered on the client (Canvas 2D, always the light theme): the final board with classic coordinate axes A..O / 1..15 on the left — premium squares @@ -342,11 +343,12 @@ dead space. Delivery per format: the **GCG** file is Web-Shared where the platform supports it; on an Android in-app client (Telegram / VK), which has neither Web Share nor a working file download, it copies the GCG text to the clipboard (with a confirming -toast); otherwise it downloads the file. The **PNG** shares or downloads the same -way where possible, but a binary image has no clipboard-text fallback — those -in-app webviews (and the desktop VK iframe) get a **preview modal** instead: the -image with a long-press / right-click save hint and a copy-image button where the -clipboard-image API exists. Statistics (durable accounts only): +toast); otherwise it downloads the file. The **PNG** is Web-Shared or downloaded the +same way, but a binary image has no clipboard-text fallback at all — so the image +option is currently **withheld inside the in-app webviews** (Telegram / VK) and +offered on the plain web and mobile browsers only; it returns there with the +server-rendered signed-URL delivery (Telegram `downloadFile` / +`VKWebAppDownloadFile`). Statistics (durable accounts only): wins, losses, draws, max points in a game, and max points for a single move (the best play, which already includes every word it formed plus the all-tiles bonus). It also shows the player's **move count** (their plays — passes and exchanges do not diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 448de8c..cecd013 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -331,8 +331,9 @@ Telegram. **двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка** финальной позиции; экспорт доступен **только после завершения партии** и никогда — для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а -партия с ИИ одноразовая). Выбор формата — нативный диалог там, где платформа его -даёт (попап Telegram), иначе собственный модал приложения. +партия с ИИ одноразовая). Выбор формата — всегда **собственный модал** приложения, +сознательно не нативный попап Telegram: его коллбек приходит без user activation и +молча ломает доставку через share/буфер, к которой ведёт выбор. **Картинка** рендерится на клиенте (Canvas 2D, всегда светлая тема): финальная доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой @@ -350,11 +351,12 @@ Telegram. Доставка по форматам: файлом **GCG** клиент делится через Web Share там, где платформа это поддерживает; в Android-приложении (Telegram / VK), где нет ни Web Share, ни рабочей загрузки файла, копирует текст GCG в буфер обмена (с -подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так -же, где возможно, но у бинарной картинки нет текстового фолбэка через буфер — в -этих встроенных webview (и в десктопном iframe VK) вместо него **модал-превью**: -картинка с подсказкой «долгое нажатие / правый клик — сохранить» и кнопкой -копирования картинки там, где есть clipboard-image API. Статистика (только у постоянных аккаунтов): +подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так же, +но у бинарной картинки нет текстового фолбэка через буфер вовсе — поэтому пункт +«картинка» пока **скрыт во встроенных webview** (Telegram / VK) и предлагается +только в обычном вебе и мобильных браузерах; туда он вернётся с серверным рендером +и доставкой по подписанному URL (Telegram `downloadFile` / +`VKWebAppDownloadFile`). Статистика (только у постоянных аккаунтов): победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все образованные им слова и бонус за все фишки). Также показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index e0a7ed6..b9b32ee 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -406,14 +406,16 @@ enabled on the first, uncached load) and flip in place when an event refreshes t - **History / export**: the in-game slide-down history lays each move out in a per-seat grid (the word(s) and the move score, no running total); the 📤 in the history header appears only once the game is finished — never in an honest-AI game (throwaway practice) — and - opens a **format chooser**: Telegram's native popup inside Telegram (image / GCG / cancel), - the app's own modal elsewhere (the accent button leads with the image, the GCG file is the - quiet alternative; GCG needs the connection, the image renders locally). The GCG file goes - by Web Share where available, a clipboard copy in an Android in-app WebView (Telegram / VK, - which has neither Web Share nor a working download), else a Blob download. The image goes by - Web Share / Blob download too, but on those in-app WebViews (and the desktop VK iframe) it - opens a **preview modal** instead — the PNG as a data: URL with a long-press / right-click - save hint and a copy-image button where `ClipboardItem` exists. Confirming a resign reveals + opens the **format chooser**: always the app's own modal, never Telegram's native popup + (a `showPopup` callback carries no user activation, which silently breaks the + `navigator.share` / clipboard delivery it leads to — on-device finding). The accent button + leads with the image where offered, the GCG file is the quiet alternative (GCG needs the + connection, the image renders locally). The GCG file goes by Web Share where available, a + clipboard copy in an Android in-app WebView (Telegram / VK, which has neither Web Share nor + a working download), else a Blob download. The image option shows **only outside the in-app + WebViews** for now (a binary PNG has no working route there — no Web Share, a dead + ``, a text-only clipboard, and the long-press menu mangles data: URLs); it + returns there with the server-rendered signed-URL delivery. Confirming a resign reveals the full board: it closes the history drawer (portrait) and zooms the board out. - **Export image** (`lib/gameimage.ts`, Canvas 2D, dynamically imported): always the light palette (pinned constants mirroring `app.css`), the final board with classic axes A..O / diff --git a/ui/e2e/export.spec.ts b/ui/e2e/export.spec.ts index f5d7154..fae1798 100644 --- a/ui/e2e/export.spec.ts +++ b/ui/e2e/export.spec.ts @@ -1,10 +1,13 @@ import { expect, test, type Page } from './fixtures'; -// The finished-game export: the history header's 📤 button opens a format chooser — -// the GCG file or the rendered PNG image (lib/gameimage) — as a native Telegram popup -// inside Telegram and as the app's own modal elsewhere. g3 ("vs Rick") is the seeded -// finished game. Web Share is force-disabled per test so every engine deterministically -// exercises the download (plain browser) or preview (in-app webview) branch. +// The finished-game export: the history header's 📤 button opens the app's own format +// chooser modal — never Telegram's native popup, whose callback runs without user +// activation and so kills navigator.share / clipboard writes (verified on-device). +// The PNG option is offered only outside the in-app WebViews (no working binary +// delivery there until the server-rendered signed-URL path lands); the GCG file is +// always offered. g3 ("vs Rick") is the seeded finished game. Web Share is +// force-disabled per test so both engines deterministically exercise the download +// branch. async function disableWebShare(page: Page): Promise { await page.addInitScript(() => { @@ -27,7 +30,7 @@ test('the export chooser downloads the PNG image on a plain browser', async ({ p await openFinishedHistory(page); await page.getByRole('button', { name: 'Export game' }).click(); - // The app's own chooser modal (not inside Telegram) offers both formats. + // The app's own chooser modal offers both formats on the plain web. await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible(); const download = page.waitForEvent('download'); await page.getByRole('button', { name: 'Image (PNG)' }).click(); @@ -44,13 +47,13 @@ test('the export chooser downloads the GCG file on a plain browser', async ({ pa expect((await download).suggestedFilename()).toMatch(/^game-.+\.gcg$/); }); -test('inside Telegram the chooser is the native popup and the image lands in the preview modal', async ({ +test('inside Telegram the chooser is the app modal and withholds the image option', async ({ page, }) => { await disableWebShare(page); - // A Telegram WebApp stub whose showPopup records its params and picks the image option — - // the native-chooser path. With no Web Share in the webview, the image must land in the - // long-press preview modal (the Android Telegram delivery), never a dead . + // A Telegram WebApp stub with showPopup present: were the chooser still native, it would + // be called — the spy locks the regression (its activation-less callback breaks share and + // clipboard delivery on real devices). await page.addInitScript(() => { Object.assign(window, { Telegram: { @@ -59,9 +62,9 @@ test('inside Telegram the chooser is the native popup and the image lands in the initDataUnsafe: {}, ready() {}, expand() {}, - showPopup(params: unknown, cb: (id: string) => void) { - (window as unknown as { __popup?: unknown }).__popup = params; - setTimeout(() => cb('image'), 0); + showPopup(_params: unknown, cb: (id: string) => void) { + (window as unknown as { __popupCalled?: boolean }).__popupCalled = true; + setTimeout(() => cb(''), 0); }, }, }, @@ -74,17 +77,11 @@ test('inside Telegram the chooser is the native popup and the image lands in the await page.locator('.scoreboard').click(); await page.getByRole('button', { name: 'Export game' }).click(); - // The native popup carried both formats plus cancel (≤3 buttons, Bot API 6.2)… - const popup = await page.evaluate( - () => (window as unknown as { __popup?: { buttons?: { id?: string; type?: string }[] } }).__popup, - ); - expect(popup?.buttons?.map((b) => b.id ?? b.type)).toEqual(['image', 'gcg', 'cancel']); - // …and no in-app chooser modal was mounted. - await expect(page.getByRole('button', { name: 'GCG file' })).toHaveCount(0); - - // The preview modal shows the rendered PNG as a data: URL (long-press-saveable) + the hint. - const img = page.locator('.export-preview'); - await expect(img).toBeVisible(); - expect(await img.getAttribute('src')).toMatch(/^data:image\/png/); - await expect(page.locator('.export-hint')).toBeVisible(); + // The app's own modal opened with the GCG option only — no PNG in an in-app WebView… + await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Image (PNG)' })).toHaveCount(0); + // …and the native popup was never used for the chooser. + expect( + await page.evaluate(() => (window as unknown as { __popupCalled?: boolean }).__popupCalled), + ).toBeFalsy(); }); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index f2eba4a..e531e96 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -22,12 +22,12 @@ import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { hintsLeft } from '../lib/hints'; - import { blobToDataURL, canCopyImage, copyImageToClipboard, shareOrDownloadGcg, shareOrDownloadImage } from '../lib/share'; + import { shareOrDownloadGcg, shareOrDownloadImage } from '../lib/share'; import { insideVK, vkCopyText } from '../lib/vk'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; - import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm, telegramShowPopup } from '../lib/telegram'; + import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram'; import { haptic } from '../lib/haptics'; import { BLANK, @@ -928,26 +928,18 @@ } // The finished-game export offers two formats — the GCG file and the rendered PNG image — - // behind one 📤 button: a native Telegram popup where available, the app's own modal - // elsewhere (the onResignClick pattern). + // behind one 📤 button, always through the app's own modal. Never Telegram's native popup + // here: its callback runs with NO user activation, so navigator.share / clipboard writes + // silently fail from it (verified on-device, TG iOS + Android). A real DOM button click in + // the modal keeps the gesture alive for the delivery APIs. let exportOpen = $state(false); - let exportPreviewUrl = $state(''); - let exportBlob: Blob | null = null; + // The PNG option is withheld in an in-app WebView (Telegram / VK) for now: a binary image + // has no working delivery there (no Web Share, a dead , text-only clipboard, + // and the long-press menu mangles data: URLs). It returns with the server-rendered + // signed-URL delivery (Telegram downloadFile / VKWebAppDownloadFile). + const exportImageAvailable = $derived(!(insideTelegram() || insideVK())); - async function onExportClick() { - if (insideTelegram() && telegramDialogsAvailable()) { - const choice = await telegramShowPopup({ - message: t('game.exportChoice'), - buttons: [ - { id: 'image', type: 'default', text: t('game.exportImageOpt') }, - { id: 'gcg', type: 'default', text: t('game.exportGcgOpt') }, - { type: 'cancel' }, - ], - }); - if (choice === 'image') void exportImage(); - else if (choice === 'gcg') void exportGcg(); - return; - } + function onExportClick() { exportOpen = true; } @@ -963,38 +955,21 @@ } // exportImage renders the finished game as a PNG (lib/gameimage, dynamically imported so the - // renderer stays out of the startup bundle) and delivers it: Web Share / download where they - // work, else the preview modal (Android Telegram/VK WebView and the desktop VK iframe, where - // neither exists for a binary file) with a long-press/right-click save hint. + // renderer stays out of the startup bundle) and delivers it — Web Share where the platform + // supports it, else a download (only reachable outside the in-app WebViews, see + // exportImageAvailable). async function exportImage() { if (!view) return; try { const { renderGameImage } = await import('../lib/gameimage'); const blob = await renderGameImage(view.game, moves, { actionLabel: moveActionLabel }); const file = new File([blob], `game-${id.slice(0, 8)}.png`, { type: 'image/png' }); - const outcome = await shareOrDownloadImage(file, insideTelegram() || insideVK()); - if (outcome === 'preview') { - exportBlob = blob; - exportPreviewUrl = await blobToDataURL(blob); - } + await shareOrDownloadImage(file); } catch (e) { handleError(e); } } - function closeExportPreview() { - exportPreviewUrl = ''; - exportBlob = null; - } - - async function copyExportImage() { - if (exportBlob && (await copyImageToClipboard(exportBlob))) { - showToast(t('game.imageCopied'), 'info'); - } else { - showToast(t('error.generic'), 'error'); - } - } - // The GCG export copies to the clipboard in an Android in-app WebView (no Web Share, a dead // ): VKWebAppCopyText inside VK — which also works in the desktop VK iframe, where // navigator.clipboard is blocked — and navigator.clipboard elsewhere. @@ -1512,11 +1487,13 @@ {#if exportOpen} (exportOpen = false)}>
- + {#if exportImageAvailable} + + {/if} - {/if} - -{/if} -