diff --git a/__tests__/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.test.tsx b/__tests__/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.test.tsx new file mode 100644 index 0000000000..2756ec8995 --- /dev/null +++ b/__tests__/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.test.tsx @@ -0,0 +1,544 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import NotificationDropReactedGroup from "@/components/brain/notifications/drop-reacted/NotificationDropReactedGroup"; +import { ApiNotificationCause } from "@/generated/models/ApiNotificationCause"; +import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import type { + GroupedReactionsItem, + INotificationDropReacted, +} from "@/types/feed.types"; + +const OverlappingAvatars = jest.fn(({ items }: { items: unknown[] }) => ( +
{JSON.stringify(items)}
+)); +let consoleWarnSpy: jest.SpyInstance; + +const NotificationHeader = jest.fn( + ({ + author, + actions, + children, + }: { + author: { handle?: string | null; pfp?: string | null }; + actions?: ReactNode; + children: ReactNode; + }) => ( +
+ {author.handle} + {author.pfp} + {children} + {actions} +
+ ) +); + +jest.mock("@/components/common/OverlappingAvatars", () => ({ + __esModule: true, + default: (props: { items: unknown[] }) => OverlappingAvatars(props), +})); + +jest.mock("@/components/brain/notifications/NotificationsFollowAllBtn", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock("@/components/brain/notifications/NotificationsFollowBtn", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock( + "@/components/brain/notifications/subcomponents/NotificationHeader", + () => ({ + __esModule: true, + default: (props: { + author: { handle?: string | null; pfp?: string | null }; + actions?: ReactNode; + children: ReactNode; + }) => NotificationHeader(props), + }) +); + +jest.mock("@/components/brain/notifications/subcomponents/NotificationDrop", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock( + "@/components/brain/notifications/drop-reacted/ReactionEmojiPreview", + () => ({ + __esModule: true, + default: ({ rawId }: { rawId: string }) => {rawId}, + }) +); + +jest.mock( + "@/components/brain/notifications/subcomponents/NotificationTimestamp", + () => ({ + __esModule: true, + default: ({ createdAt }: { createdAt: number }) => ( + {createdAt} + ), + }) +); + +jest.mock( + "@/components/brain/notifications/utils/navigationUtils", + () => ({ + __esModule: true, + getIsDirectMessage: () => false, + useWaveNavigation: () => ({ + createReplyClickHandler: () => jest.fn(), + createQuoteClickHandler: () => jest.fn(), + }), + }) +); + +function createMockProfile( + overrides: Partial & { handle: string } +): ApiProfileMin { + return { + id: overrides.id ?? `${overrides.handle}-id`, + handle: overrides.handle, + pfp: overrides.pfp ?? null, + banner1_color: overrides.banner1_color ?? null, + banner2_color: overrides.banner2_color ?? null, + cic: overrides.cic ?? 0, + rep: overrides.rep ?? 0, + tdh: overrides.tdh ?? 0, + tdh_rate: overrides.tdh_rate ?? 0, + xtdh: overrides.xtdh ?? 0, + xtdh_rate: overrides.xtdh_rate ?? 0, + level: overrides.level ?? 0, + primary_address: overrides.primary_address ?? `0x${overrides.handle}`, + subscribed_actions: overrides.subscribed_actions ?? [], + archived: overrides.archived ?? false, + active_main_stage_submission_ids: + overrides.active_main_stage_submission_ids ?? [], + winner_main_stage_drop_ids: overrides.winner_main_stage_drop_ids ?? [], + artist_of_prevote_cards: overrides.artist_of_prevote_cards ?? [], + is_wave_creator: overrides.is_wave_creator ?? false, + }; +} + +function createMockDrop(): GroupedReactionsItem["drop"] { + return { + id: "drop-1", + wave: { id: "wave-1" }, + } as GroupedReactionsItem["drop"]; +} + +function createNotification({ + id, + createdAt, + reaction, + handle, + pfp, + profileOverrides, +}: { + id: number; + createdAt: number; + reaction: string; + handle: string; + pfp: string | null; + profileOverrides?: Partial; +}): INotificationDropReacted { + return { + id, + cause: ApiNotificationCause.DropReacted, + created_at: createdAt, + read_at: null, + related_identity: createMockProfile({ + handle, + pfp, + primary_address: `0x${handle}`, + ...profileOverrides, + }), + related_drops: [createMockDrop()], + additional_context: { + reaction, + }, + }; +} + +describe("NotificationDropReactedGroup", () => { + beforeEach(() => { + OverlappingAvatars.mockClear(); + NotificationHeader.mockClear(); + consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + it("keeps an older pfp when the latest grouped notification for that user has none", () => { + render( + + ); + + expect(screen.getByText("New reactions")).toBeInTheDocument(); + expect(OverlappingAvatars).toHaveBeenCalled(); + expect(OverlappingAvatars.mock.calls[0]?.[0]?.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "gpebbles-id", + pfpUrl: "alice.png", + fallback: "GP", + }), + ]) + ); + }); + + it("renders single-actor copy when deduping leaves one visible reactor", () => { + render( + + ); + + expect(screen.queryByText("New reactions")).not.toBeInTheDocument(); + expect(screen.getByTestId("header")).toHaveTextContent("gpebbles"); + expect(screen.getByTestId("header")).toHaveTextContent("gpebbles.png"); + expect(screen.getByText("reacted")).toBeInTheDocument(); + expect(screen.getByTestId("follow-one")).toBeInTheDocument(); + expect(screen.queryByTestId("follow-all")).not.toBeInTheDocument(); + }); + + it("keeps an older handle when the latest grouped notification has a blank handle", () => { + render( + + ); + + expect(screen.queryByText("New reactions")).not.toBeInTheDocument(); + expect(screen.getByTestId("header")).toHaveTextContent("gpebbles"); + expect(screen.getByTestId("follow-one")).toBeInTheDocument(); + }); + + it("dedupes the same reactor when later notifications identify them by a different alias", () => { + render( + + ); + + expect(screen.queryByText("New reactions")).not.toBeInTheDocument(); + expect(screen.getByTestId("header")).toHaveTextContent("gpebbles"); + expect(screen.getByTestId("header")).toHaveTextContent("gpebbles.png"); + }); + + it("keeps accumulated identity data when folding an older notification into an existing reactor", () => { + render( + + ); + + expect(screen.queryByText("New reactions")).not.toBeInTheDocument(); + expect(screen.getByTestId("header")).toHaveTextContent("gpebbles"); + expect(screen.getByTestId("header")).toHaveTextContent("alice.png"); + }); + + it("merges pre-existing buckets when a later notification links their aliases", () => { + render( + + ); + + expect(screen.queryByText("New reactions")).not.toBeInTheDocument(); + expect(screen.getByTestId("header")).toHaveTextContent("gpebbles"); + expect(screen.getByTestId("header")).toHaveTextContent("alice.png"); + }); + + it("falls back to the multi-reactor layout when the only reactor has no usable handle", () => { + render( + + ); + + expect(screen.getByText("New reactions")).toBeInTheDocument(); + expect(screen.queryByTestId("header")).not.toBeInTheDocument(); + expect(screen.queryByTestId("follow-one")).not.toBeInTheDocument(); + }); + + it("keeps reactions with blank identity fields instead of dropping them", () => { + render( + + ); + + expect(OverlappingAvatars).toHaveBeenCalled(); + const avatarItems = OverlappingAvatars.mock.calls.flatMap( + (call) => (call[0]?.items as unknown[]) ?? [] + ); + expect(avatarItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "unknown-identity-1", + }), + expect.objectContaining({ + key: "prxt0-id", + }), + ]) + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "NotificationDropReactedGroup received a reaction without a usable identity key", + expect.objectContaining({ + notificationId: 1, + }) + ); + }); +}); diff --git a/__tests__/components/header/HeaderSearchModal.test.tsx b/__tests__/components/header/HeaderSearchModal.test.tsx index 7455859e2f..46f2c4ee07 100644 --- a/__tests__/components/header/HeaderSearchModal.test.tsx +++ b/__tests__/components/header/HeaderSearchModal.test.tsx @@ -23,6 +23,7 @@ const useDropForgePermissionsMock = jest.fn(); jest.mock("react-use", () => { return { + createBreakpoint: () => () => "MD", useClickAway: (_ref: any, cb: () => void) => { clickAwayCb = cb; }, @@ -286,6 +287,221 @@ describe("HeaderSearchModal", () => { ).toBe(true); }); + it("matches close pluralized page titles for page searches", async () => { + setup({ + selectedCategory: "PAGES", + sidebarSections: [ + { + key: "network", + name: "Network", + icon: () => null, + items: [{ name: "Memes Calendar", href: "/meme-calendar" }], + subsections: [], + }, + ], + queryImpl: () => ({ + isFetching: false, + data: [], + error: undefined, + refetch: jest.fn(() => Promise.resolve()), + }), + }); + + const input = screen.getByRole("textbox", { name: "Search" }); + fireEvent.change(input, { target: { value: "meme calendar" } }); + + const items = await screen.findAllByTestId("item"); + expect( + items.some( + (item) => + (item.textContent ?? "").includes('"title":"Memes Calendar"') && + (item.textContent ?? "").includes('"/meme-calendar"') + ) + ).toBe(true); + }); + + it("matches close singularized page titles for page searches", async () => { + setup({ + selectedCategory: "PAGES", + sidebarSections: [ + { + key: "network", + name: "Network", + icon: () => null, + items: [{ name: "Meme Calendar", href: "/meme-calendar" }], + subsections: [], + }, + ], + queryImpl: () => ({ + isFetching: false, + data: [], + error: undefined, + refetch: jest.fn(() => Promise.resolve()), + }), + }); + + const input = screen.getByRole("textbox", { name: "Search" }); + fireEvent.change(input, { target: { value: "memes calendar" } }); + + const items = await screen.findAllByTestId("item"); + expect( + items.some( + (item) => + (item.textContent ?? "").includes('"title":"Meme Calendar"') && + (item.textContent ?? "").includes('"/meme-calendar"') + ) + ).toBe(true); + }); + + it("matches partial token page searches with close pluralization", async () => { + setup({ + selectedCategory: "PAGES", + sidebarSections: [ + { + key: "network", + name: "Network", + icon: () => null, + items: [{ name: "Memes Calendar", href: "/meme-calendar" }], + subsections: [], + }, + ], + queryImpl: () => ({ + isFetching: false, + data: [], + error: undefined, + refetch: jest.fn(() => Promise.resolve()), + }), + }); + + const input = screen.getByRole("textbox", { name: "Search" }); + fireEvent.change(input, { target: { value: "meme cal" } }); + + const items = await screen.findAllByTestId("item"); + expect( + items.some( + (item) => + (item.textContent ?? "").includes('"title":"Memes Calendar"') && + (item.textContent ?? "").includes('"/meme-calendar"') + ) + ).toBe(true); + }); + + it("matches page queries that span breadcrumbs and titles", async () => { + setup({ + selectedCategory: "PAGES", + sidebarSections: [ + { + key: "network", + name: "Network", + icon: () => null, + items: [], + subsections: [ + { + name: "Metrics", + items: [{ name: "Health", href: "/network/health" }], + }, + ], + }, + ], + queryImpl: () => ({ + isFetching: false, + data: [], + error: undefined, + refetch: jest.fn(() => Promise.resolve()), + }), + }); + + const input = screen.getByRole("textbox", { name: "Search" }); + fireEvent.change(input, { target: { value: "metrics health" } }); + + const items = await screen.findAllByTestId("item"); + expect( + items.some( + (item) => + (item.textContent ?? "").includes('"title":"Health"') && + (item.textContent ?? "").includes('"/network/health"') + ) + ).toBe(true); + }); + + it("prioritizes exact href matches ahead of title and breadcrumb fallbacks", async () => { + setup({ + selectedCategory: "PAGES", + sidebarSections: [ + { + key: "network", + name: "Network", + icon: () => null, + items: [], + subsections: [ + { + name: "Metrics", + items: [{ name: "Health", href: "/network/health" }], + }, + ], + }, + { + key: "about", + name: "About", + icon: () => null, + items: [{ name: "Network Health", href: "/about/network-health" }], + subsections: [], + }, + ], + queryImpl: () => ({ + isFetching: false, + data: [], + error: undefined, + refetch: jest.fn(() => Promise.resolve()), + }), + }); + + const input = screen.getByRole("textbox", { name: "Search" }); + fireEvent.change(input, { target: { value: "/network/health" } }); + + const items = await screen.findAllByTestId("item"); + expect(items[0]?.textContent).toContain('"/network/health"'); + }); + + it("prioritizes path-like partial href queries ahead of token-based title matches", async () => { + setup({ + selectedCategory: "PAGES", + sidebarSections: [ + { + key: "network", + name: "Network", + icon: () => null, + items: [], + subsections: [ + { + name: "Metrics", + items: [{ name: "Health", href: "/network/health" }], + }, + ], + }, + { + key: "about", + name: "About", + icon: () => null, + items: [{ name: "Network Health", href: "/about/network-health" }], + subsections: [], + }, + ], + queryImpl: () => ({ + isFetching: false, + data: [], + error: undefined, + refetch: jest.fn(() => Promise.resolve()), + }), + }); + + const input = screen.getByRole("textbox", { name: "Search" }); + fireEvent.change(input, { target: { value: "/network/he" } }); + + const items = await screen.findAllByTestId("item"); + expect(items[0]?.textContent).toContain('"/network/health"'); + }); + it("includes drop forge pages in search results when accessible", () => { setup({ selectedCategory: "PAGES", diff --git a/__tests__/components/meme-calendar/meme-calendar.helpers.timezone.test.ts b/__tests__/components/meme-calendar/meme-calendar.helpers.timezone.test.ts index 46fcabb8a6..c760b771ab 100644 --- a/__tests__/components/meme-calendar/meme-calendar.helpers.timezone.test.ts +++ b/__tests__/components/meme-calendar/meme-calendar.helpers.timezone.test.ts @@ -1,4 +1,6 @@ import { + formatUtcMonth, + formatUtcMonthYear, mintStartInstantUtcForMintDay, nextMintDateOnOrAfter, wallTimeToUtcInstantInZone, @@ -27,6 +29,17 @@ const mintEndInstantUtcForMintDay = (mintDay: Date): Date => { }; describe("meme calendar timezone handling", () => { + const originalTz = process.env.TZ; + + afterEach(() => { + if (originalTz === undefined) { + delete process.env.TZ; + return; + } + + process.env.TZ = originalTz; + }); + it("keeps mint start anchored to 17:40 Athens time across 2024", () => { const months = Array.from({ length: 12 }, (_, idx) => idx); @@ -121,4 +134,13 @@ describe("meme calendar timezone handling", () => { expect(summerPhaseTimes[0]?.toISOString()).toBe("2024-07-01T14:40:00.000Z"); expect(winterPhaseTimes[0]?.toISOString()).toBe("2024-01-03T15:40:00.000Z"); }); + + it("formats UTC month labels consistently for US timezones", () => { + process.env.TZ = "America/Los_Angeles"; + + const seasonStart = isoDate(2026, 0, 1); + + expect(formatUtcMonth(seasonStart, "long")).toBe("January"); + expect(formatUtcMonthYear(seasonStart)).toBe("Jan 2026"); + }); }); diff --git a/__tests__/contexts/TitleContext.test.tsx b/__tests__/contexts/TitleContext.test.tsx new file mode 100644 index 0000000000..9a5ef14261 --- /dev/null +++ b/__tests__/contexts/TitleContext.test.tsx @@ -0,0 +1,120 @@ +import DynamicHeadTitle from "@/components/dynamic-head/DynamicHeadTitle"; +import { + TitleProvider, + useSetWaveData, + useTitle, +} from "@/contexts/TitleContext"; +import { render, screen, waitFor } from "@testing-library/react"; + +let mockPathname = "/waves/wave-1"; +let mockSearchParams = new URLSearchParams("divider=841669"); +let mockActiveWaveId: string | null = "wave-1"; + +jest.mock("next/navigation", () => ({ + usePathname: () => mockPathname, + useSearchParams: () => mockSearchParams, +})); + +jest.mock("@/contexts/wave/MyStreamContext", () => ({ + useMyStreamOptional: () => + mockActiveWaveId + ? { + activeWave: { + id: mockActiveWaveId, + set: jest.fn(), + }, + } + : null, +})); + +function TitleHarness({ + waveData, +}: { + readonly waveData: { name: string; newItemsCount: number } | null; +}) { + useSetWaveData(waveData); + const { title } = useTitle(); + return
{title}
; +} + +describe("TitleContext", () => { + beforeEach(() => { + mockPathname = "/waves/wave-1"; + mockSearchParams = new URLSearchParams("divider=841669"); + mockActiveWaveId = "wave-1"; + document.title = ""; + }); + + it("resets the client title when leaving a wave for the meme calendar", async () => { + const view = render( + + + + + ); + + await waitFor(() => { + expect( + screen.getByText("6529 Dev Daily Standup | Brain") + ).toBeInTheDocument(); + }); + await waitFor(() => { + expect(document.title).toBe("6529 Dev Daily Standup | Brain"); + }); + + mockPathname = "/meme-calendar"; + mockSearchParams = new URLSearchParams(); + + view.rerender( + + + + + ); + + await waitFor(() => { + expect(screen.getByText("Memes Minting Calendar")).toBeInTheDocument(); + }); + await waitFor(() => { + expect(document.title).toBe("Memes Minting Calendar"); + }); + }); + + it("resets the client title when the active wave id changes on the same route", async () => { + mockPathname = "/messages"; + mockSearchParams = new URLSearchParams("wave=wave-1"); + + const view = render( + + + + + ); + + await waitFor(() => { + expect(screen.getByText("Wave One | Brain")).toBeInTheDocument(); + }); + await waitFor(() => { + expect(document.title).toBe("Wave One | Brain"); + }); + + mockSearchParams = new URLSearchParams("wave=wave-2"); + mockActiveWaveId = "wave-2"; + + view.rerender( + + + + + ); + + await waitFor(() => { + expect(screen.getByText("Messages | Brain")).toBeInTheDocument(); + }); + await waitFor(() => { + expect(document.title).toBe("Messages | Brain"); + }); + }); +}); diff --git a/__tests__/hooks/useNotificationsQuery.test.tsx b/__tests__/hooks/useNotificationsQuery.test.tsx index b61f434be2..a12b50393c 100644 --- a/__tests__/hooks/useNotificationsQuery.test.tsx +++ b/__tests__/hooks/useNotificationsQuery.test.tsx @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react'; import { useNotificationsQuery, usePrefetchNotifications } from '@/hooks/useNotificationsQuery'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { ApiNotificationCause } from '@/generated/models/ApiNotificationCause'; jest.mock('@tanstack/react-query'); @@ -75,6 +76,54 @@ describe('useNotificationsQuery', () => { expect(queryClientMock.prefetchInfiniteQuery).toHaveBeenCalled(); }); + it('groups matching drop reactions across page boundaries only once', () => { + useInfiniteQueryMock.mockReturnValue({ + data: { + pages: [ + { + notifications: [ + { + id: 1, + cause: ApiNotificationCause.DropReacted, + created_at: 100, + read_at: null, + related_identity: { id: 'a' }, + related_drops: [{ id: 'drop-1' }], + additional_context: { reaction: ':heart:' }, + }, + ], + }, + { + notifications: [ + { + id: 2, + cause: ApiNotificationCause.DropReacted, + created_at: 200, + read_at: null, + related_identity: { id: 'b' }, + related_drops: [{ id: 'drop-1' }], + additional_context: { reaction: ':fire:' }, + }, + ], + }, + ], + }, + isSuccess: true, + isError: false, + }); + + const { result } = renderHook(() => useNotificationsQuery({ identity: 'id' })); + + expect(result.current.items).toHaveLength(1); + expect(result.current.items[0]).toEqual( + expect.objectContaining({ + type: 'grouped_reactions', + id: 2, + createdAt: 200, + }) + ); + }); + it('does nothing when prefetch called without identity', () => { const { result } = renderHook(() => usePrefetchNotifications()); result.current({ identity: null }); diff --git a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx index b358098ef4..59f827ed6b 100644 --- a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx +++ b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx @@ -3,6 +3,7 @@ import OverlappingAvatars from "@/components/common/OverlappingAvatars"; import { UserFollowBtnSize } from "@/components/user/utils/UserFollowBtn"; import type { DropInteractionParams } from "@/components/waves/drops/Drop"; +import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; import { parseIpfsUrl } from "@/helpers/Helpers"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import type { ActiveDropState } from "@/types/dropInteractionTypes"; @@ -12,7 +13,9 @@ import type { } from "@/types/feed.types"; import { Fragment, useEffect, useRef } from "react"; import NotificationsFollowAllBtn from "../NotificationsFollowAllBtn"; +import NotificationsFollowBtn from "../NotificationsFollowBtn"; import NotificationDrop from "../subcomponents/NotificationDrop"; +import NotificationHeader from "../subcomponents/NotificationHeader"; import NotificationTimestamp from "../subcomponents/NotificationTimestamp"; import { getIsDirectMessage, @@ -20,20 +23,203 @@ import { } from "../utils/navigationUtils"; import ReactionEmojiPreview from "./ReactionEmojiPreview"; +function getNonEmptyIdentityValue( + value: string | null | undefined +): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmedValue = value.trim(); + return trimmedValue === "" ? null : trimmedValue; +} + +function getIdentityKey(profile: ApiProfileMin): string { + return ( + getNonEmptyIdentityValue(profile.id) || + getNonEmptyIdentityValue(profile.handle) || + getNonEmptyIdentityValue(profile.primary_address) || + "unknown-profile" + ); +} + +function getIdentityAliases(profile: ApiProfileMin): string[] { + return [ + getNonEmptyIdentityValue(profile.id), + getNonEmptyIdentityValue(profile.handle), + getNonEmptyIdentityValue(profile.primary_address), + ].filter((alias): alias is string => alias !== null); +} + +function mergeProfiles( + preferred: ApiProfileMin, + fallback: ApiProfileMin +): ApiProfileMin { + return { + ...fallback, + ...preferred, + // Treat blank identity fields as missing so grouped notifications keep the + // most recent usable handle/avatar/address data. + handle: + getNonEmptyIdentityValue(preferred.handle) ?? + getNonEmptyIdentityValue(fallback.handle), + pfp: preferred.pfp || fallback.pfp, + primary_address: preferred.primary_address || fallback.primary_address, + }; +} + +type LatestPerUserEntry = { + latest: INotificationDropReacted; + identity: ApiProfileMin; +}; + +function getFallbackIdentityKey(notification: INotificationDropReacted): string { + const identityKey = getIdentityKey(notification.related_identity); + if (identityKey !== "unknown-profile") { + return identityKey; + } + + console.warn( + "NotificationDropReactedGroup received a reaction without a usable identity key", + { + notificationId: notification.id, + relatedIdentity: notification.related_identity, + } + ); + + return `unknown-identity-${notification.id}`; +} + +function resolveGroupedIdentityKey( + notification: INotificationDropReacted, + aliasToKey: Map +): { canonicalKey: string; matchingKeys: string[] } { + const fallbackKey = getFallbackIdentityKey(notification); + const matchingKeys = [ + ...new Set( + getIdentityAliases(notification.related_identity) + .map((alias) => aliasToKey.get(alias)) + .filter((key): key is string => key !== undefined) + ), + ]; + + return { + canonicalKey: matchingKeys[0] ?? fallbackKey, + matchingKeys, + }; +} + +function cacheIdentityAliases( + aliasToKey: Map, + identity: ApiProfileMin, + key: string +): void { + for (const alias of getIdentityAliases(identity)) { + aliasToKey.set(alias, key); + } +} + +function getMergedEntry( + existing: LatestPerUserEntry, + notification: INotificationDropReacted +): LatestPerUserEntry { + const isNewer = + notification.created_at > existing.latest.created_at || + (notification.created_at === existing.latest.created_at && + notification.id > existing.latest.id); + + if (isNewer) { + return { + latest: notification, + identity: mergeProfiles(notification.related_identity, existing.identity), + }; + } + + return { + latest: existing.latest, + identity: mergeProfiles(existing.identity, notification.related_identity), + }; +} + +function mergeLatestPerUserEntries( + primary: LatestPerUserEntry, + secondary: LatestPerUserEntry +): LatestPerUserEntry { + const shouldUseSecondary = + secondary.latest.created_at > primary.latest.created_at || + (secondary.latest.created_at === primary.latest.created_at && + secondary.latest.id > primary.latest.id); + + if (shouldUseSecondary) { + return { + latest: secondary.latest, + identity: mergeProfiles(secondary.identity, primary.identity), + }; + } + + return { + latest: primary.latest, + identity: mergeProfiles(primary.identity, secondary.identity), + }; +} + function notificationsLatestPerUser( notifications: GroupedReactionsItem["notifications"] ): INotificationDropReacted[] { - const byUser = new Map(); + const byUser = new Map(); + const aliasToKey = new Map(); + for (const n of notifications) { - const key = n.related_identity?.id ?? n.related_identity?.handle ?? ""; - if (!key) continue; - const existing = byUser.get(key); - if (!existing || n.id > existing.id) { - byUser.set(key, n); + const { canonicalKey, matchingKeys } = resolveGroupedIdentityKey( + n, + aliasToKey + ); + const matchedEntries = matchingKeys + .map((key) => ({ + key, + entry: byUser.get(key), + })) + .filter( + (value): value is { key: string; entry: LatestPerUserEntry } => + value.entry !== undefined + ); + const mergedExisting = matchedEntries.reduce( + (accumulator, { entry }) => + accumulator === null + ? entry + : mergeLatestPerUserEntries(accumulator, entry), + null + ); + + if (!mergedExisting) { + const entry = { + latest: n, + identity: n.related_identity, + }; + byUser.set(canonicalKey, entry); + cacheIdentityAliases(aliasToKey, entry.identity, canonicalKey); + continue; + } + + const entry = getMergedEntry(mergedExisting, n); + byUser.set(canonicalKey, entry); + cacheIdentityAliases(aliasToKey, entry.identity, canonicalKey); + cacheIdentityAliases(aliasToKey, n.related_identity, canonicalKey); + + for (const { key, entry: matchedEntry } of matchedEntries) { + cacheIdentityAliases(aliasToKey, matchedEntry.identity, canonicalKey); + if (key !== canonicalKey) { + byUser.delete(key); + } } } - const list = Array.from(byUser.values()); - list.sort((a, b) => a.id - b.id); + + const list = Array.from(byUser.values()) + .map(({ latest, identity }) => ({ + ...latest, + related_identity: identity, + })) + .sort((a, b) => a.created_at - b.created_at || a.id - b.id); return list; } @@ -92,6 +278,20 @@ export default function NotificationDropReactedGroup({ const latestPerUser = notificationsLatestPerUser(notifications); const fullReactors = latestPerUser.map((n) => n.related_identity); const reactionGroups = groupLatestByReaction(latestPerUser); + const singleReactor = fullReactors[0]; + const singleReactorHandle = + singleReactor && getNonEmptyIdentityValue(singleReactor.handle); + const singleReactorWithHandle = + singleReactor && singleReactorHandle + ? { + ...singleReactor, + handle: singleReactorHandle, + } + : null; + const isSingleVisibleReactor = + fullReactors.length === 1 && + reactionGroups.length === 1 && + !!singleReactorWithHandle; useEffect(() => { hasMarkedRef.current = false; @@ -107,70 +307,89 @@ export default function NotificationDropReactedGroup({ return (
-
-
-
- - New reactions - - {reactionGroups.map((rg, index) => { - const profiles = rg.notifications.map((n) => n.related_identity); - const avatarItems = profiles.map((profile) => { - const key = - profile.id ?? profile.handle ?? profile.primary_address ?? ""; - const href = profile.handle ? `/${profile.handle}` : undefined; - const displayName = - profile.handle ?? - (profile.id === null || profile.id === undefined - ? undefined - : String(profile.id)); - const title = - displayName !== undefined && displayName !== "" - ? displayName + {isSingleVisibleReactor ? ( + + } + > + + reacted + + + + + ) : ( +
+
+
+ + New reactions + + {reactionGroups.map((rg, index) => { + const avatarItems = rg.notifications.map((n) => { + const profile = n.related_identity; + const identityKey = getIdentityKey(profile); + const normalizedHandle = getNonEmptyIdentityValue( + profile.handle + ); + const key = + identityKey === "unknown-profile" + ? `unknown-identity-${n.id}` + : identityKey; + const href = normalizedHandle + ? `/${normalizedHandle}` : undefined; - return { - key, - pfpUrl: profile.pfp ? parseIpfsUrl(profile.pfp) : null, - ariaLabel: profile.handle - ? `View @${profile.handle}` - : "View profile", - fallback: profile.handle?.slice(0, 2).toUpperCase() ?? "?", - ...(href !== undefined && { href }), - ...(title !== undefined && { title }), - }; - }); - return ( - - {index > 0 && ( - - • - - )} -
- -
- + const displayName = normalizedHandle ?? profile.id; + const title = displayName || undefined; + return { + key, + pfpUrl: profile.pfp ? parseIpfsUrl(profile.pfp) : null, + ariaLabel: normalizedHandle + ? `View @${normalizedHandle}` + : "View profile", + fallback: normalizedHandle?.slice(0, 2).toUpperCase() ?? "?", + ...(href !== undefined && { href }), + ...(title !== undefined && { title }), + }; + }); + return ( + + {index > 0 && ( + + • + + )} +
+ +
+ +
-
- - ); - })} - -
- {fullReactors.length > 0 && ( -
- + + ); + })} +
- )} + {fullReactors.length > 0 && ( +
+ +
+ )} +
-
+ )} = { [DROP_FORGE_SECTIONS.LAUNCH.path]: [`${DROP_FORGE_TITLE} Launch`], }; -const getPageMatchPriority = ( - normalizedTitle: string, - hrefSegments: string[], - normalizedBreadcrumbs: string[], - normalizedSearchTerms: string[], - normalizedQuery: string -): number => { - if (normalizedTitle === normalizedQuery) return 0; - if (hrefSegments.includes(normalizedQuery)) return 1; - if (normalizedSearchTerms.includes(normalizedQuery)) return 2; - if (normalizedTitle.startsWith(normalizedQuery)) return 3; - if (normalizedBreadcrumbs.includes(normalizedQuery)) return 4; - if (normalizedSearchTerms.some((term) => term.includes(normalizedQuery))) { - return 5; +const singularizePageSearchToken = (token: string): string => { + if (token.length <= 3) { + return token; + } + + if (token.endsWith("ies") && token.length > 4) { + return `${token.slice(0, -3)}y`; + } + + if ( + token.endsWith("ches") || + token.endsWith("shes") || + token.endsWith("xes") || + token.endsWith("zes") || + token.endsWith("sses") + ) { + return token.slice(0, -2); } - if (normalizedTitle.includes(normalizedQuery)) return 6; - if (hrefSegments.some((segment) => segment.includes(normalizedQuery))) { - return 7; + + if (token.endsWith("s") && !token.endsWith("ss")) { + return token.slice(0, -1); + } + + return token; +}; + +const getPageSearchTokens = (value: string): string[] => + value + .toLowerCase() + .split(/[^a-z0-9]+/g) + .filter(Boolean) + .map(singularizePageSearchToken); + +const pageTokensMatchInOrder = ( + candidateTokens: readonly string[], + queryTokens: readonly string[], + matcher: (candidateToken: string, queryToken: string) => boolean +) => { + if (queryTokens.length === 0) { + return false; + } + + let candidateIndex = 0; + + return queryTokens.every((queryToken) => { + while (candidateIndex < candidateTokens.length) { + const candidateToken = candidateTokens[candidateIndex]; + candidateIndex += 1; + + if (candidateToken !== undefined && matcher(candidateToken, queryToken)) { + return true; + } + } + + return false; + }); +}; + +const hasCanonicalPageTokenMatch = ( + values: readonly string[], + queryTokens: readonly string[] +) => { + if (queryTokens.length === 0) { + return false; + } + + return values.some((value) => { + const candidateTokens = getPageSearchTokens(value); + return pageTokensMatchInOrder( + candidateTokens, + queryTokens, + (candidateToken, queryToken) => candidateToken === queryToken + ); + }); +}; + +const hasExactCanonicalPageTokenMatch = ( + values: readonly string[], + queryTokens: readonly string[] +) => { + if (queryTokens.length === 0) { + return false; + } + + return values.some((value) => { + const candidateTokens = getPageSearchTokens(value); + return ( + candidateTokens.length === queryTokens.length && + candidateTokens.every((token, index) => token === queryTokens[index]) + ); + }); +}; + +const hasCanonicalPageTokenPrefixMatch = ( + values: readonly string[], + queryTokens: readonly string[] +) => { + if (queryTokens.length === 0) { + return false; } - return 8; + + return values.some((value) => { + const candidateTokens = getPageSearchTokens(value); + return pageTokensMatchInOrder( + candidateTokens, + queryTokens, + (candidateToken, queryToken) => candidateToken.startsWith(queryToken) + ); + }); +}; + +const getCompositePageSearchValues = ( + normalizedTitle: string, + normalizedBreadcrumbs: readonly string[], + normalizedSearchTerms: readonly string[] +): string[] => { + const values = normalizedBreadcrumbs.flatMap((_, index) => { + const breadcrumbSuffix = normalizedBreadcrumbs.slice(index); + const suffixPrefix = breadcrumbSuffix.join(" "); + + return [normalizedTitle, ...normalizedSearchTerms] + .map((value) => [suffixPrefix, value].filter(Boolean).join(" ")) + .filter(Boolean); + }); + + return [...new Set(values)]; +}; + +type PageSearchMatchInputs = { + readonly normalizedTitle: string; + readonly normalizedHref: string; + readonly hrefSegments: string[]; + readonly normalizedBreadcrumbs: string[]; + readonly normalizedSearchTerms: string[]; + readonly compositeValues: string[]; +}; + +const getPageMatchPriority = ( + { + normalizedTitle, + normalizedHref, + hrefSegments, + normalizedBreadcrumbs, + normalizedSearchTerms, + compositeValues, + }: PageSearchMatchInputs, + normalizedQuery: string, + canonicalQueryTokens: readonly string[] +): number => { + const isPathLikeQuery = + normalizedQuery.startsWith("/") || normalizedQuery.includes("/"); + const titleValues = [normalizedTitle]; + const hrefValues = [normalizedHref, ...hrefSegments]; + const checks = [ + normalizedTitle === normalizedQuery, + normalizedHref === normalizedQuery, + isPathLikeQuery && normalizedHref.startsWith(normalizedQuery), + isPathLikeQuery && normalizedHref.includes(normalizedQuery), + hasExactCanonicalPageTokenMatch(titleValues, canonicalQueryTokens), + hrefSegments.includes(normalizedQuery), + normalizedSearchTerms.includes(normalizedQuery), + hasExactCanonicalPageTokenMatch( + normalizedSearchTerms, + canonicalQueryTokens + ), + compositeValues.includes(normalizedQuery), + hasExactCanonicalPageTokenMatch(compositeValues, canonicalQueryTokens), + normalizedTitle.startsWith(normalizedQuery), + hasCanonicalPageTokenMatch(titleValues, canonicalQueryTokens), + hasCanonicalPageTokenPrefixMatch(titleValues, canonicalQueryTokens), + normalizedBreadcrumbs.includes(normalizedQuery), + hasExactCanonicalPageTokenMatch( + normalizedBreadcrumbs, + canonicalQueryTokens + ), + hasCanonicalPageTokenPrefixMatch( + normalizedBreadcrumbs, + canonicalQueryTokens + ), + normalizedSearchTerms.some((term) => term.includes(normalizedQuery)), + hasCanonicalPageTokenMatch(normalizedSearchTerms, canonicalQueryTokens), + hasCanonicalPageTokenPrefixMatch( + normalizedSearchTerms, + canonicalQueryTokens + ), + hasCanonicalPageTokenMatch(compositeValues, canonicalQueryTokens), + hasCanonicalPageTokenPrefixMatch(compositeValues, canonicalQueryTokens), + normalizedTitle.includes(normalizedQuery), + hrefSegments.some((segment) => segment.includes(normalizedQuery)), + hasCanonicalPageTokenMatch(hrefValues, canonicalQueryTokens), + hasCanonicalPageTokenPrefixMatch(hrefValues, canonicalQueryTokens), + hasCanonicalPageTokenMatch(normalizedBreadcrumbs, canonicalQueryTokens), + ]; + + const matchIndex = checks.findIndex(Boolean); + return matchIndex === -1 ? checks.length : matchIndex; }; const pageMatchesQuery = ( @@ -176,15 +352,54 @@ const pageMatchesQuery = ( normalizedHref: string, normalizedBreadcrumbs: string[], normalizedSearchTerms: string[], - normalizedQuery: string + compositeValues: string[], + normalizedQuery: string, + canonicalQueryTokens: readonly string[] ) => { + const isPathLikeQuery = + normalizedQuery.startsWith("/") || normalizedQuery.includes("/"); if (normalizedTitle.includes(normalizedQuery)) return true; - if (normalizedHref.includes(normalizedQuery)) return true; + if (isPathLikeQuery && normalizedHref.startsWith(normalizedQuery)) { + return true; + } + if (isPathLikeQuery && normalizedHref.includes(normalizedQuery)) { + return true; + } if (normalizedSearchTerms.some((term) => term.includes(normalizedQuery))) { return true; } - return normalizedBreadcrumbs.some((breadcrumb) => - breadcrumb.includes(normalizedQuery) + if ( + normalizedBreadcrumbs.some((breadcrumb) => + breadcrumb.includes(normalizedQuery) + ) + ) { + return true; + } + if (compositeValues.some((value) => value.includes(normalizedQuery))) { + return true; + } + + return ( + hasCanonicalPageTokenMatch( + [ + normalizedTitle, + normalizedHref, + ...normalizedBreadcrumbs, + ...normalizedSearchTerms, + ...compositeValues, + ], + canonicalQueryTokens + ) || + hasCanonicalPageTokenPrefixMatch( + [ + normalizedTitle, + normalizedHref, + ...normalizedBreadcrumbs, + ...normalizedSearchTerms, + ...compositeValues, + ], + canonicalQueryTokens + ) ); }; @@ -450,6 +665,7 @@ export default function HeaderSearchModal({ return []; } const normalizedQuery = trimmedSearchValue.toLowerCase(); + const canonicalQueryTokens = getPageSearchTokens(trimmedSearchValue); if (!normalizedQuery) { return []; } @@ -464,6 +680,11 @@ export default function HeaderSearchModal({ const normalizedSearchTerms = (page.searchTerms ?? []).map((value) => value.toLowerCase() ); + const compositeValues = getCompositePageSearchValues( + normalizedTitle, + normalizedBreadcrumbs, + normalizedSearchTerms + ); if ( !pageMatchesQuery( @@ -471,23 +692,31 @@ export default function HeaderSearchModal({ normalizedHref, normalizedBreadcrumbs, normalizedSearchTerms, - normalizedQuery + compositeValues, + normalizedQuery, + canonicalQueryTokens ) ) { return accumulator; } const hrefSegments = normalizedHref.split("/").filter(Boolean); + const matchInputs = { + normalizedTitle, + normalizedHref, + hrefSegments, + normalizedBreadcrumbs, + normalizedSearchTerms, + compositeValues, + }; accumulator.push({ page, normalizedTitle, priority: getPageMatchPriority( - normalizedTitle, - hrefSegments, - normalizedBreadcrumbs, - normalizedSearchTerms, - normalizedQuery + matchInputs, + normalizedQuery, + canonicalQueryTokens ), }); diff --git a/components/meme-calendar/MemeCalendar.tsx b/components/meme-calendar/MemeCalendar.tsx index b16965af67..49bb5ab2d9 100644 --- a/components/meme-calendar/MemeCalendar.tsx +++ b/components/meme-calendar/MemeCalendar.tsx @@ -24,6 +24,8 @@ import { formatFullDate, formatFullDateTime, formatMint, + formatUtcMonth, + formatUtcMonthYear, getMintNumberForMintDate, getMonthWeeks, getRangeDatesByZoom, @@ -57,12 +59,6 @@ import { getHistoricalMintsOnUtcDay } from "./meme-calendar.szn1"; * `tw-` - configure your Tailwind setup accordingly. */ -function formatMonthYearShort(d: Date): string { - return `${d.toLocaleString("default", { - month: "short", - })} ${d.getUTCFullYear()}`; -} - function escapeHtml(value: string): string { return value .replaceAll("&", "&") @@ -145,10 +141,7 @@ interface EonViewProps { function Month({ date, onSelectDay, autoOpenYmd, displayTz }: MonthProps) { const year = date.getUTCFullYear(); const month = date.getUTCMonth(); - const monthName = new Date(Date.UTC(year, month, 1)).toLocaleString( - "default", - { month: "long" } - ); + const monthName = formatUtcMonth(new Date(Date.UTC(year, month, 1)), "long"); const weeks = getMonthWeeks(year, month); useEffect(() => { if (!autoOpenYmd) return; @@ -431,10 +424,7 @@ function YearView({ >
SZN #1
- {start.toLocaleString("default", { month: "short" })}{" "} - {start.getUTCFullYear()} -{" "} - {end.toLocaleString("default", { month: "short" })}{" "} - {end.getUTCFullYear()} + {formatUtcMonthYear(start)} - {formatUtcMonthYear(end)}
Memes #1 - #47
@@ -471,10 +461,7 @@ function YearView({ SZN #{displayedSeasonNumberFromIndex(s.sIdx)}
- {s.start.toLocaleString("default", { month: "short" })}{" "} - {s.start.getUTCFullYear()} -{" "} - {s.end.toLocaleString("default", { month: "short" })}{" "} - {s.end.getUTCFullYear()} + {formatUtcMonthYear(s.start)} - {formatUtcMonthYear(s.end)}
{s.label}
@@ -566,10 +553,7 @@ function EpochView({ Year #{y.yearNumber} ({y.start.getUTCFullYear()})
- {y.start.toLocaleString("default", { month: "short" })}{" "} - {y.start.getUTCFullYear()} -{" "} - {y.end.toLocaleString("default", { month: "short" })}{" "} - {y.end.getUTCFullYear()} + {formatUtcMonthYear(y.start)} - {formatUtcMonthYear(y.end)}
{y.label}
@@ -658,10 +642,7 @@ function PeriodView({ Epoch #{ep.epochNumber} ({ep.start.getUTCFullYear()})
- {ep.start.toLocaleString("default", { month: "short" })}{" "} - {ep.start.getUTCFullYear()} -{" "} - {ep.end.toLocaleString("default", { month: "short" })}{" "} - {ep.end.getUTCFullYear()} + {formatUtcMonthYear(ep.start)} - {formatUtcMonthYear(ep.end)}
{ep.label}
@@ -751,10 +732,7 @@ function EraView({ Period #{p.periodNumber} ({p.start.getUTCFullYear()})
- {p.start.toLocaleString("default", { month: "short" })}{" "} - {p.start.getUTCFullYear()} -{" "} - {p.end.toLocaleString("default", { month: "short" })}{" "} - {p.end.getUTCFullYear()} + {formatUtcMonthYear(p.start)} - {formatUtcMonthYear(p.end)}
{p.label}
@@ -839,10 +817,7 @@ function EonView({ seasonIndex, onSelectEra, onZoomToEra }: EonViewProps) { Era #{er.eraNumber} ({er.start.getUTCFullYear()})
- {er.start.toLocaleString("default", { month: "short" })}{" "} - {er.start.getUTCFullYear()} -{" "} - {er.end.toLocaleString("default", { month: "short" })}{" "} - {er.end.getUTCFullYear()} + {formatUtcMonthYear(er.start)} - {formatUtcMonthYear(er.end)}
{er.label}
@@ -1166,9 +1141,7 @@ export default function MemeCalendar({ displayTz }: MemeCalendarProps) { zoomLevel, seasonIndex ); - const range = `${formatMonthYearShort( - start - )} - ${formatMonthYearShort(end)}`; + const range = `${formatUtcMonthYear(start)} - ${formatUtcMonthYear(end)}`; const mintRange = isSznOneIndex(seasonIndex) ? "Memes #1 - #47" : getRangeLabel(start, end); diff --git a/components/meme-calendar/meme-calendar.helpers.tsx b/components/meme-calendar/meme-calendar.helpers.tsx index abe4e6344d..0fae66bea2 100644 --- a/components/meme-calendar/meme-calendar.helpers.tsx +++ b/components/meme-calendar/meme-calendar.helpers.tsx @@ -612,6 +612,26 @@ export type DisplayTz = "local" | "utc"; export function formatMint(n: number): string { return `#${n.toLocaleString()}`; } + +export function formatUtcMonth( + d: Date, + style: "short" | "long" = "short", + locale = "en-US" +): string { + return d.toLocaleString(locale, { + month: style, + timeZone: "UTC", + }); +} + +export function formatUtcMonthYear( + d: Date, + style: "short" | "long" = "short", + locale = "en-US" +): string { + return `${formatUtcMonth(d, style, locale)} ${d.getUTCFullYear()}`; +} + export function formatFullDate(d: Date, mode: DisplayTz = "local"): string { return d.toLocaleDateString(undefined, { weekday: "short", diff --git a/contexts/TitleContext.tsx b/contexts/TitleContext.tsx index b04e3f81b8..d4cb126b75 100644 --- a/contexts/TitleContext.tsx +++ b/contexts/TitleContext.tsx @@ -34,6 +34,7 @@ const getDefaultTitleForRoute = (pathname: string | null): string => { if (pathname.startsWith("/waves")) return "Waves | Brain"; if (pathname.startsWith("/notifications")) return "Notifications | Brain"; if (pathname.startsWith("/messages")) return "Messages | Brain"; + if (pathname.startsWith("/meme-calendar")) return "Memes Minting Calendar"; if (pathname.startsWith("/the-memes")) return "The Memes | Collections"; if (pathname.startsWith("/meme-lab")) return "Meme Lab | Collections"; if (pathname.startsWith("/network")) return "Network"; @@ -75,7 +76,9 @@ export const TitleProvider: React.FC<{ children: React.ReactNode }> = ({ const myStream = useMyStreamOptional(); const pathname = usePathname(); const searchParams = useSearchParams(); - const [title, setTitle] = useState(DEFAULT_TITLE); + const [title, setTitle] = useState(() => + getDefaultTitleForRoute(pathname) + ); const [notificationCount, setNotificationCount] = useState(0); const [waveData, setWaveData] = useState<{ name: string; @@ -83,48 +86,46 @@ export const TitleProvider: React.FC<{ children: React.ReactNode }> = ({ } | null>(null); const routeRef = useRef(pathname); const queryRef = useRef(searchParams); - const waveParam = - myStream?.activeWave.id ?? - getActiveWaveIdFromUrl({ pathname, searchParams }) ?? - null; const isWaveRoute = pathname?.startsWith("/waves") || pathname?.startsWith("/messages") || (pathname === "/" && searchParams?.get("view") === "waves"); + const waveParam = isWaveRoute + ? myStream?.activeWave.id ?? + getActiveWaveIdFromUrl({ pathname, searchParams }) ?? + null + : null; useEffect(() => { - if (routeRef.current === pathname) { - return; - } - routeRef.current = pathname; - const defaultTitle = getDefaultTitleForRoute(pathname); - setTitle(defaultTitle); - }, [pathname]); - - useEffect(() => { - const pathnameChanged = routeRef.current !== pathname; - const waveInUrl = getActiveWaveIdFromUrl({ pathname, searchParams }); - const hadWaveInUrl = getActiveWaveIdFromUrl({ - pathname: routeRef.current, - searchParams: queryRef.current, + const previousPathname = routeRef.current; + const previousSearchParams = queryRef.current; + const pathnameChanged = previousPathname !== pathname; + const currentWaveInUrl = getActiveWaveIdFromUrl({ pathname, searchParams }); + const previousWaveInUrl = getActiveWaveIdFromUrl({ + pathname: previousPathname, + searchParams: previousSearchParams, }); + routeRef.current = pathname; + queryRef.current = searchParams; + if (pathnameChanged) { - routeRef.current = pathname; - queryRef.current = searchParams; - const defaultTitle = getDefaultTitleForRoute(pathname); - setTitle(defaultTitle); + setTitle(getDefaultTitleForRoute(pathname)); + setWaveData(null); + return; + } + + if (!isWaveRoute) { setWaveData(null); return; } - const isWavesRoute = pathname?.startsWith("/waves"); - if (isWavesRoute && hadWaveInUrl && !waveInUrl) { - queryRef.current = searchParams; + if ( + previousWaveInUrl && + (!currentWaveInUrl || previousWaveInUrl !== currentWaveInUrl) + ) { setTitle(getDefaultTitleForRoute(pathname)); setWaveData(null); - } else { - queryRef.current = searchParams; } }, [pathname, searchParams]); diff --git a/hooks/useNotificationsQuery.tsx b/hooks/useNotificationsQuery.tsx index 925ea4a418..511288232e 100644 --- a/hooks/useNotificationsQuery.tsx +++ b/hooks/useNotificationsQuery.tsx @@ -166,9 +166,10 @@ export function useNotificationsQuery({ const items = useMemo(() => { if (!query.data) return []; - const grouped = (query.data.pages as TypedNotificationsResponse[]).flatMap( - (page) => groupReactionNotifications(page.notifications) - ); + const notifications = ( + query.data.pages as TypedNotificationsResponse[] + ).flatMap((page) => page.notifications); + const grouped = groupReactionNotifications(notifications); return reverse ? [...grouped].reverse() : grouped; }, [query.data, reverse]);