Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
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
2 changes: 2 additions & 0 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ const overrides = new Map([
["src-tauri/src/nostr_convert.rs", 1126],
["src/shared/api/relayClientSession.ts", 1022],
["src-tauri/src/migration.rs", 1295],
["src/app/AppShell.tsx", 1080],
["src/features/channels/useUnreadChannels.ts", 1660],
]);

await runFileSizeCheck({
Expand Down
149 changes: 136 additions & 13 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 @@ -65,6 +66,7 @@ import {
isSettingsSection,
} from "@/features/settings/ui/SettingsPanels";
import { HuddleBar, HuddleProvider } from "@/features/huddle";
import { remindersQueryKey } from "@/features/reminders/hooks";
import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider";
import { useReminderNotifications } from "@/features/reminders/useReminderNotifications";
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
Expand All @@ -77,16 +79,28 @@ 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_EVENT_REMINDER,
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 @@ -161,13 +175,85 @@ 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 disposers: Array<() => Promise<void>> = [];
const since = Math.floor(Date.now() / 1_000);
const handleLiveHomeFeedEvent = () => {
refetchHomeFeedFromLiveSignal();
Comment thread
klopez4212 marked this conversation as resolved.
};
const handleLiveReminderEvent = () => {
refetchHomeFeedFromLiveSignal();
void queryClient.invalidateQueries({
queryKey: remindersQueryKey(pubkey),
});
};

void Promise.allSettled([
relayClient.subscribeLive(
{
kinds: [...HOME_FEED_ACTION_KINDS],
"#p": [pubkey],
Comment thread
klopez4212 marked this conversation as resolved.
Comment thread
klopez4212 marked this conversation as resolved.
limit: 50,
since,
},
handleLiveHomeFeedEvent,
),
relayClient.subscribeLive(
{
authors: [pubkey],
kinds: [KIND_EVENT_REMINDER],
limit: 50,
since,
},
handleLiveReminderEvent,
),
]).then((results) => {
const nextDisposers = results.flatMap((result) =>
result.status === "fulfilled" ? [result.value] : [],
);
for (const result of results) {
if (result.status === "rejected") {
console.error(
"Failed to subscribe to live home feed actions",
result.reason,
);
}
}

if (nextDisposers.length === 0) {
return;
}

if (isCancelled) {
for (const dispose of nextDisposers) {
void dispose().catch(() => {});
}
return;
}
disposers = nextDisposers;
});

return () => {
isCancelled = true;
for (const dispose of disposers) {
void dispose().catch(() => {});
}
};
}, [identityQuery.data?.pubkey, queryClient]);
const handleChannelNotification = React.useEffectEvent(
(_channelId: string, _event: RelayEvent) => {
if (!notificationSettings.settings.desktopEnabled) return;
Expand Down Expand Up @@ -306,7 +392,9 @@ export function AppShell() {
unreadChannelIds,
unreadChannelCounts,
highPriorityUnreadChannelIds,
unreadChannelNotificationCount,
getEffectiveTimestamp: getChannelReadAt,
getOwnTimestamp: getOwnReadAt,
readStateVersion,
setContextParentResolver,
participatedRootIds,
Expand All @@ -324,14 +412,28 @@ export function AppShell() {
notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing,
onChannelMessage: handleChannelNotification,
onDmMessage: handleDmNotification,
onLiveMention: refetchHomeFeedOnLiveMention,
onLiveMention: refetchHomeFeedFromLiveSignal,
onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification,
followedRootIds,
});

const getThreadReadAt = React.useCallback(
(rootId: string) => getChannelReadAt(`thread:${rootId}`),
[getChannelReadAt],
(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(
Expand All @@ -343,6 +445,28 @@ export function AppShell() {
},
[markChannelRead],
);
const mutedRootIdsKey = [...mutedRootIds].sort().join("\0");
const threadActivityFeedItems = React.useMemo<FeedItem[]>(() => {
// mutedRootIds is a mutable Set; this key invalidates when contents change.
void mutedRootIdsKey;
return threadActivityItems
.filter((item) => {
const rootId = getThreadReference(item.tags).rootId;
return !rootId || !mutedRootIds.has(rootId);
})
.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,
}));
}, [mutedRootIds, mutedRootIdsKey, 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 @@ -361,6 +485,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 @@ -537,19 +664,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 @@ -707,7 +828,9 @@ export function AppShell() {
unfollowThread: handleUnfollowThread,
isFollowingThread,
isNotifiedForThread,
isThreadMuted: (rootId) => mutedRootIds.has(rootId),
threadActivityItems,
feedItemState,
}}
>
<HuddleProvider>
Expand Down
16 changes: 15 additions & 1 deletion 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 All @@ -18,7 +21,7 @@ type AppShellContextValue = {
getChannelReadAt: (channelId: string) => number | null;
// Thread read frontier as unix-seconds timestamp, or null when never read.
// Uses `thread:<rootId>` context keys in the same ReadStateManager.
getThreadReadAt: (rootId: string) => number | null;
getThreadReadAt: (rootId: string, channelId?: string | null) => number | null;
// Advance the thread read frontier to the given unix-seconds timestamp.
markThreadRead: (rootId: string, timestamp: number) => void;
// Bump-counter that invalidates whenever the read marker changes. Include
Expand All @@ -31,7 +34,9 @@ type AppShellContextValue = {
unfollowThread: (rootId: string) => void;
isFollowingThread: (rootId: string) => boolean;
isNotifiedForThread: (rootId: string) => boolean;
isThreadMuted: (rootId: string) => boolean;
threadActivityItems: ThreadActivityItem[];
feedItemState: FeedItemState;
};

const AppShellContext = React.createContext<AppShellContextValue>({
Expand All @@ -49,7 +54,16 @@ const AppShellContext = React.createContext<AppShellContextValue>({
unfollowThread: () => {},
isFollowingThread: () => false,
isNotifiedForThread: () => false,
isThreadMuted: () => false,
threadActivityItems: [],
feedItemState: {
doneSet: EMPTY_SET,
markDone: () => {},
markUnread: () => {},
undoDone: () => {},
undoUnread: () => {},
unreadSet: EMPTY_SET,
},
});

export function AppShellProvider({
Expand Down
7 changes: 2 additions & 5 deletions desktop/src/app/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,8 @@ function HomeRouteComponent() {
<HomeScreen
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
onOpenChannel={(channelId) => {
void goChannel(channelId);
}}
onOpenContext={(channelId, messageId) => {
void goChannel(channelId, { messageId });
onOpenContext={(channelId, messageId, threadRootId) => {
void goChannel(channelId, { messageId, threadRootId });
}}
/>
);
Expand Down
16 changes: 8 additions & 8 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export function ChannelScreen({
unfollowThread,
isFollowingThread,
isNotifiedForThread,
isThreadMuted,
readStateVersion,
} = useAppShell();
const {
Expand Down Expand Up @@ -189,9 +190,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 @@ -200,10 +201,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 Expand Up @@ -404,7 +404,7 @@ export function ChannelScreen({
getThreadReadAt,
markChannelUnread,
markThreadRead,
isNotifiedForThread,
isThreadMuted,
readStateVersion,
});
const editTargetMessage = React.useMemo(
Expand Down
Loading