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
+2
View File
@@ -267,6 +267,8 @@ jobs:
TELEGRAM_TEST_ENV: "true" TELEGRAM_TEST_ENV: "true"
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }}
VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_RU }}
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }} VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }}
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }} VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }}
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
+9 -6
View File
@@ -1560,12 +1560,15 @@ cannot submit; three-way admin filter.
unchanged). The DAWG/solver build-time agreement (the original caveat, shared with TODO-2) was unchanged). The DAWG/solver build-time agreement (the original caveat, shared with TODO-2) was
discharged in Stage 14: the dict repo builds against the published solver + pinned discharged in Stage 14: the dict repo builds against the published solver + pinned
`dafsa`/`alphabet`, byte-identical to the fixtures. `dafsa`/`alphabet`, byte-identical to the fixtures.
- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:* - **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9, extended later:*
the deep-link scheme now exists (`f<code>`, shared Go ↔ TS), the bot redeems it on the deep-link scheme exists (`f<code>`, shared Go ↔ TS), the bot redeems it on launch, and the UI
launch, and the UI shows a **share-to-Telegram** link for an issued code when offers a real **Share** control for an issued code — Telegram's native share-to-chat picker inside
`VITE_TELEGRAM_LINK` is configured. **Still open:** render the link as a **QR** so a the Mini App (`openTelegramLink` + `t.me/share/url`), the system share sheet (`navigator.share`) on
friend can add you by scanning rather than tapping/typing. The code semantics the web, else copy-the-link. The shared link points at the **same bot the player signed in through**:
(12 h TTL, single use, one active per issuer) stay as-is; only the delivery changes. it carries the session's `service_language` (now on the `Session` wire) and picks
`VITE_TELEGRAM_LINK_EN`/`_RU`, falling back to `VITE_TELEGRAM_LINK`. **Still open:** render the link
as a **QR** so a friend can add you by scanning. The code semantics (12 h TTL, single use, one active
per issuer) stay as-is; only the delivery changes.
- **TODO-6 — smart default for the friend-game "game type" (owner's idea, Stage 8).** - **TODO-6 — smart default for the friend-game "game type" (owner's idea, Stage 8).**
The play-with-friends form has no preselected variant today (an empty, required The play-with-friends form has no preselected variant today (an empty, required
pick). Default it from the player's history (the variant they play most, from pick). Default it from the player's history (the variant they play most, from
+3 -1
View File
@@ -29,7 +29,9 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
# --- UI build args (baked into the gateway image) --------------------------- # --- UI build args (baked into the gateway image) ---------------------------
VITE_TELEGRAM_BOT_ID= VITE_TELEGRAM_BOT_ID=
VITE_TELEGRAM_LINK= VITE_TELEGRAM_LINK= # fallback friend-invite Mini App link (per-bot below)
VITE_TELEGRAM_LINK_EN= # friend-invite Mini App link, English bot (full URL, e.g. https://t.me/<bot>/<app>)
VITE_TELEGRAM_LINK_RU= # friend-invite Mini App link, Russian bot (full URL)
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= # landing "Play in Telegram" link, English bot VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= # landing "Play in Telegram" link, English bot
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= # landing "Play in Telegram" link, Russian bot VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= # landing "Play in Telegram" link, Russian bot
VITE_GATEWAY_URL= VITE_GATEWAY_URL=
+3 -1
View File
@@ -84,7 +84,9 @@ connector **fails at boot** if both are empty.
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. | | `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). | | `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). |
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. | | `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: deep-link base for share-to-Telegram (e.g. `https://t.me/<bot>/<app>`). | | `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: **fallback** friend-invite Mini App link (e.g. `https://t.me/<bot>/<app>`), used when the per-bot link below is unset. |
| `VITE_TELEGRAM_LINK_EN` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link for the **English** bot (full URL, `https://t.me/<bot>/<app>``<app>` is the Mini App short name from BotFather). The friend-code share uses the link matching the bot the player signed in through. |
| `VITE_TELEGRAM_LINK_RU` | variable | _(empty)_ | UI build-arg: same for the **Russian** bot. |
| `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **English** bot (e.g. `https://t.me/Scrabble_Game`). | | `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **English** bot (e.g. `https://t.me/Scrabble_Game`). |
| `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **Russian** bot (e.g. `https://t.me/Erudit_Game`). | | `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **Russian** bot (e.g. `https://t.me/Erudit_Game`). |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
+4
View File
@@ -121,6 +121,8 @@ services:
args: args:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_LINK_EN: ${VITE_TELEGRAM_LINK_EN:-}
VITE_TELEGRAM_LINK_RU: ${VITE_TELEGRAM_LINK_RU:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-} VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-} VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
@@ -169,6 +171,8 @@ services:
args: args:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_LINK_EN: ${VITE_TELEGRAM_LINK_EN:-}
VITE_TELEGRAM_LINK_RU: ${VITE_TELEGRAM_LINK_RU:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-} VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-} VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
+4
View File
@@ -23,12 +23,16 @@ RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev"). # VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
ARG VITE_TELEGRAM_BOT_ID= ARG VITE_TELEGRAM_BOT_ID=
ARG VITE_TELEGRAM_LINK= ARG VITE_TELEGRAM_LINK=
ARG VITE_TELEGRAM_LINK_EN=
ARG VITE_TELEGRAM_LINK_RU=
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=
ARG VITE_GATEWAY_URL= ARG VITE_GATEWAY_URL=
ARG VITE_APP_VERSION= ARG VITE_APP_VERSION=
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \
VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_RU \
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \ VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \ VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \ VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
+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_URL` overrides the dev proxy target; `VITE_GATEWAY_URL` sets the runtime
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` 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 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 domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK_EN` /
share-to-Telegram deep-link base. `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` `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. 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 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'); vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456'); 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. */ /** friendCodeParam builds the start parameter that redeems a friend code. */
export const friendCodeParam = (code: string): string => 'f' + 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 * telegramBase returns the Mini App link base (e.g. https://t.me/<bot>/<app>) for a
* VITE_TELEGRAM_LINK base (e.g. https://t.me/<bot>/<app>). It returns null when the * bot language: VITE_TELEGRAM_LINK_EN / _RU when lang is en/ru, else the
* base is not configured, so callers can hide the share affordance. * 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 { function telegramBase(lang: string): string | null {
const base = import.meta.env.VITE_TELEGRAM_LINK as string | undefined; 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; if (!base) return null;
const sep = base.includes('?') ? '&' : '?'; const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}startapp=${encodeURIComponent(param)}`; return `${base}${sep}startapp=${encodeURIComponent(param)}`;
+2
View File
@@ -234,6 +234,8 @@ export const en = {
'friends.copy': 'Copy', 'friends.copy': 'Copy',
'friends.codeCopied': 'Code copied.', 'friends.codeCopied': 'Code copied.',
'friends.shareTelegram': 'Share via Telegram', 'friends.shareTelegram': 'Share via Telegram',
'friends.inviteText': "Let's be friends in Scrabble!",
'friends.linkCopied': 'Link copied.',
'friends.added': 'Added {name}.', 'friends.added': 'Added {name}.',
'friends.blockedList': 'Blocked players', 'friends.blockedList': 'Blocked players',
'friends.unblock': 'Unblock', 'friends.unblock': 'Unblock',
+2
View File
@@ -235,6 +235,8 @@ export const ru: Record<MessageKey, string> = {
'friends.copy': 'Копировать', 'friends.copy': 'Копировать',
'friends.codeCopied': 'Код скопирован.', 'friends.codeCopied': 'Код скопирован.',
'friends.shareTelegram': 'Поделиться через Telegram', 'friends.shareTelegram': 'Поделиться через Telegram',
'friends.inviteText': 'Давай дружить в Скрэббл!',
'friends.linkCopied': 'Ссылка скопирована.',
'friends.added': 'Добавлен(а) {name}.', 'friends.added': 'Добавлен(а) {name}.',
'friends.blockedList': 'Заблокированные', 'friends.blockedList': 'Заблокированные',
'friends.unblock': 'Разблокировать', 'friends.unblock': 'Разблокировать',
+14
View File
@@ -15,6 +15,7 @@ interface TelegramWebApp {
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
ready?: () => void; ready?: () => void;
expand?: () => void; expand?: () => void;
openTelegramLink?: (url: string) => void;
onEvent?: (event: string, handler: () => void) => void; onEvent?: (event: string, handler: () => void) => void;
setHeaderColor?: (color: string) => void; setHeaderColor?: (color: string) => void;
setBackgroundColor?: (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; 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. */ /** TelegramLaunch is the data a Mini App launch carries. */
export interface TelegramLaunch { export interface TelegramLaunch {
initData: string; initData: string;
+25 -2
View File
@@ -5,6 +5,7 @@
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { friendCodeParam, shareLink } from '../lib/deeplink'; import { friendCodeParam, shareLink } from '../lib/deeplink';
import { shareTelegramLink } from '../lib/telegram';
import type { AccountRef, FriendCode } from '../lib/model'; import type { AccountRef, FriendCode } from '../lib/model';
let friends = $state<AccountRef[]>([]); let friends = $state<AccountRef[]>([]);
@@ -78,6 +79,28 @@
// Clipboard may be unavailable (insecure context); leave the code on screen. // 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> </script>
<div class="page"> <div class="page">
@@ -97,7 +120,7 @@
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button> <button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
</div> </div>
{#if code} {#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="code" data-testid="friend-code">
<div class="coderow"> <div class="coderow">
<button class="codeval" onclick={copyCode}>{code.code}</button> <button class="codeval" onclick={copyCode}>{code.code}</button>
@@ -107,7 +130,7 @@
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span> </span>
{#if tg} {#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} {/if}
</div> </div>
{:else} {:else}