added stickers and emojies list

This commit is contained in:
onysd 2026-07-22 12:18:27 +03:00
parent 24276d1379
commit 43894f2fc9
26 changed files with 1118 additions and 14 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/logo.png" />
<title>OwpenGram Admin</title>
<script type="module" crossorigin src="/assets/index-CGbIqVNE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BAJpg0mp.css">
<script type="module" crossorigin src="/assets/index-B4-Dmklu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CnQEc5UV.css">
</head>
<body>
<div id="root"></div>

View file

@ -10,7 +10,8 @@ import type {
MessageListResponse,
OfficialStarGiftListResponse,
StarGiftCollectiblePreview,
StarGiftListResponse
StarGiftListResponse,
StickerSetListResponse
} from "./types";
export class APIError extends Error {
@ -67,6 +68,9 @@ export const api = {
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
},
gifts: () => request<StarGiftListResponse>("/api/gifts"),
stickerSets: (kind: string) => request<StickerSetListResponse>(`/api/stickers?kind=${encodeURIComponent(kind)}`),
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
giftAnimation: (id: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(id)}/animation`),

View file

@ -8,7 +8,9 @@ import {
Shield,
ShieldCheck,
Users,
Gift
Gift,
Sticker,
Smile
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
@ -76,6 +78,8 @@ export function Shell({
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{t("layout.stickers")}</NavLink>
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button
className="nav-section-toggle"

View file

@ -0,0 +1,71 @@
import lottie from "lottie-web/build/player/lottie_light_canvas";
import { useEffect, useRef, useState } from "react";
import { api, errorMessage } from "../api";
// One preview cell in a sticker/emoji set's preview grid. Mounted only while
// its modal is open, and only for the one set being viewed — not on the list
// page — so this never repeats the "100 animations on one page" lag.
//
// Documents come back either as Lottie JSON (TGS-animated) or as a raw raster
// image (static webp/png/etc, e.g. the "GestosLol" pack) — branch on the
// response's real Content-Type rather than assuming every document animates.
export function StickerDocumentPreview({ documentID, className = "", showError = true }: { documentID: string; className?: string; showError?: boolean }) {
const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
const [error, setError] = useState("");
const [imageURL, setImageURL] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let objectURL: string | null = null;
setError("");
setImageURL(null);
fetch(api.stickerDocumentAnimationURL(documentID), { credentials: "same-origin" })
.then(async (response) => {
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.error || response.statusText);
}
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("json")) {
const data = await response.json();
if (cancelled || !host.current) return;
animation.current?.destroy();
animation.current = lottie.loadAnimation({
container: host.current,
renderer: "canvas",
loop: true,
autoplay: true,
animationData: data
});
return;
}
const blob = await response.blob();
if (cancelled) return;
objectURL = URL.createObjectURL(blob);
setImageURL(objectURL);
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err));
});
return () => {
cancelled = true;
animation.current?.destroy();
animation.current = null;
if (objectURL) URL.revokeObjectURL(objectURL);
};
}, [documentID]);
return (
<div className={`sticker-doc-cell ${className}`.trim()}>
{imageURL ? (
<img className="sticker-doc-image" src={imageURL} alt="" />
) : (
<div className="sticker-doc-canvas" ref={host} />
)}
{error && showError && <span className="sticker-doc-error">{error}</span>}
</div>
);
}

View file

@ -59,6 +59,10 @@ const translations: Record<string, string> = {
"route.messagesSubtitle": "Console / Messages",
"route.gifts": "Star Gifts",
"route.giftsSubtitle": "Console / Star Gifts",
"route.stickers": "Stickers",
"route.stickersSubtitle": "Console / Stickers",
"route.emoji": "Emoji",
"route.emojiSubtitle": "Console / Emoji",
"layout.navigation": "Navigation",
"layout.primaryNav": "Primary navigation",
"layout.dashboard": "Overview",
@ -66,6 +70,8 @@ const translations: Record<string, string> = {
"layout.channels": "Supergroups / Channels",
"layout.messages": "Messages",
"layout.gifts": "Star Gifts",
"layout.stickers": "Stickers",
"layout.emoji": "Emoji",
"layout.privateMessages": "Private",
"layout.groupMessages": "Groups",
"layout.runtime": "Runtime",
@ -315,6 +321,30 @@ const translations: Record<string, string> = {
"gifts.validationReady": "Validation passed",
"gifts.validationHint": "Review the normalized metadata, then confirm the import.",
"gifts.confirmState": "Apply the validated state change to gift #{id}?",
"stickers.pageTitle": "Stickers",
"stickers.eyebrow": "Sticker packs — system packs (dice, animated emoji, gifts) aren't shown here, they're not hand-edited",
"stickers.emojiPageTitle": "Emoji",
"stickers.emojiEyebrow": "Custom-emoji packs — system packs aren't shown here, they're not hand-edited",
"stickers.total": "Total sets",
"stickers.searchPlaceholder": "Search set ID, short name or title",
"stickers.listSummary": "Showing {shown} of {total}",
"stickers.logo": "Logo",
"stickers.id": "ID",
"stickers.shortName": "Short name",
"stickers.title": "Title",
"stickers.count": "Documents",
"stickers.official": "Official",
"stickers.archived": "Archived",
"stickers.sortOrder": "Sort order",
"stickers.createdAt": "Created",
"stickers.archive": "Archive",
"stickers.unarchive": "Unarchive",
"stickers.saveOrder": "Save",
"stickers.saveTitle": "Save",
"stickers.delete": "Delete",
"stickers.view": "View",
"stickers.previewEyebrow": "Set contents",
"stickers.previewEmpty": "This set has no documents.",
"collectibles.manage": "Attribute pool",
"collectibles.title": "Collectible pool · Gift #{id}",
"collectibles.eyebrow": "Unique gift attributes",

View file

@ -9,6 +9,7 @@ import { GroupMessagesPage } from "./GroupMessagesPage";
import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
import { GiftsPage } from "./GiftsPage";
import { StickerSetsPage } from "./StickerSetsPage";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
@ -28,6 +29,12 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/gifts") {
return <GiftsPage />;
}
if (route.path === "/stickers") {
return <StickerSetsPage kind="stickers" />;
}
if (route.path === "/emoji") {
return <StickerSetsPage kind="emoji" />;
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (
<MessageDetailPage

View file

@ -0,0 +1,86 @@
import { ChevronLeft, ChevronRight, Loader2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
import { Alert } from "../components/ui";
import { useI18n } from "../i18n";
import type { StickerSetRow } from "../types";
// Cells per modal page. Each page fully replaces the previous one (rather
// than appending to a growing "load more" list) — accumulating batches made
// the Lottie canvases overlap visually, and a fresh page also means each
// batch's animations are properly unmounted before the next one mounts.
const PAGE_SIZE = 24;
export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; onClose: () => void }) {
const { t } = useI18n();
const [documentIDs, setDocumentIDs] = useState<string[] | null>(null);
const [error, setError] = useState("");
const [page, setPage] = useState(1);
useEffect(() => {
let cancelled = false;
setDocumentIDs(null);
setError("");
setPage(1);
api.stickerSetDocuments(set.ID).then((result) => {
if (cancelled) return;
setDocumentIDs(result.document_ids ?? []);
}).catch((err) => {
if (!cancelled) setError(errorMessage(err));
});
return () => { cancelled = true; };
}, [set.ID]);
const total = documentIDs?.length ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageStart = (currentPage - 1) * PAGE_SIZE;
const pageItems = documentIDs?.slice(pageStart, pageStart + PAGE_SIZE) ?? [];
const rangeStart = pageItems.length === 0 ? 0 : pageStart + 1;
const rangeEnd = rangeStart === 0 ? 0 : rangeStart + pageItems.length - 1;
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal sticker-preview-modal" role="dialog" aria-modal="true" aria-label={set.Title || `#${set.ID}`}>
<div className="modal-head">
<div>
<div className="eyebrow">{t("stickers.previewEyebrow")}</div>
<h2>{set.Title || `#${set.ID}`}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={t("action.close")}><X size={15} /></button>
</div>
<div className="command-body">
{error && <Alert>{error}</Alert>}
{!error && documentIDs === null && (
<div className="loading-line"><Loader2 className="spin" size={18} /> {t("common.loading")}</div>
)}
{documentIDs !== null && total === 0 && !error && (
<div className="empty-panel">{t("stickers.previewEmpty")}</div>
)}
{pageItems.length > 0 && (
<div className="sticker-doc-grid" key={currentPage}>
{pageItems.map((documentID) => <StickerDocumentPreview key={documentID} documentID={documentID} />)}
</div>
)}
{total > PAGE_SIZE && (
<div className="gift-pager">
<span className="gift-pager-range">{t("gifts.pageRange", { start: rangeStart, end: rangeEnd, total })}</span>
<div className="gift-pager-controls">
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
<ChevronLeft size={14} /> {t("gifts.pagePrev")}
</button>
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span>
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
{t("gifts.pageNext")} <ChevronRight size={14} />
</button>
</div>
</div>
)}
</div>
</section>
</div>,
document.body
);
}

View file

@ -0,0 +1,223 @@
import { Eye, ChevronLeft, ChevronRight, ImageOff, RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import type { StickerSetRow } from "../types";
import { StickerSetPreviewModal } from "./StickerSetPreviewModal";
type StickerPageSize = 10 | 20 | 50 | 100 | "all";
// Shared list/manage view for one non-system sticker-set kind ("stickers" or
// "emoji") — system packs (dice, animated emoji, premium/TON gifts, etc.) are
// filtered out server-side and never reach this page; they aren't meant to be
// hand-edited.
export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
const { t } = useI18n();
const [sets, setSets] = useState<StickerSetRow[]>([]);
const [query, setQuery] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [pageSize, setPageSize] = useState<StickerPageSize>(10);
const [page, setPage] = useState(1);
const [orderDrafts, setOrderDrafts] = useState<Record<string, string>>({});
const [titleDrafts, setTitleDrafts] = useState<Record<string, string>>({});
const [previewSet, setPreviewSet] = useState<StickerSetRow | null>(null);
const pageTitleKey = kind === "emoji" ? "stickers.emojiPageTitle" : "stickers.pageTitle";
const eyebrowKey = kind === "emoji" ? "stickers.emojiEyebrow" : "stickers.eyebrow";
async function load() {
setBusy(true);
setError("");
try {
setSets((await api.stickerSets(kind)).rows ?? []);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => { void load(); }, [kind]);
const visible = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return sets;
return sets.filter((set) =>
String(set.ID).includes(normalized) ||
set.ShortName.toLowerCase().includes(normalized) ||
set.Title.toLowerCase().includes(normalized)
);
}, [sets, query]);
useEffect(() => { setPage(1); }, [query, pageSize, kind]);
const totalPages = pageSize === "all" ? 1 : Math.max(1, Math.ceil(visible.length / pageSize));
const currentPage = Math.min(page, totalPages);
const paged = useMemo(() => {
if (pageSize === "all") return visible;
const start = (currentPage - 1) * pageSize;
return visible.slice(start, start + pageSize);
}, [visible, currentPage, pageSize]);
const rangeStart = paged.length === 0 ? 0 : pageSize === "all" ? 1 : (currentPage - 1) * pageSize + 1;
const rangeEnd = rangeStart === 0 ? 0 : rangeStart + paged.length - 1;
const counts = useMemo(() => ({
total: sets.length,
official: sets.filter((set) => set.Official).length,
archived: sets.filter((set) => set.Archived).length
}), [sets]);
return (
<PageFrame
title={t(pageTitleKey)}
eyebrow={t(eyebrowKey)}
actions={
<button className="btn" type="button" onClick={() => load()} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("stickers.total")} value={String(counts.total)} />
<Metric label={t("stickers.official")} value={String(counts.official)} tone="good" />
<Metric label={t("stickers.archived")} value={String(counts.archived)} tone={counts.archived > 0 ? "warn" : "neutral"} />
</div>
<QueryPanel>
<div className="toolbar">
<label className="searchbox">
<Search size={15} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("stickers.searchPlaceholder")} />
</label>
<label className="gift-page-size">
<span>{t("gifts.perPage")}</span>
<select
value={String(pageSize)}
onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as StickerPageSize))}
>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="all">{t("gifts.perPageAll")}</option>
</select>
</label>
<span className="gift-list-summary">{t("stickers.listSummary", { shown: visible.length, total: sets.length })}</span>
</div>
</QueryPanel>
<div className="table-wrap gift-table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("stickers.logo")}</th>
<th>{t("stickers.id")}</th>
<th>{t("stickers.shortName")}</th>
<th>{t("stickers.title")}</th>
<th>{t("stickers.count")}</th>
<th>{t("stickers.official")}</th>
<th>{t("common.status")}</th>
<th>{t("stickers.sortOrder")}</th>
<th>{t("common.actions")}</th>
</tr>
</thead>
<tbody>
{paged.map((set) => (
<tr className={set.Archived ? "gift-row-disabled" : ""} key={set.ID}>
<td>
{set.CoverDocumentID ? (
<StickerDocumentPreview documentID={set.CoverDocumentID} className="list-thumb" showError={false} />
) : (
<div className="sticker-list-thumb-empty"><ImageOff size={14} /></div>
)}
</td>
<td className="mono">{set.ID}</td>
<td className="mono">{set.ShortName || <span className="muted-cell">{t("common.none")}</span>}</td>
<td>
<div className="sort-order-editor">
<input
className="small-input title-input"
value={titleDrafts[set.ID] ?? set.Title}
onChange={(event) => setTitleDrafts((prev) => ({ ...prev, [set.ID]: event.target.value }))}
/>
<ActionButton
compact
tone="neutral"
label={t("stickers.saveTitle")}
path="/api/actions/rename-sticker-set"
payload={() => ({ set_id: set.ID, title: (titleDrafts[set.ID] ?? set.Title).trim() })}
onDone={() => void load()}
/>
</div>
</td>
<td>{set.Count}</td>
<td>{set.Official ? <Badge tone="good">{t("common.yes")}</Badge> : <Badge>{t("common.no")}</Badge>}</td>
<td>{set.Archived ? <Badge tone="danger">{t("stickers.archived")}</Badge> : <Badge tone="good">{t("common.enabled")}</Badge>}</td>
<td>
<div className="sort-order-editor">
<input
type="number"
className="small-input"
value={orderDrafts[set.ID] ?? String(set.SortOrder)}
onChange={(event) => setOrderDrafts((prev) => ({ ...prev, [set.ID]: event.target.value }))}
/>
<ActionButton
compact
tone="neutral"
label={t("stickers.saveOrder")}
path="/api/actions/set-sticker-set-sort-order"
payload={() => ({ set_id: set.ID, sort_order: Number(orderDrafts[set.ID] ?? set.SortOrder) })}
onDone={() => void load()}
/>
</div>
</td>
<td>
<div className="gift-table-actions">
<button className="btn compact-btn" type="button" onClick={() => setPreviewSet(set)}>
<Eye size={13} /> {t("stickers.view")}
</button>
<ActionButton
compact
tone="neutral"
label={set.Archived ? t("stickers.unarchive") : t("stickers.archive")}
path="/api/actions/set-sticker-set-archived"
payload={() => ({ set_id: set.ID, archived: !set.Archived })}
onDone={() => void load()}
/>
<ActionButton
compact
tone="danger"
label={t("stickers.delete")}
path="/api/actions/delete-sticker-set"
payload={() => ({ set_id: set.ID })}
onDone={() => void load()}
/>
</div>
</td>
</tr>
))}
{paged.length === 0 && <EmptyRow colSpan={9} />}
</tbody>
</table>
</div>
{pageSize !== "all" && visible.length > 0 && (
<div className="gift-pager">
<span className="gift-pager-range">{t("gifts.pageRange", { start: rangeStart, end: rangeEnd, total: visible.length })}</span>
<div className="gift-pager-controls">
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
<ChevronLeft size={14} /> {t("gifts.pagePrev")}
</button>
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span>
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
{t("gifts.pageNext")} <ChevronRight size={14} />
</button>
</div>
</div>
)}
{previewSet && <StickerSetPreviewModal set={previewSet} onClose={() => setPreviewSet(null)} />}
</PageFrame>
);
}

View file

@ -21,6 +21,8 @@ export function routeTitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/channels")) return t("route.channels");
if (pathname.startsWith("/messages")) return t("route.messages");
if (pathname.startsWith("/gifts")) return t("route.gifts");
if (pathname.startsWith("/stickers")) return t("route.stickers");
if (pathname.startsWith("/emoji")) return t("route.emoji");
return t("route.dashboard");
}
@ -29,5 +31,7 @@ export function routeSubtitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
if (pathname.startsWith("/gifts")) return t("route.giftsSubtitle");
if (pathname.startsWith("/stickers")) return t("route.stickersSubtitle");
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle");
return t("route.dashboardSubtitle");
}

View file

@ -347,6 +347,19 @@ textarea:focus {
width: 88px;
}
.sort-order-editor {
display: flex;
align-items: center;
gap: 6px;
}
.sort-order-editor .small-input {
width: 64px;
height: 32px;
}
.sort-order-editor .title-input {
width: 160px;
}
.field-inline {
display: inline-flex;
align-items: center;

View file

@ -401,6 +401,32 @@
.gift-validation-head span { color: #99cfc4; font-size: 10px; }
.gift-validation pre { max-height: 180px; overflow: auto; margin: 0; padding: 11px 12px; color: #d5fff5; font-size: 11px; }
.sticker-preview-modal { width: min(760px, 100%); }
.sticker-doc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
gap: 8px;
max-height: 420px;
overflow: auto;
padding: 2px;
}
.sticker-doc-cell {
position: relative;
display: grid;
place-items: center;
aspect-ratio: 1;
overflow: hidden;
background: var(--panel-strong);
border: 1px solid var(--line);
border-radius: 10px;
}
.sticker-doc-canvas { width: 100%; height: 100%; }
.sticker-doc-canvas canvas { width: 100% !important; height: 100% !important; }
.sticker-doc-image { width: 100%; height: 100%; object-fit: contain; }
.sticker-doc-cell.list-thumb { width: 40px; flex: 0 0 40px; }
.sticker-list-thumb-empty { display: grid; place-items: center; width: 40px; height: 40px; background: var(--panel-strong); border: 1px solid var(--line); border-radius: 9px; color: var(--muted); }
.sticker-doc-error { position: absolute; inset: 0; display: grid; place-items: center; padding: 4px; color: var(--danger); font-size: 9px; text-align: center; }
.gift-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: radial-gradient(circle, #f9f3ff, #eaf2fe); }
.gift-animation { width: 200px; height: 200px; }
.gift-animation canvas { width: 100% !important; height: 100% !important; }

View file

@ -266,6 +266,27 @@ export type CommandResult = {
error?: string;
};
export type StickerSetRow = {
// String, not number: these are 18-19 digit snowflake ids, past JS's 2^53
// safe-integer limit — see GiftID on StarGiftRow for the same convention.
ID: string;
ShortName: string;
Title: string;
Count: number;
Kind: string;
SystemKey: string;
Official: boolean;
Archived: boolean;
Installed: boolean;
SortOrder: number;
CreatedAt: string;
// Id of the set's first document, for a small list thumbnail — empty when
// the set has no documents. Same string-not-number reasoning as ID.
CoverDocumentID: string;
};
export type StickerSetListResponse = { rows: StickerSetRow[] };
export type AccountListResponse = {
query: string;
limit: number;