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

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>