Files
buzz/desktop/src/shared/ui/markdownUtils.ts
thomaspblock 9b32e055fe polish(desktop): finish Projects navigation and context chrome (#6429)
## Summary

After #6396, Projects still split chrome across the workspace header, a
copy-link control, and a labeled Actions group that mixed people,
create, and metadata. This PR finishes that surface: the right-hand
context box is unlabeled actions plus a Details group, people stacks and
contribution heatmaps are gone from that box, Create review sits with
Create task, and the top chrome is terminal / chat / info with no
copy-link. Sent project context collapses to a pill, and review file
diffs keep the last good git view instead of flashing empty while
queries refetch.

This also lands the remaining navigation polish that followed Part 3:
overview and list presentation, readme and commit layout, and opening
the latest matching conversation from the Channels tab without leaving
the project.

### Related issue
N/A. Related: #6396

## Testing
- Walked Files, Tasks, Reviews, task/review detail, overview tabs, and
chrome chat vs info in the running desktop app
- Pre-push: desktop typecheck, unit tests, Tauri checks, and file-size
gate passed
- Updated Projects smoke specs for the new context groups, Create
review, chrome order, and removed copy-link control
- Merged current `origin/main`; one conflict in discussion-channel rows
kept conversation-panel navigation and took main's bounded channel-name
lookup

## Post-Deploy Monitoring & Validation
- validate Projects workspace chrome, context box, and review file diffs
in the first staging Desktop session
- healthy signals: context box shows unlabeled actions then Details,
chat toggle sits between terminal and info, review diffs stay populated
across selection changes
- failure signals: missing Create review, restored heatmap/people in the
context box, or empty Files Changed while the review is still selected;
mitigate by reverting this PR

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
2026-08-21 05:45:55 -07:00

130 lines
4.5 KiB
TypeScript

import * as React from "react";
import type { MarkdownProps } from "./markdown/types";
/**
* Returns true when a React element is a block-level media wrapper (image or
* video). The `img` component in `createMarkdownComponents` marks its output
* with a `data-block-media` prop so we can reliably distinguish media from
* other custom components (links, mentions, etc.) that also have non-string
* types in react-markdown v10.
*/
function isBlockMedia(child: React.ReactNode): boolean {
if (!React.isValidElement(child)) return false;
const props = child.props as {
children?: React.ReactNode;
node?: { tagName?: unknown };
[key: string]: unknown;
};
const node = props?.node as { tagName?: unknown } | undefined;
if (props?.["data-block-media"] != null || node?.tagName === "img") {
return true;
}
return React.Children.toArray(props.children).some(isBlockMedia);
}
/**
* Classifies an array of React children into media vs non-media buckets.
* Used by the `p` component to detect image-only paragraphs for gallery
* rendering.
*
* "Image children" = elements marked with `data-block-media` (images/videos).
* "Non-image children" = everything else, excluding whitespace-only strings
* and `<br>` elements (injected by remarkBreaks between images).
*/
export function classifyChildren(childArray: React.ReactNode[]): {
imageChildren: React.ReactNode[];
nonImageChildren: React.ReactNode[];
} {
const imageChildren = childArray.filter(isBlockMedia);
const nonImageChildren = childArray.filter(
(child) =>
!isBlockMedia(child) &&
!(typeof child === "string" && child.trim() === "") &&
!(
React.isValidElement(child) &&
((typeof child.type === "string" && child.type === "br") ||
(child.props as { node?: { tagName?: unknown } })?.node?.tagName ===
"br")
),
);
return { imageChildren, nonImageChildren };
}
/** Returns true when a paragraph contains 2+ images and no other content. */
export function isImageOnlyParagraph(childArray: React.ReactNode[]): boolean {
const { imageChildren, nonImageChildren } = classifyChildren(childArray);
return imageChildren.length >= 2 && nonImageChildren.length === 0;
}
/**
* Returns true when a paragraph contains any image/video child. The custom
* `img` renderer always emits block-level markup (lightbox/video wrapper),
* so any such paragraph must render as `<div>` to avoid invalid `<p><div>`
* nesting — even when mixed with text or links.
*/
export function hasBlockMedia(childArray: React.ReactNode[]): boolean {
const { imageChildren } = classifyChildren(childArray);
return imageChildren.length >= 1;
}
export function shallowArrayEqual(a?: string[], b?: string[]): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
/**
* Value-equality for the small name→pubkey mention maps. Several call sites
* (forum cards, feed rows) rebuild the map inline on every render — comparing
* by value in the `Markdown` memo keeps those fresh-but-identical objects from
* re-rendering (and DOM-swapping) the whole markdown tree.
*/
export function shallowRecordEqual(
a?: Record<string, string>,
b?: Record<string, string>,
): boolean {
if (a === b) return true;
if (!a || !b) return false;
const aKeys = Object.keys(a);
if (aKeys.length !== Object.keys(b).length) return false;
for (const key of aKeys) {
if (a[key] !== b[key]) return false;
}
return true;
}
export function markdownPropsAreEqual(
prev: MarkdownProps,
next: MarkdownProps,
): boolean {
return (
prev.content === next.content &&
prev.className === next.className &&
prev.customEmoji === next.customEmoji &&
prev.hardLineBreaks === next.hardLineBreaks &&
prev.interactive === next.interactive &&
prev.blockCode === next.blockCode &&
prev.mediaInset === next.mediaInset &&
shallowRecordEqual(
prev.agentMentionPubkeysByName,
next.agentMentionPubkeysByName,
) &&
shallowRecordEqual(prev.mentionPubkeysByName, next.mentionPubkeysByName) &&
shallowArrayEqual(prev.mentionNames, next.mentionNames) &&
shallowArrayEqual(prev.channelNames, next.channelNames) &&
prev.imetaByUrl === next.imetaByUrl &&
prev.configNudgeAuthorPubkey === next.configNudgeAuthorPubkey &&
prev.searchQuery === next.searchQuery &&
prev.snapshotSharedBy === next.snapshotSharedBy &&
prev.videoReviewContext === next.videoReviewContext
);
}