Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
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
52 changes: 40 additions & 12 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import {
import { AppTopChrome } from "@/app/AppTopChrome";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions";
import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts";
import { useSettingsShortcuts } from "@/app/useSettingsShortcuts";
import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems";
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
import {
channelsQueryKey,
Expand All @@ -28,6 +30,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 @@ -161,13 +164,18 @@ 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();
});
useLiveHomeFeedActions(
identityQuery.data?.pubkey,
refetchHomeFeedFromLiveSignal,
);
const handleChannelNotification = React.useEffectEvent(
(_channelId: string, _event: RelayEvent) => {
if (!notificationSettings.settings.desktopEnabled) return;
Expand Down Expand Up @@ -306,7 +314,9 @@ export function AppShell() {
unreadChannelIds,
unreadChannelCounts,
highPriorityUnreadChannelIds,
unreadChannelNotificationCount,
getEffectiveTimestamp: getChannelReadAt,
getOwnTimestamp: getOwnReadAt,
readStateVersion,
setContextParentResolver,
participatedRootIds,
Expand All @@ -324,14 +334,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;
Comment thread
klopez4212 marked this conversation as resolved.
}

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 +367,11 @@ export function AppShell() {
},
[markChannelRead],
);
const threadActivityFeedItems = useThreadActivityFeedItems(
threadActivityItems,
mutedRootIds,
channels,
);

// 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 +390,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 +569,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 +733,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
93 changes: 80 additions & 13 deletions desktop/src/app/routes/ChannelRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { ChannelScreen } from "@/features/channels/ui/ChannelScreen";
import {
getThreadReference,
isBroadcastReply,
} from "@/features/messages/lib/threading";
import { useProfileQuery } from "@/features/profile/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { getEventById } from "@/shared/api/tauri";
Expand All @@ -18,6 +22,77 @@ type ChannelRouteScreenProps = {
targetThreadRootId: string | null;
};

const MAX_ROUTE_ANCESTOR_HOPS = 50;

async function fetchRouteEvent(eventId: string): Promise<RelayEvent | null> {
try {
return await getEventById(eventId);
} catch (error) {
console.error("Failed to load route event", eventId, error);
return null;
}
}

function getReplyParentId(event: RelayEvent): string | null {
if (isBroadcastReply(event.tags)) {
return null;
}

return getThreadReference(event.tags).parentId;
}

async function fetchRouteTargetEvents(
eventIds: string[],
targetMessageId: string | null,
targetThreadRootId: string | null,
): Promise<RelayEvent[]> {
const eventsById = new Map<string, RelayEvent>();
const addEvent = (event: RelayEvent | null) => {
if (event) {
eventsById.set(event.id, event);
}
};

const uniqueEventIds = [...new Set(eventIds)];
const initialEvents = await Promise.all(uniqueEventIds.map(fetchRouteEvent));
for (const event of initialEvents) {
addEvent(event);
}

const targetEvent = targetMessageId
? (eventsById.get(targetMessageId) ?? null)
: null;
if (!targetEvent) {
return [...eventsById.values()];
}

const targetThreadRef = getThreadReference(targetEvent.tags);
const threadRootId = targetThreadRootId ?? targetThreadRef.rootId ?? null;
if (threadRootId && !eventsById.has(threadRootId)) {
addEvent(await fetchRouteEvent(threadRootId));
}

let parentId = getReplyParentId(targetEvent);
let guard = 0;
while (
parentId &&
parentId !== threadRootId &&
guard < MAX_ROUTE_ANCESTOR_HOPS
) {
const parentEvent =
eventsById.get(parentId) ?? (await fetchRouteEvent(parentId));
if (!parentEvent) {
break;
}

eventsById.set(parentEvent.id, parentEvent);
parentId = getReplyParentId(parentEvent);
guard += 1;
}

return [...eventsById.values()];
}

export function ChannelRouteScreen({
channelId,
selectedPostId,
Expand Down Expand Up @@ -87,23 +162,15 @@ export function ChannelRouteScreen({
: null,
].filter((eventId): eventId is string => eventId !== null);

void Promise.all(
eventIds.map(async (eventId) => {
try {
return await getEventById(eventId);
} catch (error) {
console.error("Failed to load route event", eventId, error);
return null;
}
}),
void fetchRouteTargetEvents(
eventIds,
targetMessageId,
targetThreadRootId,
).then((events) => {
if (!isCancelled) {
setTargetMessageEvents((currentEvents) => {
const fetchedEvents = events.filter(
(event): event is RelayEvent => event !== null,
);
const eventsById = new Map<string, RelayEvent>();
for (const event of [...currentEvents, ...fetchedEvents]) {
for (const event of [...currentEvents, ...events]) {
eventsById.set(event.id, event);
}
return Array.from(eventsById.values());
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
Loading