Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 5 additions & 4 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ const overrides = new Map([
["src-tauri/src/nostr_convert.rs", 1126],
["src/shared/api/relayClientSession.ts", 1022],
["src-tauri/src/migration.rs", 1295],
// onMarkRead prop-pair completion (mirrors the onMarkUnread prop already
// threaded here) — a 1-line overage, not generic debt growth. Approved
// override; still queued to split with the rest of this list.
["src/features/messages/ui/MessageThreadPanel.tsx", 1002],
// onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop
// already here) for the single-toggle mark-read/unread menu item — a small
// overage from load-bearing per-message plumbing, not generic debt growth.
// Approved override; still queued to split with the rest of this list.
["src/features/messages/ui/MessageThreadPanel.tsx", 1006],
]);

await runFileSizeCheck({
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -186,6 +187,7 @@ export const ChannelPane = React.memo(function ChannelPane({
followThreadById,
isFollowingThread,
isFollowingThreadById,
isMessageUnreadById,
isJoining = false,
isSinglePanelView = false,
isSending,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ export function ChannelScreen({
getReplyDescendantIdsForMessage,
handleMarkMessageRead,
handleMarkMessageUnread,
isMessageUnread,
markRevealedRepliesRead,
openThreadHeadMessage,
threadFirstUnreadReplyId,
Expand Down Expand Up @@ -740,6 +741,7 @@ export function ChannelScreen({
followThreadById={followThread}
unfollowThreadById={unfollowThread}
isFollowingThreadById={isFollowingThread}
isMessageUnreadById={isMessageUnread}
isFollowingThread={isNotifiedForEffectiveThread}
isSending={sendMessageMutation.isPending}
isSinglePanelView={isSinglePanelView}
Expand Down
50 changes: 47 additions & 3 deletions desktop/src/features/channels/ui/useChannelUnreadState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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
Expand All @@ -315,6 +322,7 @@ export function useChannelUnreadState({
currentPubkey,
isMsgForcedUnread,
readStateVersion,
forcedUnreadVersion,
],
);
// Per-thread unread counts for the main-timeline summary rows. Unread is
Expand All @@ -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(
Expand All @@ -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,
],
);

Expand Down Expand Up @@ -427,6 +470,7 @@ export function useChannelUnreadState({
handleMarkMessageRead,
handleMarkMessageUnread,
handleMarkUnread,
isMessageUnread,
markRevealedRepliesRead,
openThreadHeadMessage,
threadFirstUnreadReplyId,
Expand Down
35 changes: 20 additions & 15 deletions desktop/src/features/messages/ui/MessageActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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
Expand Down Expand Up @@ -157,25 +159,23 @@ function MoreActionsMenu({
</DropdownMenuItem>
) : null}

{onMarkUnread ? (
{onMarkRead || onMarkUnread ? (
<DropdownMenuItem
data-testid={`mark-read-toggle-${message.id}`}
onClick={() => {
onMarkUnread(message);
}}
>
<MailOpen className="h-4 w-4" />
Mark unread
</DropdownMenuItem>
) : null}

{onMarkRead ? (
<DropdownMenuItem
onClick={() => {
onMarkRead(message);
if (isUnread) {
onMarkRead?.(message);
} else {
onMarkUnread?.(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}

Expand Down Expand Up @@ -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). */
Expand All @@ -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);
Expand Down Expand Up @@ -552,6 +556,7 @@ export function MessageActionBar({
onUnfollowThread={onUnfollowThread}
open={isDropdownOpen}
isFollowingThread={isFollowingThread}
isUnread={isUnread}
/>
) : null}
</div>
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/messages/ui/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const MessageRow = React.memo(
actionBarPlacement = "floating",
collapseDescendantsLabel,
isFollowingThread,
isUnread,
layoutVariant = "default",
message,
onCollapseDepthGuide,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -346,6 +348,7 @@ export const MessageRow = React.memo(
<MessageActionBar
channelId={channelId}
isFollowingThread={isFollowingThread}
isUnread={isUnread}
message={message}
onDelete={onDelete}
onEdit={onEdit}
Expand Down Expand Up @@ -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 ===
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ type MessageThreadPanelProps = {
toolbarExtraActions?: React.ReactNode;
widthPx: number;
isFollowingThread?: boolean;
isMessageUnreadById?: (messageId: string) => boolean;
onFollowThread?: () => void;
onUnfollowThread?: () => void;
};
Expand Down Expand Up @@ -348,6 +349,7 @@ export function MessageThreadPanel({
isSending,
isSinglePanelView = false,
isFollowingThread,
isMessageUnreadById,
onCancelEdit,
onCancelReply,
onClose,
Expand Down Expand Up @@ -644,6 +646,7 @@ export function MessageThreadPanel({
highlightedBranch?.id === threadHead.id
}
isFollowingThread={isFollowingThread}
isUnread={isMessageUnreadById?.(threadHead.id)}
layoutVariant="thread-reply"
message={threadHead}
onCollapseDescendants={
Expand Down Expand Up @@ -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}
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -146,6 +147,7 @@ const MessageTimelineBase = React.forwardRef<
isFetchingOlder = false,
followThreadById,
isFollowingThreadById,
isMessageUnreadById,
messageFooters,
personaLookup,
profiles,
Expand Down Expand Up @@ -535,6 +537,7 @@ const MessageTimelineBase = React.forwardRef<
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
isMessageUnreadById={isMessageUnreadById}
messageFooters={messageFooters}
messages={deferredMessages}
onDelete={onDelete}
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/features/messages/ui/TimelineMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -188,6 +189,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
followThreadById,
highlightedMessageId = null,
isFollowingThreadById,
isMessageUnreadById,
messageFooters,
messages,
onDelete,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -279,6 +282,7 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
followThreadById,
highlightedMessageId = null,
isFollowingThreadById,
isMessageUnreadById,
isSendingVideoReviewComment = false,
messageFooters,
onDelete,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading