Compare commits

..

2 Commits

Author SHA1 Message Date
Ilia Denisov 03eb8044ff 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.
2026-06-15 15:49:36 +02:00
Ilia Denisov 6679260d0a feat(session): carry the bot service_language on the Session wire
Thread the Telegram bot's service language (en/ru) from the session mint response
through the gateway into the FlatBuffers Session, so the UI knows which bot the
player signed in through. handleTelegramAuth refreshes the account's service
language onto the response before minting (it was set after the fetched copy).
Empty for a non-Telegram login.
2026-06-15 15:49:26 +02:00
25 changed files with 168 additions and 34 deletions
+2
View File
@@ -267,6 +267,8 @@ jobs:
TELEGRAM_TEST_ENV: "true"
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
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_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }}
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
discharged in Stage 14: the dict repo builds against the published solver + pinned
`dafsa`/`alphabet`, byte-identical to the fixtures.
- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:*
the deep-link scheme now exists (`f<code>`, shared Go ↔ TS), the bot redeems it on
launch, and the UI shows a **share-to-Telegram** link for an issued code when
`VITE_TELEGRAM_LINK` is configured. **Still open:** render the link as a **QR** so a
friend can add you by scanning rather than tapping/typing. The code semantics
(12 h TTL, single use, one active per issuer) stay as-is; only the delivery changes.
- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9, extended later:*
the deep-link scheme exists (`f<code>`, shared Go ↔ TS), the bot redeems it on launch, and the UI
offers a real **Share** control for an issued code — 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 link points at the **same bot the player signed in through**:
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).**
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
+10 -8
View File
@@ -16,10 +16,11 @@ import (
// sessionResponse is the credential returned by every auth endpoint.
type sessionResponse struct {
Token string `json:"token"`
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
DisplayName string `json:"display_name"`
Token string `json:"token"`
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
DisplayName string `json:"display_name"`
ServiceLanguage string `json:"service_language"`
}
// okResponse is a simple success acknowledgement.
@@ -159,10 +160,11 @@ type errorBody struct {
// sessionResponseFor builds the credential payload for a minted session.
func sessionResponseFor(token string, acc account.Account) sessionResponse {
return sessionResponse{
Token: token,
UserID: acc.ID.String(),
IsGuest: acc.IsGuest,
DisplayName: acc.DisplayName,
Token: token,
UserID: acc.ID.String(),
IsGuest: acc.IsGuest,
DisplayName: acc.DisplayName,
ServiceLanguage: acc.ServiceLanguage,
}
}
+1
View File
@@ -48,6 +48,7 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
s.abortErr(c, err)
return
}
acc.ServiceLanguage = req.ServiceLanguage // reflect this login's bot in the session response
s.mintSession(c, acc)
}
+3 -1
View File
@@ -29,7 +29,9 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
# --- UI build args (baked into the gateway image) ---------------------------
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_RU= # landing "Play in Telegram" link, Russian bot
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`. |
| `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_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_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). |
+4
View File
@@ -121,6 +121,8 @@ services:
args:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
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_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
@@ -169,6 +171,8 @@ services:
args:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
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_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-}
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").
ARG VITE_TELEGRAM_BOT_ID=
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_RU=
ARG VITE_GATEWAY_URL=
ARG VITE_APP_VERSION=
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
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_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
+5 -4
View File
@@ -14,10 +14,11 @@ import (
// SessionResp is the credential minted by an auth operation.
type SessionResp struct {
Token string `json:"token"`
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
DisplayName string `json:"display_name"`
Token string `json:"token"`
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
DisplayName string `json:"display_name"`
ServiceLanguage string `json:"service_language"`
}
// ProfileResp is an account's own profile.
+2
View File
@@ -36,6 +36,7 @@ func encodeSession(s backendclient.SessionResp, supportedLangs []string) []byte
token := b.CreateString(s.Token)
uid := b.CreateString(s.UserID)
name := b.CreateString(s.DisplayName)
svcLang := b.CreateString(s.ServiceLanguage)
langs := buildSupportedLanguagesVector(b, supportedLangs)
fb.SessionStart(b)
fb.SessionAddToken(b, token)
@@ -43,6 +44,7 @@ func encodeSession(s backendclient.SessionResp, supportedLangs []string) []byte
fb.SessionAddIsGuest(b, s.IsGuest)
fb.SessionAddDisplayName(b, name)
fb.SessionAddSupportedLanguages(b, langs)
fb.SessionAddServiceLanguage(b, svcLang)
b.Finish(fb.SessionEnd(b))
return b.FinishedBytes()
}
@@ -43,7 +43,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
t.Errorf("unexpected path %q", r.URL.Path)
}
_ = json.NewDecoder(r.Body).Decode(&gotBody)
_, _ = w.Write([]byte(`{"token":"tok-tg","user_id":"u-tg","is_guest":false,"display_name":"Иван"}`))
_, _ = w.Write([]byte(`{"token":"tok-tg","user_id":"u-tg","is_guest":false,"display_name":"Иван","service_language":"ru"}`))
})
defer cleanup()
@@ -67,6 +67,11 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
if got := sessionLanguages(sess); len(got) != 1 || got[0] != "ru" {
t.Errorf("session supported_languages = %v, want [ru]", got)
}
// The bot's service language rides the Session so the UI can build a share link to
// the same bot.
if got := string(sess.ServiceLanguage()); got != "ru" {
t.Errorf("session service_language = %q, want ru", got)
}
// The validated launch fields are forwarded so the backend can seed a new account;
// service_language is recorded to route the account's later out-of-app push.
if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" || gotBody["service_language"] != "ru" {
+4
View File
@@ -123,6 +123,10 @@ table Session {
is_guest:bool;
display_name:string;
supported_languages:[string];
// service_language is the language tag (en/ru) of the Telegram bot the session was
// minted through; empty for a non-Telegram login. The UI uses it to build a share
// link that points at the same bot.
service_language:string;
}
// Ack is a simple success acknowledgement (e.g. an email-code request).
+12 -1
View File
@@ -94,8 +94,16 @@ func (rcv *Session) SupportedLanguagesLength() int {
return 0
}
func (rcv *Session) ServiceLanguage() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func SessionStart(builder *flatbuffers.Builder) {
builder.StartObject(5)
builder.StartObject(6)
}
func SessionAddToken(builder *flatbuffers.Builder, token flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(token), 0)
@@ -115,6 +123,9 @@ func SessionAddSupportedLanguages(builder *flatbuffers.Builder, supportedLanguag
func SessionStartSupportedLanguagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func SessionAddServiceLanguage(builder *flatbuffers.Builder, serviceLanguage flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(serviceLanguage), 0)
}
func SessionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+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
+14 -2
View File
@@ -58,8 +58,15 @@ supportedLanguagesLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
serviceLanguage():string|null
serviceLanguage(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
serviceLanguage(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startSession(builder:flatbuffers.Builder) {
builder.startObject(5);
builder.startObject(6);
}
static addToken(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset) {
@@ -94,18 +101,23 @@ static startSupportedLanguagesVector(builder:flatbuffers.Builder, numElems:numbe
builder.startVector(4, numElems, 4);
}
static addServiceLanguage(builder:flatbuffers.Builder, serviceLanguageOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, serviceLanguageOffset, 0);
}
static endSession(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createSession(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset, userIdOffset:flatbuffers.Offset, isGuest:boolean, displayNameOffset:flatbuffers.Offset, supportedLanguagesOffset:flatbuffers.Offset):flatbuffers.Offset {
static createSession(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset, userIdOffset:flatbuffers.Offset, isGuest:boolean, displayNameOffset:flatbuffers.Offset, supportedLanguagesOffset:flatbuffers.Offset, serviceLanguageOffset:flatbuffers.Offset):flatbuffers.Offset {
Session.startSession(builder);
Session.addToken(builder, tokenOffset);
Session.addUserId(builder, userIdOffset);
Session.addIsGuest(builder, isGuest);
Session.addDisplayName(builder, displayNameOffset);
Session.addSupportedLanguages(builder, supportedLanguagesOffset);
Session.addServiceLanguage(builder, serviceLanguageOffset);
return Session.endSession(builder);
}
}
+2 -1
View File
@@ -171,6 +171,7 @@ describe('codec', () => {
isGuest: true,
displayName: 'Me',
supportedLanguages: ['en', 'ru'],
serviceLanguage: '',
});
});
@@ -311,7 +312,7 @@ describe('codec', () => {
b.finish(fb.LinkResult.endLinkResult(b));
const r = decodeLinkResult(b.asUint8Array());
expect(r.status).toBe('merged');
expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya', supportedLanguages: [] });
expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya', supportedLanguages: [], serviceLanguage: '' });
});
it('decodes an Invitation with inviter and invitees', () => {
+1
View File
@@ -292,6 +292,7 @@ function sessionFromTable(t: fb.Session): Session {
isGuest: t.isGuest(),
displayName: s(t.displayName()),
supportedLanguages,
serviceLanguage: s(t.serviceLanguage()),
};
}
+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': 'Разблокировать',
+1
View File
@@ -25,6 +25,7 @@ export const SESSION: Session = {
displayName: 'You',
// Both languages by default, so the mock-driven UI offers every variant.
supportedLanguages: ['en', 'ru'],
serviceLanguage: '',
};
export const PROFILE: Profile = {
+3
View File
@@ -234,6 +234,9 @@ export interface Session {
// variants these languages support (en -> English; ru -> Russian + Эрудит). Empty
// means ungated (all variants).
supportedLanguages: string[];
// serviceLanguage is the en/ru tag of the Telegram bot this session was minted
// through (empty for a non-Telegram login); used to share a link to the same bot.
serviceLanguage: string;
}
// LinkResult is the outcome of an account link/merge step. status is
+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}