diff --git a/packages/core/src/canvas/channelUnread.test.ts b/packages/core/src/canvas/channelUnread.test.ts new file mode 100644 index 0000000000..8e0f50169e --- /dev/null +++ b/packages/core/src/canvas/channelUnread.test.ts @@ -0,0 +1,101 @@ +import { + latestActivityForChannel, + unreadChannelIds, +} from "@posthog/core/canvas/channelUnread"; +import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity"; +import { describe, expect, it } from "vitest"; + +function mention( + overrides: Partial & { createdAt: string }, +): MentionActivityItem { + return { + messageId: `m-${overrides.createdAt}`, + taskId: "t1", + taskTitle: "Task", + channelId: "c1", + channelName: "mobile", + author: null, + content: "hey @adam", + ...overrides, + }; +} + +describe("unreadChannelIds", () => { + const cases: { + name: string; + lastSeen: Record; + expected: string[]; + }[] = [ + { + name: "a channel never seen is unread", + lastSeen: {}, + expected: ["c1"], + }, + { + name: "activity newer than the last visit is unread", + lastSeen: { c1: "2026-07-16T09:00:00.000Z" }, + expected: ["c1"], + }, + { + name: "activity older than the last visit is read", + lastSeen: { c1: "2026-07-16T11:00:00.000Z" }, + expected: [], + }, + { + name: "activity exactly at the last visit is read", + lastSeen: { c1: "2026-07-16T10:00:00.000Z" }, + expected: [], + }, + ]; + it.each(cases)("$name", ({ lastSeen, expected }) => { + const items = [mention({ createdAt: "2026-07-16T10:00:00.000Z" })]; + expect([...unreadChannelIds(items, lastSeen)]).toEqual(expected); + }); + + it("compares each channel against its own last visit", () => { + const items = [ + mention({ channelId: "c1", createdAt: "2026-07-16T10:00:00.000Z" }), + mention({ channelId: "c2", createdAt: "2026-07-16T10:00:00.000Z" }), + ]; + const unread = unreadChannelIds(items, { + c1: "2026-07-16T11:00:00.000Z", + c2: "2026-07-16T09:00:00.000Z", + }); + expect([...unread]).toEqual(["c2"]); + }); + + it("uses the newest item in a channel, whatever the order", () => { + const items = [ + mention({ messageId: "old", createdAt: "2026-07-16T08:00:00.000Z" }), + mention({ messageId: "new", createdAt: "2026-07-16T12:00:00.000Z" }), + ]; + expect([ + ...unreadChannelIds(items, { c1: "2026-07-16T10:00:00.000Z" }), + ]).toEqual(["c1"]); + }); + + it("ignores channel-less mentions", () => { + const items = [ + mention({ channelId: null, createdAt: "2026-07-16T10:00Z" }), + ]; + expect([...unreadChannelIds(items, {})]).toEqual([]); + }); +}); + +describe("latestActivityForChannel", () => { + it("returns the newest timestamp for that channel only", () => { + const items = [ + mention({ channelId: "c1", createdAt: "2026-07-16T08:00:00.000Z" }), + mention({ channelId: "c1", createdAt: "2026-07-16T12:00:00.000Z" }), + mention({ channelId: "c2", createdAt: "2026-07-16T13:00:00.000Z" }), + ]; + expect(latestActivityForChannel(items, "c1")).toBe( + "2026-07-16T12:00:00.000Z", + ); + }); + + it("is undefined for a channel with no activity, or no channel", () => { + expect(latestActivityForChannel([], "c1")).toBeUndefined(); + expect(latestActivityForChannel([], undefined)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/canvas/channelUnread.ts b/packages/core/src/canvas/channelUnread.ts new file mode 100644 index 0000000000..1c7fafe7ba --- /dev/null +++ b/packages/core/src/canvas/channelUnread.ts @@ -0,0 +1,66 @@ +import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity"; + +/** + * Which channels have activity the viewer hasn't seen — the signal behind the + * sidebar's bold channel names. + * + * "Activity" is currently an @-mention: that's the only cross-channel, + * all-users feed the client has (the mentions index), and it's what + * "notification" means elsewhere in the app. The backend exposes no per-channel + * activity timestamp, so a broader "any new message" signal would mean polling + * every user's full task list — the app's heaviest poll, deliberately retired. + * If that timestamp lands, only `latestActivityByChannel` changes shape; the + * unread comparison and the seen bookkeeping stay as they are. + * + * Keyed by backend channel id rather than name, so renaming a channel doesn't + * silently mark it unread again. + */ + +/** Newest activity per channel id. Ignores items with no channel. */ +export function latestActivityByChannel( + items: readonly MentionActivityItem[], +): Map { + const latest = new Map(); + for (const item of items) { + if (!item.channelId) continue; + const current = latest.get(item.channelId); + if (!current || item.createdAt > current) { + latest.set(item.channelId, item.createdAt); + } + } + return latest; +} + +/** + * Channel ids whose newest activity postdates the viewer's last visit. A + * channel never visited is unread as soon as it has any activity. + */ +export function unreadChannelIds( + items: readonly MentionActivityItem[], + lastSeenByChannel: Readonly>, +): Set { + const unread = new Set(); + for (const [channelId, activityAt] of latestActivityByChannel(items)) { + const seenAt = lastSeenByChannel[channelId]; + if (!seenAt || activityAt > seenAt) unread.add(channelId); + } + return unread; +} + +/** + * The newest activity in one channel, for stamping it seen while it's open. + * Scans for the one channel rather than reusing `latestActivityByChannel`, + * which would build (and throw away) a map of every other channel to answer. + */ +export function latestActivityForChannel( + items: readonly MentionActivityItem[], + channelId: string | undefined, +): string | undefined { + if (!channelId) return undefined; + let latest: string | undefined; + for (const item of items) { + if (item.channelId !== channelId) continue; + if (!latest || item.createdAt > latest) latest = item.createdAt; + } + return latest; +} diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index 3f8161fc7c..bc08328a46 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -2,6 +2,7 @@ import { HashIcon } from "@phosphor-icons/react"; import { Button, cn } from "@posthog/quill"; import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; @@ -17,6 +18,9 @@ export function ChannelHeader({ channelId }: { channelId: string }) { const channelName = channels.find((c) => c.id === channelId)?.name; const pathname = useRouterState({ select: (s) => s.location.pathname }); const isHome = pathname === `/website/${channelId}`; + // Every channel surface renders this header, so it is where "the viewer is + // in this channel" is known — and therefore where the channel is marked read. + useMarkChannelSeen(channelName); return (
diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index ca4c269794..9279e0e8e2 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -60,6 +60,7 @@ import { PERSONAL_CHANNEL_NAME, useTaskChannels, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { toast } from "@posthog/ui/primitives/toast"; @@ -291,7 +292,14 @@ function ChannelMenu({ // One channel in the list: a "# name" row that navigates to the channel home. // No expansion — the channel's surfaces live in the in-channel top nav. -function ChannelSection({ channel }: { channel: Channel }) { +function ChannelSection({ + channel, + isUnread, +}: { + channel: Channel; + /** Bolds the name: activity here the viewer hasn't seen. */ + isUnread?: boolean; +}) { const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); const base = `/website/${channel.id}`; @@ -340,10 +348,26 @@ function ChannelSection({ channel }: { channel: Channel }) { }} className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12" > - + @@ -498,6 +522,7 @@ function PersonalChannelRow() { useTaskChannels(); // The "+" dropdown (New task / New canvas), mirroring a shared channel row. const [newMenuOpen, setNewMenuOpen] = useState(false); + const isUnread = useIsChannelUnread()(PERSONAL_CHANNEL_NAME); const meFolder = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); const createAndOpenCanvas = useCreateAndOpenDashboard(meFolder?.id); @@ -560,8 +585,25 @@ function PersonalChannelRow() { onClick={() => void open()} className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12" > - - + + {PERSONAL_CHANNEL_NAME} {/* The lock and the hover "+" share the right edge, so fade the lock @@ -569,7 +611,7 @@ function PersonalChannelRow() { toggleSection(sectionId)} + // The store only exposes a toggle, so drive it from the requested value: + // an event for the state we're already in is then a no-op rather than an + // inversion. + onOpenChange={(open) => { + if (open !== isOpen) toggleSection(sectionId); + }} className={className} > {/* MenuLabel carries the sidebar's label styling; `render` keeps it a @@ -703,6 +750,8 @@ export function ChannelsList() { const { channels: allChannels, isLoading } = useChannels(); const { starredRefToShortcutId } = useChannelStars(); + const isUnread = useIsChannelUnread(); + // The "me" folder renders as the pinned personal row, not a shared channel. const channels = allChannels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME); const starred = channels.filter((c) => starredRefToShortcutId.has(c.path)); @@ -733,7 +782,11 @@ export function ChannelsList() { {starred.length > 0 && ( {starred.map((channel) => ( - + ))} )} @@ -745,7 +798,11 @@ export function ChannelsList() { )} {others.map((channel) => ( - + ))} diff --git a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx index 5920379390..9c80bb03e3 100644 --- a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx +++ b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx @@ -20,7 +20,7 @@ import { useGenerateContext } from "@posthog/ui/features/canvas/hooks/useGenerat import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; import { useNavigate } from "@tanstack/react-router"; -import { type CSSProperties, useState } from "react"; +import { type CSSProperties, useRef, useState } from "react"; // Matches Slack's "Create a channel" naming constraint. const MAX_CONTEXT_NAME_LENGTH = 80; @@ -84,6 +84,21 @@ export function CreateChannelModal({ // way through without one. const canDescribe = !busy && !!trimmedDescription; + // `busy` only disables the buttons a render after the mutation starts, so a + // double-click lands two creates before it applies — and folder creation is + // not idempotent by path, so that is two channels of the same name. Latch + // synchronously; the buttons stay the user-visible half of this. + const submittingRef = useRef(false); + const submitOnce = async (submit: () => Promise) => { + if (submittingRef.current) return; + submittingRef.current = true; + try { + await submit(); + } finally { + submittingRef.current = false; + } + }; + // Create the channel and land in its feed — the intro (name, creation line, // context.md card) and "joined" row there are derived from the channel row. // With a description, also launch the plan session that builds context.md. @@ -186,10 +201,11 @@ export function CreateChannelModal({ disabled={busy} onChange={(e) => setDescription(e.target.value)} onKeyDown={(e) => { - // ⌘/Ctrl+Enter submits; a bare Enter stays a newline. + // ⌘/Ctrl+Enter submits; a bare Enter stays a newline. Held down it + // repeats, so it goes through the same latch as the buttons. if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); - void submitDescribeStep(); + void submitOnce(submitDescribeStep); } }} /> @@ -225,7 +241,7 @@ export function CreateChannelModal({ variant="primary" disabled={!canDescribe} loading={busy} - onClick={submitDescribeStep} + onClick={() => void submitOnce(submitDescribeStep)} > Create @@ -335,7 +351,7 @@ export function CreateChannelModal({ @@ -343,7 +359,7 @@ export function CreateChannelModal({ variant="primary" disabled={!canDescribe} loading={busy} - onClick={submitDescribeStep} + onClick={() => void submitOnce(submitDescribeStep)} > Create diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 0159ab0aa0..16a29e7fbf 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -75,6 +75,9 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { // identity-resolution window (settling if the resolve fails), so fold it in: // we can't call a channel empty until we know which channel it is. const isLoading = isLoadingChannels || isResolvingChannel || isLoadingFeed; + // Marking this channel read lives in ChannelHeader (rendered by every channel + // surface), so opening Artifacts or CONTEXT.md counts as reading it too. + // Durable "PostHog agent" rows (CONTEXT.md being built, …) live on the // backend channel — the same id the feed tasks use, not the folder id. const { messages: feedMessages } = useChannelFeedMessages(backendChannel?.id); diff --git a/packages/ui/src/features/canvas/ensurePersonalChannel.test.ts b/packages/ui/src/features/canvas/ensurePersonalChannel.test.ts new file mode 100644 index 0000000000..655faf65cc --- /dev/null +++ b/packages/ui/src/features/canvas/ensurePersonalChannel.test.ts @@ -0,0 +1,79 @@ +import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { beforeEach, expect, it, vi } from "vitest"; +import { ensurePersonalChannel } from "./ensurePersonalChannel"; + +function channel(id: string, name = "me"): Channel { + return { id, name, path: `/${name}`, type: "folder" } as Channel; +} + +// The module memoises the created folder, so each test needs a fresh copy. +beforeEach(() => { + vi.resetModules(); +}); + +it("returns the existing folder without creating", async () => { + const create = vi.fn(); + const existing = channel("1"); + await expect(ensurePersonalChannel([existing], create)).resolves.toBe( + existing, + ); + expect(create).not.toHaveBeenCalled(); +}); + +it("shares one create between callers racing before it settles", async () => { + const { ensurePersonalChannel: ensure } = await import( + "./ensurePersonalChannel" + ); + const create = vi.fn( + () => new Promise((r) => setTimeout(() => r(channel("1")), 5)), + ); + + const [a, b] = await Promise.all([ensure([], create), ensure([], create)]); + + expect(create).toHaveBeenCalledTimes(1); + expect(a).toBe(b); +}); + +it("doesn't create a second folder for a caller still holding the pre-create list", async () => { + const { ensurePersonalChannel: ensure } = await import( + "./ensurePersonalChannel" + ); + const create = vi.fn(async () => channel("1")); + + // First caller creates it. The cache is seeded, but a component that hasn't + // re-rendered yet still passes the empty list it captured last render. + await ensure([], create); + const second = await ensure([], create); + + expect(create).toHaveBeenCalledTimes(1); + expect(second.id).toBe("1"); +}); + +it("prefers the list once it carries the folder, so a recreated me isn't stale", async () => { + const { ensurePersonalChannel: ensure } = await import( + "./ensurePersonalChannel" + ); + const create = vi.fn(async () => channel("1")); + await ensure([], create); + + // "me" was deleted and remade elsewhere; the list is authoritative. + const fresh = channel("2"); + await expect(ensure([fresh], create)).resolves.toBe(fresh); + // …and the stale memo is dropped rather than resurfacing afterwards. + await expect(ensure([fresh], create)).resolves.toBe(fresh); + expect(create).toHaveBeenCalledTimes(1); +}); + +it("lets a later caller retry after a failed create", async () => { + const { ensurePersonalChannel: ensure } = await import( + "./ensurePersonalChannel" + ); + const create = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce(channel("1")); + + await expect(ensure([], create)).rejects.toThrow("offline"); + await expect(ensure([], create)).resolves.toEqual(channel("1")); + expect(create).toHaveBeenCalledTimes(2); +}); diff --git a/packages/ui/src/features/canvas/ensurePersonalChannel.ts b/packages/ui/src/features/canvas/ensurePersonalChannel.ts index 88713ba0ea..8d1fe49dc9 100644 --- a/packages/ui/src/features/canvas/ensurePersonalChannel.ts +++ b/packages/ui/src/features/canvas/ensurePersonalChannel.ts @@ -8,6 +8,12 @@ import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTask // menu), so they share one in-flight create rather than guarding separately: // per-caller guards would still race each other. let inFlight: Promise | null = null; +// The in-flight promise alone isn't enough: it settles the moment the POST +// returns, but callers pass the `channels` from their last render, which hasn't +// re-rendered with the seeded cache yet. A click landing in that gap sees +// neither an existing "me" nor an in-flight create, and makes a second one. +// Remember what was created until the list catches up. +let created: Channel | null = null; /** * The user's "me" folder, creating it once if it doesn't exist yet. Concurrent @@ -19,11 +25,22 @@ export async function ensurePersonalChannel( createChannel: (name: string) => Promise, ): Promise { const existing = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); - if (existing) return existing; + if (existing) { + // The list is authoritative once it carries the folder: drop the memo, so a + // deleted-then-recreated "me" resolves fresh rather than to a dead id. + created = null; + return existing; + } + if (created) return created; if (!inFlight) { - inFlight = createChannel(PERSONAL_CHANNEL_NAME).finally(() => { - inFlight = null; - }); + inFlight = createChannel(PERSONAL_CHANNEL_NAME) + .then((channel) => { + created = channel; + return channel; + }) + .finally(() => { + inFlight = null; + }); } return inFlight; } diff --git a/packages/ui/src/features/canvas/hooks/useMarkChannelSeen.ts b/packages/ui/src/features/canvas/hooks/useMarkChannelSeen.ts new file mode 100644 index 0000000000..917ac01477 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useMarkChannelSeen.ts @@ -0,0 +1,38 @@ +import { latestActivityForChannel } from "@posthog/core/canvas/channelUnread"; +import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { useBackendChannel } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useChannelSeenStore } from "@posthog/ui/features/canvas/stores/channelSeenStore"; +import { useEffect } from "react"; + +/** + * Looking at a channel reads it: stamp it seen so the sidebar drops its bold. + * + * Called from ChannelHeader, which every channel surface renders — the feed, + * Artifacts, Recents and CONTEXT.md all count as being in the channel, and + * hanging this off the header means a new surface gets it for free rather than + * having to remember. + * + * Stamped with the newest activity rather than "now": a mention landing while + * you're looking re-stamps it, remounts don't churn the store, and the store + * can't record having seen something that hasn't happened yet. + */ +export function useMarkChannelSeen(channelName: string | undefined): void { + const { channel: backendChannel } = useBackendChannel(channelName); + const { items: mentionItems } = useMentionActivity(); + const markChannelSeen = useChannelSeenStore((s) => s.markChannelSeen); + // Writing before the persisted map lands would be merged against an empty + // map; the store folds the two, but waiting keeps the write ordered behind + // the read it is meant to supersede. + const hasHydrated = useChannelSeenStore((s) => s.hasHydrated); + + const backendChannelId = backendChannel?.id; + const latestActivityAt = latestActivityForChannel( + mentionItems, + backendChannelId, + ); + + useEffect(() => { + if (!hasHydrated || !backendChannelId || !latestActivityAt) return; + markChannelSeen(backendChannelId, latestActivityAt); + }, [hasHydrated, backendChannelId, latestActivityAt, markChannelSeen]); +} diff --git a/packages/ui/src/features/canvas/hooks/useUnreadChannels.ts b/packages/ui/src/features/canvas/hooks/useUnreadChannels.ts new file mode 100644 index 0000000000..256553e073 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useUnreadChannels.ts @@ -0,0 +1,62 @@ +import { unreadChannelIds } from "@posthog/core/canvas/channelUnread"; +import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { + normalizeChannelName, + PERSONAL_CHANNEL_NAME, + useTaskChannels, +} from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useChannelSeenStore } from "@posthog/ui/features/canvas/stores/channelSeenStore"; +import { useMemo } from "react"; + +const NONE: ReadonlySet = new Set(); + +/** + * Backend channel ids with activity the viewer hasn't seen. Shares the mentions + * query with the Activity badge through the react-query cache, so mounting this + * in the sidebar costs no extra fetch. + * + * Nothing is unread until the seen map is back from storage: an empty map reads + * exactly like "never opened anything", which would bold every channel with + * activity for the first frames of every boot. + */ +export function useUnreadChannelIds(): ReadonlySet { + const { items } = useMentionActivity(); + const lastSeenByChannel = useChannelSeenStore((s) => s.lastSeenByChannel); + const hasHydrated = useChannelSeenStore((s) => s.hasHydrated); + return useMemo( + () => (hasHydrated ? unreadChannelIds(items, lastSeenByChannel) : NONE), + [items, lastSeenByChannel, hasHydrated], + ); +} + +/** + * Is this folder channel unread, by display name? + * + * Unread is keyed by backend channel id while the sidebar's rows are folder + * channels, so something has to bridge the two. This mirrors the mapping + * useBackendChannel walks — "me" is the personal channel (matched by type, as + * its name is the backend's business), everything else matches a public channel + * by normalized name — and does it once for the whole list rather than + * resolving per row, which would fire a resolve per channel. + */ +export function useIsChannelUnread(): (channelName: string) => boolean { + const { channels: backendChannels, personalChannel } = useTaskChannels(); + const unreadIds = useUnreadChannelIds(); + + return useMemo(() => { + const unreadNames = new Set(); + for (const channel of backendChannels) { + if (channel.channel_type === "public" && unreadIds.has(channel.id)) { + unreadNames.add(channel.name); + } + } + const personalUnread = + !!personalChannel && unreadIds.has(personalChannel.id); + return (channelName: string) => { + const normalized = normalizeChannelName(channelName); + return normalized === PERSONAL_CHANNEL_NAME + ? personalUnread + : unreadNames.has(normalized); + }; + }, [backendChannels, personalChannel, unreadIds]); +} diff --git a/packages/ui/src/features/canvas/stores/channelSeenStore.test.ts b/packages/ui/src/features/canvas/stores/channelSeenStore.test.ts new file mode 100644 index 0000000000..c188a24e97 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/channelSeenStore.test.ts @@ -0,0 +1,75 @@ +import { expect, it, vi } from "vitest"; + +// Storage is IPC-backed and async in the app, so reads land after first paint. +// Everything here turns on that gap. +const readDelayMs = 5; +async function freshStore(stored: Record | null) { + vi.resetModules(); + vi.doMock("@posthog/ui/shell/rendererStorage", () => ({ + electronStorage: { + getItem: async () => { + await new Promise((r) => setTimeout(r, readDelayMs)); + return stored + ? { state: { lastSeenByChannel: stored }, version: 0 } + : null; + }, + setItem: async () => {}, + removeItem: async () => {}, + }, + })); + const { useChannelSeenStore } = await import("./channelSeenStore"); + return useChannelSeenStore; +} + +const settle = () => new Promise((r) => setTimeout(r, readDelayMs * 4)); + +it("is not hydrated until the persisted map arrives", async () => { + const store = await freshStore({ c1: "2026-01-01T00:00:00.000Z" }); + expect(store.getState().hasHydrated).toBe(false); + expect(store.getState().lastSeenByChannel).toEqual({}); + + await settle(); + expect(store.getState().hasHydrated).toBe(true); + expect(store.getState().lastSeenByChannel.c1).toBe( + "2026-01-01T00:00:00.000Z", + ); +}); + +it("keeps a stamp written before hydration instead of losing it to the stored map", async () => { + const store = await freshStore({ c1: "2026-01-01T00:00:00.000Z" }); + + // A channel opened during boot marks itself read before storage answers. + store.getState().markChannelSeen("c2", "2026-07-16T10:00:00.000Z"); + await settle(); + + expect(store.getState().lastSeenByChannel).toEqual({ + c1: "2026-01-01T00:00:00.000Z", + c2: "2026-07-16T10:00:00.000Z", + }); +}); + +it("keeps the later visit when a channel is stamped on both sides of hydration", async () => { + const store = await freshStore({ c1: "2026-01-01T00:00:00.000Z" }); + store.getState().markChannelSeen("c1", "2026-07-16T10:00:00.000Z"); + await settle(); + expect(store.getState().lastSeenByChannel.c1).toBe( + "2026-07-16T10:00:00.000Z", + ); +}); + +it("hydrates even with nothing stored, so a first run isn't stuck unhydrated", async () => { + const store = await freshStore(null); + await settle(); + expect(store.getState().hasHydrated).toBe(true); + expect(store.getState().lastSeenByChannel).toEqual({}); +}); + +it("never walks a stamp backwards", async () => { + const store = await freshStore(null); + await settle(); + store.getState().markChannelSeen("c1", "2026-07-16T10:00:00.000Z"); + store.getState().markChannelSeen("c1", "2026-07-16T09:00:00.000Z"); + expect(store.getState().lastSeenByChannel.c1).toBe( + "2026-07-16T10:00:00.000Z", + ); +}); diff --git a/packages/ui/src/features/canvas/stores/channelSeenStore.ts b/packages/ui/src/features/canvas/stores/channelSeenStore.ts new file mode 100644 index 0000000000..ab5a9d07d2 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/channelSeenStore.ts @@ -0,0 +1,69 @@ +import { electronStorage } from "@posthog/ui/shell/rendererStorage"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +// When the viewer last had each channel open, keyed by backend channel id. +// Activity newer than this bolds the channel in the sidebar; opening the +// channel clears it. Per-channel (unlike the Activity page's single +// `lastSeenAt`) so reading one channel doesn't mark every other one read. +interface ChannelSeenState { + lastSeenByChannel: Record; + /** False until the persisted map is back from storage — see `merge` below. */ + hasHydrated: boolean; + markChannelSeen: (channelId: string, at: string) => void; +} + +/** Keep whichever visit is later, so a stamp is never walked backwards. */ +function latestSeen( + a: Record, + b: Record, +): Record { + const merged = { ...a }; + for (const [channelId, at] of Object.entries(b)) { + const current = merged[channelId]; + if (!current || at > current) merged[channelId] = at; + } + return merged; +} + +export const useChannelSeenStore = create()( + persist( + (set) => ({ + lastSeenByChannel: {}, + hasHydrated: false, + markChannelSeen: (channelId, at) => + set((state) => { + // A channel visited after its newest activity is read; re-stamping it + // with an older mention would bold it again. + const current = state.lastSeenByChannel[channelId]; + if (current && current >= at) return state; + return { + lastSeenByChannel: { ...state.lastSeenByChannel, [channelId]: at }, + }; + }), + }), + { + name: "channels-seen", + storage: electronStorage, + partialize: (state) => ({ lastSeenByChannel: state.lastSeenByChannel }), + // Storage is async (IPC), so a channel opened during boot can stamp + // itself seen before the persisted map arrives. zustand's default merge + // would then replace that stamp with what was on disk and lose it, so + // fold the two together instead and keep the later visit per channel. + merge: (persisted, current) => ({ + ...current, + lastSeenByChannel: latestSeen( + current.lastSeenByChannel, + (persisted as Partial)?.lastSeenByChannel ?? {}, + ), + }), + onRehydrateStorage: () => (state) => { + // Readers gate on this: before it flips, an empty map is + // indistinguishable from "nothing has ever been read", which would + // bold every channel that has any activity. + useChannelSeenStore.setState({ hasHydrated: true }); + return state; + }, + }, + ), +);