Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default defineConfig({
"**/channel-mute.spec.ts",
"**/channel-star.spec.ts",
"**/channel-controls.spec.ts",
"**/channel-activity-popover.spec.ts",
"**/active-turn-resilience.spec.ts",
"**/profile-active-turn.spec.ts",
"**/config-bridge-screenshots.spec.ts",
Expand Down
21 changes: 20 additions & 1 deletion desktop/src/app/AppShell.helpers.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";

import { shouldBounceForChannelNotification } from "./AppShell.helpers.ts";
import {
markAllReadSources,
shouldBounceForChannelNotification,
} from "./AppShell.helpers.ts";

test("shouldBounceForChannelNotification_allowsTopLevelChannelMessages", () => {
assert.equal(shouldBounceForChannelNotification([["h", "channel"]]), true);
Expand All @@ -27,3 +30,19 @@ test("shouldBounceForChannelNotification_allowsBroadcastReplies", () => {
true,
);
});

test("markAllReadSources clears Inbox overrides before channel markers", () => {
const calls = [];

markAllReadSources({
unreadFeedItemIds: new Set(["first-inbox-item", "second-inbox-item"]),
undoUnreadFeedItem: (itemId) => calls.push(`inbox:${itemId}`),
markAllChannelReadMarkers: () => calls.push("channels"),
});

assert.deepEqual(calls, [
"inbox:first-inbox-item",
"inbox:second-inbox-item",
"channels",
]);
});
15 changes: 15 additions & 0 deletions desktop/src/app/AppShell.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,21 @@ export function shouldBounceForChannelNotification(tags: string[][]): boolean {
return !isThreadReply(tags);
}

export function markAllReadSources({
markAllChannelReadMarkers,
undoUnreadFeedItem,
unreadFeedItemIds,
}: {
markAllChannelReadMarkers: () => void;
undoUnreadFeedItem: (itemId: string) => void;
unreadFeedItemIds: ReadonlySet<string>;
}) {
for (const itemId of unreadFeedItemIds) {
undoUnreadFeedItem(itemId);
}
markAllChannelReadMarkers();
}

export function toSearchHit(
target: DesktopNotificationTarget,
): SearchHit | null {
Expand Down
93 changes: 44 additions & 49 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Outlet, useLocation } from "@tanstack/react-router";
import { deriveShellRoute } from "@/app/AppShell.helpers";
import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers";
import { AppShellProvider } from "@/app/AppShellContext";
import * as BuzzTheme from "@/app/BuzzThemeSurfaces";
import { AppShellOverlays } from "@/app/AppShellOverlays";
Expand All @@ -15,7 +15,7 @@ import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts";
import { useSettingsShortcuts } from "@/app/useSettingsShortcuts";
import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications";
import { useAppShellLifecycleEffects } from "@/app/useAppShellLifecycleEffects";
import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems";
import { useChannelActivityProjection } from "@/app/useChannelActivityProjection";
import { useTauriWindowDrag } from "@/app/useTauriWindowDrag";
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
import {
Expand All @@ -26,7 +26,6 @@ import {
useOpenDmMutation,
} from "@/features/channels/hooks";
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
import { msgContextKey } from "@/features/channels/readState/readStateFormat";
import { useMembershipNotifications } from "@/features/channels/useMembershipNotifications";
import { useFeedItemState } from "@/features/home/useFeedItemState";
import { useThreadFollows } from "@/features/messages/lib/useThreadFollows";
Expand Down Expand Up @@ -324,10 +323,12 @@ export function AppShell() {
} = useThreadFollows(identityQuery.data?.pubkey);

const {
markAllChannelsRead,
markAllChannelsRead: markAllChannelReadMarkers,
markChannelRead,
markChannelUnread,
clearChannelUnreadSource,
unreadChannelIds,
topLevelUnreadChannelIds,
unreadChannelCounts,
highPriorityUnreadChannelIds,
unreadChannelNotificationCount,
Expand All @@ -338,6 +339,7 @@ export function AppShell() {
participatedRootIds,
authoredRootIds,
mentionedRootIds,
recordThreadInteraction,
threadActivityItems,
mutedRootIds,
muteThread,
Expand All @@ -356,54 +358,39 @@ export function AppShell() {
followedRootIds,
});

const getThreadReadAt = React.useCallback(
(rootId: string, channelId?: string | null) => {
const threadReadAt = getOwnReadAt(`thread:${rootId}`);
if (!channelId) {
return threadReadAt;
}

const channelReadAt = getChannelReadAt(channelId);
if (threadReadAt === null) {
return channelReadAt;
}
if (channelReadAt === null) {
return threadReadAt;
}
return Math.max(threadReadAt, channelReadAt);
},
[getChannelReadAt, getOwnReadAt],
);

const markThreadRead = React.useCallback(
(rootId: string, timestamp: number) => {
markChannelRead(
`thread:${rootId}`,
new Date(timestamp * 1_000).toISOString(),
);
},
[markChannelRead],
);
const markAllChannelsRead = React.useCallback(() => {
markAllReadSources({
markAllChannelReadMarkers,
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
undoUnreadFeedItem: feedItemState.undoUnread,
unreadFeedItemIds: feedItemState.unreadSet,
});
}, [
feedItemState.undoUnread,
feedItemState.unreadSet,
markAllChannelReadMarkers,
]);

// Per-message read frontier (LP4 v3): effective(msg:<id>) folds through the
// channel, so a channel-read clears messages older than the top-level frontier.
const getMessageReadAt = React.useCallback(
(messageId: string) => getChannelReadAt(msgContextKey(messageId)),
[getChannelReadAt],
);
const markMessageRead = React.useCallback(
(messageId: string, timestamp: number) =>
markChannelRead(
msgContextKey(messageId),
new Date(timestamp * 1_000).toISOString(),
),
[markChannelRead],
);
const threadActivityFeedItems = useThreadActivityFeedItems(
const {
getThreadReadAt,
markThreadRead,
getMessageReadAt,
getChannelActivityItemReadAt,
markMessageRead,
threadActivityFeedItems,
locallyUnreadFeedItems,
unreadThreadFeedItems,
unreadThreadChannelIds,
} = useChannelActivityProjection({
channels,
feed: homeFeedQuery.data?.feed,
unreadFeedItemIds: feedItemState.unreadSet,
getChannelReadAt,
getOwnReadAt,
markChannelRead,
readStateVersion,
threadActivityItems,
mutedRootIds,
channels,
);
});

// Badge count consumes the shared NIP-RS read-state from useUnreadChannels.
const { homeBadgeCount, homeBadgeCountExcludingHighPriority } =
Expand Down Expand Up @@ -709,6 +696,7 @@ export function AppShell() {
markAllChannelsRead,
markChannelRead,
markChannelUnread,
clearChannelUnreadSource,
openBrowseChannels: handleOpenBrowseChannels,
openCreateChannel: handleOpenCreateChannel,
openChannelManagement: (channelId?: string) => {
Expand All @@ -721,16 +709,23 @@ export function AppShell() {
getThreadReadAt,
markThreadRead,
getMessageReadAt,
getChannelActivityItemReadAt,
markMessageRead,
readStateVersion,
setContextParentResolver,
followThread: handleFollowThread,
unfollowThread: handleUnfollowThread,
isFollowingThread,
isNotifiedForThread,
recordThreadInteraction,
isThreadMuted: (rootId) => mutedRootIds.has(rootId),
threadActivityItems,
threadActivityFeedItems,
locallyUnreadFeedItems,
unreadThreadFeedItems,
unreadThreadChannelIds,
topLevelUnreadChannelIds,
hasSidebarUnreadProjections: true,
feedItemState,
onOpenSettings: handleOpenSettings,
}}
Expand Down
41 changes: 39 additions & 2 deletions desktop/src/app/AppShellContext.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from "react";
import type { ForcedUnreadSource } from "@/features/channels/forcedUnreadStore";
import type { ContextParentResolver } from "@/features/channels/readState/readStateManager";
import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels";
import type { FeedItemState } from "@/features/home/useFeedItemState";
Expand All @@ -12,9 +13,16 @@ type AppShellContextValue = {
markChannelRead: (
channelId: string,
readAt: string | null | undefined,
options?: { topLevelOnly?: boolean },
options?: {
preserveForcedUnread?: boolean;
topLevelOnly?: boolean;
},
) => void;
markChannelUnread: (channelId: string, source?: ForcedUnreadSource) => void;
clearChannelUnreadSource: (
channelId: string,
source: ForcedUnreadSource,
) => void;
markChannelUnread: (channelId: string) => void;
openBrowseChannels: () => void;
openCreateChannel: () => void;
openChannelManagement: (channelId?: string) => void;
Expand All @@ -31,6 +39,11 @@ type AppShellContextValue = {
// read. Uses `msg:<id>` context keys folded through the active channel by the
// parent resolver (LP4 v3 per-message badge model).
getMessageReadAt: (messageId: string) => number | null;
// Read frontier for a channel-activity item, scoped to that item's own
// message and channel rather than the currently mounted channel resolver.
getChannelActivityItemReadAt: (
item: Pick<FeedItem, "channelId" | "id">,
) => number | null;
// Advance a single message's read marker to the given unix-seconds timestamp.
markMessageRead: (messageId: string, timestamp: number) => void;
// Bump-counter that invalidates whenever the read marker changes. Include
Expand All @@ -43,9 +56,25 @@ type AppShellContextValue = {
unfollowThread: (rootId: string) => void;
isFollowingThread: (rootId: string) => boolean;
isNotifiedForThread: (rootId: string) => boolean;
recordThreadInteraction: (rootId: string) => void;
isThreadMuted: (rootId: string) => boolean;
threadActivityItems: ThreadActivityItem[];
threadActivityFeedItems: FeedItem[];
// Home-feed items explicitly reopened from Inbox. Kept separate from live
// thread activity so older rows can be projected into channel hover cards
// without duplicating the Home feed itself.
locallyUnreadFeedItems: FeedItem[];
// Thread rows that remain unread until their own message/thread marker is
// advanced. Unlike the broad channel unread set, this includes the active
// channel so simply landing in it does not hide the wayfinding signal.
unreadThreadFeedItems: FeedItem[];
unreadThreadChannelIds: ReadonlySet<string>;
// Ordinary unread channel-level activity. Sidebar rows use this for text
// emphasis only; thread activity owns the dot.
topLevelUnreadChannelIds: ReadonlySet<string>;
// Lets isolated component tests retain the legacy hasUnread fallback while
// the mounted shell uses the split projections above.
hasSidebarUnreadProjections: boolean;
feedItemState: FeedItemState;
// Open the Settings panel at the given section. Available on all surfaces
// that render under AppShell (channel, home, projects, pulse, agents).
Expand All @@ -57,23 +86,31 @@ const AppShellContext = React.createContext<AppShellContextValue>({
markAllChannelsRead: () => {},
markChannelRead: () => {},
markChannelUnread: () => {},
clearChannelUnreadSource: () => {},
openBrowseChannels: () => {},
openCreateChannel: () => {},
openChannelManagement: () => {},
getChannelReadAt: () => null,
getThreadReadAt: () => null,
markThreadRead: () => {},
getMessageReadAt: () => null,
getChannelActivityItemReadAt: () => null,
markMessageRead: () => {},
readStateVersion: 0,
setContextParentResolver: () => {},
followThread: () => {},
unfollowThread: () => {},
isFollowingThread: () => false,
isNotifiedForThread: () => false,
recordThreadInteraction: () => {},
isThreadMuted: () => false,
threadActivityItems: [],
threadActivityFeedItems: [],
locallyUnreadFeedItems: [],
unreadThreadFeedItems: [],
unreadThreadChannelIds: EMPTY_SET,
topLevelUnreadChannelIds: EMPTY_SET,
hasSidebarUnreadProjections: false,
feedItemState: {
doneSet: EMPTY_SET,
markDone: () => {},
Expand Down
30 changes: 30 additions & 0 deletions desktop/src/app/useChannelActivityProjection.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";

import { resolveChannelActivityFeedItemReadAt } from "./useChannelActivityProjection.ts";

test("channel activity read state folds the item's own message and channel markers", () => {
const markers = new Map([
["msg:reply-general", 100],
["general", 200],
["random", 500],
]);

assert.equal(
resolveChannelActivityFeedItemReadAt(
{ id: "reply-general", channelId: "general" },
(contextId) => markers.get(contextId) ?? null,
),
200,
);
});

test("channel activity read state honors a channel marker without a message marker", () => {
assert.equal(
resolveChannelActivityFeedItemReadAt(
{ id: "reply-general", channelId: "general" },
(contextId) => (contextId === "general" ? 300 : null),
),
300,
);
});
Loading
Loading