summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-14 21:45:11 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-14 21:45:11 +0200
commit78a0a27a72e5e6c3d349abe2cb28ce6cd1e64d83 (patch)
tree1b21aa4493f79df1560e7d89aaad2d55ee6c33a9 /apps
parentb68bf004549e08ae81f99c9ece15fd7e7969c385 (diff)
downloadopencut-78a0a27a72e5e6c3d349abe2cb28ce6cd1e64d83.tar.gz
feat: editor feedback
Diffstat (limited to 'apps')
-rw-r--r--apps/web/src/app/api/feedback/route.ts31
-rw-r--r--apps/web/src/components/editor/editor-header.tsx2
-rw-r--r--apps/web/src/lib/db/schema.ts8
-rw-r--r--apps/web/src/lib/feedback/components/feedback-popover.tsx202
-rw-r--r--apps/web/src/lib/feedback/index.ts3
-rw-r--r--apps/web/src/lib/feedback/queries.ts14
-rw-r--r--apps/web/src/lib/feedback/types.ts11
-rw-r--r--apps/web/src/lib/stickers/index.ts634
8 files changed, 588 insertions, 317 deletions
diff --git a/apps/web/src/app/api/feedback/route.ts b/apps/web/src/app/api/feedback/route.ts
new file mode 100644
index 00000000..b13ec2bf
--- /dev/null
+++ b/apps/web/src/app/api/feedback/route.ts
@@ -0,0 +1,31 @@
+import { type NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/lib/feedback";
+
+const submitSchema = z.object({
+ message: z
+ .string()
+ .min(1, "Message is required")
+ .max(MAX_MESSAGE_LENGTH, "Message too long"),
+});
+
+export async function POST(request: NextRequest) {
+ const { limited } = await checkRateLimit({ request });
+ if (limited) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 });
+ }
+
+ const body = await request.json();
+ const result = submitSchema.safeParse(body);
+
+ if (!result.success) {
+ return NextResponse.json(
+ { error: "Invalid input", details: result.error.flatten().fieldErrors },
+ { status: 400 },
+ );
+ }
+
+ const entry = await submitFeedback(result.data);
+ return NextResponse.json({ entry }, { status: 201 });
+}
diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx
index ddfcaad4..44e8af68 100644
--- a/apps/web/src/components/editor/editor-header.tsx
+++ b/apps/web/src/components/editor/editor-header.tsx
@@ -15,6 +15,7 @@ import { DeleteProjectDialog } from "./dialogs/delete-project-dialog";
import { useRouter } from "next/navigation";
import { FaDiscord } from "react-icons/fa6";
import { ExportButton } from "./export-button";
+import { FeedbackPopover } from "@/lib/feedback/components/feedback-popover";
import { ThemeToggle } from "../theme-toggle";
import { DEFAULT_LOGO_URL } from "@/lib/site/brand";
import { SOCIAL_LINKS } from "@/lib/site/social";
@@ -34,6 +35,7 @@ export function EditorHeader() {
<EditableProjectName />
</div>
<nav className="flex items-center gap-2">
+ <FeedbackPopover />
<ExportButton />
<ThemeToggle />
</nav>
diff --git a/apps/web/src/lib/db/schema.ts b/apps/web/src/lib/db/schema.ts
index 88d71191..8f8be248 100644
--- a/apps/web/src/lib/db/schema.ts
+++ b/apps/web/src/lib/db/schema.ts
@@ -48,6 +48,14 @@ export const accounts = pgTable("accounts", {
updatedAt: timestamp("updated_at").notNull(),
}).enableRLS();
+export const feedback = pgTable("feedback", {
+ id: text("id").primaryKey(),
+ message: text("message").notNull(),
+ createdAt: timestamp("created_at")
+ .$defaultFn(() => new Date())
+ .notNull(),
+});
+
export const verifications = pgTable("verifications", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
diff --git a/apps/web/src/lib/feedback/components/feedback-popover.tsx b/apps/web/src/lib/feedback/components/feedback-popover.tsx
new file mode 100644
index 00000000..9bdc48f4
--- /dev/null
+++ b/apps/web/src/lib/feedback/components/feedback-popover.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Form,
+ FormField,
+ FormItem,
+ FormControl,
+ clearFormDraft,
+} from "@/components/ui/form";
+import type { FeedbackEntry } from "../types";
+
+const PERSIST_KEY = "feedback-draft";
+const HISTORY_KEY = "feedback-history";
+const MAX_HISTORY = 20;
+
+interface FeedbackFormValues {
+ message: string;
+}
+
+function readHistory(): FeedbackEntry[] {
+ try {
+ const stored = localStorage.getItem(HISTORY_KEY);
+ return stored ? (JSON.parse(stored) as FeedbackEntry[]) : [];
+ } catch {
+ return [];
+ }
+}
+
+function writeHistory({ entries }: { entries: FeedbackEntry[] }): void {
+ try {
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
+ } catch {
+ // localStorage may be full or unavailable
+ }
+}
+
+function useFeedback() {
+ const [entries, setEntries] = useState<FeedbackEntry[]>(readHistory);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ async function submit({
+ values,
+ onSuccess,
+ }: {
+ values: FeedbackFormValues;
+ onSuccess: () => void;
+ }) {
+ if (isSubmitting) return;
+ setIsSubmitting(true);
+
+ try {
+ const res = await fetch("/api/feedback", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(values),
+ });
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => null);
+ throw new Error(data?.error ?? "Failed to submit");
+ }
+
+ const { entry } = await res.json();
+ const next = [entry, ...entries].slice(0, MAX_HISTORY);
+ setEntries(next);
+ writeHistory({ entries: next });
+ onSuccess();
+ toast.success("Feedback sent");
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to send feedback",
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return { entries, isSubmitting, submit };
+}
+
+export function FeedbackPopover() {
+ const [open, setOpen] = useState(false);
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button variant="outline" className="h-8">
+ Send feedback
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent align="end" className="w-80 p-0">
+ <FeedbackPopoverContent onClose={() => setOpen(false)} />
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function FeedbackPopoverContent({ onClose }: { onClose: () => void }) {
+ const { entries, isSubmitting, submit } = useFeedback();
+
+ const form = useForm<FeedbackFormValues>({
+ defaultValues: { message: "" },
+ });
+
+ async function handleSubmit(values: FeedbackFormValues) {
+ await submit({
+ values,
+ onSuccess: () => {
+ form.reset({ message: "" });
+ clearFormDraft({ key: PERSIST_KEY });
+ onClose();
+ },
+ });
+ }
+
+ return (
+ <div className="flex flex-col">
+ <Form persistKey={PERSIST_KEY} {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col">
+ <FormField
+ control={form.control}
+ name="message"
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Textarea
+ placeholder="Thoughts, bugs, ideas..."
+ className="min-h-[7rem] text-sm p-3 bg-background shadow-none border-none! resize-none"
+ {...field}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ <div className="flex justify-end border-t px-3 py-2 gap-2">
+ {!form.watch("message").trim() && (
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={onClose}
+ >
+ Cancel
+ </Button>
+ )}
+ <Button
+ type="submit"
+ size="sm"
+ disabled={isSubmitting || !form.watch("message").trim()}
+ >
+ {isSubmitting ? <Spinner /> : "Send"}
+ </Button>
+ </div>
+ </form>
+ </Form>
+
+ {entries.length > 0 && (
+ <div className="border-t">
+ <div className="px-3 py-2">
+ <span className="text-xs font-medium text-muted-foreground">
+ Previous feedback
+ </span>
+ </div>
+ <div className="max-h-48 overflow-y-auto px-3 pb-3">
+ <div className="flex flex-col gap-2">
+ {entries.map((entry) => (
+ <FeedbackEntryItem key={entry.id} entry={entry} />
+ ))}
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
+
+function FeedbackEntryItem({ entry }: { entry: FeedbackEntry }) {
+ const formatted = new Date(entry.createdAt).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ });
+
+ return (
+ <div className="rounded-md border px-2.5 py-2">
+ <p className="text-sm whitespace-pre-wrap break-words">{entry.message}</p>
+ <span className="mt-1 block text-xs text-muted-foreground">
+ {formatted}
+ </span>
+ </div>
+ );
+}
diff --git a/apps/web/src/lib/feedback/index.ts b/apps/web/src/lib/feedback/index.ts
new file mode 100644
index 00000000..dfd0743c
--- /dev/null
+++ b/apps/web/src/lib/feedback/index.ts
@@ -0,0 +1,3 @@
+export { submitFeedback } from "./queries";
+export { MAX_MESSAGE_LENGTH } from "./types";
+export type { FeedbackEntry, SubmitFeedbackInput } from "./types";
diff --git a/apps/web/src/lib/feedback/queries.ts b/apps/web/src/lib/feedback/queries.ts
new file mode 100644
index 00000000..6962e71f
--- /dev/null
+++ b/apps/web/src/lib/feedback/queries.ts
@@ -0,0 +1,14 @@
+import { db, feedback } from "@/lib/db";
+import { generateUUID } from "@/utils/id";
+import type { FeedbackEntry, SubmitFeedbackInput } from "./types";
+
+export async function submitFeedback({
+ message,
+}: SubmitFeedbackInput): Promise<FeedbackEntry> {
+ const id = generateUUID();
+ const now = new Date();
+
+ await db.insert(feedback).values({ id, message, createdAt: now });
+
+ return { id, message, createdAt: now.toISOString() };
+}
diff --git a/apps/web/src/lib/feedback/types.ts b/apps/web/src/lib/feedback/types.ts
new file mode 100644
index 00000000..358b1354
--- /dev/null
+++ b/apps/web/src/lib/feedback/types.ts
@@ -0,0 +1,11 @@
+export const MAX_MESSAGE_LENGTH = 5000;
+
+export interface FeedbackEntry {
+ id: string;
+ message: string;
+ createdAt: string;
+}
+
+export interface SubmitFeedbackInput {
+ message: string;
+}
diff --git a/apps/web/src/lib/stickers/index.ts b/apps/web/src/lib/stickers/index.ts
index d3e14ed1..2d71d097 100644
--- a/apps/web/src/lib/stickers/index.ts
+++ b/apps/web/src/lib/stickers/index.ts
@@ -1,317 +1,317 @@
-import {
- STICKER_CATEGORIES,
-} from "@/lib/stickers/categories";
-import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/lib/stickers/intrinsic-size";
-import type { StickerCategory } from "@/lib/stickers/types";
-import { stickersRegistry } from "./registry";
-import { resolveStickerId } from "./resolver";
-import { registerDefaultStickerProviders } from "./providers";
-import { parseStickerId } from "./sticker-id";
-import type {
- StickerBrowseResult,
- StickerItem,
- StickerProvider,
- StickerSearchResult,
-} from "./types";
-
-const DEFAULT_BROWSE_LIMIT = 12;
-const DEFAULT_SEARCH_LIMIT = 100;
-
-function mergeSearchResults({
- results,
-}: {
- results: StickerSearchResult[];
-}): StickerSearchResult {
- const deduplicatedItems = new Map<
- string,
- StickerSearchResult["items"][number]
- >();
- let total = 0;
- let hasMore = false;
-
- for (const result of results) {
- total += result.total;
- hasMore = hasMore || result.hasMore;
- for (const item of result.items) {
- if (!deduplicatedItems.has(item.id)) {
- deduplicatedItems.set(item.id, item);
- }
- }
- }
-
- return {
- items: Array.from(deduplicatedItems.values()),
- total,
- hasMore,
- };
-}
-
-function getProviderByCategory({
- category,
-}: {
- category: StickerCategory;
-}): StickerProvider | null {
- if (category === "all") {
- return null;
- }
-
- try {
- return stickersRegistry.get(category);
- } catch {
- return null;
- }
-}
-
-function getEmptyBrowseResult(): StickerBrowseResult {
- return {
- sections: [],
- };
-}
-
-function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
- const stickerIdParts = stickerId.split(":");
- if (stickerIdParts.length <= 1) {
- return stickerId;
- }
- return (
- stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
- stickerId
- );
-}
-
-function toRecentStickerItem({
- stickerId,
-}: {
- stickerId: string;
-}): StickerItem | null {
- try {
- const { providerId } = parseStickerId({ stickerId });
- return {
- id: stickerId,
- provider: providerId,
- name: getStickerNameFromId({ stickerId }),
- previewUrl: resolveStickerId({
- stickerId,
- options: { width: 64, height: 64 },
- }),
- metadata: {},
- };
- } catch {
- return null;
- }
-}
-
-export async function searchStickers({
- query,
- category,
- limit = DEFAULT_SEARCH_LIMIT,
-}: {
- query: string;
- category: StickerCategory;
- limit?: number;
-}): Promise<StickerSearchResult> {
- registerDefaultStickerProviders({});
-
- const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
- if (effectiveCategory !== "all") {
- const provider = getProviderByCategory({ category: effectiveCategory });
- if (!provider) {
- return {
- items: [],
- total: 0,
- hasMore: false,
- };
- }
- return provider.search({
- query,
- options: { limit },
- });
- }
-
- const providers = stickersRegistry.getAll();
- if (providers.length === 0) {
- return {
- items: [],
- total: 0,
- hasMore: false,
- };
- }
-
- const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
- const settledResults = await Promise.allSettled(
- providers.map((provider) =>
- provider.search({
- query,
- options: { limit: perProviderLimit },
- }),
- ),
- );
-
- const fulfilledResults = settledResults
- .filter(
- (result): result is PromiseFulfilledResult<StickerSearchResult> =>
- result.status === "fulfilled",
- )
- .map((result) => result.value);
-
- return mergeSearchResults({
- results: fulfilledResults,
- });
-}
-
-export async function searchAll({
- query,
- limit = DEFAULT_SEARCH_LIMIT,
-}: {
- query: string;
- limit?: number;
-}): Promise<StickerBrowseResult> {
- registerDefaultStickerProviders({});
-
- const providers = stickersRegistry.getAll();
- if (providers.length === 0) {
- return { sections: [] };
- }
-
- const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
- const settledResults = await Promise.allSettled(
- providers.map(async (provider) => {
- const result = await provider.search({
- query,
- options: { limit: perProviderLimit },
- });
- return { provider, result };
- }),
- );
-
- const sections: StickerBrowseResult["sections"] = [];
- for (const settled of settledResults) {
- if (settled.status !== "fulfilled") {
- continue;
- }
- const { provider, result } = settled.value;
- if (result.items.length === 0) {
- continue;
- }
- const category = provider.id as StickerCategory;
- sections.push({
- id: category,
- title: STICKER_CATEGORIES[category] ?? provider.id,
- items: result.items,
- hasMore: result.hasMore,
- layout: "grid",
- });
- }
-
- return { sections };
-}
-
-export async function browseCategory({
- category,
-}: {
- category: StickerCategory;
-}): Promise<StickerBrowseResult> {
- registerDefaultStickerProviders({});
-
- const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
- if (effectiveCategory === "all") {
- return getEmptyBrowseResult();
- }
-
- const provider = getProviderByCategory({ category: effectiveCategory });
- if (!provider) {
- return getEmptyBrowseResult();
- }
-
- return provider.browse({ options: {} });
-}
-
-export async function browseAll({
- recentStickers,
- limit = DEFAULT_BROWSE_LIMIT,
-}: {
- recentStickers: string[];
- limit?: number;
-}): Promise<StickerBrowseResult> {
- registerDefaultStickerProviders({});
-
- const sections: StickerBrowseResult["sections"] = [];
- const recentItems = recentStickers
- .map((stickerId) => toRecentStickerItem({ stickerId }))
- .filter((item): item is StickerItem => item !== null);
-
- if (recentItems.length > 0) {
- sections.push({
- id: "recent",
- title: "Recently used",
- items: recentItems.slice(0, limit),
- hasMore: recentItems.length > limit,
- layout: "row",
- });
- }
-
- const settledResults = await Promise.allSettled(
- stickersRegistry.getAll().map(async (provider) => {
- const browseResult = await provider.browse({
- options: { limit },
- });
- const firstSection = browseResult.sections[0];
-
- if (!firstSection || firstSection.items.length === 0) {
- return null;
- }
-
- const category = provider.id as StickerCategory;
- return {
- ...firstSection,
- id: category,
- title: STICKER_CATEGORIES[category] ?? firstSection.title,
- layout: "row" as const,
- action: {
- type: "see-all" as const,
- category,
- sectionId: firstSection.id,
- },
- };
- }),
- );
-
- for (const result of settledResults) {
- if (result.status === "fulfilled" && result.value) {
- sections.push(result.value);
- }
- }
-
- return { sections };
-}
-
-export async function resolveStickerIntrinsicSize({
- stickerId,
-}: {
- stickerId: string;
-}): Promise<{ width: number; height: number }> {
- const url = resolveStickerId({ stickerId });
- return new Promise((resolve) => {
- const img = new Image();
- img.onload = () =>
- resolve({ width: img.naturalWidth, height: img.naturalHeight });
- img.onerror = () =>
- resolve({
- width: STICKER_INTRINSIC_SIZE_FALLBACK,
- height: STICKER_INTRINSIC_SIZE_FALLBACK,
- });
- img.src = url;
- });
-}
-
-export { resolveStickerId };
-export { resolveQueryToRegions, getRegionLabel } from "./providers/flags";
-export type {
- StickerBrowseResult,
- StickerBrowseSection,
- StickerCategory,
- StickerItem,
- StickerProvider,
- StickerResolveOptions,
- StickerSearchResult,
-} from "./types";
+import {
+ STICKER_CATEGORIES,
+} from "@/lib/stickers/categories";
+import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/lib/stickers/intrinsic-size";
+import type { StickerCategory } from "@/lib/stickers/types";
+import { stickersRegistry } from "./registry";
+import { resolveStickerId } from "./resolver";
+import { registerDefaultStickerProviders } from "./providers";
+import { parseStickerId } from "./sticker-id";
+import type {
+ StickerBrowseResult,
+ StickerItem,
+ StickerProvider,
+ StickerSearchResult,
+} from "./types";
+
+const DEFAULT_BROWSE_LIMIT = 12;
+const DEFAULT_SEARCH_LIMIT = 100;
+
+function mergeSearchResults({
+ results,
+}: {
+ results: StickerSearchResult[];
+}): StickerSearchResult {
+ const deduplicatedItems = new Map<
+ string,
+ StickerSearchResult["items"][number]
+ >();
+ let total = 0;
+ let hasMore = false;
+
+ for (const result of results) {
+ total += result.total;
+ hasMore = hasMore || result.hasMore;
+ for (const item of result.items) {
+ if (!deduplicatedItems.has(item.id)) {
+ deduplicatedItems.set(item.id, item);
+ }
+ }
+ }
+
+ return {
+ items: Array.from(deduplicatedItems.values()),
+ total,
+ hasMore,
+ };
+}
+
+function getProviderByCategory({
+ category,
+}: {
+ category: StickerCategory;
+}): StickerProvider | null {
+ if (category === "all") {
+ return null;
+ }
+
+ try {
+ return stickersRegistry.get(category);
+ } catch {
+ return null;
+ }
+}
+
+function getEmptyBrowseResult(): StickerBrowseResult {
+ return {
+ sections: [],
+ };
+}
+
+function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
+ const stickerIdParts = stickerId.split(":");
+ if (stickerIdParts.length <= 1) {
+ return stickerId;
+ }
+ return (
+ stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
+ stickerId
+ );
+}
+
+function toRecentStickerItem({
+ stickerId,
+}: {
+ stickerId: string;
+}): StickerItem | null {
+ try {
+ const { providerId } = parseStickerId({ stickerId });
+ return {
+ id: stickerId,
+ provider: providerId,
+ name: getStickerNameFromId({ stickerId }),
+ previewUrl: resolveStickerId({
+ stickerId,
+ options: { width: 64, height: 64 },
+ }),
+ metadata: {},
+ };
+ } catch {
+ return null;
+ }
+}
+
+export async function searchStickers({
+ query,
+ category,
+ limit = DEFAULT_SEARCH_LIMIT,
+}: {
+ query: string;
+ category: StickerCategory;
+ limit?: number;
+}): Promise<StickerSearchResult> {
+ registerDefaultStickerProviders({});
+
+ const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
+ if (effectiveCategory !== "all") {
+ const provider = getProviderByCategory({ category: effectiveCategory });
+ if (!provider) {
+ return {
+ items: [],
+ total: 0,
+ hasMore: false,
+ };
+ }
+ return provider.search({
+ query,
+ options: { limit },
+ });
+ }
+
+ const providers = stickersRegistry.getAll();
+ if (providers.length === 0) {
+ return {
+ items: [],
+ total: 0,
+ hasMore: false,
+ };
+ }
+
+ const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
+ const settledResults = await Promise.allSettled(
+ providers.map((provider) =>
+ provider.search({
+ query,
+ options: { limit: perProviderLimit },
+ }),
+ ),
+ );
+
+ const fulfilledResults = settledResults
+ .filter(
+ (result): result is PromiseFulfilledResult<StickerSearchResult> =>
+ result.status === "fulfilled",
+ )
+ .map((result) => result.value);
+
+ return mergeSearchResults({
+ results: fulfilledResults,
+ });
+}
+
+export async function searchAll({
+ query,
+ limit = DEFAULT_SEARCH_LIMIT,
+}: {
+ query: string;
+ limit?: number;
+}): Promise<StickerBrowseResult> {
+ registerDefaultStickerProviders({});
+
+ const providers = stickersRegistry.getAll();
+ if (providers.length === 0) {
+ return { sections: [] };
+ }
+
+ const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
+ const settledResults = await Promise.allSettled(
+ providers.map(async (provider) => {
+ const result = await provider.search({
+ query,
+ options: { limit: perProviderLimit },
+ });
+ return { provider, result };
+ }),
+ );
+
+ const sections: StickerBrowseResult["sections"] = [];
+ for (const settled of settledResults) {
+ if (settled.status !== "fulfilled") {
+ continue;
+ }
+ const { provider, result } = settled.value;
+ if (result.items.length === 0) {
+ continue;
+ }
+ const category = provider.id as StickerCategory;
+ sections.push({
+ id: category,
+ title: STICKER_CATEGORIES[category] ?? provider.id,
+ items: result.items,
+ hasMore: result.hasMore,
+ layout: "grid",
+ });
+ }
+
+ return { sections };
+}
+
+export async function browseCategory({
+ category,
+}: {
+ category: StickerCategory;
+}): Promise<StickerBrowseResult> {
+ registerDefaultStickerProviders({});
+
+ const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
+ if (effectiveCategory === "all") {
+ return getEmptyBrowseResult();
+ }
+
+ const provider = getProviderByCategory({ category: effectiveCategory });
+ if (!provider) {
+ return getEmptyBrowseResult();
+ }
+
+ return provider.browse({ options: {} });
+}
+
+export async function browseAll({
+ recentStickers,
+ limit = DEFAULT_BROWSE_LIMIT,
+}: {
+ recentStickers: string[];
+ limit?: number;
+}): Promise<StickerBrowseResult> {
+ registerDefaultStickerProviders({});
+
+ const sections: StickerBrowseResult["sections"] = [];
+ const recentItems = recentStickers
+ .map((stickerId) => toRecentStickerItem({ stickerId }))
+ .filter((item): item is StickerItem => item !== null);
+
+ if (recentItems.length > 0) {
+ sections.push({
+ id: "recent",
+ title: "Recently used",
+ items: recentItems.slice(0, limit),
+ hasMore: recentItems.length > limit,
+ layout: "row",
+ });
+ }
+
+ const settledResults = await Promise.allSettled(
+ stickersRegistry.getAll().map(async (provider) => {
+ const browseResult = await provider.browse({
+ options: { limit },
+ });
+ const firstSection = browseResult.sections[0];
+
+ if (!firstSection || firstSection.items.length === 0) {
+ return null;
+ }
+
+ const category = provider.id as StickerCategory;
+ return {
+ ...firstSection,
+ id: category,
+ title: STICKER_CATEGORIES[category] ?? firstSection.title,
+ layout: "row" as const,
+ action: {
+ type: "see-all" as const,
+ category,
+ sectionId: firstSection.id,
+ },
+ };
+ }),
+ );
+
+ for (const result of settledResults) {
+ if (result.status === "fulfilled" && result.value) {
+ sections.push(result.value);
+ }
+ }
+
+ return { sections };
+}
+
+export async function resolveStickerIntrinsicSize({
+ stickerId,
+}: {
+ stickerId: string;
+}): Promise<{ width: number; height: number }> {
+ const url = resolveStickerId({ stickerId });
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () =>
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
+ img.onerror = () =>
+ resolve({
+ width: STICKER_INTRINSIC_SIZE_FALLBACK,
+ height: STICKER_INTRINSIC_SIZE_FALLBACK,
+ });
+ img.src = url;
+ });
+}
+
+export { resolveStickerId };
+export { resolveQueryToRegions, getRegionLabel } from "./providers/flags";
+export type {
+ StickerBrowseResult,
+ StickerBrowseSection,
+ StickerCategory,
+ StickerItem,
+ StickerProvider,
+ StickerResolveOptions,
+ StickerSearchResult,
+} from "./types";