Merge pull request 'feat(ui): настоящий share friend-code + ссылка по текущему боту (service_language)' (#66) from feat/friend-invite-share into development
This commit was merged in pull request #66.
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import { t } from './lib/i18n/index.svelte';
|
||||
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
||||
import Toast from './components/Toast.svelte';
|
||||
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
||||
import Login from './screens/Login.svelte';
|
||||
import Lobby from './screens/Lobby.svelte';
|
||||
import NewGame from './screens/NewGame.svelte';
|
||||
@@ -108,6 +109,7 @@
|
||||
{/if}
|
||||
|
||||
<Toast />
|
||||
<StaleInviteModal />
|
||||
|
||||
<style>
|
||||
.splash {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { app, dismissStaleInvite } from '../lib/app.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { botUsername } from '../lib/deeplink';
|
||||
import { telegramOpenLink } from '../lib/telegram';
|
||||
import Modal from './Modal.svelte';
|
||||
|
||||
// Point at the bot the player signed in through (its service language), falling back to
|
||||
// the interface locale, so an ru player is sent to the ru bot and an en player to the en one.
|
||||
const username = $derived(botUsername(app.session?.serviceLanguage || app.locale));
|
||||
// Split the message around the {bot} token so the bot handle renders as an inline link.
|
||||
const parts = $derived(t('friends.staleInvite').split('{bot}'));
|
||||
|
||||
function openBot() {
|
||||
if (username) {
|
||||
const url = `https://t.me/${username}`;
|
||||
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
|
||||
// back to a new tab.
|
||||
if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank');
|
||||
}
|
||||
dismissStaleInvite();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if app.staleInvite}
|
||||
<Modal title={t('friends.staleInviteTitle')} onclose={dismissStaleInvite}>
|
||||
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
|
||||
<button class="ok" onclick={dismissStaleInvite}>{t('common.ok')}</button>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.msg {
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.bot {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ok {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
telegramHaptic,
|
||||
telegramLaunch,
|
||||
telegramOnEvent,
|
||||
telegramRequestFullscreen,
|
||||
telegramSetChrome,
|
||||
} from './telegram';
|
||||
import { parseStartParam } from './deeplink';
|
||||
@@ -62,6 +63,10 @@ export const app = $state<{
|
||||
/** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
|
||||
* with friend requests) and the Settings → Info badge. */
|
||||
feedbackReplyUnread: boolean;
|
||||
/** Whether to show the "outdated invite link" notice: set when a Telegram deep-link friend
|
||||
* code is already used/expired, so the visitor lands in the lobby with a gentle pointer to
|
||||
* the bot instead of a scary error on the Friends screen. */
|
||||
staleInvite: boolean;
|
||||
/** Monotonic counter bumped when the app returns to the foreground without the live stream
|
||||
* having dropped. An open game watches it to refetch once, recovering an in-game event shed
|
||||
* from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */
|
||||
@@ -83,6 +88,7 @@ export const app = $state<{
|
||||
notifications: 0,
|
||||
chatUnread: {},
|
||||
feedbackReplyUnread: false,
|
||||
staleInvite: false,
|
||||
resync: 0,
|
||||
});
|
||||
|
||||
@@ -134,6 +140,11 @@ export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
|
||||
toastTimer = setTimeout(() => (app.toast = null), 4000);
|
||||
}
|
||||
|
||||
/** dismissStaleInvite hides the outdated-invite-link notice (the user acknowledged it). */
|
||||
export function dismissStaleInvite(): void {
|
||||
app.staleInvite = false;
|
||||
}
|
||||
|
||||
/** clearChatUnread resets a game's unread chat-message count (called when its chat is opened). */
|
||||
export function clearChatUnread(gameId: string): void {
|
||||
if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 };
|
||||
@@ -457,6 +468,10 @@ export async function bootstrap(): Promise<void> {
|
||||
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
|
||||
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
|
||||
telegramDisableVerticalSwipes();
|
||||
// On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged
|
||||
// listener above then re-syncs the safe-area insets. Desktop keeps the bot's full-size
|
||||
// window. No-op on clients predating Bot API 8.0.
|
||||
telegramRequestFullscreen();
|
||||
try {
|
||||
await adoptSession(await gateway.authTelegram(launch.initData));
|
||||
// A blocked account skips deep-link routing — the blocked screen overlays every route.
|
||||
@@ -491,13 +506,27 @@ async function routeStartParam(param: string): Promise<void> {
|
||||
navigate(`/game/${link.id}`);
|
||||
return;
|
||||
case 'friendCode':
|
||||
navigate('/friends');
|
||||
try {
|
||||
const friend = await gateway.friendCodeRedeem(link.code);
|
||||
navigate('/friends');
|
||||
showToast(t('friends.added', { name: friend.displayName }));
|
||||
void refreshNotifications();
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
const code = err instanceof GatewayError ? err.code : '';
|
||||
if (code === 'self_relation') {
|
||||
// Tapping your own invite link redeems your own code: a friendly note, not the
|
||||
// scary "can't do that to yourself" error.
|
||||
navigate('/friends');
|
||||
showToast(t('friends.selfInvite'));
|
||||
} else if (code === 'friend_code_invalid') {
|
||||
// A previously shared link whose single-use, 12h code is already spent or expired:
|
||||
// land in the lobby and gently point at the bot, rather than a red error on Friends.
|
||||
navigate('/');
|
||||
app.staleInvite = true;
|
||||
} else {
|
||||
navigate('/friends');
|
||||
handleError(err);
|
||||
}
|
||||
}
|
||||
return;
|
||||
default:
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -292,6 +292,7 @@ function sessionFromTable(t: fb.Session): Session {
|
||||
isGuest: t.isGuest(),
|
||||
displayName: s(t.displayName()),
|
||||
supportedLanguages,
|
||||
serviceLanguage: s(t.serviceLanguage()),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink';
|
||||
import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink';
|
||||
|
||||
describe('parseStartParam', () => {
|
||||
it('classifies game / invitation / friend code', () => {
|
||||
@@ -35,4 +35,38 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('botUsername', () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it('returns null without a configured base', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK', '');
|
||||
expect(botUsername()).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts the bot handle from the Mini App link', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
|
||||
expect(botUsername()).toBe('bot');
|
||||
});
|
||||
|
||||
it('picks the per-bot handle 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(botUsername('en')).toBe('enbot');
|
||||
expect(botUsername('ru')).toBe('rubot');
|
||||
});
|
||||
});
|
||||
|
||||
+38
-5
@@ -36,13 +36,46 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* botUsername extracts the bot's @username (without the @) from the configured Mini App
|
||||
* link for a bot language: the first path segment of https://t.me/<bot>/<app>. Returns
|
||||
* null when no base is configured or the link carries no path, so callers can fall back
|
||||
* when they cannot point at a specific bot.
|
||||
*/
|
||||
export function botUsername(lang = ''): string | null {
|
||||
const base = telegramBase(lang);
|
||||
if (!base) return null;
|
||||
try {
|
||||
const seg = new URL(base).pathname.split('/').filter(Boolean)[0];
|
||||
return seg || null;
|
||||
} catch {
|
||||
return 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)}`;
|
||||
|
||||
@@ -234,6 +234,11 @@ export const en = {
|
||||
'friends.copy': 'Copy',
|
||||
'friends.codeCopied': 'Code copied.',
|
||||
'friends.shareTelegram': 'Share via Telegram',
|
||||
'friends.inviteText': "Let's play Scrabble!",
|
||||
'friends.linkCopied': 'Link copied.',
|
||||
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
|
||||
'friends.staleInviteTitle': 'Link expired',
|
||||
'friends.staleInvite': 'You opened the game from an outdated link. Open the bot {bot} to play and get notifications.',
|
||||
'friends.added': 'Added {name}.',
|
||||
'friends.blockedList': 'Blocked players',
|
||||
'friends.unblock': 'Unblock',
|
||||
|
||||
@@ -235,6 +235,11 @@ export const ru: Record<MessageKey, string> = {
|
||||
'friends.copy': 'Копировать',
|
||||
'friends.codeCopied': 'Код скопирован.',
|
||||
'friends.shareTelegram': 'Поделиться через Telegram',
|
||||
'friends.inviteText': 'Давай играть в Эрудит!',
|
||||
'friends.linkCopied': 'Ссылка скопирована.',
|
||||
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
|
||||
'friends.staleInviteTitle': 'Ссылка устарела',
|
||||
'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.',
|
||||
'friends.added': 'Добавлен(а) {name}.',
|
||||
'friends.blockedList': 'Заблокированные',
|
||||
'friends.unblock': 'Разблокировать',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { insideTelegram, telegramLaunch } from './telegram';
|
||||
import { insideTelegram, telegramClosingConfirmation, telegramLaunch, telegramRequestFullscreen } from './telegram';
|
||||
|
||||
function stubWebApp(initData: string, startParam?: string) {
|
||||
vi.stubGlobal('window', {
|
||||
@@ -37,3 +37,66 @@ describe('telegram launch detection', () => {
|
||||
expect(launch.theme?.bg_color).toBe('#101418');
|
||||
});
|
||||
});
|
||||
|
||||
// stubClient stands up a fake WebApp on the given platform with spies for the mobile-gated
|
||||
// chrome toggles, so the platform gate can be asserted without a real Telegram client.
|
||||
function stubClient(platform?: string) {
|
||||
const enable = vi.fn();
|
||||
const disable = vi.fn();
|
||||
const requestFullscreen = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
Telegram: {
|
||||
WebApp: { platform, enableClosingConfirmation: enable, disableClosingConfirmation: disable, requestFullscreen },
|
||||
},
|
||||
});
|
||||
return { enable, disable, requestFullscreen };
|
||||
}
|
||||
|
||||
const mobilePlatforms = ['ios', 'android', 'android_x'];
|
||||
const desktopPlatforms = ['tdesktop', 'macos', 'web', undefined];
|
||||
|
||||
describe('telegramClosingConfirmation', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('arms the close guard on mobile clients', () => {
|
||||
for (const p of mobilePlatforms) {
|
||||
const { enable } = stubClient(p);
|
||||
telegramClosingConfirmation(true);
|
||||
expect(enable, `platform=${p}`).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
it('skips the close guard on desktop clients (the dialog there is just noise)', () => {
|
||||
for (const p of desktopPlatforms) {
|
||||
const { enable } = stubClient(p);
|
||||
telegramClosingConfirmation(true);
|
||||
expect(enable, `platform=${p}`).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('always lifts the guard on leave, regardless of platform', () => {
|
||||
const { disable } = stubClient('tdesktop');
|
||||
telegramClosingConfirmation(false);
|
||||
expect(disable).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('telegramRequestFullscreen', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('goes immersive fullscreen on mobile clients', () => {
|
||||
for (const p of mobilePlatforms) {
|
||||
const { requestFullscreen } = stubClient(p);
|
||||
telegramRequestFullscreen();
|
||||
expect(requestFullscreen, `platform=${p}`).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves desktop clients as a standard window (the bot full-size setting fills it)', () => {
|
||||
for (const p of desktopPlatforms) {
|
||||
const { requestFullscreen } = stubClient(p);
|
||||
telegramRequestFullscreen();
|
||||
expect(requestFullscreen, `platform=${p}`).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+57
-4
@@ -8,6 +8,7 @@ import type { TelegramThemeParams } from './theme';
|
||||
interface TelegramWebApp {
|
||||
initData: string;
|
||||
initDataUnsafe?: { start_param?: string };
|
||||
platform?: string;
|
||||
themeParams?: TelegramThemeParams;
|
||||
colorScheme?: 'light' | 'dark';
|
||||
isFullscreen?: boolean;
|
||||
@@ -15,6 +16,8 @@ interface TelegramWebApp {
|
||||
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||
ready?: () => void;
|
||||
expand?: () => void;
|
||||
requestFullscreen?: () => void;
|
||||
openTelegramLink?: (url: string) => void;
|
||||
onEvent?: (event: string, handler: () => void) => void;
|
||||
setHeaderColor?: (color: string) => void;
|
||||
setBackgroundColor?: (color: string) => void;
|
||||
@@ -49,6 +52,29 @@ export function insideTelegram(): boolean {
|
||||
return !!w && typeof w.initData === 'string' && w.initData.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramOpenLink opens a t.me link through the Mini App SDK, so Telegram navigates to
|
||||
* it natively (e.g. a bot chat) rather than spawning an in-app browser tab. Returns false
|
||||
* outside Telegram or when the SDK lacks the method, so the caller can fall back to a plain
|
||||
* window.open.
|
||||
*/
|
||||
export function telegramOpenLink(url: string): boolean {
|
||||
const w = webApp();
|
||||
if (!w?.openTelegramLink) return false;
|
||||
w.openTelegramLink(url);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return telegramOpenLink(`https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`);
|
||||
}
|
||||
|
||||
/** TelegramLaunch is the data a Mini App launch carries. */
|
||||
export interface TelegramLaunch {
|
||||
initData: string;
|
||||
@@ -142,13 +168,40 @@ export function telegramHaptic(kind: Haptic): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramClosingConfirmation toggles the confirmation Telegram shows when the user
|
||||
* swipes the Mini App closed — enabled during an active game so it is not lost by accident.
|
||||
* isMobilePlatform reports whether the Mini App runs on a Telegram mobile client — iOS or
|
||||
* Android (the latter reported as 'android' by Telegram for Android and 'android_x' by
|
||||
* Telegram X). Desktop clients (tdesktop, macOS, web) report other values. Used to limit
|
||||
* mobile-only chrome such as the close guard and immersive fullscreen.
|
||||
*/
|
||||
function isMobilePlatform(): boolean {
|
||||
const p = webApp()?.platform;
|
||||
return p === 'ios' || p === 'android' || p === 'android_x';
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramRequestFullscreen asks Telegram to open the Mini App in fullscreen (Bot API 8.0+),
|
||||
* but only on mobile clients — mirroring how Telegram's own Mini Apps go immersive on phones
|
||||
* while staying a standard window on desktop (where the bot's full-size setting already fills
|
||||
* the window). A no-op outside Telegram, on desktop, or on clients predating the method.
|
||||
*/
|
||||
export function telegramRequestFullscreen(): void {
|
||||
if (isMobilePlatform()) webApp()?.requestFullscreen?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramClosingConfirmation toggles the confirmation Telegram shows when the user swipes
|
||||
* the Mini App closed — enabled during an active game so it is not lost by accident. The
|
||||
* guard is only armed on mobile clients: on desktop, closing a window is deliberate and
|
||||
* Telegram surfaces a "changes may not be saved" dialog that is just noise here (drafts
|
||||
* auto-save), so the confirmation is skipped there.
|
||||
*/
|
||||
export function telegramClosingConfirmation(on: boolean): void {
|
||||
const w = webApp();
|
||||
if (on) w?.enableClosingConfirmation?.();
|
||||
else w?.disableClosingConfirmation?.();
|
||||
if (on) {
|
||||
if (isMobilePlatform()) w?.enableClosingConfirmation?.();
|
||||
} else {
|
||||
w?.disableClosingConfirmation?.();
|
||||
}
|
||||
}
|
||||
|
||||
let backHandler: (() => void) | null = null;
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { GatewayError } from '../lib/client';
|
||||
import { localeFrom, t } from '../lib/i18n/index.svelte';
|
||||
import { translate } from '../lib/i18n/catalog';
|
||||
import { friendCodeParam, shareLink } from '../lib/deeplink';
|
||||
import { shareTelegramLink } from '../lib/telegram';
|
||||
import type { AccountRef, FriendCode } from '../lib/model';
|
||||
|
||||
let friends = $state<AccountRef[]>([]);
|
||||
@@ -61,7 +64,11 @@
|
||||
showToast(t('friends.added', { name: friend.displayName }));
|
||||
await load();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
if (e instanceof GatewayError && e.code === 'self_relation') {
|
||||
showToast(t('friends.selfInvite')); // redeeming your own code: a friendly, non-error note
|
||||
} else {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +85,30 @@
|
||||
// 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, lang: string) {
|
||||
// The caption is in the bot's language (Эрудит for ru, Scrabble for en), not the
|
||||
// interface language — the recipient lands in that bot.
|
||||
const text = translate(localeFrom(lang), '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 +128,8 @@
|
||||
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
|
||||
</div>
|
||||
{#if code}
|
||||
{@const tg = shareLink(friendCodeParam(code.code))}
|
||||
{@const lang = app.session?.serviceLanguage || app.locale}
|
||||
{@const tg = shareLink(friendCodeParam(code.code), lang)}
|
||||
<div class="code" data-testid="friend-code">
|
||||
<div class="coderow">
|
||||
<button class="codeval" onclick={copyCode}>{code.code}</button>
|
||||
@@ -107,7 +139,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, lang)}>{t('friends.shareTelegram')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user