diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-14 21:45:11 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-14 21:45:11 +0200 |
| commit | 78a0a27a72e5e6c3d349abe2cb28ce6cd1e64d83 (patch) | |
| tree | 1b21aa4493f79df1560e7d89aaad2d55ee6c33a9 /apps/web/src | |
| parent | b68bf004549e08ae81f99c9ece15fd7e7969c385 (diff) | |
| download | opencut-78a0a27a72e5e6c3d349abe2cb28ce6cd1e64d83.tar.gz | |
feat: editor feedback
Diffstat (limited to 'apps/web/src')
| -rw-r--r-- | apps/web/src/app/api/feedback/route.ts | 31 | ||||
| -rw-r--r-- | apps/web/src/components/editor/editor-header.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/lib/db/schema.ts | 8 | ||||
| -rw-r--r-- | apps/web/src/lib/feedback/components/feedback-popover.tsx | 202 | ||||
| -rw-r--r-- | apps/web/src/lib/feedback/index.ts | 3 | ||||
| -rw-r--r-- | apps/web/src/lib/feedback/queries.ts | 14 | ||||
| -rw-r--r-- | apps/web/src/lib/feedback/types.ts | 11 | ||||
| -rw-r--r-- | apps/web/src/lib/stickers/index.ts | 634 |
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"; |
