Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
f6387f9
Improve inbox thread updates
klopez4212 Jun 18, 2026
66d905a
Add inbox row hover actions
klopez4212 Jun 18, 2026
084e718
Match inbox tray icons
klopez4212 Jun 18, 2026
9dd8636
Rename home tab to inbox
klopez4212 Jun 19, 2026
ff49917
Polish inbox hover tray motion
klopez4212 Jun 19, 2026
1bf8410
Align inbox tray styling with message actions
klopez4212 Jun 19, 2026
c4433d6
Fix inbox preview two-line clamp
klopez4212 Jun 19, 2026
279381c
Soften inbox hover tray styling
klopez4212 Jun 19, 2026
2482442
Try inbox header actions menu
klopez4212 Jun 19, 2026
ec77b23
Move inbox controls to right side
klopez4212 Jun 19, 2026
892c3ea
Put inbox options after filter
klopez4212 Jun 19, 2026
3c6a727
Match inbox tray radius to message actions
klopez4212 Jun 19, 2026
f631953
Place inbox options before filter
klopez4212 Jun 19, 2026
7b68dd8
Move inbox options to panel left
klopez4212 Jun 19, 2026
e549905
Open inbox channel actions in place
klopez4212 Jun 19, 2026
d2aecb0
Remove duplicate inbox detail read menu
klopez4212 Jun 19, 2026
2217200
Fade inbox hover tray without movement
klopez4212 Jun 19, 2026
460a75f
Align inbox message action tray
klopez4212 Jun 19, 2026
a2cb35c
Address inbox review feedback
klopez4212 Jun 19, 2026
b58412e
Keep DM thread replies in unread tracking
klopez4212 Jun 19, 2026
1159fba
Merge main into inbox updates
klopez4212 Jun 19, 2026
742b8c5
Recompute inbox badge for muted threads
klopez4212 Jun 19, 2026
6facbc5
Fix inbox PR CI failures
klopez4212 Jun 19, 2026
5ce9be2
Address inbox PR review feedback
klopez4212 Jun 20, 2026
049aa2b
Scope inbox nav E2E selectors
klopez4212 Jun 20, 2026
faad87f
Merge main into inbox updates
klopez4212 Jun 20, 2026
e379d6f
Restore unread hook return fields
klopez4212 Jun 20, 2026
704d620
Invalidate reminders on live updates
klopez4212 Jun 20, 2026
a419a8a
Fix desktop smoke failures
klopez4212 Jun 20, 2026
7bd0f59
Clear casual thread badges on open
klopez4212 Jun 21, 2026
08e71a4
Fix inbox unread review feedback
klopez4212 Jun 21, 2026
3ef9b83
Merge remote-tracking branch 'origin/main' into kennylopez-inbox-updates
klopez4212 Jun 21, 2026
a052dc8
Fix active thread read marker lookup
klopez4212 Jun 21, 2026
f669a6f
Fix grouped inbox unread overrides
klopez4212 Jun 21, 2026
f093f3f
Address remaining inbox review feedback
klopez4212 Jun 21, 2026
070c632
Retry home live feed subscriptions
klopez4212 Jun 21, 2026
c0d93de
Fix inbox thread filter grouping
klopez4212 Jun 22, 2026
0ba27ec
Count local unread feed rows in badge
klopez4212 Jun 22, 2026
2b8684a
Polish inbox row spacing and actions
klopez4212 Jun 22, 2026
d2405d9
Count local unread inbox rows in dock badge
klopez4212 Jun 22, 2026
4a2984f
Harden onboarding banner e2e assertion
klopez4212 Jun 22, 2026
4c8e9ac
Filter muted thread activity from inbox
wesbillman Jun 22, 2026
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
86 changes: 75 additions & 11 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from "@/features/channels/hooks";
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
import { useMembershipNotifications } from "@/features/channels/useMembershipNotifications";
import { useFeedItemState } from "@/features/home/useFeedItemState";
import { getThreadReference } from "@/features/messages/lib/threading";
import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify";
import { useThreadFollows } from "@/features/messages/lib/useThreadFollows";
Expand Down Expand Up @@ -77,16 +78,24 @@ import { useIdentityQuery } from "@/shared/api/hooks";
import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
import { joinChannel } from "@/shared/api/tauri";
import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
import type {
Channel,
FeedItem,
RelayEvent,
SearchHit,
} from "@/shared/api/types";
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
import { MainInsetProvider } from "@/shared/layout/MainInsetContext";
import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks";
import { KIND_APPROVAL_REQUEST, KIND_REMINDER } from "@/shared/constants/kinds";
import { ConnectionBanner } from "@/shared/ui/ConnectionBanner";
import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";

const HOME_FEED_ACTION_KINDS = [KIND_APPROVAL_REQUEST, KIND_REMINDER] as const;

const LazySettingsScreen = React.lazy(async () => {
const module = await import("@/features/settings/ui/SettingsScreen");
return { default: module.SettingsScreen };
Expand Down Expand Up @@ -163,13 +172,53 @@ export function AppShell() {
const setUserStatusMutation = useSetUserStatusMutation(deferredPubkey);
const { feedProfilesQuery, homeFeedQuery, notificationSettings } =
useHomeFeedNotifications(identityQuery.data?.pubkey);
const feedItemState = useFeedItemState(identityQuery.data?.pubkey);
useReminderNotifications(
identityQuery.data?.pubkey,
notificationSettings.settings,
);
const refetchHomeFeedOnLiveMention = React.useEffectEvent(() => {
const refetchHomeFeedFromLiveSignal = React.useEffectEvent(() => {
void homeFeedQuery.refetch();
});
React.useEffect(() => {
const pubkey = identityQuery.data?.pubkey?.trim().toLowerCase() ?? "";
if (!pubkey) {
return;
}

let isCancelled = false;
let dispose: (() => Promise<void>) | null = null;

void relayClient
.subscribeLive(
{
kinds: [...HOME_FEED_ACTION_KINDS],
"#p": [pubkey],
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
limit: 50,
since: Math.floor(Date.now() / 1_000),
},
() => {
refetchHomeFeedFromLiveSignal();
},
)
.then((nextDispose) => {
if (isCancelled) {
void nextDispose().catch(() => {});
return;
}
dispose = nextDispose;
})
.catch((error) => {
console.error("Failed to subscribe to live home feed actions", error);
});

return () => {
isCancelled = true;
if (dispose) {
void dispose().catch(() => {});
}
};
}, [identityQuery.data?.pubkey]);
const handleChannelNotification = React.useEffectEvent(
(_channelId: string, _event: RelayEvent) => {
if (!notificationSettings.settings.desktopEnabled) return;
Expand Down Expand Up @@ -307,6 +356,7 @@ export function AppShell() {
markChannelUnread,
unreadChannelIds,
highPriorityUnreadChannelIds,
unreadChannelNotificationCount,
getEffectiveTimestamp: getChannelReadAt,
readStateVersion,
setContextParentResolver,
Expand All @@ -325,7 +375,7 @@ export function AppShell() {
notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing,
onChannelMessage: handleChannelNotification,
onDmMessage: handleDmNotification,
onLiveMention: refetchHomeFeedOnLiveMention,
onLiveMention: refetchHomeFeedFromLiveSignal,
onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification,
followedRootIds,
});
Expand All @@ -344,6 +394,22 @@ export function AppShell() {
},
[markChannelRead],
);
const threadActivityFeedItems = React.useMemo<FeedItem[]>(
() =>
threadActivityItems.map((item) => ({
id: item.id,
kind: item.kind,
pubkey: item.pubkey,
content: item.content,
createdAt: item.createdAt,
channelId: item.channelId,
channelName: item.channelName,
channelType: undefined,
tags: item.tags,
category: "activity" as const,
})),
[threadActivityItems],
);

// Badge count is computed here (rather than inside useHomeFeedNotifications)
// so it can consume the NIP-RS read-state lifted from the single
Expand All @@ -362,6 +428,9 @@ export function AppShell() {
highPriorityUnreadChannelIds,
feedProfilesQuery.data?.profiles,
mutedChannelIds,
feedItemState.unreadSet,
threadActivityFeedItems,
Comment thread
klopez4212 marked this conversation as resolved.
getThreadReadAt,
);

const isNotifiedForThread = React.useCallback(
Expand Down Expand Up @@ -542,19 +611,13 @@ export function AppShell() {

React.useEffect(() => {
const numericCount =
highPriorityUnreadChannelIds.size + homeBadgeCountExcludingHighPriority;
unreadChannelNotificationCount + homeBadgeCountExcludingHighPriority;
if (numericCount > 0) {
void setDesktopAppBadge({ kind: "count", count: numericCount });
} else if (unreadChannelIds.size > 0) {
void setDesktopAppBadge({ kind: "dot" });
} else {
void setDesktopAppBadge({ kind: "none" });
}
}, [
homeBadgeCountExcludingHighPriority,
highPriorityUnreadChannelIds.size,
unreadChannelIds.size,
]);
}, [homeBadgeCountExcludingHighPriority, unreadChannelNotificationCount]);

// Dispatch `buzz://message` deep links into the router.
useMessageDeepLinks();
Expand Down Expand Up @@ -715,6 +778,7 @@ export function AppShell() {
setTopbarSearchHidden,
setTopbarSearchLoading,
threadActivityItems,
feedItemState,
}}
>
<HuddleProvider>
Expand Down
12 changes: 12 additions & 0 deletions desktop/src/app/AppShellContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import * as React from "react";
import type { ContextParentResolver } from "@/features/channels/readState/readStateManager";
import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels";
import type { FeedItemState } from "@/features/home/useFeedItemState";

const EMPTY_SET = new Set<string>();

type AppShellContextValue = {
markAllChannelsRead: () => void;
Expand Down Expand Up @@ -34,6 +37,7 @@ type AppShellContextValue = {
setTopbarSearchHidden: (hidden: boolean) => void;
setTopbarSearchLoading: (loading: boolean) => void;
threadActivityItems: ThreadActivityItem[];
feedItemState: FeedItemState;
};

const AppShellContext = React.createContext<AppShellContextValue>({
Expand All @@ -54,6 +58,14 @@ const AppShellContext = React.createContext<AppShellContextValue>({
setTopbarSearchHidden: () => {},
setTopbarSearchLoading: () => {},
threadActivityItems: [],
feedItemState: {
doneSet: EMPTY_SET,
markDone: () => {},
markUnread: () => {},
undoDone: () => {},
undoUnread: () => {},
unreadSet: EMPTY_SET,
},
});

export function AppShellProvider({
Expand Down
13 changes: 6 additions & 7 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,9 @@ export function ChannelScreen({
// No `lastMessageAt` fallback: that timestamp is reply-inclusive (the backend
// takes MAX(created_at) over kind-9 events without a parent filter), so using
// it when the window has no top-level message would advance the channel
// marker past an unread reply and clear its thread/sidebar unread — the exact
// regression Fix A prevents. null suppresses the marker advance (markChannelRead
// early-returns on markAt === null) until a real top-level position is known.
// marker past an unread reply and clear its thread unread. null suppresses
// the marker advance (markChannelRead early-returns on markAt === null) until
// a real top-level position is known.
const activeReadAt = latestActiveMessage
? new Date(latestActiveMessage.created_at * 1_000).toISOString()
: null;
Expand All @@ -170,10 +170,9 @@ export function ChannelScreen({
return;
}
// Passive channel-open: advance the marker to the newest top-level message
// only (NIP-RS Option 1). `topLevelOnly` stops the read-state layer folding
// in observed thread replies, so opening a channel clears the timeline but
// leaves thread badges intact until each thread is opened — and leaves the
// channel's sidebar dot lit (the reply is still unread for the channel).
// only (NIP-RS Option 1). Opening a channel clears the main timeline while
// leaving thread badges and Home inbox thread activity intact until each
// thread itself is read.
markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true });
}, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]);
// Install the NIP-RS parent resolver: every `thread:<root>` context evaluated
Expand Down
118 changes: 19 additions & 99 deletions desktop/src/features/channels/unreadReadMarker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,8 @@ import assert from "node:assert/strict";
import test from "node:test";

import { computeChannelUnreadMarker } from "../messages/lib/unreadMarker.ts";
import {
buildChannelThreadRoots,
channelUnreadFrontier,
resolveChannelReadMarker,
} from "./useUnreadChannels.ts";
import { isThreadReply } from "../messages/lib/threading.ts";
import { resolveChannelReadMarker } from "./useUnreadChannels.ts";

function topLevel(id, createdAt) {
return { id, createdAt, author: "a", time: "", body: "", depth: 0 };
Expand Down Expand Up @@ -78,104 +75,27 @@ test("resolveChannelReadMarker_noCallerNoObserved_returnsNull", () => {
assert.equal(result.clearObserved, false);
});

// --- Fix 2: sidebar dot folds per-thread read markers into the channel frontier ---
// --- Sidebar dot routing: only main-channel activity belongs to the channel dot ---

function replyItem(channelId, rootId) {
return {
id: `${channelId}:${rootId}:${Math.random()}`,
channelId,
tags: [["e", rootId, "", "root"]],
};
}

// rootId for these fixtures is the "root"-marked e-tag.
const getRootId = (tags) =>
tags.find((t) => t[0] === "e" && t[3] === "root")?.[1] ?? null;

test("buildChannelThreadRoots_groupsRootsByChannel", () => {
const items = [
replyItem("chan-a", "root-1"),
replyItem("chan-a", "root-1"), // dedup within a channel
replyItem("chan-a", "root-2"),
replyItem("chan-b", "root-3"),
];
const map = buildChannelThreadRoots(items, getRootId);

assert.deepEqual([...(map.get("chan-a") ?? [])].sort(), ["root-1", "root-2"]);
assert.deepEqual([...(map.get("chan-b") ?? [])], ["root-3"]);
assert.equal(map.has("chan-c"), false);
});

test("buildChannelThreadRoots_skipsItemsWithNoRoot", () => {
const items = [{ id: "x", channelId: "chan-a", tags: [["p", "someone"]] }];
const map = buildChannelThreadRoots(items, getRootId);

assert.equal(map.size, 0);
});

test("channelUnreadFrontier_unopenedThreadReply_dotPersists", () => {
// Channel's only unread is a thread reply at t=500. The channel marker sits
// at the newest TOP-LEVEL message (t=300, Option-1) and the thread has never
// been opened (own marker null). Folded frontier stays at 300 < 500 → unread.
const channelMarker = 300;
const threadRoots = new Set(["root-1"]);
const getThreadOwnMarker = () => null; // never opened

const frontier = channelUnreadFrontier(
channelMarker,
threadRoots,
getThreadOwnMarker,
);

const latest = 500; // the thread reply timestamp
assert.equal(frontier, 300);
assert.equal(latest > frontier, true); // dot present (Will-accepted)
});

test("channelUnreadFrontier_openedThreadReply_dotClears", () => {
// Same channel, but the user opened the thread: markThreadRead advanced
// thread:root-1's OWN marker to 500 (the reply). Folded frontier rises to
// 500 ≥ latest → dot clears, even though the channel marker is still 300.
const channelMarker = 300;
const threadRoots = new Set(["root-1"]);
const getThreadOwnMarker = (rootId) => (rootId === "root-1" ? 500 : null);

const frontier = channelUnreadFrontier(
channelMarker,
threadRoots,
getThreadOwnMarker,
);

const latest = 500;
assert.equal(frontier, 500);
assert.equal(latest > frontier, false); // dot cleared
});

test("channelUnreadFrontier_noThreadRoots_usesChannelMarker", () => {
// No thread roots observed (or evicted) → channel marker governs unchanged.
test("isThreadReply detects normal threaded replies", () => {
assert.equal(
channelUnreadFrontier(300, undefined, () => null),
300,
);
assert.equal(
channelUnreadFrontier(null, undefined, () => null),
null,
isThreadReply([
["h", "chan-a"],
["e", "root-1", "", "root"],
["e", "parent-1", "", "reply"],
]),
true,
);
});

test("channelUnreadFrontier_nullChannelMarker_threadMarkerGoverns", () => {
// Channel never marked read but a thread was opened: the thread's own marker
// becomes the frontier rather than crashing on the null channel marker.
const frontier = channelUnreadFrontier(null, new Set(["root-1"]), () => 500);
assert.equal(frontier, 500);
});

test("channelUnreadFrontier_takesMaxAcrossMultipleThreads", () => {
// Two opened threads with different markers → the highest wins.
const frontier = channelUnreadFrontier(
100,
new Set(["root-1", "root-2"]),
(rootId) => (rootId === "root-1" ? 400 : 700),
test("isThreadReply treats top-level and broadcast replies as channel activity", () => {
assert.equal(isThreadReply([["h", "chan-a"]]), false);
assert.equal(
isThreadReply([
["h", "chan-a"],
["broadcast", "1"],
["e", "root-1", "", "reply"],
]),
false,
);
assert.equal(frontier, 700);
});
Loading