mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
28db41dfd3
commit
ce994df74e
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user