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:
parent
313624eab2
commit
90792cdfab
22 changed files with 214 additions and 135 deletions
|
|
@ -1371,7 +1371,6 @@ type giveGiftAPIRequest struct {
|
|||
ModelAttributeID int64 `json:"model_attribute_id,string"`
|
||||
PatternAttributeID int64 `json:"pattern_attribute_id,string"`
|
||||
BackdropAttributeID int64 `json:"backdrop_attribute_id,string"`
|
||||
Num int `json:"num"`
|
||||
}
|
||||
|
||||
func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -1391,7 +1390,6 @@ func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
|
|||
ModelAttributeID: body.ModelAttributeID,
|
||||
PatternAttributeID: body.PatternAttributeID,
|
||||
BackdropAttributeID: body.BackdropAttributeID,
|
||||
Num: body.Num,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/give", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-C0WDPjsF.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-C0WDPjsF.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
57
cmd/telesrv-admin/web/src/components/StaticLottie.tsx
Normal file
57
cmd/telesrv-admin/web/src/components/StaticLottie.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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": "默认使用系统账号 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": "赠送礼物",
|
||||
|
|
@ -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": "Выдача подарков",
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -358,7 +358,6 @@ type GiveGiftRequest struct {
|
|||
ModelAttributeID int64 `json:"model_attribute_id"`
|
||||
PatternAttributeID int64 `json:"pattern_attribute_id"`
|
||||
BackdropAttributeID int64 `json:"backdrop_attribute_id"`
|
||||
Num int `json:"num"`
|
||||
}
|
||||
|
||||
type StarGiftCollectibleAnimationUpload struct {
|
||||
|
|
@ -871,6 +870,11 @@ func (s *Service) SetUserFlags(ctx context.Context, req SetUserFlagsRequest) (Co
|
|||
if s == nil || s.users == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
||||
}
|
||||
// scam and fake are mutually exclusive (a peer is never both in Telegram).
|
||||
// scam takes precedence so the two never persist together.
|
||||
if req.Scam {
|
||||
req.Fake = false
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetUserFlags, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||
if err != nil {
|
||||
|
|
@ -967,8 +971,8 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
|
|||
if req.Upgrade && recipient.Type != domain.PeerTypeUser {
|
||||
return CommandResult{}, fmt.Errorf("upgraded gift delivery is supported for user recipients only")
|
||||
}
|
||||
if !req.Upgrade && (req.ModelAttributeID > 0 || req.PatternAttributeID > 0 || req.BackdropAttributeID > 0 || req.Num > 0) {
|
||||
return CommandResult{}, fmt.Errorf("collectible attributes and number require upgrade")
|
||||
if !req.Upgrade && (req.ModelAttributeID > 0 || req.PatternAttributeID > 0 || req.BackdropAttributeID > 0) {
|
||||
return CommandResult{}, fmt.Errorf("collectible attributes require upgrade")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionGiveGift, req.UserID, recipient, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
|
|
@ -1012,9 +1016,6 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
|
|||
if req.BackdropAttributeID > 0 && !collectibleAttrPresent(preview.Backdrops, req.BackdropAttributeID) {
|
||||
return CommandResult{}, fmt.Errorf("backdrop attribute %d is not part of gift %d", req.BackdropAttributeID, req.GiftID)
|
||||
}
|
||||
if req.Num > 0 && req.Num > preview.SupplyTotal {
|
||||
return CommandResult{}, fmt.Errorf("number %d exceeds collectible supply %d", req.Num, preview.SupplyTotal)
|
||||
}
|
||||
details["collectible_supply_total"] = preview.SupplyTotal
|
||||
details["collectible_issued"] = preview.Issued
|
||||
if req.ModelAttributeID > 0 {
|
||||
|
|
@ -1026,9 +1027,6 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
|
|||
if req.BackdropAttributeID > 0 {
|
||||
details["backdrop_attribute_id"] = req.BackdropAttributeID
|
||||
}
|
||||
if req.Num > 0 {
|
||||
details["num"] = req.Num
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.DryRun {
|
||||
|
|
@ -1044,7 +1042,6 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
|
|||
ModelAttributeID: req.ModelAttributeID,
|
||||
PatternAttributeID: req.PatternAttributeID,
|
||||
BackdropAttributeID: req.BackdropAttributeID,
|
||||
Num: req.Num,
|
||||
}); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -328,21 +328,21 @@ type StarGiftUpgradeRequest struct {
|
|||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
|
||||
// Admin-controlled minting overrides. When non-zero these pin the specific
|
||||
// collectible attributes / number instead of the random pool draw and the
|
||||
// sequential issued+1 number. They are only honoured on the admin grant path;
|
||||
// the DB FK (attribute must belong to the revision) and UNIQUE(gift_id,num)
|
||||
// constraints remain the source of truth.
|
||||
// Admin-controlled attribute overrides. When non-zero these pin the specific
|
||||
// collectible model/pattern/backdrop instead of the random pool draw. They
|
||||
// are only honoured on the admin grant path; the DB FK (attribute must belong
|
||||
// to the revision) remains the source of truth. The collectible number is
|
||||
// always assigned automatically (sequential).
|
||||
ModelAttributeID int64
|
||||
PatternAttributeID int64
|
||||
BackdropAttributeID int64
|
||||
Num int
|
||||
}
|
||||
|
||||
// AdminStarGiftGrant is one admin "give gift" command: deliver GiftID to
|
||||
// Recipient from Sender (0 => official system account 777000) at no charge.
|
||||
// When Upgrade is set the gift is minted as a collectible; the optional
|
||||
// attribute IDs / Num pin specific collectible facts (0 => random/auto).
|
||||
// attribute IDs pin specific model/pattern/backdrop (0 => random). The
|
||||
// collectible number is always assigned automatically.
|
||||
type AdminStarGiftGrant struct {
|
||||
SenderID int64
|
||||
Recipient Peer
|
||||
|
|
@ -353,7 +353,6 @@ type AdminStarGiftGrant struct {
|
|||
ModelAttributeID int64
|
||||
PatternAttributeID int64
|
||||
BackdropAttributeID int64
|
||||
Num int
|
||||
}
|
||||
|
||||
type StarGiftPurchaseRequest struct {
|
||||
|
|
@ -927,7 +926,6 @@ var (
|
|||
ErrStarGiftAlreadyUpgraded = errors.New("stargift: already upgraded")
|
||||
ErrStarGiftCollectibleSoldOut = errors.New("stargift: collectible supply exhausted")
|
||||
ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition")
|
||||
ErrStarGiftCollectibleNumberTaken = errors.New("stargift: collectible number already taken")
|
||||
ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found")
|
||||
ErrStarGiftCollectionsFull = errors.New("stargift: collections full")
|
||||
ErrStarGiftUnavailable = errors.New("stargift: unavailable")
|
||||
|
|
|
|||
|
|
@ -451,7 +451,9 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
|
|||
Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0,
|
||||
Verified: ch.Verified,
|
||||
Scam: ch.Scam,
|
||||
Fake: ch.Fake,
|
||||
// scam and fake are mutually exclusive in Telegram; a peer flagged as
|
||||
// both would render neither badge on clients. scam takes precedence.
|
||||
Fake: ch.Fake && !ch.Scam,
|
||||
Gigagroup: ch.Gigagroup,
|
||||
Broadcast: ch.Broadcast,
|
||||
Megagroup: ch.Megagroup,
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ func tgUser(u domain.User) *tg.User {
|
|||
Phone: u.Phone,
|
||||
Verified: u.Verified,
|
||||
Scam: u.Scam,
|
||||
Fake: u.Fake,
|
||||
// scam and fake are mutually exclusive in Telegram; a peer flagged as
|
||||
// both would render neither badge on clients. scam takes precedence.
|
||||
Fake: u.Fake && !u.Scam,
|
||||
Support: u.Support,
|
||||
Contact: u.Contact,
|
||||
MutualContact: u.Mutual,
|
||||
|
|
|
|||
|
|
@ -523,10 +523,17 @@ func (r *Router) onPaymentsGetSavedStarGifts(ctx context.Context, req *tg.Paymen
|
|||
if r.deps.Gifts == nil {
|
||||
return emptySavedStarGifts(), nil
|
||||
}
|
||||
// Gifts hidden from the profile (unsaved) are visible only to the owner (or a
|
||||
// channel admin). Never trust the client's exclude_unsaved flag for other
|
||||
// viewers: force-exclude hidden gifts unless the requester manages the owner.
|
||||
excludeUnsaved := req.ExcludeUnsaved
|
||||
if r.ensureCanManageStarGiftOwner(ctx, userID, owner) != nil {
|
||||
excludeUnsaved = true
|
||||
}
|
||||
collectionID, _ := req.GetCollectionID()
|
||||
page, err := r.deps.Gifts.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
|
||||
Owner: owner,
|
||||
ExcludeUnsaved: req.ExcludeUnsaved,
|
||||
ExcludeUnsaved: excludeUnsaved,
|
||||
ExcludeSaved: req.ExcludeSaved,
|
||||
ExcludeUnlimited: req.ExcludeUnlimited,
|
||||
ExcludeUnique: req.ExcludeUnique,
|
||||
|
|
@ -556,6 +563,17 @@ func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.Input
|
|||
return emptySavedStarGifts(), nil
|
||||
}
|
||||
gifts := make([]domain.SavedStarGift, 0, len(refs))
|
||||
// A gift hidden from the profile (unsaved) is visible only to the owner or a
|
||||
// channel admin. Memoize the manage check per owner to avoid repeat lookups.
|
||||
manageCache := make(map[domain.Peer]bool)
|
||||
canManageOwner := func(owner domain.Peer) bool {
|
||||
if v, ok := manageCache[owner]; ok {
|
||||
return v
|
||||
}
|
||||
v := r.ensureCanManageStarGiftOwner(ctx, userID, owner) == nil
|
||||
manageCache[owner] = v
|
||||
return v
|
||||
}
|
||||
for _, ref := range refs {
|
||||
dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref)
|
||||
if err != nil {
|
||||
|
|
@ -569,6 +587,9 @@ func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.Input
|
|||
return nil, internalErr()
|
||||
}
|
||||
if found && !g.Converted {
|
||||
if g.Unsaved && !canManageOwner(g.Owner) {
|
||||
continue
|
||||
}
|
||||
gifts = append(gifts, g)
|
||||
}
|
||||
}
|
||||
|
|
@ -669,9 +690,14 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave
|
|||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrStarGiftNotFound):
|
||||
return false, starGiftInvalidErr()
|
||||
case errors.Is(err, domain.ErrStarGiftAlreadyConverted):
|
||||
case errors.Is(err, domain.ErrStarGiftNotFound),
|
||||
errors.Is(err, domain.ErrStarGiftAlreadyConverted),
|
||||
errors.Is(err, domain.ErrStarGiftAlreadyUpgraded),
|
||||
errors.Is(err, domain.ErrStarGiftOwnerInvalid),
|
||||
errors.Is(err, domain.ErrStarGiftUnavailable):
|
||||
// These are known business conditions (e.g. converting an already
|
||||
// upgraded/unique gift). Surface a clean client error instead of a
|
||||
// 500 INTERNAL_SERVER_ERROR.
|
||||
return false, starGiftInvalidErr()
|
||||
default:
|
||||
return false, internalErr()
|
||||
|
|
|
|||
|
|
@ -90,7 +90,6 @@ func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64,
|
|||
ModelAttributeID: grant.ModelAttributeID,
|
||||
PatternAttributeID: grant.PatternAttributeID,
|
||||
BackdropAttributeID: grant.BackdropAttributeID,
|
||||
Num: grant.Num,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,19 +164,6 @@ WHERE collectible_revision_id=$1 AND crafted
|
|||
}
|
||||
|
||||
num := revision.Issued + 1
|
||||
if req.Num > 0 {
|
||||
if req.Num > revision.SupplyTotal {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
var numTaken bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM unique_star_gifts WHERE gift_id=$1 AND num=$2)`, locked.GiftID, req.Num).Scan(&numTaken); err != nil {
|
||||
return fmt.Errorf("check collectible number availability: %w", err)
|
||||
}
|
||||
if numTaken {
|
||||
return domain.ErrStarGiftCollectibleNumberTaken
|
||||
}
|
||||
num = req.Num
|
||||
}
|
||||
var uniqueID int64
|
||||
if err := tx.QueryRow(ctx, `SELECT nextval('unique_star_gift_id_seq')`).Scan(&uniqueID); err != nil {
|
||||
return fmt.Errorf("allocate unique star gift id: %w", err)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ type userBaseValue struct {
|
|||
CountryCode string `json:"country_code"`
|
||||
Verified bool `json:"verified"`
|
||||
Support bool `json:"support"`
|
||||
// scam / fake 同理必须随缓存往返:丢失会让缓存命中路径把带标记的账号输出成
|
||||
// 普通账号,导致资料页 SCAM/FAKE 标记随缓存命中/未命中间歇性消失(与 bot 列同坑)。
|
||||
Scam bool `json:"scam,omitempty"`
|
||||
Fake bool `json:"fake,omitempty"`
|
||||
// bot 字段必须随缓存往返:丢失会让缓存命中路径把 bot 输出成普通用户,
|
||||
// 污染客户端本地缓存(TDesktop 的 bot 标记不可逆)。
|
||||
Bot bool `json:"bot,omitempty"`
|
||||
|
|
@ -78,6 +82,8 @@ func baseValueFromUser(u domain.User) userBaseValue {
|
|||
CountryCode: u.CountryCode,
|
||||
Verified: u.Verified,
|
||||
Support: u.Support,
|
||||
Scam: u.Scam,
|
||||
Fake: u.Fake,
|
||||
Bot: u.Bot,
|
||||
BotInfoVersion: u.BotInfoVersion,
|
||||
PremiumUntil: u.PremiumUntil,
|
||||
|
|
@ -110,6 +116,8 @@ func (v userBaseValue) user() domain.User {
|
|||
CountryCode: v.CountryCode,
|
||||
Verified: v.Verified,
|
||||
Support: v.Support,
|
||||
Scam: v.Scam,
|
||||
Fake: v.Fake,
|
||||
Bot: v.Bot,
|
||||
BotInfoVersion: v.BotInfoVersion,
|
||||
PremiumUntil: v.PremiumUntil,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue