mirror of
https://github.com/block/buzz.git
synced 2026-11-09 02:41:57 +00:00
## 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>
130 lines
4.5 KiB
TypeScript
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
|
|
);
|
|
}
|