Files
scrabble-game/ui/src/screens/Friends.svelte
T
Ilia Denisov d76f1f4026
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m33s
fix(vk): friend-code link is the plain vk.com/app link (VK strips iframe query payload)
The contour diagnostic confirmed VK forwards only the signed vk_* params to the iframe — a
custom payload on the app link is dropped (the '#' eaten by the vk.com SPA, a '?' query
stripped), so the friend-code cannot ride the link. The VK share link is now just
vk.com/app<id>; the recipient enters the copied code by hand (VKWebAppCopyText works). The
vkStartParam reader + bootVK routing stay as a no-op, ready for a post-moderation channel.
Removes the temporary deep-link diagnostic.
2026-06-29 22:58:43 +02:00

498 lines
15 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import Modal from '../components/Modal.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, vkShareLink } from '../lib/deeplink';
import { insideTelegram, shareTelegramLink, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
import { insideVK, vkCopyText, vkShare } from '../lib/vk';
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('');
// The friend row whose kebab actions are slid open, like the lobby list.
let revealedId = $state<string | null>(null);
// Pending confirmation targets: the friend account awaiting a block / unfriend confirm.
let blockTarget = $state<AccountRef | null>(null);
let unfriendTarget = $state<AccountRef | null>(null);
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));
// toggleReveal slides one friend row open (closing any other), exposing its
// block / unfriend icon actions; tapping the same kebab again closes it.
function toggleReveal(id: string): void {
revealedId = revealedId === id ? null : id;
}
// confirmBlock / confirmUnfriend run the pending action once its modal is
// accepted, then clear the target and the revealed row.
function confirmBlock(target = blockTarget): void {
blockTarget = null;
revealedId = null;
if (target) void blockUser(target.accountId);
}
function confirmUnfriend(target = unfriendTarget): void {
unfriendTarget = null;
revealedId = null;
if (target) void remove(target.accountId);
}
// onBlockClick / onUnfriendClick: inside the Mini App (and online) confirm with Telegram's native
// dialog and act on accept; otherwise open the in-app confirm modal (the offline / web path).
async function onBlockClick(f: AccountRef): Promise<void> {
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
if (await telegramShowConfirm(`${t('friends.blockConfirm')}\n${f.displayName}`)) confirmBlock(f);
return;
}
blockTarget = f;
}
async function onUnfriendClick(f: AccountRef): Promise<void> {
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
if (await telegramShowConfirm(`${t('friends.unfriendConfirm')}\n${f.displayName}`)) confirmUnfriend(f);
return;
}
unfriendTarget = f;
}
// While a friend row is slid open, a tap anywhere outside its action buttons
// closes it again. Taps on a kebab are skipped so its own toggle stays in charge.
$effect(() => {
if (revealedId === null) return;
function onDown(e: PointerEvent) {
const el = e.target as Element | null;
if (el?.closest('.acts') || el?.closest('.kebab')) return;
revealedId = null;
}
window.addEventListener('pointerdown', onDown, true);
return () => window.removeEventListener('pointerdown', onDown, true);
});
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;
// Inside the VK iframe navigator.clipboard is blocked, so copy via VK Bridge there.
if (insideVK()) {
if (await vkCopyText(code.code)) showToast(t('friends.codeCopied'));
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');
// Inside VK navigator.share is absent (desktop iframe) and the clipboard is blocked: share via VK
// Bridge, falling back to a VK-Bridge clipboard copy if the share is dismissed or unavailable.
if (insideVK()) {
if (await vkShare(url)) return;
if (await vkCopyText(url)) showToast(t('friends.linkCopied'));
return;
}
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 shareUrl = insideVK() ? vkShareLink() : 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 shareUrl}
<button type="button" class="link" onclick={() => shareInvite(shareUrl)}>{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>
<div class="list">
{#each incoming as r (r.accountId)}
<div class="rowwrap">
<div class="row">
<span class="who">{r.displayName}</span>
<span class="btns">
<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>
</div>
{/each}
</div>
</section>
{/if}
<section>
<h3>{t('friends.yours')}</h3>
{#if friends.length}
<div class="list">
{#each friends as f (f.accountId)}
<div class="rowwrap" class:revealed={revealedId === f.accountId}>
<div class="acts">
<button class="iconbtn" onclick={() => onBlockClick(f)} disabled={!connection.online} aria-label={t('friends.block')}>🚫</button>
<button class="iconbtn" onclick={() => onUnfriendClick(f)} disabled={!connection.online} aria-label={t('friends.unfriend')}>✖️</button>
</div>
<div class="row">
<span class="who">{f.displayName}</span>
<button class="kebab" onclick={() => toggleReveal(f.accountId)} aria-label={t('friends.actions')}></button>
</div>
</div>
{/each}
</div>
{:else}
<p class="muted">{t('friends.none')}</p>
{/if}
</section>
{#if blocked.length || robotBlocks.length}
<section>
<h3>{t('friends.blockedList')}</h3>
<div class="list">
{#each blocked as b (b.accountId)}
<div class="rowwrap">
<div class="row">
<span class="who">{b.displayName}</span>
<span class="btns">
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
</span>
</div>
</div>
{/each}
{#each robotBlocks as r (r.id)}
<div class="rowwrap">
<div class="row">
<span class="who">{r.displayName}</span>
<span class="btns">
<button class="ghost" onclick={() => unblock(r.id)} disabled={!connection.online}>{t('friends.unblock')}</button>
</span>
</div>
</div>
{/each}
</div>
</section>
{/if}
{#if blockTarget}
<Modal title={t('friends.blockConfirm')} onclose={() => (blockTarget = null)}>
<p class="confirm-name">{blockTarget.displayName}</p>
<div class="confirm-row">
<button class="cancel" onclick={() => (blockTarget = null)}>{t('common.cancel')}</button>
<button class="danger" onclick={() => confirmBlock()} disabled={!connection.online}>{t('friends.block')}</button>
</div>
</Modal>
{/if}
{#if unfriendTarget}
<Modal title={t('friends.unfriendConfirm')} onclose={() => (unfriendTarget = null)}>
<p class="confirm-name">{unfriendTarget.displayName}</p>
<div class="confirm-row">
<button class="cancel" onclick={() => (unfriendTarget = null)}>{t('common.cancel')}</button>
<button class="danger" onclick={() => confirmUnfriend()} disabled={!connection.online}>{t('friends.unfriend')}</button>
</div>
</Modal>
{/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;
}
.list {
display: flex;
flex-direction: column;
}
/* One-line rows split by hairlines, mirroring the lobby list. */
.rowwrap {
position: relative;
overflow: hidden;
}
.rowwrap + .rowwrap {
border-top: 1px solid var(--border);
}
/* Block / unfriend icon actions sit behind the friend row, exposed when it slides left. */
.acts {
position: absolute;
inset: 0 0 0 auto;
display: flex;
align-items: stretch;
}
.iconbtn {
flex: 0 0 auto;
width: 48px;
border: none;
background: var(--bg-elev);
color: var(--text);
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
}
.iconbtn + .iconbtn {
border-left: 1px solid var(--border); /* the vertical divider between 🚫 and ✖️ */
}
.row {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
background: var(--bg);
transform: translateX(0);
transition: transform 0.18s ease;
}
.rowwrap.revealed .row {
transform: translateX(-96px); /* 2 × 48px icon buttons */
}
.kebab {
flex: 0 0 auto;
width: 30px;
padding: 6px 0;
border: none;
background: none;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
}
.btns {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.who {
flex: 1;
min-width: 0;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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);
}
.confirm-name {
margin: 0 0 12px;
font-weight: 600;
overflow-wrap: anywhere; /* a long display name wraps instead of stretching the sheet */
}
.confirm-row {
display: flex;
gap: 8px;
}
.confirm-row button {
flex: 1;
padding: 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.confirm-row .danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
</style>