Files
scrabble-game/ui/src/screens/Friends.svelte
T
Ilia Denisov f8fab4a4c2 fix(ui): rename Friends "Share via Telegram" to "Share"
The share button uses the OS Web Share sheet outside Telegram (the TG share picker only inside it), so "via Telegram" was misleading. Rename to a neutral "Share"/"Поделиться" and drop the now-unused tgshare class.
2026-06-23 21:51:52 +02:00

469 lines
14 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 } 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('');
// 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(): void {
const target = blockTarget;
blockTarget = null;
revealedId = null;
if (target) void blockUser(target.accountId);
}
function confirmUnfriend(): void {
const target = unfriendTarget;
unfriendTarget = null;
revealedId = null;
if (target) void remove(target.accountId);
}
// 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;
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" 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>
<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={() => (blockTarget = f)} disabled={!connection.online} aria-label={t('friends.block')}>🚫</button>
<button class="iconbtn" onclick={() => (unfriendTarget = 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>