f8fab4a4c2
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.
469 lines
14 KiB
Svelte
469 lines
14 KiB
Svelte
<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>
|