Release: development → master #125

Merged
developer merged 6 commits from development into master 2026-06-22 22:36:42 +00:00
5 changed files with 188 additions and 42 deletions
Showing only changes of commit 5a80696fe8 - Show all commits
+3
View File
@@ -213,6 +213,9 @@ block **overrides but does not delete** an existing friendship (so you may block
they keep seeing you as one); active games are never interrupted — you can finish them, with they keep seeing you as one); active games are never interrupted — you can finish them, with
the blocked opponent's chat composer hidden (only the log remains). Blocking from a game card the blocked opponent's chat composer hidden (only the log remains). Blocking from a game card
mirrors the block in **Settings → Friends**; **unblock** and **unfriend** live there only. mirrors the block in **Settings → Friends**; **unblock** and **unfriend** live there only.
On Settings → Friends each friend is a one-line row whose right-hand kebab (⋮) slides open
**block 🚫** and **remove ✖️** icon actions, and each action is gated by a confirmation
that names the friend (*Block this player?* / *Remove from friends?*).
Blocking an **auto-match opponent who is secretly a robot** behaves the same in that game Blocking an **auto-match opponent who is secretly a robot** behaves the same in that game
(struck name, hidden composer) and lists the blocked opponent under the name you saw, but is (struck name, hidden composer) and lists the blocked opponent under the name you saw, but is
recorded only against that game — the disguise holds, the shared robot is never globally recorded only against that game — the disguise holds, the shared robot is never globally
+3
View File
@@ -218,6 +218,9 @@ _Вход сейчас только через провайдера, поэто
заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в
партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей** партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей**
есть только там. есть только там.
В **Настройках → Друзья** каждый друг — однострочник, чей правый кебаб (⋮) выдвигает
иконки-действия **заблокировать 🚫** и **удалить ✖️**, и каждое действие подтверждается
диалогом с именем друга (*Заблокировать?* / *Удалить из друзей?*).
Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же
(зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем, (зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем,
которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий
+3
View File
@@ -227,6 +227,9 @@ export const en = {
'friends.decline': 'Decline', 'friends.decline': 'Decline',
'friends.unfriend': 'Remove', 'friends.unfriend': 'Remove',
'friends.block': 'Block', 'friends.block': 'Block',
'friends.actions': 'Actions',
'friends.blockConfirm': 'Block this player?',
'friends.unfriendConfirm': 'Remove from friends?',
'friends.add': 'Add a friend', 'friends.add': 'Add a friend',
'friends.addFromGame': 'Add to friends', 'friends.addFromGame': 'Add to friends',
'friends.blockFromGame': 'Block player', 'friends.blockFromGame': 'Block player',
+3
View File
@@ -228,6 +228,9 @@ export const ru: Record<MessageKey, string> = {
'friends.decline': 'Отклонить', 'friends.decline': 'Отклонить',
'friends.unfriend': 'Удалить', 'friends.unfriend': 'Удалить',
'friends.block': 'Заблокировать', 'friends.block': 'Заблокировать',
'friends.actions': 'Действия',
'friends.blockConfirm': 'Заблокировать?',
'friends.unfriendConfirm': 'Удалить из друзей?',
'friends.add': 'Добавить друга', 'friends.add': 'Добавить друга',
'friends.addFromGame': 'В друзья', 'friends.addFromGame': 'В друзья',
'friends.blockFromGame': 'Заблокировать', 'friends.blockFromGame': 'Заблокировать',
+155 -21
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte'; import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
@@ -16,6 +17,11 @@
let robotBlocks = $state<RobotBlockEntry[]>([]); let robotBlocks = $state<RobotBlockEntry[]>([]);
let code = $state<FriendCode | null>(null); let code = $state<FriendCode | null>(null);
let redeemInput = $state(''); 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() { async function load() {
try { try {
@@ -52,6 +58,28 @@
const blockUser = (id: string) => act(() => gateway.block(id)); const blockUser = (id: string) => act(() => gateway.block(id));
const unblock = (id: string) => act(() => gateway.unblock(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);
}
async function getCode() { async function getCode() {
try { try {
code = await gateway.friendCodeIssue(); code = await gateway.friendCodeIssue();
@@ -152,30 +180,39 @@
{#if incoming.length} {#if incoming.length}
<section> <section>
<h3>{t('friends.incoming')}</h3> <h3>{t('friends.incoming')}</h3>
<div class="list">
{#each incoming as r (r.accountId)} {#each incoming as r (r.accountId)}
<div class="item"> <div class="rowwrap">
<div class="row">
<span class="who">{r.displayName}</span> <span class="who">{r.displayName}</span>
<span class="acts"> <span class="btns">
<button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button> <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> <button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button>
</span> </span>
</div> </div>
</div>
{/each} {/each}
</div>
</section> </section>
{/if} {/if}
<section> <section>
<h3>{t('friends.yours')}</h3> <h3>{t('friends.yours')}</h3>
{#if friends.length} {#if friends.length}
<div class="list">
{#each friends as f (f.accountId)} {#each friends as f (f.accountId)}
<div class="item"> <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> <span class="who">{f.displayName}</span>
<span class="acts"> <button class="kebab" onclick={() => toggleReveal(f.accountId)} aria-label={t('friends.actions')}></button>
<button class="ghost" onclick={() => remove(f.accountId)} disabled={!connection.online}>{t('friends.unfriend')}</button> </div>
<button class="ghost danger" onclick={() => blockUser(f.accountId)} disabled={!connection.online}>{t('friends.block')}</button>
</span>
</div> </div>
{/each} {/each}
</div>
{:else} {:else}
<p class="muted">{t('friends.none')}</p> <p class="muted">{t('friends.none')}</p>
{/if} {/if}
@@ -184,20 +221,49 @@
{#if blocked.length || robotBlocks.length} {#if blocked.length || robotBlocks.length}
<section> <section>
<h3>{t('friends.blockedList')}</h3> <h3>{t('friends.blockedList')}</h3>
<div class="list">
{#each blocked as b (b.accountId)} {#each blocked as b (b.accountId)}
<div class="item"> <div class="rowwrap">
<div class="row">
<span class="who">{b.displayName}</span> <span class="who">{b.displayName}</span>
<span class="btns">
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button> <button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
</span>
</div>
</div> </div>
{/each} {/each}
{#each robotBlocks as r (r.id)} {#each robotBlocks as r (r.id)}
<div class="item"> <div class="rowwrap">
<div class="row">
<span class="who">{r.displayName}</span> <span class="who">{r.displayName}</span>
<span class="btns">
<button class="ghost" onclick={() => unblock(r.id)} disabled={!connection.online}>{t('friends.unblock')}</button> <button class="ghost" onclick={() => unblock(r.id)} disabled={!connection.online}>{t('friends.unblock')}</button>
</span>
</div>
</div> </div>
{/each} {/each}
</div>
</section> </section>
{/if} {/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} {/if}
</div> </div>
@@ -279,28 +345,76 @@
padding: 4px 0; padding: 4px 0;
text-align: left; text-align: left;
} }
.item { .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; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--border); background: var(--bg);
background: var(--surface); transform: translateX(0);
border-radius: var(--radius-sm); transition: transform 0.18s ease;
margin-bottom: 8px; }
.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 { .who {
flex: 1;
min-width: 0;
font-weight: 600; font-weight: 600;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.acts {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.btn { .btn {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid var(--accent); border: 1px solid var(--accent);
@@ -315,7 +429,27 @@
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.ghost.danger { .confirm-name {
color: var(--danger, #c0392b); 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> </style>