Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
New platform/telegram connector (own container, bot token only there): - go-telegram/bot long-poll loop: /start deep-links + Mini App launch button. - gRPC API pkg/proto/telegram/v1 (Telegram service): ValidateInitData, Notify (renders a localized message + deep-link button), SendToUser/SendToGameChannel (admin, wired in Stage 10). Generic methods are platform-agnostic (external_id). - Bot API base override for Telegram's test environment; Dockerfile + compose (VPN sidecar, no public ingress); README. Gateway: - initData validation relocated from the gateway into the connector; the gateway calls ValidateInitData over gRPC (GATEWAY_CONNECTOR_ADDR), drops the bot token, and deletes internal/auth. - Out-of-app push: runPushPump routes events whose recipient has no live in-app stream to connector.Notify, gated by /internal/push-target + the in-app-only flag (race-free de-dup); HasSubscribers added to the push hub. Backend: - Migration 00007 accounts.notifications_in_app_only (default true) + jetgen. - ProvisionTelegram seeds a new account's language/display name from the launch fields; IdentityExternalID reverse lookup; /internal/push-target handler. UI: - Telegram Mini App launch: detect initData, apply themeParams, authTelegram, route the deep-link start_param (g/i/f); /telegram/ guard redirects outside Telegram. Vite relative base + telegram-web-app.js. In-app-only profile toggle; share-to-Telegram link for a friend code. Vitest + Playwright coverage. Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only (Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN, ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
This commit is contained in:
@@ -82,8 +82,13 @@ awayEnd(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
notificationsInAppOnly():boolean {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 24);
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : true;
|
||||
}
|
||||
|
||||
static startProfile(builder:flatbuffers.Builder) {
|
||||
builder.startObject(10);
|
||||
builder.startObject(11);
|
||||
}
|
||||
|
||||
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
|
||||
@@ -126,12 +131,16 @@ static addAwayEnd(builder:flatbuffers.Builder, awayEndOffset:flatbuffers.Offset)
|
||||
builder.addFieldOffset(9, awayEndOffset, 0);
|
||||
}
|
||||
|
||||
static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInAppOnly:boolean) {
|
||||
builder.addFieldInt8(10, +notificationsInAppOnly, +true);
|
||||
}
|
||||
|
||||
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, notificationsInAppOnly:boolean):flatbuffers.Offset {
|
||||
Profile.startProfile(builder);
|
||||
Profile.addUserId(builder, userIdOffset);
|
||||
Profile.addDisplayName(builder, displayNameOffset);
|
||||
@@ -143,6 +152,7 @@ static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offse
|
||||
Profile.addIsGuest(builder, isGuest);
|
||||
Profile.addAwayStart(builder, awayStartOffset);
|
||||
Profile.addAwayEnd(builder, awayEndOffset);
|
||||
Profile.addNotificationsInAppOnly(builder, notificationsInAppOnly);
|
||||
return Profile.endProfile(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,13 @@ blockFriendRequests():boolean {
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
notificationsInAppOnly():boolean {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 18);
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : true;
|
||||
}
|
||||
|
||||
static startUpdateProfileRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(7);
|
||||
builder.startObject(8);
|
||||
}
|
||||
|
||||
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
|
||||
@@ -97,12 +102,16 @@ static addBlockFriendRequests(builder:flatbuffers.Builder, blockFriendRequests:b
|
||||
builder.addFieldInt8(6, +blockFriendRequests, +false);
|
||||
}
|
||||
|
||||
static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInAppOnly:boolean) {
|
||||
builder.addFieldInt8(7, +notificationsInAppOnly, +true);
|
||||
}
|
||||
|
||||
static endUpdateProfileRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean):flatbuffers.Offset {
|
||||
static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean, notificationsInAppOnly:boolean):flatbuffers.Offset {
|
||||
UpdateProfileRequest.startUpdateProfileRequest(builder);
|
||||
UpdateProfileRequest.addDisplayName(builder, displayNameOffset);
|
||||
UpdateProfileRequest.addPreferredLanguage(builder, preferredLanguageOffset);
|
||||
@@ -111,6 +120,7 @@ static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset
|
||||
UpdateProfileRequest.addAwayEnd(builder, awayEndOffset);
|
||||
UpdateProfileRequest.addBlockChat(builder, blockChat);
|
||||
UpdateProfileRequest.addBlockFriendRequests(builder, blockFriendRequests);
|
||||
UpdateProfileRequest.addNotificationsInAppOnly(builder, notificationsInAppOnly);
|
||||
return UpdateProfileRequest.endUpdateProfileRequest(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import { gateway } from './gateway';
|
||||
import { GatewayError } from './client';
|
||||
import { navigate, router } from './router.svelte';
|
||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
||||
import { applyReduceMotion, applyTheme, type ThemePref } from './theme';
|
||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
|
||||
import { insideTelegram, onTelegramPath, telegramLaunch } from './telegram';
|
||||
import { parseStartParam } from './deeplink';
|
||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||
import type { BoardLabelMode } from './boardlabels';
|
||||
|
||||
@@ -144,6 +146,28 @@ export async function bootstrap(): Promise<void> {
|
||||
setLocale(guess);
|
||||
}
|
||||
|
||||
// Telegram Mini App launch: apply the platform theme, authenticate via initData,
|
||||
// and route any deep-link start parameter. On the dedicated /telegram/ entry path
|
||||
// outside Telegram (no initData), refuse to render and send the visitor to the
|
||||
// site root.
|
||||
if (onTelegramPath() && !insideTelegram()) {
|
||||
if (typeof location !== 'undefined') location.replace('/');
|
||||
return;
|
||||
}
|
||||
if (insideTelegram()) {
|
||||
const launch = telegramLaunch();
|
||||
if (launch.theme) applyTelegramTheme(launch.theme);
|
||||
try {
|
||||
await adoptSession(await gateway.authTelegram(launch.initData));
|
||||
await routeStartParam(launch.startParam);
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
navigate('/login');
|
||||
}
|
||||
app.ready = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await loadSession();
|
||||
if (saved) {
|
||||
await adoptSession(saved);
|
||||
@@ -154,6 +178,32 @@ export async function bootstrap(): Promise<void> {
|
||||
app.ready = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* routeStartParam navigates a Telegram deep-link start parameter to its target: a
|
||||
* specific game, the friends screen with a friend-code redemption, or the lobby
|
||||
* (where invitations surface as a badge).
|
||||
*/
|
||||
async function routeStartParam(param: string): Promise<void> {
|
||||
const link = parseStartParam(param);
|
||||
switch (link.kind) {
|
||||
case 'game':
|
||||
navigate(`/game/${link.id}`);
|
||||
return;
|
||||
case 'friendCode':
|
||||
navigate('/friends');
|
||||
try {
|
||||
const friend = await gateway.friendCodeRedeem(link.code);
|
||||
showToast(t('friends.added', { name: friend.displayName }));
|
||||
void refreshNotifications();
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
return;
|
||||
default:
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginGuest(): Promise<void> {
|
||||
try {
|
||||
const s = await gateway.authGuest(app.locale);
|
||||
@@ -233,6 +283,7 @@ async function persistLanguageToServer(locale: Locale): Promise<void> {
|
||||
awayEnd: p.awayEnd,
|
||||
blockChat: p.blockChat,
|
||||
blockFriendRequests: p.blockFriendRequests,
|
||||
notificationsInAppOnly: p.notificationsInAppOnly,
|
||||
});
|
||||
} catch {
|
||||
// The client locale already changed; the server sync is best-effort.
|
||||
|
||||
@@ -52,6 +52,7 @@ export type Unsubscribe = () => void;
|
||||
|
||||
export interface GatewayClient {
|
||||
// --- auth (unauthenticated) ---
|
||||
authTelegram(initData: string): Promise<Session>;
|
||||
authGuest(locale?: string): Promise<Session>;
|
||||
authEmailRequest(email: string): Promise<void>;
|
||||
authEmailLogin(email: string, code: string): Promise<Session>;
|
||||
|
||||
@@ -146,6 +146,14 @@ export function encodeChatPost(gameId: string, body: string): Uint8Array {
|
||||
return finish(b, fb.ChatPostRequest.endChatPostRequest(b));
|
||||
}
|
||||
|
||||
export function encodeTelegramLogin(initData: string): Uint8Array {
|
||||
const b = new Builder(512);
|
||||
const d = b.createString(initData);
|
||||
fb.TelegramLoginRequest.startTelegramLoginRequest(b);
|
||||
fb.TelegramLoginRequest.addInitData(b, d);
|
||||
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
|
||||
}
|
||||
|
||||
export function encodeGuestLogin(locale: string): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
const l = b.createString(locale);
|
||||
@@ -264,6 +272,7 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
||||
blockChat: p.blockChat(),
|
||||
blockFriendRequests: p.blockFriendRequests(),
|
||||
isGuest: p.isGuest(),
|
||||
notificationsInAppOnly: p.notificationsInAppOnly(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -444,6 +453,7 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
|
||||
fb.UpdateProfileRequest.addAwayEnd(b, ae);
|
||||
fb.UpdateProfileRequest.addBlockChat(b, p.blockChat);
|
||||
fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests);
|
||||
fb.UpdateProfileRequest.addNotificationsInAppOnly(b, p.notificationsInAppOnly);
|
||||
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink';
|
||||
|
||||
describe('parseStartParam', () => {
|
||||
it('classifies game / invitation / friend code', () => {
|
||||
expect(parseStartParam('g7c9e6679')).toEqual({ kind: 'game', id: '7c9e6679' });
|
||||
expect(parseStartParam('iabc-123')).toEqual({ kind: 'invitation', id: 'abc-123' });
|
||||
expect(parseStartParam('f123456')).toEqual({ kind: 'friendCode', code: '123456' });
|
||||
});
|
||||
|
||||
it('falls back to the lobby for empty / unknown / value-less params', () => {
|
||||
expect(parseStartParam('')).toEqual({ kind: 'lobby' });
|
||||
expect(parseStartParam(undefined)).toEqual({ kind: 'lobby' });
|
||||
expect(parseStartParam(null)).toEqual({ kind: 'lobby' });
|
||||
expect(parseStartParam('x-nope')).toEqual({ kind: 'lobby' });
|
||||
expect(parseStartParam('g')).toEqual({ kind: 'lobby' });
|
||||
});
|
||||
|
||||
it('round-trips the build helpers', () => {
|
||||
expect(parseStartParam(gameParam('id1'))).toEqual({ kind: 'game', id: 'id1' });
|
||||
expect(parseStartParam(invitationParam('id2'))).toEqual({ kind: 'invitation', id: 'id2' });
|
||||
expect(parseStartParam(friendCodeParam('654321'))).toEqual({ kind: 'friendCode', code: '654321' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('shareLink', () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it('returns null without a configured base', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK', '');
|
||||
expect(shareLink('gx')).toBeNull();
|
||||
});
|
||||
|
||||
it('wraps a payload in a startapp link', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
|
||||
expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Telegram Mini App deep-link "start parameters", mirroring the connector's Go
|
||||
// scheme (platform/telegram/internal/deeplink): a one-character kind prefix plus a
|
||||
// value —
|
||||
// g<game uuid> open that game
|
||||
// i<invitation uuid> open that invitation
|
||||
// f<6-digit code> redeem that friend code
|
||||
// An empty or unrecognised parameter opens the lobby.
|
||||
|
||||
export type DeepLink =
|
||||
| { kind: 'lobby' }
|
||||
| { kind: 'game'; id: string }
|
||||
| { kind: 'invitation'; id: string }
|
||||
| { kind: 'friendCode'; code: string };
|
||||
|
||||
/** parseStartParam classifies a Telegram start parameter into a routing target. */
|
||||
export function parseStartParam(param: string | undefined | null): DeepLink {
|
||||
if (!param) return { kind: 'lobby' };
|
||||
const value = param.slice(1);
|
||||
if (!value) return { kind: 'lobby' };
|
||||
switch (param[0]) {
|
||||
case 'g':
|
||||
return { kind: 'game', id: value };
|
||||
case 'i':
|
||||
return { kind: 'invitation', id: value };
|
||||
case 'f':
|
||||
return { kind: 'friendCode', code: value };
|
||||
default:
|
||||
return { kind: 'lobby' };
|
||||
}
|
||||
}
|
||||
|
||||
/** gameParam builds the start parameter that opens a game. */
|
||||
export const gameParam = (id: string): string => 'g' + id;
|
||||
/** invitationParam builds the start parameter that opens an invitation. */
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function shareLink(param: string): string | null {
|
||||
const base = import.meta.env.VITE_TELEGRAM_LINK as string | undefined;
|
||||
if (!base) return null;
|
||||
const sep = base.includes('?') ? '&' : '?';
|
||||
return `${base}${sep}startapp=${encodeURIComponent(param)}`;
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export const en = {
|
||||
'profile.to': 'To',
|
||||
'profile.blockChat': 'Disable chat',
|
||||
'profile.blockFriendRequests': 'Disable friend requests',
|
||||
'profile.notificationsInAppOnly': 'Notifications in the app only',
|
||||
'profile.email': 'Email',
|
||||
'profile.bindEmail': 'Bind email',
|
||||
'profile.emailCode': 'Confirmation code',
|
||||
@@ -180,6 +181,7 @@ export const en = {
|
||||
'friends.redeem': 'Add',
|
||||
'friends.copy': 'Copy',
|
||||
'friends.codeCopied': 'Code copied.',
|
||||
'friends.shareTelegram': 'Share via Telegram',
|
||||
'friends.added': 'Added {name}.',
|
||||
'friends.blockedList': 'Blocked players',
|
||||
'friends.unblock': 'Unblock',
|
||||
|
||||
@@ -111,6 +111,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'profile.to': 'До',
|
||||
'profile.blockChat': 'Отключить чат',
|
||||
'profile.blockFriendRequests': 'Отключить заявки в друзья',
|
||||
'profile.notificationsInAppOnly': 'Уведомления только в приложении',
|
||||
'profile.email': 'Эл. почта',
|
||||
'profile.bindEmail': 'Привязать почту',
|
||||
'profile.emailCode': 'Код подтверждения',
|
||||
@@ -181,6 +182,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'friends.redeem': 'Добавить',
|
||||
'friends.copy': 'Копировать',
|
||||
'friends.codeCopied': 'Код скопирован.',
|
||||
'friends.shareTelegram': 'Поделиться через Telegram',
|
||||
'friends.added': 'Добавлен(а) {name}.',
|
||||
'friends.blockedList': 'Заблокированные',
|
||||
'friends.unblock': 'Разблокировать',
|
||||
|
||||
@@ -100,6 +100,9 @@ export class MockGateway implements GatewayClient {
|
||||
}
|
||||
|
||||
// --- auth ---
|
||||
async authTelegram(): Promise<Session> {
|
||||
return { ...SESSION, isGuest: false };
|
||||
}
|
||||
async authGuest(): Promise<Session> {
|
||||
return { ...SESSION };
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export const PROFILE: Profile = {
|
||||
blockChat: false,
|
||||
blockFriendRequests: false,
|
||||
isGuest: false,
|
||||
notificationsInAppOnly: true,
|
||||
};
|
||||
|
||||
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
|
||||
|
||||
@@ -107,6 +107,8 @@ export interface Profile {
|
||||
blockChat: boolean;
|
||||
blockFriendRequests: boolean;
|
||||
isGuest: boolean;
|
||||
/** Confine notifications to the in-app stream (no out-of-app platform push). */
|
||||
notificationsInAppOnly: boolean;
|
||||
}
|
||||
|
||||
/** The full editable profile sent to profileUpdate (overwrites every field). */
|
||||
@@ -118,6 +120,7 @@ export interface ProfileUpdate {
|
||||
awayEnd: string;
|
||||
blockChat: boolean;
|
||||
blockFriendRequests: boolean;
|
||||
notificationsInAppOnly: boolean;
|
||||
}
|
||||
|
||||
/** A referenced account with its display name (friend, blocked user, invitee). */
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { insideTelegram, telegramLaunch } from './telegram';
|
||||
|
||||
function stubWebApp(initData: string, startParam?: string) {
|
||||
vi.stubGlobal('window', {
|
||||
Telegram: {
|
||||
WebApp: {
|
||||
initData,
|
||||
initDataUnsafe: startParam ? { start_param: startParam } : {},
|
||||
themeParams: { bg_color: '#101418' },
|
||||
ready: () => {},
|
||||
expand: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('telegram launch detection', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('is not inside Telegram without a window', () => {
|
||||
expect(insideTelegram()).toBe(false);
|
||||
});
|
||||
|
||||
it('is inside Telegram only with non-empty initData', () => {
|
||||
stubWebApp('');
|
||||
expect(insideTelegram()).toBe(false);
|
||||
stubWebApp('query_id=abc');
|
||||
expect(insideTelegram()).toBe(true);
|
||||
});
|
||||
|
||||
it('telegramLaunch returns initData, start param and theme', () => {
|
||||
stubWebApp('query_id=abc', 'g123');
|
||||
const launch = telegramLaunch();
|
||||
expect(launch.initData).toBe('query_id=abc');
|
||||
expect(launch.startParam).toBe('g123');
|
||||
expect(launch.theme?.bg_color).toBe('#101418');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// Telegram Mini App SDK access. The official telegram-web-app.js (loaded in
|
||||
// index.html) exposes window.Telegram.WebApp; this wraps the subset the app uses:
|
||||
// launch detection, initData (for auth.telegram), the deep-link start parameter,
|
||||
// theme params, and ready()/expand(). Every helper is safe to call outside Telegram.
|
||||
|
||||
import type { TelegramThemeParams } from './theme';
|
||||
|
||||
interface TelegramWebApp {
|
||||
initData: string;
|
||||
initDataUnsafe?: { start_param?: string };
|
||||
themeParams?: TelegramThemeParams;
|
||||
ready?: () => void;
|
||||
expand?: () => void;
|
||||
}
|
||||
|
||||
function webApp(): TelegramWebApp | undefined {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
return (window as unknown as { Telegram?: { WebApp?: TelegramWebApp } }).Telegram?.WebApp;
|
||||
}
|
||||
|
||||
/**
|
||||
* insideTelegram reports whether the app launched as a Telegram Mini App — the SDK
|
||||
* is present and carries non-empty initData (an ordinary browser tab has neither).
|
||||
*/
|
||||
export function insideTelegram(): boolean {
|
||||
const w = webApp();
|
||||
return !!w && typeof w.initData === 'string' && w.initData.length > 0;
|
||||
}
|
||||
|
||||
/** TelegramLaunch is the data a Mini App launch carries. */
|
||||
export interface TelegramLaunch {
|
||||
initData: string;
|
||||
startParam: string;
|
||||
theme: TelegramThemeParams | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramLaunch readies the Mini App (full-height, ready signal) and returns its
|
||||
* launch data: the raw initData (for auth.telegram), the deep-link start parameter
|
||||
* (from the SDK or, for a bot web_app button, the page URL), and the theme params.
|
||||
*/
|
||||
export function telegramLaunch(): TelegramLaunch {
|
||||
const w = webApp();
|
||||
if (!w) return { initData: '', startParam: startParamFromURL(), theme: undefined };
|
||||
w.ready?.();
|
||||
w.expand?.();
|
||||
const startParam = w.initDataUnsafe?.start_param ?? startParamFromURL();
|
||||
return { initData: w.initData, startParam, theme: w.themeParams };
|
||||
}
|
||||
|
||||
/**
|
||||
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
|
||||
* launch button carries the deep-link there rather than in initDataUnsafe.
|
||||
*/
|
||||
function startParamFromURL(): string {
|
||||
if (typeof location === 'undefined') return '';
|
||||
return new URLSearchParams(location.search).get('startapp') ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* onTelegramPath reports whether the app is served under the dedicated Telegram
|
||||
* entry path (/telegram/); outside Telegram on that path the app refuses to render.
|
||||
*/
|
||||
export function onTelegramPath(): boolean {
|
||||
if (typeof location === 'undefined') return false;
|
||||
return location.pathname.startsWith('/telegram/');
|
||||
}
|
||||
@@ -54,6 +54,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
token = t;
|
||||
},
|
||||
|
||||
async authTelegram(initData) {
|
||||
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData)));
|
||||
},
|
||||
async authGuest(locale) {
|
||||
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '')));
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { friendCodeParam, shareLink } from '../lib/deeplink';
|
||||
import type { AccountRef, FriendCode } from '../lib/model';
|
||||
|
||||
let friends = $state<AccountRef[]>([]);
|
||||
@@ -97,6 +98,7 @@
|
||||
<button class="btn" onclick={redeem}>{t('friends.redeem')}</button>
|
||||
</div>
|
||||
{#if code}
|
||||
{@const tg = shareLink(friendCodeParam(code.code))}
|
||||
<div class="code" data-testid="friend-code">
|
||||
<div class="coderow">
|
||||
<button class="codeval" onclick={copyCode}>{code.code}</button>
|
||||
@@ -105,6 +107,9 @@
|
||||
<span class="codehint">
|
||||
{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>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button class="link" onclick={getCode}>{t('friends.getCode')}</button>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
let endM = $state('00');
|
||||
let blockChat = $state(false);
|
||||
let blockFriendRequests = $state(false);
|
||||
let notificationsInAppOnly = $state(true);
|
||||
let emailInput = $state('');
|
||||
let codeInput = $state('');
|
||||
let emailSent = $state(false);
|
||||
@@ -47,6 +48,7 @@
|
||||
[endH, endM] = splitTime(p.awayEnd);
|
||||
blockChat = p.blockChat;
|
||||
blockFriendRequests = p.blockFriendRequests;
|
||||
notificationsInAppOnly = p.notificationsInAppOnly;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
@@ -68,6 +70,7 @@
|
||||
awayEnd,
|
||||
blockChat,
|
||||
blockFriendRequests,
|
||||
notificationsInAppOnly,
|
||||
});
|
||||
editing = false;
|
||||
showToast(t('profile.saved'));
|
||||
@@ -143,6 +146,10 @@
|
||||
<input type="checkbox" bind:checked={blockFriendRequests} />
|
||||
<span>{t('profile.blockFriendRequests')}</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" bind:checked={notificationsInAppOnly} />
|
||||
<span>{t('profile.notificationsInAppOnly')}</span>
|
||||
</label>
|
||||
<div class="formacts">
|
||||
<button type="submit" class="btn" disabled={!formValid}>{t('common.save')}</button>
|
||||
<button type="button" class="ghost" onclick={() => (editing = false)}>{t('common.cancel')}</button>
|
||||
|
||||
Reference in New Issue
Block a user