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>
This commit is contained in:
Will Pfleger
2026-06-22 23:24:58 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 28db41dfd3
commit ce994df74e
10 changed files with 159 additions and 22 deletions
@@ -168,6 +168,7 @@ type ChannelPaneProps = {
followThreadById?: (rootId: string) => void;
unfollowThreadById?: (rootId: string) => void;
isFollowingThreadById?: (rootId: string) => boolean;
isMessageUnreadById?: (messageId: string) => boolean;
};
export const ChannelPane = React.memo(function ChannelPane({
@@ -186,6 +187,7 @@ export const ChannelPane = React.memo(function ChannelPane({
followThreadById,
isFollowingThread,
isFollowingThreadById,
isMessageUnreadById,
isJoining = false,
isSinglePanelView = false,
isSending,
@@ -658,6 +660,7 @@ export const ChannelPane = React.memo(function ChannelPane({
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
isFollowingThreadById={isFollowingThreadById}
isMessageUnreadById={isMessageUnreadById}
personaLookup={personaLookup}
profiles={profiles}
unfollowThreadById={unfollowThreadById}
@@ -807,6 +810,7 @@ export const ChannelPane = React.memo(function ChannelPane({
editTarget={threadEditTarget}
firstUnreadReplyId={threadFirstUnreadReplyId}
isFollowingThread={isFollowingThread}
isMessageUnreadById={isMessageUnreadById}
isSending={isSending}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
@@ -394,6 +394,7 @@ export function ChannelScreen({
getReplyDescendantIdsForMessage,
handleMarkMessageRead,
handleMarkMessageUnread,
isMessageUnread,
markRevealedRepliesRead,
openThreadHeadMessage,
threadFirstUnreadReplyId,
@@ -740,6 +741,7 @@ export function ChannelScreen({
followThreadById={followThread}
unfollowThreadById={unfollowThread}
isFollowingThreadById={isFollowingThread}
isMessageUnreadById={isMessageUnread}
isFollowingThread={isNotifiedForEffectiveThread}
isSending={sendMessageMutation.isPending}
isSinglePanelView={isSinglePanelView}
@@ -86,7 +86,10 @@ export function useChannelUnreadState({
// the marker for such channels to avoid that visible contradiction. The flag
// is cleared on re-open (a fresh snapshot is recomputed for the channel).
const forcedUnreadRef = React.useRef(new Set<string>());
const [, forceUnreadRender] = React.useReducer((n: number) => n + 1, 0);
const [forcedUnreadVersion, forceUnreadRender] = React.useReducer(
(n: number) => n + 1,
0,
);
// Per-message analog of forcedUnreadRef (LP4 v3 mark-unread). A monotonic
// grow-only msg:<id> marker cannot move the read-line backward, so a
// deliberate mark-unread lives in this session-local set, read ONLY as an
@@ -147,6 +150,10 @@ export function useChannelUnreadState({
() => buildCreatedAtByMessageId(timelineMessages),
[timelineMessages],
);
const messageById = React.useMemo(
() => new Map(timelineMessages.map((message) => [message.id, message])),
[timelineMessages],
);
const threadPanelIndex = React.useMemo(
() => buildThreadPanelIndex(timelineMessages),
[timelineMessages],
@@ -291,7 +298,7 @@ export function useChannelUnreadState({
// unread descendant with no separate expanded-subtree gate. readStateVersion
// is an intentional recompute trigger so the counts re-read after any marker
// advances.
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is the intentional recompute trigger
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and forcedUnreadVersion are intentional recompute triggers
const threadReplyUnreadCounts = React.useMemo(
() =>
openThreadHeadId
@@ -315,6 +322,7 @@ export function useChannelUnreadState({
currentPubkey,
isMsgForcedUnread,
readStateVersion,
forcedUnreadVersion,
],
);
// Per-thread unread counts for the main-timeline summary rows. Unread is
@@ -323,7 +331,7 @@ export function useChannelUnreadState({
// the parent resolver, so reading an ancestor never clears a descendant
// (LP4 Issue 2 by construction). readStateVersion is an intentional recompute
// trigger so the badge re-reads after any marker advances.
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is the intentional recompute trigger
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and forcedUnreadVersion are intentional recompute triggers
const threadUnreadCounts = React.useMemo(
() =>
computeThreadBadgeCounts(
@@ -342,6 +350,41 @@ export function useChannelUnreadState({
isThreadMuted,
isMsgForcedUnread,
readStateVersion,
forcedUnreadVersion,
],
);
// Per-message unread predicate for the mark-read/unread menu toggle. Reuses
// computeThreadUnreadMarker — the exact function the badge counts call
// (computeThreadBadgeCounts) — over a single-message array, so the menu label
// and the badge can never disagree: one source of truth, no re-derived
// predicate to drift. A message absent from the timeline (never loaded) is
// treated as read, matching the badge, which only tallies loaded messages.
// readStateVersion recomputes on marker advances; forcedUnreadVersion bumps
// on every mark-read/unread so the callback identity changes and the value
// re-flows through the memoized message subtree (forcedUnreadMsgRef is a ref,
// invisible to React on its own). Both keep the menu label and the badge —
// which read the same computeThreadUnreadMarker predicate — from drifting.
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and forcedUnreadVersion are intentional recompute triggers
const isMessageUnread = React.useCallback(
(messageId: string): boolean => {
const message = messageById.get(messageId);
if (!message) return false;
const { firstUnreadReplyId } = computeThreadUnreadMarker(
[message],
getMessageReadAt,
currentPubkey,
isMsgForcedUnread,
);
return firstUnreadReplyId !== null;
},
[
messageById,
getMessageReadAt,
currentPubkey,
isMsgForcedUnread,
readStateVersion,
forcedUnreadVersion,
],
);
@@ -427,6 +470,7 @@ export function useChannelUnreadState({
handleMarkMessageRead,
handleMarkMessageUnread,
handleMarkUnread,
isMessageUnread,
markRevealedRepliesRead,
openThreadHeadMessage,
threadFirstUnreadReplyId,
@@ -84,6 +84,7 @@ function MoreActionsMenu({
onUnfollowThread,
open,
isFollowingThread,
isUnread,
}: {
/** Channel UUID for the "Copy link" action. When null/undefined, the
* Copy link entry is hidden (e.g. inbox preview rows that don't have it). */
@@ -99,6 +100,7 @@ function MoreActionsMenu({
onUnfollowThread?: (message: TimelineMessage) => void;
open: boolean;
isFollowingThread?: boolean;
isUnread?: boolean;
}) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
// Set true the moment the user picks "Edit message". The
@@ -157,25 +159,23 @@ function MoreActionsMenu({
</DropdownMenuItem>
) : null}
{onMarkUnread ? (
{onMarkRead || onMarkUnread ? (
<DropdownMenuItem
data-testid={`mark-read-toggle-${message.id}`}
onClick={() => {
onMarkUnread(message);
if (isUnread) {
onMarkRead?.(message);
} else {
onMarkUnread?.(message);
}
}}
>
<MailOpen className="h-4 w-4" />
Mark unread
</DropdownMenuItem>
) : null}
{onMarkRead ? (
<DropdownMenuItem
onClick={() => {
onMarkRead(message);
}}
>
<MailCheck className="h-4 w-4" />
Mark read
{isUnread ? (
<MailCheck className="h-4 w-4" />
) : (
<MailOpen className="h-4 w-4" />
)}
{isUnread ? "Mark read" : "Mark unread"}
</DropdownMenuItem>
) : null}
@@ -356,6 +356,7 @@ export function MessageActionBar({
reactionErrorMessage = null,
reactions,
isFollowingThread,
isUnread,
}: {
/** Channel UUID — required for the "Copy link" action; when omitted the
* action is hidden (callers like the home inbox that lack the context). */
@@ -374,6 +375,9 @@ export function MessageActionBar({
reactionErrorMessage?: string | null;
reactions: TimelineReaction[];
isFollowingThread?: boolean;
/** Current read state of the clicked message, from the same predicate the
* unread badge uses. Drives the single mark-read/unread toggle label. */
isUnread?: boolean;
}) {
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
@@ -552,6 +556,7 @@ export function MessageActionBar({
onUnfollowThread={onUnfollowThread}
open={isDropdownOpen}
isFollowingThread={isFollowingThread}
isUnread={isUnread}
/>
) : null}
</div>
@@ -56,6 +56,7 @@ export const MessageRow = React.memo(
actionBarPlacement = "floating",
collapseDescendantsLabel,
isFollowingThread,
isUnread,
layoutVariant = "default",
message,
onCollapseDepthGuide,
@@ -89,6 +90,7 @@ export const MessageRow = React.memo(
actionBarPlacement?: "floating" | "inside";
collapseDescendantsLabel?: string;
isFollowingThread?: boolean;
isUnread?: boolean;
layoutVariant?: "default" | "thread-reply";
message: TimelineMessage;
onCollapseDepthGuide?: (message: TimelineMessage) => void;
@@ -346,6 +348,7 @@ export const MessageRow = React.memo(
<MessageActionBar
channelId={channelId}
isFollowingThread={isFollowingThread}
isUnread={isUnread}
message={message}
onDelete={onDelete}
onEdit={onEdit}
@@ -739,6 +742,7 @@ export const MessageRow = React.memo(
prev.highlightThreadLineDepths === next.highlightThreadLineDepths &&
prev.hoverBackground === next.hoverBackground &&
prev.isFollowingThread === next.isFollowingThread &&
prev.isUnread === next.isUnread &&
prev.layoutVariant === next.layoutVariant &&
prev.onCollapseDepthGuide === next.onCollapseDepthGuide &&
prev.onCollapseDepthGuideHoverChange ===
@@ -89,6 +89,7 @@ type MessageThreadPanelProps = {
toolbarExtraActions?: React.ReactNode;
widthPx: number;
isFollowingThread?: boolean;
isMessageUnreadById?: (messageId: string) => boolean;
onFollowThread?: () => void;
onUnfollowThread?: () => void;
};
@@ -348,6 +349,7 @@ export function MessageThreadPanel({
isSending,
isSinglePanelView = false,
isFollowingThread,
isMessageUnreadById,
onCancelEdit,
onCancelReply,
onClose,
@@ -644,6 +646,7 @@ export function MessageThreadPanel({
highlightedBranch?.id === threadHead.id
}
isFollowingThread={isFollowingThread}
isUnread={isMessageUnreadById?.(threadHead.id)}
layoutVariant="thread-reply"
message={threadHead}
onCollapseDescendants={
@@ -764,6 +767,7 @@ export function MessageThreadPanel({
}
highlightThreadLineDepths={highlightedLineDepths}
hoverBackground={!entry.summary}
isUnread={isMessageUnreadById?.(entry.message.id)}
layoutVariant="thread-reply"
message={entry.message}
onCollapseDepthGuide={handleCollapseDepthGuide}
@@ -54,6 +54,7 @@ type MessageTimelineProps = {
profiles?: UserProfileLookup;
followThreadById?: (rootId: string) => void;
isFollowingThreadById?: (rootId: string) => boolean;
isMessageUnreadById?: (messageId: string) => boolean;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onMarkUnread?: (message: TimelineMessage) => void;
@@ -146,6 +147,7 @@ const MessageTimelineBase = React.forwardRef<
isFetchingOlder = false,
followThreadById,
isFollowingThreadById,
isMessageUnreadById,
messageFooters,
personaLookup,
profiles,
@@ -535,6 +537,7 @@ const MessageTimelineBase = React.forwardRef<
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
isMessageUnreadById={isMessageUnreadById}
messageFooters={messageFooters}
messages={deferredMessages}
onDelete={onDelete}
@@ -36,6 +36,7 @@ type TimelineMessageListProps = {
followThreadById?: (rootId: string) => void;
highlightedMessageId?: string | null;
isFollowingThreadById?: (rootId: string) => boolean;
isMessageUnreadById?: (messageId: string) => boolean;
messageFooters?: Record<string, React.ReactNode>;
messages: TimelineMessage[];
onDelete?: (message: TimelineMessage) => void;
@@ -188,6 +189,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
followThreadById,
highlightedMessageId = null,
isFollowingThreadById,
isMessageUnreadById,
messageFooters,
messages,
onDelete,
@@ -236,6 +238,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
isMessageUnreadById={isMessageUnreadById}
isSendingVideoReviewComment={isSendingVideoReviewComment}
key={row.key}
messageFooters={messageFooters}
@@ -279,6 +282,7 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
followThreadById,
highlightedMessageId = null,
isFollowingThreadById,
isMessageUnreadById,
isSendingVideoReviewComment = false,
messageFooters,
onDelete,
@@ -374,6 +378,7 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
? isFollowingThreadById(message.id)
: undefined
}
isUnread={isMessageUnreadById?.(message.id)}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
@@ -424,6 +429,7 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={message.id === highlightedMessageId || isSearchActive}
isUnread={isMessageUnreadById?.(message.id)}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey