diff --git a/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx b/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx index 1418f29ac5..3ec9dfed4b 100644 --- a/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx +++ b/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx @@ -1,42 +1,57 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import BrainLeftSidebarWave from '@/components/brain/left-sidebar/waves/BrainLeftSidebarWave'; -import { ApiWaveType } from '@/generated/models/ApiWaveType'; -import { usePrefetchWaveData } from '@/hooks/usePrefetchWaveData'; -import { useMyStream } from '@/contexts/wave/MyStreamContext'; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import BrainLeftSidebarWave from "@/components/brain/left-sidebar/waves/BrainLeftSidebarWave"; +import { ApiWaveType } from "@/generated/models/ApiWaveType"; +import { usePrefetchWaveData } from "@/hooks/usePrefetchWaveData"; +import { useMyStream } from "@/contexts/wave/MyStreamContext"; -jest.mock('next/link', () => ({ +jest.mock("next/link", () => ({ __esModule: true, default: ({ href, children, onMouseEnter, onClick, className }: any) => ( - {children} + + {children} + ), })); -jest.mock('@/hooks/useDeviceInfo', () => ({ +jest.mock("@/hooks/useDeviceInfo", () => ({ __esModule: true, default: () => ({ isApp: false }), })); -jest.mock('@/contexts/wave/MyStreamContext', () => ({ +jest.mock("@/contexts/wave/MyStreamContext", () => ({ useMyStream: jest.fn(), })); -jest.mock('@/hooks/usePrefetchWaveData'); -jest.mock('@/components/waves/WavePicture', () => (props: any) => {props.name}); -jest.mock('@/components/brain/left-sidebar/waves/BrainLeftSidebarWaveDropTime', () => (props: any) => {props.time}); -jest.mock('@/components/brain/left-sidebar/waves/BrainLeftSidebarWavePin', () => (props: any) =>
{String(props.isPinned)}
); +jest.mock("@/hooks/usePrefetchWaveData"); +jest.mock("@/components/waves/WavePicture", () => (props: any) => ( + {props.name} +)); +jest.mock( + "@/components/brain/left-sidebar/waves/BrainLeftSidebarWaveDropTime", + () => (props: any) => {props.time} +); +jest.mock( + "@/components/brain/left-sidebar/waves/BrainLeftSidebarWavePin", + () => (props: any) =>
{String(props.isPinned)}
+); const mockedPrefetch = usePrefetchWaveData as jest.Mock; const mockedUseMyStream = useMyStream as jest.Mock; -describe('BrainLeftSidebarWave', () => { +describe("BrainLeftSidebarWave", () => { const prefetch = jest.fn(); const onHover = jest.fn(); const setActiveWave = jest.fn(); let activeWaveId: string | null = null; const baseWave = { - id: '1', + id: "1", type: ApiWaveType.Chat, - name: 'Chat Wave', - picture: '', + name: "Chat Wave", + picture: "", contributors: [], newDropsCount: { count: 2, latestDropTimestamp: 123 }, isPinned: false, @@ -55,72 +70,92 @@ describe('BrainLeftSidebarWave', () => { })); }); - it('prefetches wave data on hover when not active', async () => { + it("prefetches wave data on hover when not active", async () => { render(); - const link = screen.getByRole('link'); + const link = screen.getByRole("link"); await userEvent.hover(link); - expect(onHover).toHaveBeenCalledWith('1'); - expect(prefetch).toHaveBeenCalledWith('1'); + expect(onHover).toHaveBeenCalledWith("1"); + expect(prefetch).toHaveBeenCalledWith("1"); }); - it('does not prefetch when hovering active wave', async () => { - activeWaveId = '1'; + it("does not prefetch when hovering active wave", async () => { + activeWaveId = "1"; mockedUseMyStream.mockImplementation(() => ({ activeWave: { id: activeWaveId, set: setActiveWave }, })); render(); - await userEvent.hover(screen.getByRole('link')); + await userEvent.hover(screen.getByRole("link")); expect(onHover).not.toHaveBeenCalled(); expect(prefetch).not.toHaveBeenCalled(); }); - it('computes href based on current wave', () => { - const { rerender } = render(); - expect(screen.getByRole('link')).toHaveAttribute('href', '/waves?wave=1'); - activeWaveId = '1'; + it("computes href based on current wave", () => { + const { rerender } = render( + + ); + expect(screen.getByRole("link")).toHaveAttribute("href", "/waves?wave=1"); + activeWaveId = "1"; mockedUseMyStream.mockImplementation(() => ({ activeWave: { id: activeWaveId, set: setActiveWave }, })); rerender(); - expect(screen.getByRole('link')).toHaveAttribute('href', '/waves'); + expect(screen.getByRole("link")).toHaveAttribute("href", "/waves"); }); - it('pushes shallow route on click', async () => { + it("pushes shallow route on click", async () => { render(); - const link = screen.getByRole('link'); + const link = screen.getByRole("link"); await userEvent.click(link); - expect(setActiveWave).toHaveBeenCalledWith('1', { isDirectMessage: false, divider: null }); + expect(setActiveWave).toHaveBeenCalledWith("1", { + isDirectMessage: false, + divider: null, + }); }); - it('shows drop indicators for non-chat waves', () => { - const dropWave = { ...baseWave, id: '2', type: ApiWaveType.Approve }; + it("shows drop indicators for non-chat waves", () => { + const dropWave = { ...baseWave, id: "2", type: ApiWaveType.Approve }; render(); - expect(screen.getByTestId('drop-time')).toHaveTextContent('123'); + expect(screen.getByTestId("drop-time")).toHaveTextContent("123"); }); - it('includes firstUnreadDropSerialNo in href when present', () => { - const waveWithUnread = { ...baseWave, id: '3', firstUnreadDropSerialNo: 42 }; + it("includes firstUnreadDropSerialNo in href when present", () => { + const waveWithUnread = { + ...baseWave, + id: "3", + firstUnreadDropSerialNo: 42, + }; render(); - expect(screen.getByRole('link')).toHaveAttribute('href', '/waves?divider=42&wave=3'); + expect(screen.getByRole("link")).toHaveAttribute( + "href", + "/waves?divider=42&wave=3" + ); }); - it('does not include serialNo in href when firstUnreadDropSerialNo is null', () => { - const waveWithoutUnread = { ...baseWave, id: '4', firstUnreadDropSerialNo: null }; + it("does not include serialNo in href when firstUnreadDropSerialNo is null", () => { + const waveWithoutUnread = { + ...baseWave, + id: "4", + firstUnreadDropSerialNo: null, + }; render(); - expect(screen.getByRole('link')).toHaveAttribute('href', '/waves?wave=4'); + expect(screen.getByRole("link")).toHaveAttribute("href", "/waves?wave=4"); }); - it('shows muted indicator when wave is muted', () => { - const mutedWave = { ...baseWave, id: '5', isMuted: true }; + it("shows muted indicator when wave is muted", () => { + const mutedWave = { ...baseWave, id: "5", isMuted: true }; render(); - const bellSlashIcons = document.querySelectorAll('[data-icon="bell-slash"]'); + const bellSlashIcons = document.querySelectorAll( + '[data-icon="bell-slash"]' + ); expect(bellSlashIcons.length).toBeGreaterThan(0); }); - it('does not show muted indicator when wave is not muted', () => { - const unmutedWave = { ...baseWave, id: '6', isMuted: false }; + it("does not show muted indicator when wave is not muted", () => { + const unmutedWave = { ...baseWave, id: "6", isMuted: false }; render(); - const bellSlashIcons = document.querySelectorAll('[data-icon="bell-slash"]'); + const bellSlashIcons = document.querySelectorAll( + '[data-icon="bell-slash"]' + ); expect(bellSlashIcons.length).toBe(0); }); }); diff --git a/__tests__/components/waves/drops/DropMobileMenuHandler.test.tsx b/__tests__/components/waves/drops/DropMobileMenuHandler.test.tsx index 9df8813a1d..f830590b1b 100644 --- a/__tests__/components/waves/drops/DropMobileMenuHandler.test.tsx +++ b/__tests__/components/waves/drops/DropMobileMenuHandler.test.tsx @@ -1,33 +1,49 @@ -import { render, fireEvent, act } from '@testing-library/react'; -import React from 'react'; -import DropMobileMenuHandler from '@/components/waves/drops/DropMobileMenuHandler'; -import { DropSize } from '@/helpers/waves/drop.helpers'; +import { render, fireEvent, act } from "@testing-library/react"; +import React from "react"; +import DropMobileMenuHandler from "@/components/waves/drops/DropMobileMenuHandler"; +import { DropSize } from "@/helpers/waves/drop.helpers"; -jest.mock('@/hooks/isMobileDevice', () => () => true); -jest.mock('@/hooks/useIsTouchDevice', () => ({ __esModule: true, default: () => true })); +jest.mock("@/hooks/isMobileDevice", () => () => true); +jest.mock("@/hooks/useIsTouchDevice", () => ({ + __esModule: true, + default: () => true, +})); -jest.mock('@/components/waves/drops/WaveDropMobileMenu', () => ({ +jest.mock("@/components/waves/drops/WaveDropMobileMenu", () => ({ __esModule: true, default: (props: any) => ( -
props.onReply()} /> + + ) : ( +
+ {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 + )} +
+ +
+
+ + {wave?.name ?? titlePlaceholder} + + {wave && ( +
+ +
+ )} +
+ + {authorSection} + + {wave && ( +
+ + + + +
+ )} +
+
+ + ); +} 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; }; -