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"`
|
ModelAttributeID int64 `json:"model_attribute_id,string"`
|
||||||
PatternAttributeID int64 `json:"pattern_attribute_id,string"`
|
PatternAttributeID int64 `json:"pattern_attribute_id,string"`
|
||||||
BackdropAttributeID int64 `json:"backdrop_attribute_id,string"`
|
BackdropAttributeID int64 `json:"backdrop_attribute_id,string"`
|
||||||
Num int `json:"num"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
|
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,
|
ModelAttributeID: body.ModelAttributeID,
|
||||||
PatternAttributeID: body.PatternAttributeID,
|
PatternAttributeID: body.PatternAttributeID,
|
||||||
BackdropAttributeID: body.BackdropAttributeID,
|
BackdropAttributeID: body.BackdropAttributeID,
|
||||||
Num: body.Num,
|
|
||||||
}
|
}
|
||||||
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/give", req)
|
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/give", req)
|
||||||
writeCommandResultAPI(w, result, err)
|
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>
|
||||||
<script type="module" crossorigin src="/assets/index-Bx7A77x9.js"></script>
|
<script type="module" crossorigin src="/assets/index-C0WDPjsF.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-XV_IEG5m.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BwxAoLbQ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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 { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ActionButton } from "./ActionButton";
|
import { ActionButton } from "./ActionButton";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import { toInt } from "../lib/format";
|
import { toInt } from "../lib/format";
|
||||||
|
|
@ -134,6 +134,33 @@ export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow
|
||||||
const [joinToSend, setJoinToSend] = useState(channel.JoinToSend);
|
const [joinToSend, setJoinToSend] = useState(channel.JoinToSend);
|
||||||
const [joinRequest, setJoinRequest] = useState(channel.JoinRequest);
|
const [joinRequest, setJoinRequest] = useState(channel.JoinRequest);
|
||||||
const [slowmode, setSlowmode] = useState(String(channel.SlowmodeSeconds));
|
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 (
|
return (
|
||||||
<div className="attr-block">
|
<div className="attr-block">
|
||||||
<label className="checkline"><input type="checkbox" checked={gigagroup} onChange={(e) => setGigagroup(e.target.checked)} /> {t("attr.gigagroup")}</label>
|
<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} />}
|
icon={<Settings2 size={15} />}
|
||||||
tone="warn"
|
tone="warn"
|
||||||
path="/api/actions/set-channel-settings"
|
path="/api/actions/set-channel-settings"
|
||||||
payload={() => ({
|
payload={buildPatch}
|
||||||
channel_id: channel.ID,
|
|
||||||
gigagroup,
|
|
||||||
antispam,
|
|
||||||
participants_hidden: hidden,
|
|
||||||
noforwards,
|
|
||||||
join_to_send: joinToSend,
|
|
||||||
join_request: joinRequest,
|
|
||||||
slowmode_seconds: toInt(slowmode)
|
|
||||||
})}
|
|
||||||
onDone={onDone}
|
onDone={onDone}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@ export function ScamFakeBadges({ scam, fake }: { scam: boolean; fake: boolean })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScamFakeActions renders the two independent toggles. The combined setter
|
// ScamFakeActions renders the two toggles. scam and fake are mutually exclusive
|
||||||
// receives the full desired state, so each button flips one flag and keeps the
|
// (a peer is never both in Telegram), so enabling one clears the other; the
|
||||||
// other unchanged.
|
// combined setter always receives the full desired state.
|
||||||
export function ScamFakeActions({
|
export function ScamFakeActions({
|
||||||
idKey,
|
idKey,
|
||||||
id,
|
id,
|
||||||
|
|
@ -43,7 +43,7 @@ export function ScamFakeActions({
|
||||||
icon={<ShieldAlert size={15} />}
|
icon={<ShieldAlert size={15} />}
|
||||||
tone="danger"
|
tone="danger"
|
||||||
path={path}
|
path={path}
|
||||||
payload={() => ({ [idKey]: id, scam: !scam, fake })}
|
payload={() => ({ [idKey]: id, scam: !scam, fake: !scam ? false : fake })}
|
||||||
onDone={onDone}
|
onDone={onDone}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
|
@ -51,7 +51,7 @@ export function ScamFakeActions({
|
||||||
icon={<ShieldX size={15} />}
|
icon={<ShieldX size={15} />}
|
||||||
tone="danger"
|
tone="danger"
|
||||||
path={path}
|
path={path}
|
||||||
payload={() => ({ [idKey]: id, scam, fake: !fake })}
|
payload={() => ({ [idKey]: id, fake: !fake, scam: !fake ? false : scam })}
|
||||||
onDone={onDone}
|
onDone={onDone}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -337,17 +337,15 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"giveGift.pickChannel": "Recipient channel",
|
"giveGift.pickChannel": "Recipient channel",
|
||||||
"giveGift.recipientRequired": "Select a recipient first",
|
"giveGift.recipientRequired": "Select a recipient first",
|
||||||
"giveGift.sender": "Sender account ID",
|
"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.message": "Attached message (optional)",
|
||||||
"giveGift.messagePlaceholder": "Shown with the gift",
|
"giveGift.messagePlaceholder": "Shown with the gift",
|
||||||
"giveGift.hideName": "Hide sender name from recipient",
|
"giveGift.hideName": "Hide sender name from recipient",
|
||||||
"giveGift.upgrade": "Deliver as upgraded collectible",
|
"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.model": "Model",
|
||||||
"giveGift.pattern": "Pattern",
|
"giveGift.pattern": "Pattern",
|
||||||
"giveGift.backdrop": "Backdrop",
|
"giveGift.backdrop": "Backdrop",
|
||||||
"giveGift.number": "Number",
|
|
||||||
"giveGift.numberAuto": "Auto",
|
|
||||||
"giveGift.random": "Random",
|
"giveGift.random": "Random",
|
||||||
"giveGift.confirm": "Give gift",
|
"giveGift.confirm": "Give gift",
|
||||||
"giveGifts.pageTitle": "Give Gifts",
|
"giveGifts.pageTitle": "Give Gifts",
|
||||||
|
|
@ -821,17 +819,15 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"giveGift.pickChannel": "接收频道",
|
"giveGift.pickChannel": "接收频道",
|
||||||
"giveGift.recipientRequired": "请先选择接收方",
|
"giveGift.recipientRequired": "请先选择接收方",
|
||||||
"giveGift.sender": "发送方账号 ID",
|
"giveGift.sender": "发送方账号 ID",
|
||||||
"giveGift.senderHint": "默认使用系统账号 777000(Telesrv)。",
|
"giveGift.senderHint": "礼物始终由系统账号 777000(Telesrv)发送。",
|
||||||
"giveGift.message": "附加留言(可选)",
|
"giveGift.message": "附加留言(可选)",
|
||||||
"giveGift.messagePlaceholder": "随礼物一起显示",
|
"giveGift.messagePlaceholder": "随礼物一起显示",
|
||||||
"giveGift.hideName": "对接收方隐藏发送方名称",
|
"giveGift.hideName": "对接收方隐藏发送方名称",
|
||||||
"giveGift.upgrade": "作为升级收藏品发放",
|
"giveGift.upgrade": "作为升级收藏品发放",
|
||||||
"giveGift.upgradeNote": "礼物将铸造为唯一收藏品。可在下方指定具体属性和编号,或保持“随机”从已发布的属性池中抽取。需要存在有剩余供应量的已发布收藏品升级。",
|
"giveGift.upgradeNote": "礼物将铸造为唯一收藏品。可在下方指定具体属性,或保持“随机”从已发布的属性池中抽取。编号自动分配。需要存在有剩余供应量的已发布收藏品升级。",
|
||||||
"giveGift.model": "模型",
|
"giveGift.model": "模型",
|
||||||
"giveGift.pattern": "图案",
|
"giveGift.pattern": "图案",
|
||||||
"giveGift.backdrop": "背景",
|
"giveGift.backdrop": "背景",
|
||||||
"giveGift.number": "编号",
|
|
||||||
"giveGift.numberAuto": "自动",
|
|
||||||
"giveGift.random": "随机",
|
"giveGift.random": "随机",
|
||||||
"giveGift.confirm": "赠送礼物",
|
"giveGift.confirm": "赠送礼物",
|
||||||
"giveGifts.pageTitle": "赠送礼物",
|
"giveGifts.pageTitle": "赠送礼物",
|
||||||
|
|
@ -1305,17 +1301,15 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"giveGift.pickChannel": "Получатель (канал)",
|
"giveGift.pickChannel": "Получатель (канал)",
|
||||||
"giveGift.recipientRequired": "Сначала выберите получателя",
|
"giveGift.recipientRequired": "Сначала выберите получателя",
|
||||||
"giveGift.sender": "ID аккаунта-отправителя",
|
"giveGift.sender": "ID аккаунта-отправителя",
|
||||||
"giveGift.senderHint": "По умолчанию системный аккаунт 777000 (Telesrv).",
|
"giveGift.senderHint": "Подарки всегда отправляются от системного аккаунта 777000 (Telesrv).",
|
||||||
"giveGift.message": "Сообщение к подарку (необязательно)",
|
"giveGift.message": "Сообщение к подарку (необязательно)",
|
||||||
"giveGift.messagePlaceholder": "Показывается вместе с подарком",
|
"giveGift.messagePlaceholder": "Показывается вместе с подарком",
|
||||||
"giveGift.hideName": "Скрыть имя отправителя от получателя",
|
"giveGift.hideName": "Скрыть имя отправителя от получателя",
|
||||||
"giveGift.upgrade": "Выдать как улучшенный коллекционный",
|
"giveGift.upgrade": "Выдать как улучшенный коллекционный",
|
||||||
"giveGift.upgradeNote": "Подарок будет отчеканен как уникальный коллекционный. Ниже можно выбрать конкретные атрибуты и номер или оставить «Случайно» для выбора из опубликованного пула. Требуется опубликованное коллекционное улучшение с остатком тиража.",
|
"giveGift.upgradeNote": "Подарок будет отчеканен как уникальный коллекционный. Ниже можно выбрать конкретные атрибуты или оставить «Случайно» для выбора из опубликованного пула. Номер присваивается автоматически. Требуется опубликованное коллекционное улучшение с остатком тиража.",
|
||||||
"giveGift.model": "Модель",
|
"giveGift.model": "Модель",
|
||||||
"giveGift.pattern": "Узор",
|
"giveGift.pattern": "Узор",
|
||||||
"giveGift.backdrop": "Фон",
|
"giveGift.backdrop": "Фон",
|
||||||
"giveGift.number": "Номер",
|
|
||||||
"giveGift.numberAuto": "Авто",
|
|
||||||
"giveGift.random": "Случайно",
|
"giveGift.random": "Случайно",
|
||||||
"giveGift.confirm": "Выдать подарок",
|
"giveGift.confirm": "Выдать подарок",
|
||||||
"giveGifts.pageTitle": "Выдача подарков",
|
"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 { 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 { api, errorMessage } from "../api";
|
||||||
|
import { StaticLottie } from "../components/StaticLottie";
|
||||||
import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui";
|
import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import type { EmojiListResponse, EmojiRow } from "../types";
|
import type { EmojiListResponse, EmojiRow } from "../types";
|
||||||
|
|
@ -18,38 +18,25 @@ function isAnimated(mime: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmojiPreview({ row }: { row: EmojiRow }) {
|
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));
|
const [failed, setFailed] = useState(!isAnimated(row.MimeType));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAnimated(row.MimeType)) {
|
setFailed(!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;
|
|
||||||
};
|
|
||||||
}, [row.DocumentID, row.MimeType]);
|
}, [row.DocumentID, row.MimeType]);
|
||||||
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
return <div className="emoji-glyph">{row.Alt || "🙂"}</div>;
|
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 }) {
|
function EmojiCard({ row }: { row: EmojiRow }) {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
||||||
const [kind, setKind] = useState<RecipientKind>("user");
|
const [kind, setKind] = useState<RecipientKind>("user");
|
||||||
const [user, setUser] = useState<AccountRow | null>(null);
|
const [user, setUser] = useState<AccountRow | null>(null);
|
||||||
const [channel, setChannel] = useState<ChannelRow | null>(null);
|
const [channel, setChannel] = useState<ChannelRow | null>(null);
|
||||||
const [sender, setSender] = useState(SYSTEM_SENDER);
|
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [hideName, setHideName] = useState(false);
|
const [hideName, setHideName] = useState(false);
|
||||||
const [upgrade, setUpgrade] = 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 [modelID, setModelID] = useState("0");
|
||||||
const [patternID, setPatternID] = useState("0");
|
const [patternID, setPatternID] = useState("0");
|
||||||
const [backdropID, setBackdropID] = useState("0");
|
const [backdropID, setBackdropID] = useState("0");
|
||||||
const [num, setNum] = useState("");
|
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
const [result, setResult] = useState<CommandResult | null>(null);
|
const [result, setResult] = useState<CommandResult | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
@ -47,7 +45,6 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
||||||
setModelID("0");
|
setModelID("0");
|
||||||
setPatternID("0");
|
setPatternID("0");
|
||||||
setBackdropID("0");
|
setBackdropID("0");
|
||||||
setNum("");
|
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setError("");
|
setError("");
|
||||||
}, [gift.GiftID]);
|
}, [gift.GiftID]);
|
||||||
|
|
@ -63,11 +60,10 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
||||||
}, [upgradable, preview, gift.GiftID]);
|
}, [upgradable, preview, gift.GiftID]);
|
||||||
|
|
||||||
function buildPayload(confirm: boolean): Record<string, unknown> {
|
function buildPayload(confirm: boolean): Record<string, unknown> {
|
||||||
const senderID = Number.parseInt(sender.trim() || SYSTEM_SENDER, 10);
|
|
||||||
const parsedNum = Number.parseInt(num.trim(), 10);
|
|
||||||
return {
|
return {
|
||||||
gift_id: gift.GiftID,
|
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,
|
user_id: kind === "user" ? recipientID : 0,
|
||||||
channel_id: kind === "channel" ? recipientID : 0,
|
channel_id: kind === "channel" ? recipientID : 0,
|
||||||
hide_name: hideName,
|
hide_name: hideName,
|
||||||
|
|
@ -76,13 +72,12 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
||||||
model_attribute_id: upgradable ? modelID : "0",
|
model_attribute_id: upgradable ? modelID : "0",
|
||||||
pattern_attribute_id: upgradable ? patternID : "0",
|
pattern_attribute_id: upgradable ? patternID : "0",
|
||||||
backdrop_attribute_id: upgradable ? backdropID : "0",
|
backdrop_attribute_id: upgradable ? backdropID : "0",
|
||||||
num: upgradable && Number.isFinite(parsedNum) && parsedNum > 0 ? parsedNum : 0,
|
|
||||||
reason: reason.trim(),
|
reason: reason.trim(),
|
||||||
confirm
|
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;
|
const canConfirm = result?.dry_run && !result.error;
|
||||||
|
|
||||||
async function run(confirm: boolean) {
|
async function run(confirm: boolean) {
|
||||||
|
|
@ -134,7 +129,7 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
||||||
|
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
<span>{t("giveGift.sender")}</span>
|
<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>
|
<small className="field-hint">{t("giveGift.senderHint")}</small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|
@ -152,7 +147,7 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
||||||
{kind === "user" && (
|
{kind === "user" && (
|
||||||
<>
|
<>
|
||||||
<label className="gift-switch">
|
<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 className="gift-switch-track" aria-hidden="true"><span /></span>
|
||||||
<span>{t("giveGift.upgrade")}</span>
|
<span>{t("giveGift.upgrade")}</span>
|
||||||
</label>
|
</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>)}
|
{(preview.backdrops ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { Gift, RefreshCw, Search } from "lucide-react";
|
import { Gift, RefreshCw, Search } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
|
import { StaticLottie } from "../components/StaticLottie";
|
||||||
import { Alert, Badge, PageFrame } from "../components/ui";
|
import { Alert, Badge, PageFrame } from "../components/ui";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import type { StarGiftRow } from "../types";
|
import type { StarGiftRow } from "../types";
|
||||||
import { LottiePreview } from "./GiftsPage";
|
|
||||||
import { GiveGiftForm } from "./GiveGiftForm";
|
import { GiveGiftForm } from "./GiveGiftForm";
|
||||||
|
|
||||||
export function GiveGiftsPage() {
|
export function GiveGiftsPage() {
|
||||||
|
|
@ -58,13 +58,13 @@ export function GiveGiftsPage() {
|
||||||
<button key={gift.GiftID} type="button" role="option" aria-selected={active}
|
<button key={gift.GiftID} type="button" role="option" aria-selected={active}
|
||||||
className={`give-gift-option ${active ? "selected" : ""} ${gift.Enabled ? "" : "gift-row-disabled"}`}
|
className={`give-gift-option ${active ? "selected" : ""} ${gift.Enabled ? "" : "gift-row-disabled"}`}
|
||||||
onClick={() => setSelected(gift)}>
|
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">
|
<span className="give-gift-option-info">
|
||||||
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
|
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
|
||||||
<span className="mono">#{gift.GiftID}</span>
|
<span className="mono">#{gift.GiftID}</span>
|
||||||
<span className="give-gift-option-meta">
|
</span>
|
||||||
{gift.Enabled ? <Badge>⭐ {gift.Stars}</Badge> : <Badge tone="neutral">{t("common.disabled")}</Badge>}
|
<span className="give-gift-option-price">
|
||||||
</span>
|
{gift.Enabled ? <Badge>⭐ {gift.Stars}</Badge> : <Badge tone="neutral">{t("common.disabled")}</Badge>}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,7 @@
|
||||||
|
|
||||||
/* Collectible attribute pickers reuse .gift-fields-grid but need equal columns
|
/* Collectible attribute pickers reuse .gift-fields-grid but need equal columns
|
||||||
and site-styled selects rather than the import modal's fixed template. */
|
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 select,
|
||||||
.give-gift-attrs input {
|
.give-gift-attrs input {
|
||||||
width: 100%; min-width: 0; height: 38px; padding: 0 32px 0 10px;
|
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-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 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 { display: grid; gap: 10px; align-content: start; }
|
||||||
.give-gift-picker-head { display: flex; align-items: center; gap: 12px; }
|
.give-gift-picker-head { display: flex; align-items: center; gap: 12px; }
|
||||||
.give-gift-picker-head .searchbox { flex: 1 1 auto; }
|
.give-gift-picker-head .searchbox { flex: 1 1 auto; }
|
||||||
.give-gift-picker-list {
|
.give-gift-picker-list {
|
||||||
display: grid; gap: 8px; max-height: 640px; padding: 8px; overflow: auto;
|
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); scrollbar-gutter: stable;
|
background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
.give-gift-option {
|
.give-gift-option {
|
||||||
display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 11px; align-items: center; min-width: 0;
|
display: grid; grid-template-columns: 46px minmax(0, 1fr) auto; gap: 11px; align-items: center; min-width: 0;
|
||||||
padding: 9px; text-align: left; color: var(--text); background: var(--panel);
|
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);
|
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;
|
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: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.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 { 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 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-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 {
|
.give-gift-panel {
|
||||||
display: grid; gap: 12px; padding: 16px; min-width: 0;
|
display: grid; gap: 12px; padding: 16px; min-width: 0;
|
||||||
background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--radius-lg);
|
background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--radius-lg);
|
||||||
|
|
@ -645,7 +646,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji-id {
|
.emoji-id {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
@ -659,11 +662,17 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji-id .mono {
|
.emoji-id .mono {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emoji-id svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.emoji-id:hover {
|
.emoji-id:hover {
|
||||||
border-color: var(--brand-tint-border);
|
border-color: var(--brand-tint-border);
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,12 @@
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-field input:disabled,
|
||||||
|
.form-field textarea:disabled {
|
||||||
|
opacity: .6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.command-preview {
|
.command-preview {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,6 @@ type GiveGiftRequest struct {
|
||||||
ModelAttributeID int64 `json:"model_attribute_id"`
|
ModelAttributeID int64 `json:"model_attribute_id"`
|
||||||
PatternAttributeID int64 `json:"pattern_attribute_id"`
|
PatternAttributeID int64 `json:"pattern_attribute_id"`
|
||||||
BackdropAttributeID int64 `json:"backdrop_attribute_id"`
|
BackdropAttributeID int64 `json:"backdrop_attribute_id"`
|
||||||
Num int `json:"num"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type StarGiftCollectibleAnimationUpload struct {
|
type StarGiftCollectibleAnimationUpload struct {
|
||||||
|
|
@ -871,6 +870,11 @@ func (s *Service) SetUserFlags(ctx context.Context, req SetUserFlagsRequest) (Co
|
||||||
if s == nil || s.users == nil {
|
if s == nil || s.users == nil {
|
||||||
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
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) {
|
return s.runCommand(ctx, req.CommandMeta, ActionSetUserFlags, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -967,8 +971,8 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
|
||||||
if req.Upgrade && recipient.Type != domain.PeerTypeUser {
|
if req.Upgrade && recipient.Type != domain.PeerTypeUser {
|
||||||
return CommandResult{}, fmt.Errorf("upgraded gift delivery is supported for user recipients only")
|
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) {
|
if !req.Upgrade && (req.ModelAttributeID > 0 || req.PatternAttributeID > 0 || req.BackdropAttributeID > 0) {
|
||||||
return CommandResult{}, fmt.Errorf("collectible attributes and number require upgrade")
|
return CommandResult{}, fmt.Errorf("collectible attributes require upgrade")
|
||||||
}
|
}
|
||||||
return s.runCommand(ctx, req.CommandMeta, ActionGiveGift, req.UserID, recipient, req, func() (CommandResult, error) {
|
return s.runCommand(ctx, req.CommandMeta, ActionGiveGift, req.UserID, recipient, req, func() (CommandResult, error) {
|
||||||
details := map[string]any{
|
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) {
|
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)
|
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_supply_total"] = preview.SupplyTotal
|
||||||
details["collectible_issued"] = preview.Issued
|
details["collectible_issued"] = preview.Issued
|
||||||
if req.ModelAttributeID > 0 {
|
if req.ModelAttributeID > 0 {
|
||||||
|
|
@ -1026,9 +1027,6 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
|
||||||
if req.BackdropAttributeID > 0 {
|
if req.BackdropAttributeID > 0 {
|
||||||
details["backdrop_attribute_id"] = req.BackdropAttributeID
|
details["backdrop_attribute_id"] = req.BackdropAttributeID
|
||||||
}
|
}
|
||||||
if req.Num > 0 {
|
|
||||||
details["num"] = req.Num
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.DryRun {
|
if req.DryRun {
|
||||||
|
|
@ -1044,7 +1042,6 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
|
||||||
ModelAttributeID: req.ModelAttributeID,
|
ModelAttributeID: req.ModelAttributeID,
|
||||||
PatternAttributeID: req.PatternAttributeID,
|
PatternAttributeID: req.PatternAttributeID,
|
||||||
BackdropAttributeID: req.BackdropAttributeID,
|
BackdropAttributeID: req.BackdropAttributeID,
|
||||||
Num: req.Num,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return CommandResult{}, err
|
return CommandResult{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -328,21 +328,21 @@ type StarGiftUpgradeRequest struct {
|
||||||
OriginAuthKeyID [8]byte
|
OriginAuthKeyID [8]byte
|
||||||
OriginSessionID int64
|
OriginSessionID int64
|
||||||
|
|
||||||
// Admin-controlled minting overrides. When non-zero these pin the specific
|
// Admin-controlled attribute overrides. When non-zero these pin the specific
|
||||||
// collectible attributes / number instead of the random pool draw and the
|
// collectible model/pattern/backdrop instead of the random pool draw. They
|
||||||
// sequential issued+1 number. They are only honoured on the admin grant path;
|
// are only honoured on the admin grant path; the DB FK (attribute must belong
|
||||||
// the DB FK (attribute must belong to the revision) and UNIQUE(gift_id,num)
|
// to the revision) remains the source of truth. The collectible number is
|
||||||
// constraints remain the source of truth.
|
// always assigned automatically (sequential).
|
||||||
ModelAttributeID int64
|
ModelAttributeID int64
|
||||||
PatternAttributeID int64
|
PatternAttributeID int64
|
||||||
BackdropAttributeID int64
|
BackdropAttributeID int64
|
||||||
Num int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminStarGiftGrant is one admin "give gift" command: deliver GiftID to
|
// AdminStarGiftGrant is one admin "give gift" command: deliver GiftID to
|
||||||
// Recipient from Sender (0 => official system account 777000) at no charge.
|
// Recipient from Sender (0 => official system account 777000) at no charge.
|
||||||
// When Upgrade is set the gift is minted as a collectible; the optional
|
// 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 {
|
type AdminStarGiftGrant struct {
|
||||||
SenderID int64
|
SenderID int64
|
||||||
Recipient Peer
|
Recipient Peer
|
||||||
|
|
@ -353,7 +353,6 @@ type AdminStarGiftGrant struct {
|
||||||
ModelAttributeID int64
|
ModelAttributeID int64
|
||||||
PatternAttributeID int64
|
PatternAttributeID int64
|
||||||
BackdropAttributeID int64
|
BackdropAttributeID int64
|
||||||
Num int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type StarGiftPurchaseRequest struct {
|
type StarGiftPurchaseRequest struct {
|
||||||
|
|
@ -927,7 +926,6 @@ var (
|
||||||
ErrStarGiftAlreadyUpgraded = errors.New("stargift: already upgraded")
|
ErrStarGiftAlreadyUpgraded = errors.New("stargift: already upgraded")
|
||||||
ErrStarGiftCollectibleSoldOut = errors.New("stargift: collectible supply exhausted")
|
ErrStarGiftCollectibleSoldOut = errors.New("stargift: collectible supply exhausted")
|
||||||
ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition")
|
ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition")
|
||||||
ErrStarGiftCollectibleNumberTaken = errors.New("stargift: collectible number already taken")
|
|
||||||
ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found")
|
ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found")
|
||||||
ErrStarGiftCollectionsFull = errors.New("stargift: collections full")
|
ErrStarGiftCollectionsFull = errors.New("stargift: collections full")
|
||||||
ErrStarGiftUnavailable = errors.New("stargift: unavailable")
|
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,
|
Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0,
|
||||||
Verified: ch.Verified,
|
Verified: ch.Verified,
|
||||||
Scam: ch.Scam,
|
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,
|
Gigagroup: ch.Gigagroup,
|
||||||
Broadcast: ch.Broadcast,
|
Broadcast: ch.Broadcast,
|
||||||
Megagroup: ch.Megagroup,
|
Megagroup: ch.Megagroup,
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,9 @@ func tgUser(u domain.User) *tg.User {
|
||||||
Phone: u.Phone,
|
Phone: u.Phone,
|
||||||
Verified: u.Verified,
|
Verified: u.Verified,
|
||||||
Scam: u.Scam,
|
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,
|
Support: u.Support,
|
||||||
Contact: u.Contact,
|
Contact: u.Contact,
|
||||||
MutualContact: u.Mutual,
|
MutualContact: u.Mutual,
|
||||||
|
|
|
||||||
|
|
@ -523,10 +523,17 @@ func (r *Router) onPaymentsGetSavedStarGifts(ctx context.Context, req *tg.Paymen
|
||||||
if r.deps.Gifts == nil {
|
if r.deps.Gifts == nil {
|
||||||
return emptySavedStarGifts(), 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()
|
collectionID, _ := req.GetCollectionID()
|
||||||
page, err := r.deps.Gifts.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
|
page, err := r.deps.Gifts.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
|
||||||
Owner: owner,
|
Owner: owner,
|
||||||
ExcludeUnsaved: req.ExcludeUnsaved,
|
ExcludeUnsaved: excludeUnsaved,
|
||||||
ExcludeSaved: req.ExcludeSaved,
|
ExcludeSaved: req.ExcludeSaved,
|
||||||
ExcludeUnlimited: req.ExcludeUnlimited,
|
ExcludeUnlimited: req.ExcludeUnlimited,
|
||||||
ExcludeUnique: req.ExcludeUnique,
|
ExcludeUnique: req.ExcludeUnique,
|
||||||
|
|
@ -556,6 +563,17 @@ func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.Input
|
||||||
return emptySavedStarGifts(), nil
|
return emptySavedStarGifts(), nil
|
||||||
}
|
}
|
||||||
gifts := make([]domain.SavedStarGift, 0, len(refs))
|
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 {
|
for _, ref := range refs {
|
||||||
dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref)
|
dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -569,6 +587,9 @@ func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.Input
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
}
|
}
|
||||||
if found && !g.Converted {
|
if found && !g.Converted {
|
||||||
|
if g.Unsaved && !canManageOwner(g.Owner) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
gifts = append(gifts, g)
|
gifts = append(gifts, g)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -669,9 +690,14 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, domain.ErrStarGiftNotFound):
|
case errors.Is(err, domain.ErrStarGiftNotFound),
|
||||||
return false, starGiftInvalidErr()
|
errors.Is(err, domain.ErrStarGiftAlreadyConverted),
|
||||||
case 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()
|
return false, starGiftInvalidErr()
|
||||||
default:
|
default:
|
||||||
return false, internalErr()
|
return false, internalErr()
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,6 @@ func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64,
|
||||||
ModelAttributeID: grant.ModelAttributeID,
|
ModelAttributeID: grant.ModelAttributeID,
|
||||||
PatternAttributeID: grant.PatternAttributeID,
|
PatternAttributeID: grant.PatternAttributeID,
|
||||||
BackdropAttributeID: grant.BackdropAttributeID,
|
BackdropAttributeID: grant.BackdropAttributeID,
|
||||||
Num: grant.Num,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -164,19 +164,6 @@ WHERE collectible_revision_id=$1 AND crafted
|
||||||
}
|
}
|
||||||
|
|
||||||
num := revision.Issued + 1
|
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
|
var uniqueID int64
|
||||||
if err := tx.QueryRow(ctx, `SELECT nextval('unique_star_gift_id_seq')`).Scan(&uniqueID); err != nil {
|
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)
|
return fmt.Errorf("allocate unique star gift id: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ type userBaseValue struct {
|
||||||
CountryCode string `json:"country_code"`
|
CountryCode string `json:"country_code"`
|
||||||
Verified bool `json:"verified"`
|
Verified bool `json:"verified"`
|
||||||
Support bool `json:"support"`
|
Support bool `json:"support"`
|
||||||
|
// scam / fake 同理必须随缓存往返:丢失会让缓存命中路径把带标记的账号输出成
|
||||||
|
// 普通账号,导致资料页 SCAM/FAKE 标记随缓存命中/未命中间歇性消失(与 bot 列同坑)。
|
||||||
|
Scam bool `json:"scam,omitempty"`
|
||||||
|
Fake bool `json:"fake,omitempty"`
|
||||||
// bot 字段必须随缓存往返:丢失会让缓存命中路径把 bot 输出成普通用户,
|
// bot 字段必须随缓存往返:丢失会让缓存命中路径把 bot 输出成普通用户,
|
||||||
// 污染客户端本地缓存(TDesktop 的 bot 标记不可逆)。
|
// 污染客户端本地缓存(TDesktop 的 bot 标记不可逆)。
|
||||||
Bot bool `json:"bot,omitempty"`
|
Bot bool `json:"bot,omitempty"`
|
||||||
|
|
@ -78,6 +82,8 @@ func baseValueFromUser(u domain.User) userBaseValue {
|
||||||
CountryCode: u.CountryCode,
|
CountryCode: u.CountryCode,
|
||||||
Verified: u.Verified,
|
Verified: u.Verified,
|
||||||
Support: u.Support,
|
Support: u.Support,
|
||||||
|
Scam: u.Scam,
|
||||||
|
Fake: u.Fake,
|
||||||
Bot: u.Bot,
|
Bot: u.Bot,
|
||||||
BotInfoVersion: u.BotInfoVersion,
|
BotInfoVersion: u.BotInfoVersion,
|
||||||
PremiumUntil: u.PremiumUntil,
|
PremiumUntil: u.PremiumUntil,
|
||||||
|
|
@ -110,6 +116,8 @@ func (v userBaseValue) user() domain.User {
|
||||||
CountryCode: v.CountryCode,
|
CountryCode: v.CountryCode,
|
||||||
Verified: v.Verified,
|
Verified: v.Verified,
|
||||||
Support: v.Support,
|
Support: v.Support,
|
||||||
|
Scam: v.Scam,
|
||||||
|
Fake: v.Fake,
|
||||||
Bot: v.Bot,
|
Bot: v.Bot,
|
||||||
BotInfoVersion: v.BotInfoVersion,
|
BotInfoVersion: v.BotInfoVersion,
|
||||||
PremiumUntil: v.PremiumUntil,
|
PremiumUntil: v.PremiumUntil,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue