-
Notifications
You must be signed in to change notification settings - Fork 58
feat(channels): bold a channel name when it has unseen activity #3513
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
8aa7a1f
feat(channels): bold a channel name when it has unseen activity
adamleithp 1009a4d
feat(channels): dim the channel list, brighten unread and the open ch…
adamleithp 2cff652
fix(channels): address review — hydration race, double-submit, seen d…
adamleithp bbc1c9a
Merge branch 'main' into posthog-code/channel-unread-bold
adamleithp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<MentionActivityItem> & { 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<string, string>; | ||
| 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(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string> { | ||
| const latest = new Map<string, string>(); | ||
| 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<Record<string, string>>, | ||
| ): Set<string> { | ||
| const unread = new Set<string>(); | ||
| 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; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The mentions cache keeps only its newest 300 items, but this loop treats that cache as a complete channel index. After enough mentions arrive, an unread channel whose latest mention was evicted disappears from this set, so its sidebar row incorrectly returns to normal weight.