feat(ui): real friend-invite share with a per-bot link
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s

The friend-code 'share' was an <a> that just opened the bot. Make it a real share:
Telegram's native share-to-chat picker inside the Mini App (openTelegramLink +
t.me/share/url), the system share sheet (navigator.share) on the web, else copy the
link. The shared deep link points at the same bot the player is in — it picks
VITE_TELEGRAM_LINK_EN/_RU by the session's service language, falling back to the
single VITE_TELEGRAM_LINK. Adds the per-bot build args across Dockerfile / compose /
ci.yaml / .env / docs; PLAN TODO-5 updated.
This commit is contained in:
Ilia Denisov
2026-06-15 15:49:36 +02:00
parent 6679260d0a
commit 03eb8044ff
13 changed files with 107 additions and 17 deletions
+5 -2
View File
@@ -28,8 +28,11 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
`GATEWAY_URL` overrides the dev proxy target; `VITE_GATEWAY_URL` sets the runtime
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID`
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
share-to-Telegram deep-link base. `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU`
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK_EN` /
`VITE_TELEGRAM_LINK_RU` are the per-bot friend-invite Mini App links (full URL
`https://t.me/<bot>/<app>`; the share picks the one matching the bot the player
signed in through, falling back to `VITE_TELEGRAM_LINK`).
`VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU`
are the per-language "Play in Telegram" links shown on the landing page.
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
+13
View File
@@ -35,4 +35,17 @@ describe('shareLink', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456');
});
it('picks the per-bot base by language', () => {
vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/enbot/app');
vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/rubot/app');
expect(shareLink('f1', 'en')).toBe('https://t.me/enbot/app?startapp=f1');
expect(shareLink('f1', 'ru')).toBe('https://t.me/rubot/app?startapp=f1');
});
it('falls back to the language-agnostic base when the per-bot one is unset', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/fallback/app');
vi.stubEnv('VITE_TELEGRAM_LINK_RU', '');
expect(shareLink('f1', 'ru')).toBe('https://t.me/fallback/app?startapp=f1');
});
});
+21 -5
View File
@@ -36,13 +36,29 @@ export const invitationParam = (id: string): string => 'i' + id;
/** friendCodeParam builds the start parameter that redeems a friend code. */
export const friendCodeParam = (code: string): string => 'f' + code;
function envVar(name: string): string | undefined {
return (import.meta.env as Record<string, string | undefined>)[name];
}
/**
* shareLink wraps a deep-link start parameter in a t.me Mini App link, using the
* VITE_TELEGRAM_LINK base (e.g. https://t.me/<bot>/<app>). It returns null when the
* base is not configured, so callers can hide the share affordance.
* telegramBase returns the Mini App link base (e.g. https://t.me/<bot>/<app>) for a
* bot language: VITE_TELEGRAM_LINK_EN / _RU when lang is en/ru, else the
* language-agnostic VITE_TELEGRAM_LINK. Returns null when none is configured, so a
* shared link points at the same bot the player signed in through.
*/
export function shareLink(param: string): string | null {
const base = import.meta.env.VITE_TELEGRAM_LINK as string | undefined;
function telegramBase(lang: string): string | null {
const byLang =
lang === 'ru' ? envVar('VITE_TELEGRAM_LINK_RU') : lang === 'en' ? envVar('VITE_TELEGRAM_LINK_EN') : undefined;
return byLang || envVar('VITE_TELEGRAM_LINK') || null;
}
/**
* shareLink wraps a deep-link start parameter in a t.me Mini App link for the given
* bot language (the session's service language). Returns null when no base is
* configured, so callers can hide the share affordance.
*/
export function shareLink(param: string, lang = ''): string | null {
const base = telegramBase(lang);
if (!base) return null;
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}startapp=${encodeURIComponent(param)}`;
+2
View File
@@ -234,6 +234,8 @@ export const en = {
'friends.copy': 'Copy',
'friends.codeCopied': 'Code copied.',
'friends.shareTelegram': 'Share via Telegram',
'friends.inviteText': "Let's be friends in Scrabble!",
'friends.linkCopied': 'Link copied.',
'friends.added': 'Added {name}.',
'friends.blockedList': 'Blocked players',
'friends.unblock': 'Unblock',
+2
View File
@@ -235,6 +235,8 @@ export const ru: Record<MessageKey, string> = {
'friends.copy': 'Копировать',
'friends.codeCopied': 'Код скопирован.',
'friends.shareTelegram': 'Поделиться через Telegram',
'friends.inviteText': 'Давай дружить в Скрэббл!',
'friends.linkCopied': 'Ссылка скопирована.',
'friends.added': 'Добавлен(а) {name}.',
'friends.blockedList': 'Заблокированные',
'friends.unblock': 'Разблокировать',
+14
View File
@@ -15,6 +15,7 @@ interface TelegramWebApp {
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
ready?: () => void;
expand?: () => void;
openTelegramLink?: (url: string) => void;
onEvent?: (event: string, handler: () => void) => void;
setHeaderColor?: (color: string) => void;
setBackgroundColor?: (color: string) => void;
@@ -49,6 +50,19 @@ export function insideTelegram(): boolean {
return !!w && typeof w.initData === 'string' && w.initData.length > 0;
}
/**
* shareTelegramLink opens Telegram's native "share to a chat" picker for url with a
* caption, through the Mini App SDK (https://t.me/share/url). Returns false outside
* Telegram or when the SDK lacks the method, so the caller can fall back to the Web
* Share API. Works consistently on iOS and Android within Telegram.
*/
export function shareTelegramLink(url: string, text: string): boolean {
const w = webApp();
if (!w?.openTelegramLink) return false;
w.openTelegramLink(`https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`);
return true;
}
/** TelegramLaunch is the data a Mini App launch carries. */
export interface TelegramLaunch {
initData: string;
+25 -2
View File
@@ -5,6 +5,7 @@
import { gateway } from '../lib/gateway';
import { t } from '../lib/i18n/index.svelte';
import { friendCodeParam, shareLink } from '../lib/deeplink';
import { shareTelegramLink } from '../lib/telegram';
import type { AccountRef, FriendCode } from '../lib/model';
let friends = $state<AccountRef[]>([]);
@@ -78,6 +79,28 @@
// Clipboard may be unavailable (insecure context); leave the code on screen.
}
}
// shareInvite shares the friend-code deep link: inside Telegram via the native
// "share to chat" picker; on the web via the system share sheet; failing both, it
// copies the link to the clipboard.
async function shareInvite(url: string) {
const text = t('friends.inviteText');
if (shareTelegramLink(url, text)) return;
if (typeof navigator !== 'undefined' && navigator.share) {
try {
await navigator.share({ text, url });
return;
} catch {
return; // the user dismissed the share sheet
}
}
try {
await navigator.clipboard.writeText(url);
showToast(t('friends.linkCopied'));
} catch {
// Clipboard unavailable (insecure context); nothing more to do.
}
}
</script>
<div class="page">
@@ -97,7 +120,7 @@
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
</div>
{#if code}
{@const tg = shareLink(friendCodeParam(code.code))}
{@const tg = shareLink(friendCodeParam(code.code), app.session?.serviceLanguage || app.locale)}
<div class="code" data-testid="friend-code">
<div class="coderow">
<button class="codeval" onclick={copyCode}>{code.code}</button>
@@ -107,7 +130,7 @@
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span>
{#if tg}
<a class="link tgshare" href={tg} target="_blank" rel="noopener">{t('friends.shareTelegram')}</a>
<button type="button" class="link tgshare" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
{/if}
</div>
{:else}