-
+
diff --git a/components/waves/list/WaveItemWide.tsx b/components/waves/list/WaveItemWide.tsx
new file mode 100644
index 0000000000..4bd0327891
--- /dev/null
+++ b/components/waves/list/WaveItemWide.tsx
@@ -0,0 +1,364 @@
+"use client";
+
+import type { KeyboardEvent, MouseEvent, ReactNode } from "react";
+import { useCallback } from "react";
+import Image from "next/image";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import {
+ ChatBubbleLeftRightIcon,
+ UserGroupIcon,
+} from "@heroicons/react/24/outline";
+import type { ApiWave } from "@/generated/models/ApiWave";
+import { getRandomColorWithSeed, numberWithCommas } from "@/helpers/Helpers";
+import { getWaveRoute } from "@/helpers/navigation.helpers";
+import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers";
+import WaveItemFollow from "./WaveItemFollow";
+
+const LEVEL_CLASSES: ReadonlyArray<{
+ readonly minLevel: number;
+ readonly classes: string;
+}> = [
+ { minLevel: 80, classes: "tw-text-[#55B075] tw-ring-[#55B075]" },
+ { minLevel: 60, classes: "tw-text-[#AABE68] tw-ring-[#AABE68]" },
+ { minLevel: 40, classes: "tw-text-[#DAC660] tw-ring-[#DAC660]" },
+ { minLevel: 20, classes: "tw-text-[#DAAC60] tw-ring-[#DAAC60]" },
+ { minLevel: 0, classes: "tw-text-[#DA8C60] tw-ring-[#DA8C60]" },
+];
+
+const DEFAULT_LEVEL_CLASS = LEVEL_CLASSES.at(-1)?.classes ?? "";
+const CARD_BASE_CLASSES =
+ "tw-@container/wave tw-group tw-rounded-xl tw-bg-iron-950 tw-backdrop-blur-sm tw-shadow-sm tw-shadow-black/20 tw-transition-all tw-duration-300 tw-ease-out";
+const CARD_INTERACTIVE_CLASSES =
+ "tw-cursor-pointer desktop-hover:hover:tw-shadow-lg desktop-hover:hover:tw-shadow-black/40 desktop-hover:hover:tw-translate-y-[-1px] focus-visible:tw-ring-2 focus-visible:tw-ring-primary-500 focus-visible:tw-ring-offset-2 focus-visible:tw-ring-offset-iron-900 focus-visible:tw-outline-none";
+
+const INTERACTIVE_TAGS = new Set([
+ "A",
+ "BUTTON",
+ "INPUT",
+ "SELECT",
+ "TEXTAREA",
+ "LABEL",
+]);
+
+const shouldSkipNavigation = (
+ target: HTMLElement | null,
+ root: HTMLElement
+): boolean => {
+ let current: HTMLElement | null = target;
+
+ while (current) {
+ if (current === root) {
+ break;
+ }
+
+ if (current.dataset["waveItemInteractive"] === "true") {
+ return true;
+ }
+
+ if (INTERACTIVE_TAGS.has(current.tagName)) {
+ return true;
+ }
+
+ current = current.parentElement;
+ }
+
+ return false;
+};
+
+type CardContainerProps = {
+ readonly isInteractive: boolean;
+ readonly href?: string | undefined;
+ readonly ariaLabel?: string | undefined;
+ readonly children: ReactNode;
+ readonly onClick?:
+ | ((event: MouseEvent
) => void)
+ | undefined;
+ readonly onKeyDown?:
+ | ((event: KeyboardEvent) => void)
+ | undefined;
+};
+
+function CardContainer({
+ isInteractive,
+ href,
+ ariaLabel,
+ onClick,
+ onKeyDown,
+ children,
+}: CardContainerProps) {
+ const className = `${CARD_BASE_CLASSES} ${
+ isInteractive ? CARD_INTERACTIVE_CLASSES : ""
+ } tw-no-underline`;
+
+ if (isInteractive && href) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+function getCardLabel(href?: string, label?: string | null) {
+ if (!href) {
+ return undefined;
+ }
+ return label ? `View wave ${label}` : "View wave";
+}
+
+function resolveLevelClasses(level?: number | null) {
+ return (
+ LEVEL_CLASSES.find((levelClass) => levelClass.minLevel <= (level ?? 0))
+ ?.classes ?? DEFAULT_LEVEL_CLASS
+ );
+}
+
+export default function WaveItemWide({
+ wave,
+ userPlaceholder,
+ titlePlaceholder,
+}: {
+ readonly wave?: ApiWave | undefined;
+ readonly userPlaceholder?: string | undefined;
+ readonly titlePlaceholder?: string | undefined;
+}) {
+ const router = useRouter();
+ const author = wave?.author;
+ const authorHref = author?.handle ? `/${author.handle}` : undefined;
+ const authorLevel = author?.level ?? 0;
+
+ const banner1 =
+ author?.banner1_color ??
+ getRandomColorWithSeed(author?.handle ?? userPlaceholder ?? "");
+
+ const banner2 =
+ author?.banner2_color ??
+ getRandomColorWithSeed(author?.handle ?? userPlaceholder ?? "");
+
+ const isDirectMessage = wave?.chat.scope.group?.is_direct_message ?? false;
+
+ const waveHref = wave
+ ? getWaveRoute({
+ waveId: wave.id,
+ isDirectMessage,
+ isApp: false,
+ })
+ : undefined;
+
+ const labelValue = wave?.name ?? wave?.id;
+ const cardLabel = getCardLabel(waveHref, labelValue);
+ const isInteractive = Boolean(waveHref);
+
+ const authorAvatar = author?.pfp ? (
+
+ ) : (
+
+ );
+
+ const authorLevelBadge = (
+
+ Level {authorLevel}
+
+ );
+
+ const authorWrapperClass = "tw-flex tw-items-center tw-gap-2 tw-min-w-0";
+
+ const handleAuthorClick = useCallback(
+ (event: MouseEvent) => {
+ if (!authorHref) {
+ return;
+ }
+ if (event.metaKey || event.ctrlKey) {
+ event.preventDefault();
+ event.stopPropagation();
+ window.open(authorHref, "_blank", "noopener,noreferrer");
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ router.push(authorHref);
+ },
+ [authorHref, router]
+ );
+
+ const handleAuthorAuxClick = useCallback(
+ (event: MouseEvent) => {
+ if (!authorHref || event.button !== 1) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ window.open(authorHref, "_blank", "noopener,noreferrer");
+ },
+ [authorHref]
+ );
+
+ const authorSection = authorHref ? (
+
+ {authorAvatar}
+
+ {author?.handle ?? userPlaceholder}
+
+ {authorLevelBadge}
+
+ ) : (
+
+ {authorAvatar}
+
+ {author?.handle ?? userPlaceholder}
+
+ {authorLevelBadge}
+
+ );
+
+ const handleCardClick = useCallback(
+ (event: MouseEvent) => {
+ if (!waveHref) {
+ return;
+ }
+ const target = event.target as HTMLElement | null;
+ if (shouldSkipNavigation(target, event.currentTarget)) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+ if (event.button === 1 || event.metaKey || event.ctrlKey) {
+ return;
+ }
+ if (!event.defaultPrevented) {
+ event.preventDefault();
+ router.push(waveHref);
+ }
+ },
+ [router, waveHref]
+ );
+
+ const handleCardKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (!waveHref || event.target !== event.currentTarget) {
+ return;
+ }
+ if (
+ event.key === "Enter" ||
+ event.key === " " ||
+ event.code === "Space"
+ ) {
+ event.preventDefault();
+ router.push(waveHref);
+ }
+ },
+ [router, waveHref]
+ );
+
+ const dropsCount = wave?.metrics.drops_count ?? 0;
+ const subscribersCount = wave?.metrics.subscribers_count ?? 0;
+
+ return (
+
+
+
+
+ {wave?.picture && (
+
+ )}
+
+
+
+
+
+ {wave?.name ?? titlePlaceholder}
+
+ {wave && (
+
+
+
+ )}
+
+
+ {authorSection}
+
+ {wave && (
+
+
+
+
+ {numberWithCommas(dropsCount)}
+
+
+ {dropsCount === 1 ? "Drop" : "Drops"}
+
+
+
+
+
+ {numberWithCommas(subscribersCount)}
+
+ Joined
+
+
+ )}
+
+
+
+ );
+}
diff --git a/hooks/useDeviceInfo.ts b/hooks/useDeviceInfo.ts
index 447effdddb..34126cc885 100644
--- a/hooks/useDeviceInfo.ts
+++ b/hooks/useDeviceInfo.ts
@@ -16,7 +16,10 @@ export default function useDeviceInfo(): DeviceInfo {
const getInfo = useCallback(
(touchDetected: boolean): DeviceInfo => {
- if (typeof globalThis === "undefined" || typeof navigator === "undefined") {
+ if (
+ typeof globalThis === "undefined" ||
+ typeof navigator === "undefined"
+ ) {
return {
isMobileDevice: false,
hasTouchScreen: false,
@@ -43,7 +46,8 @@ export default function useDeviceInfo(): DeviceInfo {
/Android|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
const iPadDesktopUA = ua.includes("Macintosh") && hasTouchScreen;
const appleMobile = /(iPhone|iPad|iPod)/i.test(ua) || iPadDesktopUA;
- const widthMobile = win.matchMedia?.("(max-width: 768px)")?.matches ?? false;
+ const widthMobile =
+ win.matchMedia?.("(max-width: 768px)")?.matches ?? false;
const isMobileDevice =
uaDataMobile ??
diff --git a/hooks/useWaves.ts b/hooks/useWaves.ts
index 0c937d5e38..0048d84eef 100644
--- a/hooks/useWaves.ts
+++ b/hooks/useWaves.ts
@@ -2,7 +2,7 @@
import { useInfiniteQuery } from "@tanstack/react-query";
-import { useContext, useEffect, useState } from "react";
+import { useContext, useMemo, useState } from "react";
import { useDebounce } from "react-use";
import { AuthContext } from "@/components/auth/Auth";
@@ -16,6 +16,7 @@ interface SearchWavesParams {
readonly limit: number;
readonly serial_no_less_than?: number | undefined;
readonly group_id?: string | undefined;
+ readonly direct_message?: boolean | undefined;
}
interface UseWavesParams {
@@ -24,6 +25,7 @@ interface UseWavesParams {
readonly limit?: number | undefined;
readonly refetchInterval?: number | undefined;
readonly enabled?: boolean | undefined;
+ readonly directMessage?: boolean | undefined;
}
export function useWaves({
@@ -32,29 +34,25 @@ export function useWaves({
limit = 20,
refetchInterval = Infinity,
enabled = true,
+ directMessage,
}: UseWavesParams) {
const { connectedProfile, activeProfileProxy } = useContext(AuthContext);
- const getUsePublicWaves = () =>
- !connectedProfile?.handle || !!activeProfileProxy;
- const [usePublicWaves, setUsePublicWaves] = useState(getUsePublicWaves());
- useEffect(
- () => setUsePublicWaves(getUsePublicWaves()),
- [connectedProfile, activeProfileProxy]
- );
-
- const getParams = (): SearchWavesParams => ({
- author: identity ?? undefined,
- name: waveName ?? undefined,
- limit,
- });
+ const usePublicWaves = !connectedProfile?.handle || !!activeProfileProxy;
- const [params, setParams] = useState(getParams());
- useEffect(() => setParams(getParams()), [identity, waveName]);
+ const params = useMemo(
+ () => ({
+ author: identity ?? undefined,
+ name: waveName ?? undefined,
+ limit,
+ direct_message: directMessage,
+ }),
+ [identity, waveName, limit, directMessage]
+ );
const [debouncedParams, setDebouncedParams] =
useState(params);
- useDebounce(() => setDebouncedParams(params), 200, [params]);
+ useDebounce(() => setDebouncedParams(params), 200, [params]);
const authQuery = useInfiniteQuery({
queryKey: [QueryKey.WAVES, debouncedParams],
@@ -62,7 +60,7 @@ export function useWaves({
const queryParams: Record = {};
queryParams["limit"] = `${limit}`;
- if (pageParam) {
+ if (typeof pageParam === "number") {
queryParams["serial_no_less_than"] = `${pageParam}`;
}
if (debouncedParams.author) {
@@ -71,6 +69,9 @@ export function useWaves({
if (debouncedParams.name) {
queryParams["name"] = debouncedParams.name;
}
+ if (debouncedParams.direct_message !== undefined) {
+ queryParams["direct_message"] = `${debouncedParams.direct_message}`;
+ }
return await commonApiFetch({
endpoint: `waves`,
params: queryParams,
@@ -87,7 +88,7 @@ export function useWaves({
queryKey: [QueryKey.WAVES_PUBLIC, debouncedParams],
queryFn: async ({ pageParam }: { pageParam: number | null }) => {
const queryParams: Record = {};
- if (pageParam) {
+ if (typeof pageParam === "number") {
queryParams["serial_no_less_than"] = `${pageParam}`;
}
if (debouncedParams.author) {
@@ -96,6 +97,9 @@ export function useWaves({
if (debouncedParams.name) {
queryParams["name"] = debouncedParams.name;
}
+ if (debouncedParams.direct_message !== undefined) {
+ queryParams["direct_message"] = `${debouncedParams.direct_message}`;
+ }
return await commonApiFetch({
endpoint: `waves-public`,
params: queryParams,
@@ -108,20 +112,14 @@ export function useWaves({
...getDefaultQueryRetry(),
});
- const getWaves = (): ApiWave[] => {
+ const waves = useMemo(() => {
+ if (!enabled) {
+ return [];
+ }
if (usePublicWaves) {
return publicQuery.data?.pages.flat() ?? [];
}
return authQuery.data?.pages.flat() ?? [];
- };
-
- const [waves, setWaves] = useState(getWaves());
- useEffect(() => {
- if (!enabled) {
- setWaves([]);
- return;
- }
- setWaves(getWaves());
}, [enabled, authQuery.data, publicQuery.data, usePublicWaves]);
const activeQuery = usePublicWaves ? publicQuery : authQuery;
diff --git a/hooks/useWavesList.ts b/hooks/useWavesList.ts
index ef0625d669..198c5f2539 100644
--- a/hooks/useWavesList.ts
+++ b/hooks/useWavesList.ts
@@ -1,13 +1,6 @@
"use client";
-import {
- useContext,
- useMemo,
- useCallback,
- useRef,
- useEffect,
- useState,
-} from "react";
+import { useContext, useMemo, useCallback, useRef } from "react";
import { AuthContext } from "@/components/auth/Auth";
import { useWavesOverview } from "./useWavesOverview";
import { WAVE_FOLLOWING_WAVES_PARAMS } from "@/components/react-query-wrapper/utils/query-utils";
@@ -53,11 +46,6 @@ const useWavesList = () => {
refetch: refetchPinnedWaves,
} = usePinnedWavesServer();
const [following] = useShowFollowingWaves();
- // Use state for allWaves instead of ref to ensure reactivity
- const [allWaves, setAllWaves] = useState([]);
-
- // Use ref to avoid too many re-renders for derived values
- const prevMainWavesRef = useRef([]);
const prevPinnedWavesRef = useRef([]);
// Track connected identity state - memoize to prevent re-renders
@@ -84,7 +72,6 @@ const useWavesList = () => {
const mainWavesMap = useMemo(() => {
const map = new Map();
mainWaves.forEach((wave) => map.set(wave.id, wave));
- prevMainWavesRef.current = mainWaves;
return map;
}, [mainWaves]);
@@ -94,7 +81,7 @@ const useWavesList = () => {
}, [mainWavesMap]);
// Server provides full pinned waves data, so no individual fetching needed
- const missingPinnedIds: string[] = [];
+ const missingPinnedIds = useMemo(() => [], []);
// Function to refetch all waves (main and pinned)
const refetchAllWaves = useCallback(() => {
// Refetch main waves overview
@@ -181,16 +168,7 @@ const useWavesList = () => {
// New drops counting logic has been removed and will be managed by context
- // Update allWaves state when the combined waves change
- useEffect(() => {
- // Using a functional update and removing the allWaves dependency
- // to break the infinite update cycle
- setAllWaves((prevWaves) => {
- return !areWavesEqual(combinedWaves, prevWaves)
- ? combinedWaves
- : prevWaves;
- });
- }, [combinedWaves]);
+ const allWaves = combinedWaves;
// Use server-side loading and error states
const isPinnedWavesLoading = isPinnedWavesLoadingServer;
@@ -225,7 +203,7 @@ const useWavesList = () => {
removePinnedWave: unpinWave,
// Additional data that might be useful
- mainWaves: prevMainWavesRef.current,
+ mainWaves,
missingPinnedIds,
mainWavesRefetch,
// Refetch all waves including main and pinned
@@ -238,12 +216,12 @@ const useWavesList = () => {
hasNextPage,
fetchNextPageStable,
mainWavesStatus,
+ mainWaves,
allPinnedWaves,
isPinnedWavesLoading,
hasPinnedWavesError,
pinWave,
unpinWave,
- prevMainWavesRef.current,
missingPinnedIds,
mainWavesRefetch,
refetchAllWaves,
diff --git a/lib/cache/lruTtl.ts b/lib/cache/lruTtl.ts
index f478834692..d09801020e 100644
--- a/lib/cache/lruTtl.ts
+++ b/lib/cache/lruTtl.ts
@@ -37,8 +37,30 @@ export default class LruTtlCache {
return entry.value;
}
- set(key: K, value: V): void {
- const expiresAt = Date.now() + this.ttlMs;
+ has(key: K): boolean {
+ const entry = this.map.get(key);
+ if (!entry) {
+ return false;
+ }
+
+ if (Date.now() > entry.expiresAt) {
+ this.map.delete(key);
+ return false;
+ }
+
+ return true;
+ }
+
+ delete(key: K): boolean {
+ return this.map.delete(key);
+ }
+
+ clear(): void {
+ this.map.clear();
+ }
+
+ set(key: K, value: V, ttlMs?: number): void {
+ const expiresAt = Date.now() + this.normalizeTtlMs(ttlMs);
if (this.map.has(key)) {
this.map.delete(key);
}
@@ -47,6 +69,13 @@ export default class LruTtlCache {
this.prune();
}
+ private normalizeTtlMs(ttlMs: number | undefined): number {
+ if (typeof ttlMs === "number" && Number.isFinite(ttlMs)) {
+ return Math.max(1000, ttlMs);
+ }
+ return this.ttlMs;
+ }
+
private prune(): void {
const now = Date.now();
for (const key of Array.from(this.map.keys())) {
diff --git a/package.json b/package.json
index 03e5742ce9..0528986b32 100644
--- a/package.json
+++ b/package.json
@@ -14,9 +14,9 @@
"prebuild": "npm run build:env-schema && npm run generate",
"postbuild": "next-sitemap --config next-sitemap.config.ts",
"start": "next start -p 3001",
- "test": "jest --silent --verbose=false --coverageReporters=none",
- "test-json": "jest --silent --json --outputFile=test-results/jest-results.json --forceExit ; npm run test-extract-failed-names",
- "test-json-changed": "jest --silent --json --outputFile=test-results/jest-results.json --changedSince=main --coverage=false --forceExit ; npm run test-extract-failed-names",
+ "test": "cross-env NODE_ENV=test jest --silent --verbose=false --coverageReporters=none",
+ "test-json": "cross-env NODE_ENV=test jest --silent --json --outputFile=test-results/jest-results.json --forceExit ; npm run test-extract-failed-names",
+ "test-json-changed": "cross-env NODE_ENV=test jest --silent --json --outputFile=test-results/jest-results.json --changedSince=main --coverage=false --forceExit ; npm run test-extract-failed-names",
"test-extract-failed-names": "node test-results/list-failed-tests.cjs",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
@@ -37,7 +37,7 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"format:changed": "bash -lc '{ git diff --name-only -z main...HEAD -- \"*.js\" \"*.jsx\" \"*.ts\" \"*.tsx\" \"*.json\" \"*.css\" \"*.scss\" \":(exclude)generated/**\"; git ls-files --others --exclude-standard -z -- \"*.js\" \"*.jsx\" \"*.ts\" \"*.tsx\" \"*.json\" \"*.css\" \"*.scss\" \":(exclude)generated/**\"; } | xargs -0 prettier --write --ignore-unknown'",
- "format:uncommitted": "bash -lc '{ git diff --name-only -z HEAD -- \"*.js\" \"*.jsx\" \"*.ts\" \"*.tsx\" \"*.json\" \"*.css\" \"*.scss\" \":(exclude)generated/**\"; git ls-files --others --exclude-standard -z -- \"*.js\" \"*.jsx\" \"*.ts\" \"*.tsx\" \"*.json\" \"*.css\" \"*.scss\" \":(exclude)generated/**\"; } | xargs -0 prettier --write --ignore-unknown'",
+ "format:uncommitted": "bash -lc '{ git diff --name-only -z --diff-filter=ACMR HEAD -- \"*.js\" \"*.jsx\" \"*.ts\" \"*.tsx\" \"*.json\" \"*.css\" \"*.scss\" \":(exclude)generated/**\"; git ls-files --others --exclude-standard -z -- \"*.js\" \"*.jsx\" \"*.ts\" \"*.tsx\" \"*.json\" \"*.css\" \"*.scss\" \":(exclude)generated/**\"; } | xargs -0 prettier --write --ignore-unknown'",
"deadcode:knip": "knip",
"deadcode:deps": "depcheck --ignores=\"eslint,eslint-*,@types/*,jest,ts-jest,playwright,@playwright/test,tailwindcss,postcss,autoprefixer\" --ignore-patterns=\".next,coverage,public,generated,tmp_gen_outp\"",
"deadcode": "npm run deadcode:knip && npm run deadcode:deps",
diff --git a/services/alchemy/index.ts b/services/alchemy/index.ts
index 153875b221..8285d1611c 100644
--- a/services/alchemy/index.ts
+++ b/services/alchemy/index.ts
@@ -5,7 +5,6 @@ if (typeof window !== "undefined") {
throw new Error("Alchemy services must be used server-side");
}
-
export type * from "./types";
/** @api */
export { searchNftCollections, getContractOverview } from "./collections";
diff --git a/services/api/farcaster.ts b/services/api/farcaster.ts
index cf8bea8435..4d633fecf2 100644
--- a/services/api/farcaster.ts
+++ b/services/api/farcaster.ts
@@ -1,6 +1,13 @@
import type { FarcasterPreviewResponse } from "@/types/farcaster.types";
+import LruTtlCache from "@/lib/cache/lruTtl";
-const previewCache = new Map>();
+const FARCASTER_PREVIEW_CACHE_TTL_MS = 5 * 60 * 1000;
+const FARCASTER_PREVIEW_CACHE_MAX_ITEMS = 200;
+
+const previewCache = new LruTtlCache>({
+ max: FARCASTER_PREVIEW_CACHE_MAX_ITEMS,
+ ttlMs: FARCASTER_PREVIEW_CACHE_TTL_MS,
+});
const normalizeUrl = (url: string): string => url.trim();
diff --git a/services/api/link-preview-api.ts b/services/api/link-preview-api.ts
index 6bbe7c3500..b8e38c3e3e 100644
--- a/services/api/link-preview-api.ts
+++ b/services/api/link-preview-api.ts
@@ -1,3 +1,5 @@
+import LruTtlCache from "@/lib/cache/lruTtl";
+
interface LinkPreviewMedia {
readonly url?: string | null | undefined;
readonly secureUrl?: string | null | undefined;
@@ -74,7 +76,13 @@ export type LinkPreviewResponse =
| GenericLinkPreviewResponse
| GoogleWorkspaceLinkPreview;
-const linkPreviewCache = new Map>();
+const LINK_PREVIEW_CACHE_TTL_MS = 5 * 60 * 1000;
+const LINK_PREVIEW_CACHE_MAX_ITEMS = 200;
+
+const linkPreviewCache = new LruTtlCache>({
+ max: LINK_PREVIEW_CACHE_MAX_ITEMS,
+ ttlMs: LINK_PREVIEW_CACHE_TTL_MS,
+});
const normalizeUrl = (url: string): string => url.trim();
diff --git a/services/api/tiktok-preview.ts b/services/api/tiktok-preview.ts
index fa17f7a296..502981a4f8 100644
--- a/services/api/tiktok-preview.ts
+++ b/services/api/tiktok-preview.ts
@@ -1,5 +1,9 @@
+import LruTtlCache from "@/lib/cache/lruTtl";
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_CACHE_ENTRIES = 50;
+const MAX_ALIAS_ENTRIES = MAX_CACHE_ENTRIES * 4;
+const ALIAS_TTL_MS = CACHE_TTL_MS * 3;
export type TikTokPreviewKind = "video" | "profile";
@@ -31,7 +35,10 @@ type CacheEntry = {
};
const cache = new Map();
-const aliasMap = new Map();
+const aliasMap = new LruTtlCache({
+ max: MAX_ALIAS_ENTRIES,
+ ttlMs: ALIAS_TTL_MS,
+});
const requests = new Map>();
function getCacheKey(url: string): string {
diff --git a/services/api/wikimedia-card.ts b/services/api/wikimedia-card.ts
index 81b450c07f..72cdaed4ff 100644
--- a/services/api/wikimedia-card.ts
+++ b/services/api/wikimedia-card.ts
@@ -1,3 +1,5 @@
+import LruTtlCache from "@/lib/cache/lruTtl";
+
export type WikimediaSource = "wikipedia" | "wikimedia-commons" | "wikidata";
export interface WikimediaImage {
@@ -86,7 +88,13 @@ export type WikimediaCardResponse =
| WikimediaWikidataCard
| WikimediaUnavailableCard;
-const wikimediaCache = new Map>();
+const WIKIMEDIA_CARD_CACHE_TTL_MS = 5 * 60 * 1000;
+const WIKIMEDIA_CARD_CACHE_MAX_ITEMS = 200;
+
+const wikimediaCache = new LruTtlCache>({
+ max: WIKIMEDIA_CARD_CACHE_MAX_ITEMS,
+ ttlMs: WIKIMEDIA_CARD_CACHE_TTL_MS,
+});
const normalizeUrl = (url: string): string => url.trim();
@@ -135,4 +143,3 @@ export const fetchWikimediaCard = async (
return requestPromise;
};
-