Skip to content

Commit ce994df

Browse files
wpfleger96npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
andauthored
fix(desktop): collapse mark-read/unread menu into one toggling item (#1188)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
1 parent 28db41d commit ce994df

10 files changed

Lines changed: 159 additions & 22 deletions

desktop/scripts/check-file-sizes.mjs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,11 @@ const overrides = new Map([
3939
["src-tauri/src/nostr_convert.rs", 1126],
4040
["src/shared/api/relayClientSession.ts", 1022],
4141
["src-tauri/src/migration.rs", 1295],
42-
// onMarkRead prop-pair completion (mirrors the onMarkUnread prop already
43-
// threaded here) — a 1-line overage, not generic debt growth. Approved
44-
// override; still queued to split with the rest of this list.
45-
["src/features/messages/ui/MessageThreadPanel.tsx", 1002],
42+
// onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop
43+
// already here) for the single-toggle mark-read/unread menu item — a small
44+
// overage from load-bearing per-message plumbing, not generic debt growth.
45+
// Approved override; still queued to split with the rest of this list.
46+
["src/features/messages/ui/MessageThreadPanel.tsx", 1006],
4647
]);
4748

4849
await runFileSizeCheck({

desktop/src/features/channels/ui/ChannelPane.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ type ChannelPaneProps = {
168168
followThreadById?: (rootId: string) => void;
169169
unfollowThreadById?: (rootId: string) => void;
170170
isFollowingThreadById?: (rootId: string) => boolean;
171+
isMessageUnreadById?: (messageId: string) => boolean;
171172
};
172173

173174
export const ChannelPane = React.memo(function ChannelPane({
@@ -186,6 +187,7 @@ export const ChannelPane = React.memo(function ChannelPane({
186187
followThreadById,
187188
isFollowingThread,
188189
isFollowingThreadById,
190+
isMessageUnreadById,
189191
isJoining = false,
190192
isSinglePanelView = false,
191193
isSending,
@@ -658,6 +660,7 @@ export const ChannelPane = React.memo(function ChannelPane({
658660
hasOlderMessages={hasOlderMessages}
659661
isFetchingOlder={isFetchingOlder}
660662
isFollowingThreadById={isFollowingThreadById}
663+
isMessageUnreadById={isMessageUnreadById}
661664
personaLookup={personaLookup}
662665
profiles={profiles}
663666
unfollowThreadById={unfollowThreadById}
@@ -807,6 +810,7 @@ export const ChannelPane = React.memo(function ChannelPane({
807810
editTarget={threadEditTarget}
808811
firstUnreadReplyId={threadFirstUnreadReplyId}
809812
isFollowingThread={isFollowingThread}
813+
isMessageUnreadById={isMessageUnreadById}
810814
isSending={isSending}
811815
isSinglePanelView={
812816
useSplitAuxiliaryPane ? false : isSinglePanelView

desktop/src/features/channels/ui/ChannelScreen.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ export function ChannelScreen({
394394
getReplyDescendantIdsForMessage,
395395
handleMarkMessageRead,
396396
handleMarkMessageUnread,
397+
isMessageUnread,
397398
markRevealedRepliesRead,
398399
openThreadHeadMessage,
399400
threadFirstUnreadReplyId,
@@ -740,6 +741,7 @@ export function ChannelScreen({
740741
followThreadById={followThread}
741742
unfollowThreadById={unfollowThread}
742743
isFollowingThreadById={isFollowingThread}
744+
isMessageUnreadById={isMessageUnread}
743745
isFollowingThread={isNotifiedForEffectiveThread}
744746
isSending={sendMessageMutation.isPending}
745747
isSinglePanelView={isSinglePanelView}

desktop/src/features/channels/ui/useChannelUnreadState.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,10 @@ export function useChannelUnreadState({
8686
// the marker for such channels to avoid that visible contradiction. The flag
8787
// is cleared on re-open (a fresh snapshot is recomputed for the channel).
8888
const forcedUnreadRef = React.useRef(new Set<string>());
89-
const [, forceUnreadRender] = React.useReducer((n: number) => n + 1, 0);
89+
const [forcedUnreadVersion, forceUnreadRender] = React.useReducer(
90+
(n: number) => n + 1,
91+
0,
92+
);
9093
// Per-message analog of forcedUnreadRef (LP4 v3 mark-unread). A monotonic
9194
// grow-only msg:<id> marker cannot move the read-line backward, so a
9295
// deliberate mark-unread lives in this session-local set, read ONLY as an
@@ -147,6 +150,10 @@ export function useChannelUnreadState({
147150
() => buildCreatedAtByMessageId(timelineMessages),
148151
[timelineMessages],
149152
);
153+
const messageById = React.useMemo(
154+
() => new Map(timelineMessages.map((message) => [message.id, message])),
155+
[timelineMessages],
156+
);
150157
const threadPanelIndex = React.useMemo(
151158
() => buildThreadPanelIndex(timelineMessages),
152159
[timelineMessages],
@@ -291,7 +298,7 @@ export function useChannelUnreadState({
291298
// unread descendant with no separate expanded-subtree gate. readStateVersion
292299
// is an intentional recompute trigger so the counts re-read after any marker
293300
// advances.
294-
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is the intentional recompute trigger
301+
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and forcedUnreadVersion are intentional recompute triggers
295302
const threadReplyUnreadCounts = React.useMemo(
296303
() =>
297304
openThreadHeadId
@@ -315,6 +322,7 @@ export function useChannelUnreadState({
315322
currentPubkey,
316323
isMsgForcedUnread,
317324
readStateVersion,
325+
forcedUnreadVersion,
318326
],
319327
);
320328
// Per-thread unread counts for the main-timeline summary rows. Unread is
@@ -323,7 +331,7 @@ export function useChannelUnreadState({
323331
// the parent resolver, so reading an ancestor never clears a descendant
324332
// (LP4 Issue 2 by construction). readStateVersion is an intentional recompute
325333
// trigger so the badge re-reads after any marker advances.
326-
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is the intentional recompute trigger
334+
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and forcedUnreadVersion are intentional recompute triggers
327335
const threadUnreadCounts = React.useMemo(
328336
() =>
329337
computeThreadBadgeCounts(
@@ -342,6 +350,41 @@ export function useChannelUnreadState({
342350
isThreadMuted,
343351
isMsgForcedUnread,
344352
readStateVersion,
353+
forcedUnreadVersion,
354+
],
355+
);
356+
357+
// Per-message unread predicate for the mark-read/unread menu toggle. Reuses
358+
// computeThreadUnreadMarker — the exact function the badge counts call
359+
// (computeThreadBadgeCounts) — over a single-message array, so the menu label
360+
// and the badge can never disagree: one source of truth, no re-derived
361+
// predicate to drift. A message absent from the timeline (never loaded) is
362+
// treated as read, matching the badge, which only tallies loaded messages.
363+
// readStateVersion recomputes on marker advances; forcedUnreadVersion bumps
364+
// on every mark-read/unread so the callback identity changes and the value
365+
// re-flows through the memoized message subtree (forcedUnreadMsgRef is a ref,
366+
// invisible to React on its own). Both keep the menu label and the badge —
367+
// which read the same computeThreadUnreadMarker predicate — from drifting.
368+
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and forcedUnreadVersion are intentional recompute triggers
369+
const isMessageUnread = React.useCallback(
370+
(messageId: string): boolean => {
371+
const message = messageById.get(messageId);
372+
if (!message) return false;
373+
const { firstUnreadReplyId } = computeThreadUnreadMarker(
374+
[message],
375+
getMessageReadAt,
376+
currentPubkey,
377+
isMsgForcedUnread,
378+
);
379+
return firstUnreadReplyId !== null;
380+
},
381+
[
382+
messageById,
383+
getMessageReadAt,
384+
currentPubkey,
385+
isMsgForcedUnread,
386+
readStateVersion,
387+
forcedUnreadVersion,
345388
],
346389
);
347390

@@ -427,6 +470,7 @@ export function useChannelUnreadState({
427470
handleMarkMessageRead,
428471
handleMarkMessageUnread,
429472
handleMarkUnread,
473+
isMessageUnread,
430474
markRevealedRepliesRead,
431475
openThreadHeadMessage,
432476
threadFirstUnreadReplyId,

desktop/src/features/messages/ui/MessageActionBar.tsx

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ function MoreActionsMenu({
8484
onUnfollowThread,
8585
open,
8686
isFollowingThread,
87+
isUnread,
8788
}: {
8889
/** Channel UUID for the "Copy link" action. When null/undefined, the
8990
* Copy link entry is hidden (e.g. inbox preview rows that don't have it). */
@@ -99,6 +100,7 @@ function MoreActionsMenu({
99100
onUnfollowThread?: (message: TimelineMessage) => void;
100101
open: boolean;
101102
isFollowingThread?: boolean;
103+
isUnread?: boolean;
102104
}) {
103105
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
104106
// Set true the moment the user picks "Edit message". The
@@ -157,25 +159,23 @@ function MoreActionsMenu({
157159
</DropdownMenuItem>
158160
) : null}
159161

160-
{onMarkUnread ? (
162+
{onMarkRead || onMarkUnread ? (
161163
<DropdownMenuItem
164+
data-testid={`mark-read-toggle-${message.id}`}
162165
onClick={() => {
163-
onMarkUnread(message);
164-
}}
165-
>
166-
<MailOpen className="h-4 w-4" />
167-
Mark unread
168-
</DropdownMenuItem>
169-
) : null}
170-
171-
{onMarkRead ? (
172-
<DropdownMenuItem
173-
onClick={() => {
174-
onMarkRead(message);
166+
if (isUnread) {
167+
onMarkRead?.(message);
168+
} else {
169+
onMarkUnread?.(message);
170+
}
175171
}}
176172
>
177-
<MailCheck className="h-4 w-4" />
178-
Mark read
173+
{isUnread ? (
174+
<MailCheck className="h-4 w-4" />
175+
) : (
176+
<MailOpen className="h-4 w-4" />
177+
)}
178+
{isUnread ? "Mark read" : "Mark unread"}
179179
</DropdownMenuItem>
180180
) : null}
181181

@@ -356,6 +356,7 @@ export function MessageActionBar({
356356
reactionErrorMessage = null,
357357
reactions,
358358
isFollowingThread,
359+
isUnread,
359360
}: {
360361
/** Channel UUID — required for the "Copy link" action; when omitted the
361362
* action is hidden (callers like the home inbox that lack the context). */
@@ -374,6 +375,9 @@ export function MessageActionBar({
374375
reactionErrorMessage?: string | null;
375376
reactions: TimelineReaction[];
376377
isFollowingThread?: boolean;
378+
/** Current read state of the clicked message, from the same predicate the
379+
* unread badge uses. Drives the single mark-read/unread toggle label. */
380+
isUnread?: boolean;
377381
}) {
378382
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
379383
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
@@ -552,6 +556,7 @@ export function MessageActionBar({
552556
onUnfollowThread={onUnfollowThread}
553557
open={isDropdownOpen}
554558
isFollowingThread={isFollowingThread}
559+
isUnread={isUnread}
555560
/>
556561
) : null}
557562
</div>

desktop/src/features/messages/ui/MessageRow.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const MessageRow = React.memo(
5656
actionBarPlacement = "floating",
5757
collapseDescendantsLabel,
5858
isFollowingThread,
59+
isUnread,
5960
layoutVariant = "default",
6061
message,
6162
onCollapseDepthGuide,
@@ -89,6 +90,7 @@ export const MessageRow = React.memo(
8990
actionBarPlacement?: "floating" | "inside";
9091
collapseDescendantsLabel?: string;
9192
isFollowingThread?: boolean;
93+
isUnread?: boolean;
9294
layoutVariant?: "default" | "thread-reply";
9395
message: TimelineMessage;
9496
onCollapseDepthGuide?: (message: TimelineMessage) => void;
@@ -346,6 +348,7 @@ export const MessageRow = React.memo(
346348
<MessageActionBar
347349
channelId={channelId}
348350
isFollowingThread={isFollowingThread}
351+
isUnread={isUnread}
349352
message={message}
350353
onDelete={onDelete}
351354
onEdit={onEdit}
@@ -739,6 +742,7 @@ export const MessageRow = React.memo(
739742
prev.highlightThreadLineDepths === next.highlightThreadLineDepths &&
740743
prev.hoverBackground === next.hoverBackground &&
741744
prev.isFollowingThread === next.isFollowingThread &&
745+
prev.isUnread === next.isUnread &&
742746
prev.layoutVariant === next.layoutVariant &&
743747
prev.onCollapseDepthGuide === next.onCollapseDepthGuide &&
744748
prev.onCollapseDepthGuideHoverChange ===

desktop/src/features/messages/ui/MessageThreadPanel.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ type MessageThreadPanelProps = {
8989
toolbarExtraActions?: React.ReactNode;
9090
widthPx: number;
9191
isFollowingThread?: boolean;
92+
isMessageUnreadById?: (messageId: string) => boolean;
9293
onFollowThread?: () => void;
9394
onUnfollowThread?: () => void;
9495
};
@@ -348,6 +349,7 @@ export function MessageThreadPanel({
348349
isSending,
349350
isSinglePanelView = false,
350351
isFollowingThread,
352+
isMessageUnreadById,
351353
onCancelEdit,
352354
onCancelReply,
353355
onClose,
@@ -644,6 +646,7 @@ export function MessageThreadPanel({
644646
highlightedBranch?.id === threadHead.id
645647
}
646648
isFollowingThread={isFollowingThread}
649+
isUnread={isMessageUnreadById?.(threadHead.id)}
647650
layoutVariant="thread-reply"
648651
message={threadHead}
649652
onCollapseDescendants={
@@ -764,6 +767,7 @@ export function MessageThreadPanel({
764767
}
765768
highlightThreadLineDepths={highlightedLineDepths}
766769
hoverBackground={!entry.summary}
770+
isUnread={isMessageUnreadById?.(entry.message.id)}
767771
layoutVariant="thread-reply"
768772
message={entry.message}
769773
onCollapseDepthGuide={handleCollapseDepthGuide}

desktop/src/features/messages/ui/MessageTimeline.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ type MessageTimelineProps = {
5454
profiles?: UserProfileLookup;
5555
followThreadById?: (rootId: string) => void;
5656
isFollowingThreadById?: (rootId: string) => boolean;
57+
isMessageUnreadById?: (messageId: string) => boolean;
5758
onDelete?: (message: TimelineMessage) => void;
5859
onEdit?: (message: TimelineMessage) => void;
5960
onMarkUnread?: (message: TimelineMessage) => void;
@@ -146,6 +147,7 @@ const MessageTimelineBase = React.forwardRef<
146147
isFetchingOlder = false,
147148
followThreadById,
148149
isFollowingThreadById,
150+
isMessageUnreadById,
149151
messageFooters,
150152
personaLookup,
151153
profiles,
@@ -535,6 +537,7 @@ const MessageTimelineBase = React.forwardRef<
535537
followThreadById={followThreadById}
536538
highlightedMessageId={highlightedMessageId}
537539
isFollowingThreadById={isFollowingThreadById}
540+
isMessageUnreadById={isMessageUnreadById}
538541
messageFooters={messageFooters}
539542
messages={deferredMessages}
540543
onDelete={onDelete}

desktop/src/features/messages/ui/TimelineMessageList.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type TimelineMessageListProps = {
3636
followThreadById?: (rootId: string) => void;
3737
highlightedMessageId?: string | null;
3838
isFollowingThreadById?: (rootId: string) => boolean;
39+
isMessageUnreadById?: (messageId: string) => boolean;
3940
messageFooters?: Record<string, React.ReactNode>;
4041
messages: TimelineMessage[];
4142
onDelete?: (message: TimelineMessage) => void;
@@ -188,6 +189,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
188189
followThreadById,
189190
highlightedMessageId = null,
190191
isFollowingThreadById,
192+
isMessageUnreadById,
191193
messageFooters,
192194
messages,
193195
onDelete,
@@ -236,6 +238,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
236238
followThreadById={followThreadById}
237239
highlightedMessageId={highlightedMessageId}
238240
isFollowingThreadById={isFollowingThreadById}
241+
isMessageUnreadById={isMessageUnreadById}
239242
isSendingVideoReviewComment={isSendingVideoReviewComment}
240243
key={row.key}
241244
messageFooters={messageFooters}
@@ -279,6 +282,7 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
279282
followThreadById,
280283
highlightedMessageId = null,
281284
isFollowingThreadById,
285+
isMessageUnreadById,
282286
isSendingVideoReviewComment = false,
283287
messageFooters,
284288
onDelete,
@@ -374,6 +378,7 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
374378
? isFollowingThreadById(message.id)
375379
: undefined
376380
}
381+
isUnread={isMessageUnreadById?.(message.id)}
377382
message={message}
378383
onDelete={
379384
onDelete && currentPubkey && message.pubkey === currentPubkey
@@ -424,6 +429,7 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
424429
agentPubkeys={agentPubkeys}
425430
channelId={channelId}
426431
highlighted={message.id === highlightedMessageId || isSearchActive}
432+
isUnread={isMessageUnreadById?.(message.id)}
427433
message={message}
428434
onDelete={
429435
onDelete && currentPubkey && message.pubkey === currentPubkey

0 commit comments

Comments
 (0)