Allow removing friends from leaderboard

This commit is contained in:
Codex 2026-09-07 21:25:44 +00:00
parent 3d8729c1ae
commit 44134458c1
6 changed files with 64 additions and 4 deletions

View file

@ -106,6 +106,16 @@ def parse_cancellation(raw: str) -> int:
return transaction_id
def parse_friend_removal(raw: str) -> int:
payload = decode_payload(raw)
if payload.get("type") != "friend_remove" or payload.get("v") != 1:
raise ValueError("Неизвестный формат данных")
friend_user_id = payload.get("friend_user_id")
if not isinstance(friend_user_id, int) or friend_user_id <= 0:
raise ValueError("Некорректный ID друга")
return friend_user_id
class TelegramBotService:
def __init__(self, settings: Settings, database: Database):
self.settings = settings
@ -187,6 +197,11 @@ class TelegramBotService:
if not self.database.cancel_transaction(user.id, transaction_id):
raise ValueError("Запись не найдена или уже отменена")
response = "Запись отменена и больше не участвует в расчётах."
elif action == "friend_remove":
friend_user_id = parse_friend_removal(raw)
if not self.database.remove_friendship(user.id, friend_user_id):
raise ValueError("Пользователь не найден в списке друзей")
response = "Пользователь удалён из друзей."
else:
raise ValueError("Неизвестный формат данных")
except ValueError as exc:

View file

@ -298,6 +298,18 @@ class Database:
).fetchall()
return [int(row["friend_id"]) for row in rows]
def remove_friendship(self, first_user_id: int, second_user_id: int) -> bool:
if first_user_id == second_user_id:
return False
low, high = sorted((first_user_id, second_user_id))
with closing(self.connect()) as connection:
cursor = connection.execute(
"DELETE FROM friendships WHERE user_id_low = ? AND user_id_high = ?",
(low, high),
)
connection.commit()
return cursor.rowcount > 0
def set_meta(self, key: str, value: str) -> None:
with closing(self.connect()) as connection:
connection.execute(

View file

@ -449,13 +449,36 @@
? `<img class="leader-avatar" src="..${item.avatar_url}" alt="" loading="lazy" />`
: `<span class="leader-avatar avatar-fallback">${initial}</span>`;
const percent = Number(item.saved_percent);
const canRemove = state.board === "friends" && item.user_id !== userId;
return `<div class="leader-row ${item.user_id === userId ? "me" : ""}">
<span class="rank">${index + 1}</span>
${avatar}
<span class="leader-name"><strong>${escapeHtml(item.nickname)}</strong><small>${item.user_id === userId ? "Это вы" : money.format(item.balance_cents / 100)}</small></span>
<span class="leader-percent ${percent < 0 ? "negative" : ""}">${percent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%</span>
<span class="leader-name"><strong>${escapeHtml(item.nickname)}</strong>${item.user_id === userId ? "<small>Это вы</small>" : ""}</span>
<span class="leader-actions">
<strong class="leader-percent ${percent < 0 ? "negative" : ""}">${percent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%</strong>
${canRemove ? `<button class="friend-remove" type="button" data-friend-user-id="${item.user_id}">Удалить</button>` : ""}
</span>
</div>`;
}).join("");
list.querySelectorAll(".friend-remove").forEach((button) => {
button.addEventListener("click", () => removeFriend(Number(button.dataset.friendUserId)));
});
}
function removeFriend(friendUserId) {
const send = (confirmed) => {
if (!confirmed) return;
sendTelegramPayload({ v: 1, type: "friend_remove", friend_user_id: friendUserId });
};
if (tg?.showConfirm && tg.isVersionAtLeast?.("6.2")) {
try {
tg.showConfirm("Удалить пользователя из друзей?", send);
return;
} catch (error) {
console.warn("Telegram confirmation failed", error);
}
}
send(window.confirm("Удалить пользователя из друзей?"));
}
async function shareInvite() {

View file

@ -6,9 +6,9 @@
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#111111" />
<title>Пожитки</title>
<link rel="stylesheet" href="./styles.css?v=18" />
<link rel="stylesheet" href="./styles.css?v=19" />
<script src="./telegram-web-app.js"></script>
<script src="./app.js?v=17" defer></script>
<script src="./app.js?v=19" defer></script>
</head>
<body>
<main class="shell">

View file

@ -200,8 +200,10 @@ h2 { margin: 0; font-size: 23px; letter-spacing: -.025em; }
.leader-name { min-width: 0; }
.leader-name strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.leader-name small { color: var(--muted); font-size: 10px; }
.leader-actions { display: grid; justify-items: end; gap: 4px; }
.leader-percent { font-size: 15px; font-weight: 850; color: var(--accent); }
.leader-percent.negative { color: var(--danger); }
.friend-remove { padding: 1px 0; border: 0; background: transparent; color: var(--danger); font-size: 10px; }
.friend-hint { margin: 13px 18px; color: var(--muted); font-size: 11px; line-height: 1.5; text-align: center; }
.bottom-nav { position: fixed; z-index: 10; bottom: max(12px, calc(var(--app-safe-bottom) + 8px)); left: 50%; display: flex; align-items: stretch; gap: 9px; width: min(340px, calc(100% - var(--app-safe-left) - var(--app-safe-right) - 24px)); height: 54px; transform: translateX(-50%); }

View file

@ -5,6 +5,7 @@ import pytest
from app.bot import (
parse_cancellation,
parse_category,
parse_friend_removal,
parse_transaction,
webapp_url_for_user,
)
@ -56,6 +57,13 @@ def test_friendship_is_symmetric_and_idempotent(tmp_path: Path) -> None:
assert not database.add_friendship(3, 7)
assert database.friend_ids(3) == [7]
assert database.friend_ids(7) == [3]
assert database.remove_friendship(3, 7)
assert database.friend_ids(3) == []
assert database.friend_ids(7) == []
assert not database.remove_friendship(3, 7)
assert parse_friend_removal(
'{"v":1,"type":"friend_remove","friend_user_id":7}'
) == 7
def test_filtered_leaderboard(tmp_path: Path) -> None: