fix(admin): resolve moderation, gift, and admin-panel bugs from QA

- scam/fake: enforce mutual exclusivity (scam precedence) at TL conversion, admin command, and UI toggles; both flags could render as neither
- scam/fake flicker: persist scam/fake in the Redis user base cache so cache hits no longer drop the flags
- getSavedStarGifts: hidden (unsaved) gifts are now force-excluded for non-owner viewers; same guard for get-by-ref
- convertStarGift: map already-upgraded/owner-invalid/unavailable to a clean client error instead of 500
- channel force-settings: send only changed fields (partial patch) and re-sync toggles after reload, so one setting no longer resets another
- give gifts: removed custom collectible numbers (auto sequential only); locked sender to the system account 777000
- give gifts UI: fixed picker card spacing/right gap; static (hover-play) Lottie previews on emoji + picker to stop render lag; fixed emoji ID field overflow
This commit is contained in:
epilepticseizureee 2026-07-23 06:24:53 +03:00
parent 313624eab2
commit 90792cdfab
22 changed files with 214 additions and 135 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -21,8 +21,8 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-Bx7A77x9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-XV_IEG5m.css">
<script type="module" crossorigin src="/assets/index-C0WDPjsF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwxAoLbQ.css">
</head>
<body>
<div id="root"></div>

View file

@ -0,0 +1,57 @@
import lottie from "lottie-web/build/player/lottie_light_canvas";
import { useEffect, useRef } from "react";
// StaticLottie renders a single (first) frame of a Lottie/TGS animation instead
// of looping it, so a grid of many stickers/emoji does not keep the canvas
// rendering and pinning the CPU. It plays only while hovered, then resets to the
// static frame. Use it for list/grid previews; keep the looping player for
// single, focused previews.
export function StaticLottie({
loader,
cacheKey,
className,
playOnHover = true,
onError
}: {
loader: () => Promise<Record<string, unknown>>;
cacheKey: string;
className?: string;
playOnHover?: boolean;
onError?: () => void;
}) {
const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
useEffect(() => {
let cancelled = false;
loader()
.then((data) => {
if (cancelled || !host.current) return;
animation.current?.destroy();
animation.current = lottie.loadAnimation({
container: host.current,
renderer: "canvas",
loop: true,
autoplay: false,
animationData: structuredClone(data)
});
animation.current.goToAndStop(0, true);
})
.catch(() => onError?.());
return () => {
cancelled = true;
animation.current?.destroy();
animation.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cacheKey]);
function play() {
if (playOnHover) animation.current?.play();
}
function reset() {
if (playOnHover) animation.current?.goToAndStop(0, true);
}
return <div className={className} ref={host} onMouseEnter={play} onMouseLeave={reset} />;
}

View file

@ -1,5 +1,5 @@
import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ActionButton } from "./ActionButton";
import { useI18n } from "../i18n";
import { toInt } from "../lib/format";
@ -134,6 +134,33 @@ export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow
const [joinToSend, setJoinToSend] = useState(channel.JoinToSend);
const [joinRequest, setJoinRequest] = useState(channel.JoinRequest);
const [slowmode, setSlowmode] = useState(String(channel.SlowmodeSeconds));
// Re-sync the toggles with the persisted state whenever the channel reloads
// (e.g. after applying a change), so previously-applied settings stay checked.
useEffect(() => {
setGigagroup(channel.Gigagroup);
setAntispam(channel.AntiSpam);
setHidden(channel.ParticipantsHidden);
setNoforwards(channel.NoForwards);
setJoinToSend(channel.JoinToSend);
setJoinRequest(channel.JoinRequest);
setSlowmode(String(channel.SlowmodeSeconds));
}, [channel]);
// Send only the fields the admin actually changed. The backend applies a
// partial patch (nil = leave unchanged), so an unrelated setting is never
// reset when another one is applied.
function buildPatch() {
const patch: Record<string, unknown> = { channel_id: channel.ID };
if (gigagroup !== channel.Gigagroup) patch.gigagroup = gigagroup;
if (antispam !== channel.AntiSpam) patch.antispam = antispam;
if (hidden !== channel.ParticipantsHidden) patch.participants_hidden = hidden;
if (noforwards !== channel.NoForwards) patch.noforwards = noforwards;
if (joinToSend !== channel.JoinToSend) patch.join_to_send = joinToSend;
if (joinRequest !== channel.JoinRequest) patch.join_request = joinRequest;
if (toInt(slowmode) !== channel.SlowmodeSeconds) patch.slowmode_seconds = toInt(slowmode);
return patch;
}
return (
<div className="attr-block">
<label className="checkline"><input type="checkbox" checked={gigagroup} onChange={(e) => setGigagroup(e.target.checked)} /> {t("attr.gigagroup")}</label>
@ -151,16 +178,7 @@ export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow
icon={<Settings2 size={15} />}
tone="warn"
path="/api/actions/set-channel-settings"
payload={() => ({
channel_id: channel.ID,
gigagroup,
antispam,
participants_hidden: hidden,
noforwards,
join_to_send: joinToSend,
join_request: joinRequest,
slowmode_seconds: toInt(slowmode)
})}
payload={buildPatch}
onDone={onDone}
/>
</div>

View file

@ -17,9 +17,9 @@ export function ScamFakeBadges({ scam, fake }: { scam: boolean; fake: boolean })
);
}
// ScamFakeActions renders the two independent toggles. The combined setter
// receives the full desired state, so each button flips one flag and keeps the
// other unchanged.
// ScamFakeActions renders the two toggles. scam and fake are mutually exclusive
// (a peer is never both in Telegram), so enabling one clears the other; the
// combined setter always receives the full desired state.
export function ScamFakeActions({
idKey,
id,
@ -43,7 +43,7 @@ export function ScamFakeActions({
icon={<ShieldAlert size={15} />}
tone="danger"
path={path}
payload={() => ({ [idKey]: id, scam: !scam, fake })}
payload={() => ({ [idKey]: id, scam: !scam, fake: !scam ? false : fake })}
onDone={onDone}
/>
<ActionButton
@ -51,7 +51,7 @@ export function ScamFakeActions({
icon={<ShieldX size={15} />}
tone="danger"
path={path}
payload={() => ({ [idKey]: id, scam, fake: !fake })}
payload={() => ({ [idKey]: id, fake: !fake, scam: !fake ? false : scam })}
onDone={onDone}
/>
</div>

View file

@ -337,17 +337,15 @@ const translations: Record<Language, Record<string, string>> = {
"giveGift.pickChannel": "Recipient channel",
"giveGift.recipientRequired": "Select a recipient first",
"giveGift.sender": "Sender account ID",
"giveGift.senderHint": "Defaults to the system account 777000 (Telesrv).",
"giveGift.senderHint": "Gifts are always sent from the system account 777000 (Telesrv).",
"giveGift.message": "Attached message (optional)",
"giveGift.messagePlaceholder": "Shown with the gift",
"giveGift.hideName": "Hide sender name from recipient",
"giveGift.upgrade": "Deliver as upgraded collectible",
"giveGift.upgradeNote": "The gift is minted as a unique collectible. Pick specific attributes and a number below, or leave them on Random to draw from the published pool. Requires a published collectible upgrade with remaining supply.",
"giveGift.upgradeNote": "The gift is minted as a unique collectible. Pick specific attributes below, or leave them on Random to draw from the published pool. The collectible number is assigned automatically. Requires a published collectible upgrade with remaining supply.",
"giveGift.model": "Model",
"giveGift.pattern": "Pattern",
"giveGift.backdrop": "Backdrop",
"giveGift.number": "Number",
"giveGift.numberAuto": "Auto",
"giveGift.random": "Random",
"giveGift.confirm": "Give gift",
"giveGifts.pageTitle": "Give Gifts",
@ -821,17 +819,15 @@ const translations: Record<Language, Record<string, string>> = {
"giveGift.pickChannel": "接收频道",
"giveGift.recipientRequired": "请先选择接收方",
"giveGift.sender": "发送方账号 ID",
"giveGift.senderHint": "默认使用系统账号 777000Telesrv。",
"giveGift.senderHint": "礼物始终由系统账号 777000Telesrv发送。",
"giveGift.message": "附加留言(可选)",
"giveGift.messagePlaceholder": "随礼物一起显示",
"giveGift.hideName": "对接收方隐藏发送方名称",
"giveGift.upgrade": "作为升级收藏品发放",
"giveGift.upgradeNote": "礼物将铸造为唯一收藏品。可在下方指定具体属性和编号,或保持“随机”从已发布的属性池中抽取。需要存在有剩余供应量的已发布收藏品升级。",
"giveGift.upgradeNote": "礼物将铸造为唯一收藏品。可在下方指定具体属性,或保持“随机”从已发布的属性池中抽取。编号自动分配。需要存在有剩余供应量的已发布收藏品升级。",
"giveGift.model": "模型",
"giveGift.pattern": "图案",
"giveGift.backdrop": "背景",
"giveGift.number": "编号",
"giveGift.numberAuto": "自动",
"giveGift.random": "随机",
"giveGift.confirm": "赠送礼物",
"giveGifts.pageTitle": "赠送礼物",
@ -1305,17 +1301,15 @@ const translations: Record<Language, Record<string, string>> = {
"giveGift.pickChannel": "Получатель (канал)",
"giveGift.recipientRequired": "Сначала выберите получателя",
"giveGift.sender": "ID аккаунта-отправителя",
"giveGift.senderHint": "По умолчанию системный аккаунт 777000 (Telesrv).",
"giveGift.senderHint": "Подарки всегда отправляются от системного аккаунта 777000 (Telesrv).",
"giveGift.message": "Сообщение к подарку (необязательно)",
"giveGift.messagePlaceholder": "Показывается вместе с подарком",
"giveGift.hideName": "Скрыть имя отправителя от получателя",
"giveGift.upgrade": "Выдать как улучшенный коллекционный",
"giveGift.upgradeNote": "Подарок будет отчеканен как уникальный коллекционный. Ниже можно выбрать конкретные атрибуты и номер или оставить «Случайно» для выбора из опубликованного пула. Требуется опубликованное коллекционное улучшение с остатком тиража.",
"giveGift.upgradeNote": "Подарок будет отчеканен как уникальный коллекционный. Ниже можно выбрать конкретные атрибуты или оставить «Случайно» для выбора из опубликованного пула. Номер присваивается автоматически. Требуется опубликованное коллекционное улучшение с остатком тиража.",
"giveGift.model": "Модель",
"giveGift.pattern": "Узор",
"giveGift.backdrop": "Фон",
"giveGift.number": "Номер",
"giveGift.numberAuto": "Авто",
"giveGift.random": "Случайно",
"giveGift.confirm": "Выдать подарок",
"giveGifts.pageTitle": "Выдача подарков",

View file

@ -1,7 +1,7 @@
import lottie from "lottie-web/build/player/lottie_light_canvas";
import { Check, ChevronRight, Copy, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { StaticLottie } from "../components/StaticLottie";
import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import type { EmojiListResponse, EmojiRow } from "../types";
@ -18,38 +18,25 @@ function isAnimated(mime: string): boolean {
}
function EmojiPreview({ row }: { row: EmojiRow }) {
const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
const [failed, setFailed] = useState(!isAnimated(row.MimeType));
useEffect(() => {
if (!isAnimated(row.MimeType)) {
setFailed(true);
return;
}
let cancelled = false;
api.emojiAnimation(row.DocumentID).then((data) => {
if (cancelled || !host.current) return;
animation.current?.destroy();
animation.current = lottie.loadAnimation({
container: host.current,
renderer: "canvas",
loop: true,
autoplay: true,
animationData: structuredClone(data)
});
}).catch(() => setFailed(true));
return () => {
cancelled = true;
animation.current?.destroy();
animation.current = null;
};
setFailed(!isAnimated(row.MimeType));
}, [row.DocumentID, row.MimeType]);
if (failed) {
return <div className="emoji-glyph">{row.Alt || "🙂"}</div>;
}
return <div className="emoji-anim" ref={host} />;
// Render a static first frame (plays only on hover) so a full grid of emoji
// does not keep every Lottie canvas animating and lag the page.
return (
<StaticLottie
className="emoji-anim"
cacheKey={row.DocumentID}
loader={() => api.emojiAnimation(row.DocumentID)}
onError={() => setFailed(true)}
/>
);
}
function EmojiCard({ row }: { row: EmojiRow }) {

View file

@ -20,7 +20,6 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
const [kind, setKind] = useState<RecipientKind>("user");
const [user, setUser] = useState<AccountRow | null>(null);
const [channel, setChannel] = useState<ChannelRow | null>(null);
const [sender, setSender] = useState(SYSTEM_SENDER);
const [message, setMessage] = useState("");
const [hideName, setHideName] = useState(false);
const [upgrade, setUpgrade] = useState(false);
@ -29,7 +28,6 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
const [modelID, setModelID] = useState("0");
const [patternID, setPatternID] = useState("0");
const [backdropID, setBackdropID] = useState("0");
const [num, setNum] = useState("");
const [reason, setReason] = useState("");
const [result, setResult] = useState<CommandResult | null>(null);
const [error, setError] = useState("");
@ -47,7 +45,6 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
setModelID("0");
setPatternID("0");
setBackdropID("0");
setNum("");
setResult(null);
setError("");
}, [gift.GiftID]);
@ -63,11 +60,10 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
}, [upgradable, preview, gift.GiftID]);
function buildPayload(confirm: boolean): Record<string, unknown> {
const senderID = Number.parseInt(sender.trim() || SYSTEM_SENDER, 10);
const parsedNum = Number.parseInt(num.trim(), 10);
return {
gift_id: gift.GiftID,
sender_user_id: Number.isFinite(senderID) ? senderID : 0,
// Gifts are always sent from the official system account (777000).
sender_user_id: Number(SYSTEM_SENDER),
user_id: kind === "user" ? recipientID : 0,
channel_id: kind === "channel" ? recipientID : 0,
hide_name: hideName,
@ -76,13 +72,12 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
model_attribute_id: upgradable ? modelID : "0",
pattern_attribute_id: upgradable ? patternID : "0",
backdrop_attribute_id: upgradable ? backdropID : "0",
num: upgradable && Number.isFinite(parsedNum) && parsedNum > 0 ? parsedNum : 0,
reason: reason.trim(),
confirm
};
}
const previewPayload = useMemo(() => buildPayload(false), [gift.GiftID, kind, recipientID, sender, message, hideName, upgrade, modelID, patternID, backdropID, num, reason]);
const previewPayload = useMemo(() => buildPayload(false), [gift.GiftID, kind, recipientID, message, hideName, upgrade, modelID, patternID, backdropID, reason]);
const canConfirm = result?.dry_run && !result.error;
async function run(confirm: boolean) {
@ -134,7 +129,7 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
<label className="form-field">
<span>{t("giveGift.sender")}</span>
<input value={sender} inputMode="numeric" onChange={(event) => { setSender(event.target.value.replace(/[^0-9]/g, "")); setResult(null); }} placeholder={SYSTEM_SENDER} />
<input value={SYSTEM_SENDER} disabled readOnly />
<small className="field-hint">{t("giveGift.senderHint")}</small>
</label>
@ -152,7 +147,7 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
{kind === "user" && (
<>
<label className="gift-switch">
<input type="checkbox" checked={upgrade} onChange={(event) => { setUpgrade(event.target.checked); if (!event.target.checked) { setModelID("0"); setPatternID("0"); setBackdropID("0"); setNum(""); } setResult(null); }} />
<input type="checkbox" checked={upgrade} onChange={(event) => { setUpgrade(event.target.checked); if (!event.target.checked) { setModelID("0"); setPatternID("0"); setBackdropID("0"); } setResult(null); }} />
<span className="gift-switch-track" aria-hidden="true"><span /></span>
<span>{t("giveGift.upgrade")}</span>
</label>
@ -181,10 +176,6 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
{(preview.backdrops ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select>
</label>
<label>
<span>{t("giveGift.number")}</span>
<input type="number" min="1" max={preview.supply_total ?? undefined} value={num} placeholder={t("giveGift.numberAuto")} onChange={(event) => { setNum(event.target.value.replace(/[^0-9]/g, "")); setResult(null); }} />
</label>
</div>
)}
</>

View file

@ -1,10 +1,10 @@
import { Gift, RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api";
import { StaticLottie } from "../components/StaticLottie";
import { Alert, Badge, PageFrame } from "../components/ui";
import { useI18n } from "../i18n";
import type { StarGiftRow } from "../types";
import { LottiePreview } from "./GiftsPage";
import { GiveGiftForm } from "./GiveGiftForm";
export function GiveGiftsPage() {
@ -58,13 +58,13 @@ export function GiveGiftsPage() {
<button key={gift.GiftID} type="button" role="option" aria-selected={active}
className={`give-gift-option ${active ? "selected" : ""} ${gift.Enabled ? "" : "gift-row-disabled"}`}
onClick={() => setSelected(gift)}>
<LottiePreview giftID={gift.GiftID} revision={gift.Revision} compact />
<StaticLottie className="give-gift-thumb" cacheKey={`${gift.GiftID}:${gift.Revision}`} loader={() => api.giftAnimation(gift.GiftID)} />
<span className="give-gift-option-info">
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
<span className="mono">#{gift.GiftID}</span>
<span className="give-gift-option-meta">
{gift.Enabled ? <Badge> {gift.Stars}</Badge> : <Badge tone="neutral">{t("common.disabled")}</Badge>}
</span>
</span>
<span className="give-gift-option-price">
{gift.Enabled ? <Badge> {gift.Stars}</Badge> : <Badge tone="neutral">{t("common.disabled")}</Badge>}
</span>
</button>
);

View file

@ -297,7 +297,7 @@
/* Collectible attribute pickers reuse .gift-fields-grid but need equal columns
and site-styled selects rather than the import modal's fixed template. */
.give-gift-attrs { grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: end; }
.give-gift-attrs { grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; }
.give-gift-attrs select,
.give-gift-attrs input {
width: 100%; min-width: 0; height: 38px; padding: 0 32px 0 10px;
@ -314,27 +314,28 @@
.give-gift-attrs input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--focus); outline: none; }
/* Give Gifts page: two-panel picker + form */
.give-gift-layout { display: grid; grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); gap: 16px; align-items: start; }
.give-gift-layout { display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); gap: 16px; align-items: start; }
.give-gift-picker { display: grid; gap: 10px; align-content: start; }
.give-gift-picker-head { display: flex; align-items: center; gap: 12px; }
.give-gift-picker-head .searchbox { flex: 1 1 auto; }
.give-gift-picker-list {
display: grid; gap: 8px; max-height: 640px; padding: 8px; overflow: auto;
background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-lg); scrollbar-gutter: stable;
display: grid; gap: 8px; max-height: 640px; padding: 8px; overflow-y: auto;
background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-lg);
}
.give-gift-option {
display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 11px; align-items: center; min-width: 0;
padding: 9px; text-align: left; color: var(--text); background: var(--panel);
display: grid; grid-template-columns: 46px minmax(0, 1fr) auto; gap: 11px; align-items: center; min-width: 0;
padding: 9px 11px; text-align: left; color: var(--text); background: var(--panel);
border: 1px solid var(--line); border-radius: var(--radius); cursor: pointer; box-shadow: var(--shadow-sm);
transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
.give-gift-option:hover { border-color: var(--brand-tint-border); box-shadow: var(--shadow); transform: translateY(-1px); }
.give-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px var(--focus), var(--shadow); }
.give-gift-option .gift-animation-shell.compact { pointer-events: none; }
.give-gift-thumb { display: grid; place-items: center; width: 46px; height: 46px; }
.give-gift-thumb canvas { width: 100% !important; height: 100% !important; }
.give-gift-option-info { display: grid; gap: 3px; min-width: 0; }
.give-gift-option-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.give-gift-option-info .mono { color: var(--muted); font-size: 10px; }
.give-gift-option-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; }
.give-gift-option-price { justify-self: end; white-space: nowrap; }
.give-gift-panel {
display: grid; gap: 12px; padding: 16px; min-width: 0;
background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--radius-lg);
@ -645,7 +646,9 @@
}
.emoji-id {
display: inline-flex;
display: flex;
width: 100%;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 6px;
@ -659,11 +662,17 @@
}
.emoji-id .mono {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emoji-id svg {
flex: 0 0 auto;
}
.emoji-id:hover {
border-color: var(--brand-tint-border);
color: var(--brand);

View file

@ -117,6 +117,12 @@
font-weight: 800;
}
.form-field input:disabled,
.form-field textarea:disabled {
opacity: .6;
cursor: not-allowed;
}
.command-preview {
display: grid;
gap: 8px;