Files
scrabble-game/ui/src/screens/Friends.svelte
T
Ilia Denisov 57c778f9b2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
feat(telegram,game): single bot + per-user variant preferences
Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.

- Telegram: one bot; drop service_language and the supported_languages
  set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
  connector proto). The single bot renders chat and out-of-app push in
  the recipient's preferred_language; remove the game-language push
  routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
  {erudit_ru}, CHECK non-empty + subset of the three variants). Gates
  the New Game picker, vs-AI and the friend invitation the player
  creates, enforced server-side (HTTP 400 otherwise); an invited friend
  may still accept any variant. Edited on the Settings screen; variants
  are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
  and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
  VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
  to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
  backend, ui, UI_DESIGN, PRERELEASE).

The migration squash is deferred to a follow-up PR.
2026-06-20 14:23:25 +02:00

322 lines
9.0 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte';
import { friendCodeParam, shareLink } from '../lib/deeplink';
import { shareTelegramLink } from '../lib/telegram';
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
let friends = $state<AccountRef[]>([]);
let incoming = $state<AccountRef[]>([]);
let blocked = $state<AccountRef[]>([]);
// Per-game disguised-robot blocks, shown as distinct entries alongside blocked humans.
let robotBlocks = $state<RobotBlockEntry[]>([]);
let code = $state<FriendCode | null>(null);
let redeemInput = $state('');
async function load() {
try {
const [fl, inc, bl] = await Promise.all([
gateway.friendsList(),
gateway.friendsIncoming(),
gateway.blocksList(),
]);
friends = fl;
incoming = inc;
blocked = bl.blocked;
robotBlocks = bl.robots;
} catch (e) {
handleError(e);
}
}
onMount(() => {
if (!app.profile?.isGuest) void load();
});
async function act(fn: () => Promise<unknown>) {
try {
await fn();
await load();
void refreshNotifications();
} catch (e) {
handleError(e);
}
}
const respond = (id: string, accept: boolean) => act(() => gateway.friendRespond(id, accept));
const remove = (id: string) => act(() => gateway.unfriend(id));
const blockUser = (id: string) => act(() => gateway.block(id));
const unblock = (id: string) => act(() => gateway.unblock(id));
async function getCode() {
try {
code = await gateway.friendCodeIssue();
} catch (e) {
handleError(e);
}
}
async function redeem() {
const c = redeemInput.trim();
if (!c) return;
try {
const friend = await gateway.friendCodeRedeem(c);
redeemInput = '';
showToast(t('friends.added', { name: friend.displayName }));
await load();
} catch (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);
}
}
}
function codeTime(unix: number): string {
return new Date(unix * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
async function copyCode() {
if (!code) return;
try {
await navigator.clipboard.writeText(code.code);
showToast(t('friends.codeCopied'));
} catch {
// 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">
{#if app.profile?.isGuest}
<p class="muted">{t('profile.guestLocked')}</p>
{:else}
<section>
<h3>{t('friends.add')}</h3>
<div class="addrow">
<input
class="codein"
bind:value={redeemInput}
placeholder={t('friends.codePlaceholder')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={redeem} disabled={!connection.online}>{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>
<button class="copy" onclick={copyCode} aria-label={t('friends.copy')}>📋</button>
</div>
<span class="codehint">
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span>
{#if tg}
<button type="button" class="link tgshare" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
{/if}
</div>
{:else}
<button class="link" onclick={getCode} disabled={!connection.online}>{t('friends.getCode')}</button>
{/if}
</section>
{#if incoming.length}
<section>
<h3>{t('friends.incoming')}</h3>
{#each incoming as r (r.accountId)}
<div class="item">
<span class="who">{r.displayName}</span>
<span class="acts">
<button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button>
<button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button>
</span>
</div>
{/each}
</section>
{/if}
<section>
<h3>{t('friends.yours')}</h3>
{#if friends.length}
{#each friends as f (f.accountId)}
<div class="item">
<span class="who">{f.displayName}</span>
<span class="acts">
<button class="ghost" onclick={() => remove(f.accountId)} disabled={!connection.online}>{t('friends.unfriend')}</button>
<button class="ghost danger" onclick={() => blockUser(f.accountId)} disabled={!connection.online}>{t('friends.block')}</button>
</span>
</div>
{/each}
{:else}
<p class="muted">{t('friends.none')}</p>
{/if}
</section>
{#if blocked.length || robotBlocks.length}
<section>
<h3>{t('friends.blockedList')}</h3>
{#each blocked as b (b.accountId)}
<div class="item">
<span class="who">{b.displayName}</span>
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
</div>
{/each}
{#each robotBlocks as r (r.id)}
<div class="item">
<span class="who">{r.displayName}</span>
<button class="ghost" onclick={() => unblock(r.id)} disabled={!connection.online}>{t('friends.unblock')}</button>
</div>
{/each}
</section>
{/if}
{/if}
</div>
<style>
.page {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 22px;
}
h3 {
margin: 0 0 10px;
font-size: 0.95rem;
color: var(--text-muted);
}
.muted {
color: var(--text-muted);
margin: 0;
}
.addrow {
display: flex;
gap: 8px;
}
.codein {
flex: 1;
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
letter-spacing: 0.3em;
font-size: 1.1rem;
}
.code {
margin-top: 10px;
padding: 12px 14px;
border: 1px dashed var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
gap: 4px;
}
.coderow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.codeval {
font-size: 1.8rem;
font-weight: 700;
letter-spacing: 0.3em;
background: none;
border: none;
color: var(--text);
padding: 0;
cursor: pointer;
text-align: left;
font-family: inherit;
}
.copy {
flex: 0 0 auto;
background: none;
border: none;
font-size: 1.4rem;
padding: 4px;
cursor: pointer;
}
.codehint {
font-size: 0.8rem;
color: var(--text-muted);
}
.link {
margin-top: 10px;
background: none;
border: none;
color: var(--accent);
padding: 4px 0;
text-align: left;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius-sm);
margin-bottom: 8px;
}
.who {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acts {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.btn {
padding: 8px 12px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
.ghost {
padding: 8px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.ghost.danger {
color: var(--danger, #c0392b);
}
</style>