fix(export): per-platform polish from the on-device review
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
- TG iOS: the OS share sheet with the fetched file (WKWebView has navigator.share — the долгоживущий debug-overlay Share path proves it). The chooser stays the app modal on this one platform: a native popup callback cannot supply the user activation the sheet needs. TG Android/desktop keep the native popup + downloadFile (verified perfect, untouched). - VK mobile: the PNG now rides VKWebAppDownloadFile like the GCG — the owner-verified path that lands in VK's native share flow; the ShowImages viewer is dropped (its preview offered save only, no share). - VK desktop iframe: both formats are plain anchor downloads (it is an ordinary browser; the viewer/bridge path made no sense there).
This commit is contained in:
@@ -70,6 +70,7 @@ test('inside Telegram the chooser is the native popup and delivery the native do
|
||||
WebApp: {
|
||||
initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef',
|
||||
initDataUnsafe: {},
|
||||
platform: 'android',
|
||||
ready() {},
|
||||
expand() {},
|
||||
showPopup(params: { buttons?: { id?: string; type?: string }[] }, cb: (id: string) => void) {
|
||||
@@ -142,6 +143,60 @@ test('a mobile browser gets the OS share sheet with the fetched file', async ({
|
||||
expect(shared.type).toBe('image/png');
|
||||
});
|
||||
|
||||
test('Telegram on iOS keeps the app chooser and opens the OS share sheet', async ({ page }) => {
|
||||
// TG iOS: WKWebView has navigator.share, and the sheet is the preferred delivery — but
|
||||
// it needs the user activation a native-popup callback cannot provide, so the chooser
|
||||
// stays the app modal on this one platform.
|
||||
await page.addInitScript(() => {
|
||||
const shared: { name: string; type: string }[] = [];
|
||||
Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true });
|
||||
Object.defineProperty(navigator, 'share', {
|
||||
value: async (data: { files?: File[] }) => {
|
||||
for (const f of data.files ?? []) shared.push({ name: f.name, type: f.type });
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
(window as unknown as { __shared: typeof shared }).__shared = shared;
|
||||
Object.assign(window, {
|
||||
Telegram: {
|
||||
WebApp: {
|
||||
initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef',
|
||||
initDataUnsafe: {},
|
||||
platform: 'ios',
|
||||
ready() {},
|
||||
expand() {},
|
||||
showPopup() {
|
||||
(window as unknown as { __popupCalled?: boolean }).__popupCalled = true;
|
||||
},
|
||||
downloadFile() {
|
||||
(window as unknown as { __downloadCalled?: boolean }).__downloadCalled = true;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await routeDl(page);
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
await page.getByRole('button', { name: /Rick/ }).click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
await page.locator('.scoreboard').click();
|
||||
await page.getByRole('button', { name: 'Export game' }).click();
|
||||
|
||||
// The app modal opened (both formats) — the native popup was not used…
|
||||
await expect(page.getByRole('button', { name: 'Image (PNG)' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Image (PNG)' }).click();
|
||||
// …and the artifact went to the OS share sheet, not TG's download dialog.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as unknown as { __shared: unknown[] }).__shared))
|
||||
.toHaveLength(1);
|
||||
const flags = await page.evaluate(() => {
|
||||
const w = window as unknown as { __popupCalled?: boolean; __downloadCalled?: boolean };
|
||||
return { popup: !!w.__popupCalled, download: !!w.__downloadCalled };
|
||||
});
|
||||
expect(flags).toEqual({ popup: false, download: false });
|
||||
});
|
||||
|
||||
test('a legacy Telegram client (no downloadFile) hides the image and copies the GCG', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
// Headless engines deny the real clipboard; a permissive stub keeps the legacy
|
||||
|
||||
+29
-23
@@ -23,11 +23,11 @@
|
||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||
import { hintsLeft } from '../lib/hints';
|
||||
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
|
||||
import { insideVK, vkCopyText, vkDownloadFile, vkShowImages } from '../lib/vk';
|
||||
import { insideVK, vkCopyText, vkDownloadFile, vkPlatform } 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, telegramCanDownloadFile, telegramDialogsAvailable, telegramDownloadFile, telegramShowConfirm, telegramShowPopup } from '../lib/telegram';
|
||||
import { insideTelegram, telegramCanDownloadFile, telegramDialogsAvailable, telegramDownloadFile, telegramPlatform, telegramShowConfirm, telegramShowPopup } from '../lib/telegram';
|
||||
import { haptic } from '../lib/haptics';
|
||||
import {
|
||||
BLANK,
|
||||
@@ -933,25 +933,26 @@
|
||||
|
||||
// The finished-game export offers two formats — the GCG file and the server-rendered PNG
|
||||
// image — behind one 📤 button, both minted as one signed relative URL and delivered by
|
||||
// the most platform-native affordance:
|
||||
// Telegram native showPopup chooser → native downloadFile dialog. All-native and
|
||||
// activation-safe: a popup callback carries no user activation, but the
|
||||
// whole TG chain is bridge calls, never gesture-gated Web APIs.
|
||||
// VK our modal chooser (VK has no native one) → the PNG opens in VK's native
|
||||
// image viewer (VKWebAppShowImages — on screen at once, save/share from the
|
||||
// viewer's own controls); the GCG goes through VKWebAppDownloadFile; either
|
||||
// falls back to a plain anchor download when the bridge call fails (the
|
||||
// desktop iframe is an ordinary browser).
|
||||
// browsers our modal → the OS share sheet with the fetched file where files can be
|
||||
// shared (mobile), else a plain anchor download (desktop).
|
||||
// the best affordance each platform actually has (every branch owner-verified on-device):
|
||||
// TG Android/desktop native showPopup chooser → native downloadFile dialog (whose own
|
||||
// preview shares onwards). All bridge calls — activation-safe.
|
||||
// TG iOS our modal chooser → the OS share sheet (WKWebView has
|
||||
// navigator.share; a native-popup callback could not open it — no
|
||||
// user activation — so the chooser stays the app modal here).
|
||||
// VK mobile our modal (VK has no native chooser) → VKWebAppDownloadFile for
|
||||
// both formats — the client lands in its native share/preview flow.
|
||||
// VK desktop iframe our modal → plain anchor downloads (an ordinary browser).
|
||||
// mobile browsers our modal → the OS share sheet with the fetched file.
|
||||
// desktop browsers our modal → plain anchor download.
|
||||
let exportOpen = $state(false);
|
||||
// The only platform without the URL delivery is a legacy Telegram client predating
|
||||
// Bot API 8.0 downloadFile: the GCG falls back to the old clipboard copy there, and
|
||||
// the image option is not offered (and the chooser stays our modal).
|
||||
const exportImageAvailable = $derived(!insideTelegram() || telegramCanDownloadFile());
|
||||
// TG iOS shares via the OS sheet regardless of Bot API 8.0; elsewhere in Telegram a
|
||||
// legacy client without downloadFile keeps the old GCG clipboard copy and hides the
|
||||
// image option (and the chooser stays our modal).
|
||||
const tgShareSheet = $derived(insideTelegram() && telegramPlatform() === 'ios');
|
||||
const exportImageAvailable = $derived(!insideTelegram() || tgShareSheet || telegramCanDownloadFile());
|
||||
|
||||
async function onExportClick() {
|
||||
if (insideTelegram() && telegramCanDownloadFile() && telegramDialogsAvailable()) {
|
||||
if (insideTelegram() && !tgShareSheet && telegramCanDownloadFile() && telegramDialogsAvailable()) {
|
||||
const choice = await telegramShowPopup({
|
||||
message: t('game.exportChoice'),
|
||||
buttons: [
|
||||
@@ -984,7 +985,7 @@
|
||||
// the URL so the server-rendered image matches what the player would have seen locally.
|
||||
const EXPORT_ACTIONS = ['pass', 'exchange', 'resign', 'timeout'] as const;
|
||||
async function exportArtifact(kind: 'png' | 'gcg') {
|
||||
if (insideTelegram() && !telegramCanDownloadFile()) {
|
||||
if (insideTelegram() && !tgShareSheet && !telegramCanDownloadFile()) {
|
||||
void exportGcgLegacy();
|
||||
return;
|
||||
}
|
||||
@@ -994,14 +995,19 @@
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
|
||||
const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone);
|
||||
const url = new URL(path, location.origin).href;
|
||||
if (insideTelegram() && telegramDownloadFile(url, filename)) return;
|
||||
const mime = kind === 'png' ? 'image/png' : 'text/plain';
|
||||
if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return;
|
||||
if (insideVK()) {
|
||||
if (kind === 'png' && (await vkShowImages(url))) return;
|
||||
if (kind === 'gcg' && (await vkDownloadFile(url, filename))) return;
|
||||
// Desktop iframe: an ordinary browser, both formats download by anchor. Mobile
|
||||
// clients take VKWebAppDownloadFile into VK's native share/preview flow; a failed
|
||||
// bridge call falls back to the anchor.
|
||||
if (!vkPlatform().startsWith('desktop') && (await vkDownloadFile(url, filename))) return;
|
||||
downloadUrl(url, filename);
|
||||
return;
|
||||
}
|
||||
const outcome = await shareUrlAsFile(url, filename, kind === 'png' ? 'image/png' : 'text/plain');
|
||||
// TG iOS and plain browsers: the OS share sheet with the fetched file (falls back to
|
||||
// a download where files cannot be shared — the desktop browser).
|
||||
const outcome = await shareUrlAsFile(url, filename, mime);
|
||||
if (outcome === 'failed') showToast(t('error.generic'), 'error');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
|
||||
@@ -387,6 +387,12 @@ export function telegramCanDownloadFile(): boolean {
|
||||
return !!webApp()?.downloadFile;
|
||||
}
|
||||
|
||||
/** telegramPlatform returns the SDK's platform tag ('ios', 'android', 'tdesktop', …) or ''
|
||||
* outside Telegram — the export delivery branches on it (iOS gets the OS share sheet). */
|
||||
export function telegramPlatform(): string {
|
||||
return webApp()?.platform ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramDownloadFile asks Telegram to download url as fileName through its native
|
||||
* dialog — the Mini App file delivery that works on every Telegram platform (a webview
|
||||
|
||||
@@ -155,20 +155,6 @@ export async function vkShare(link: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkShowImages opens VK's native image viewer on url — the image is on screen at once and
|
||||
* the viewer's own controls save/share it, which beats a blind background download for a
|
||||
* picture. Resolves false on any failure or outside VK, so the caller can fall back.
|
||||
*/
|
||||
export async function vkShowImages(url: string): Promise<boolean> {
|
||||
try {
|
||||
await (await bridge()).send('VKWebAppShowImages', { images: [url] });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) —
|
||||
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false
|
||||
|
||||
Reference in New Issue
Block a user