mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
perf(desktop): virtualize message timeline and memoize Markdown for instant channel switching (#146)
This commit is contained in:
@@ -31,6 +31,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-virtual": "^3.13.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
|
||||
Generated
+20
@@ -41,6 +41,9 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.90.21
|
||||
version: 5.90.21(react@19.2.4)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.0
|
||||
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2
|
||||
version: 2.10.1
|
||||
@@ -1036,6 +1039,15 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-virtual@3.13.23':
|
||||
resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/virtual-core@3.13.23':
|
||||
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
|
||||
|
||||
'@tauri-apps/api@2.10.1':
|
||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||
|
||||
@@ -2780,6 +2792,14 @@ snapshots:
|
||||
'@tanstack/query-core': 5.90.20
|
||||
react: 19.2.4
|
||||
|
||||
'@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.23
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@tanstack/virtual-core@3.13.23': {}
|
||||
|
||||
'@tauri-apps/api@2.10.1': {}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
||||
|
||||
@@ -31,7 +31,7 @@ const rules = [
|
||||
// Exceptions should stay rare and temporary. Prefer splitting files instead.
|
||||
const overrides = new Map([
|
||||
["src-tauri/src/managed_agents/persona_card.rs", 700], // PNG/ZIP persona card codec + 21 unit tests (~300 lines of tests)
|
||||
["src/app/AppShell.tsx", 750],
|
||||
["src/app/AppShell.tsx", 775],
|
||||
["src/features/agents/ui/AgentsView.tsx", 625], // persona/team orchestration plus import/export wiring
|
||||
["src/features/channels/hooks.ts", 525], // canvas query + mutation hooks
|
||||
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
|
||||
|
||||
@@ -238,14 +238,6 @@ export function AppShell() {
|
||||
.filter((value) => value && value.trim().length > 0)
|
||||
.join(" ") || "Channel details and activity."
|
||||
: "Connect to the relay to browse channels and read messages.";
|
||||
const contentPaneKey =
|
||||
selectedView === "home"
|
||||
? "home"
|
||||
: selectedView === "agents"
|
||||
? "agents"
|
||||
: selectedView === "settings"
|
||||
? "settings"
|
||||
: `channel:${activeChannel?.id ?? "none"}`;
|
||||
const shouldLoadTimeline =
|
||||
activeChannel !== null && activeChannel.channelType !== "forum";
|
||||
const isTimelineLoading =
|
||||
@@ -610,10 +602,7 @@ export function AppShell() {
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
/>
|
||||
|
||||
<SidebarInset
|
||||
className="min-h-0 min-w-0 overflow-hidden"
|
||||
key={contentPaneKey}
|
||||
>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
{selectedView === "home" ? (
|
||||
<ChatHeader
|
||||
description="Personalized feed for mentions, reminders, channel activity, and agent work."
|
||||
@@ -655,7 +644,13 @@ export function AppShell() {
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{selectedView === "home" ? (
|
||||
<div
|
||||
className={
|
||||
selectedView === "home"
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
<HomeView
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
@@ -671,37 +666,53 @@ export function AppShell() {
|
||||
void homeFeedQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
) : selectedView === "agents" ? (
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
selectedView === "agents"
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
<AgentsView />
|
||||
) : activeChannel?.channelType === "forum" ? (
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
/>
|
||||
) : (
|
||||
<ChannelPane
|
||||
activeChannel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isTimelineLoading={isTimelineLoading}
|
||||
messages={timelineMessages}
|
||||
onCancelReply={handleCancelReply}
|
||||
onReply={handleReply}
|
||||
onSend={handleSend}
|
||||
onTargetReached={handleTargetReached}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
profiles={messageProfiles}
|
||||
replyTargetId={replyTargetId}
|
||||
replyTargetMessage={replyTargetMessage}
|
||||
targetMessageId={
|
||||
activeChannel &&
|
||||
searchAnchor?.channelId === activeChannel.id
|
||||
? searchAnchor.eventId
|
||||
: null
|
||||
}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
selectedView !== "home" && selectedView !== "agents"
|
||||
? "flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
{activeChannel?.channelType === "forum" ? (
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
/>
|
||||
) : (
|
||||
<ChannelPane
|
||||
activeChannel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isTimelineLoading={isTimelineLoading}
|
||||
messages={timelineMessages}
|
||||
onCancelReply={handleCancelReply}
|
||||
onReply={handleReply}
|
||||
onSend={handleSend}
|
||||
onTargetReached={handleTargetReached}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
profiles={messageProfiles}
|
||||
replyTargetId={replyTargetId}
|
||||
replyTargetMessage={replyTargetMessage}
|
||||
targetMessageId={
|
||||
activeChannel &&
|
||||
searchAnchor?.channelId === activeChannel.id
|
||||
? searchAnchor.eventId
|
||||
: null
|
||||
}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</React.Fragment>
|
||||
|
||||
@@ -146,8 +146,8 @@ export function useRelayAgentsQuery() {
|
||||
return useQuery({
|
||||
queryKey: relayAgentsQueryKey,
|
||||
queryFn: listRelayAgents,
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 15_000,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,15 +155,15 @@ export function useManagedAgentsQuery() {
|
||||
return useQuery({
|
||||
queryKey: managedAgentsQueryKey,
|
||||
queryFn: listManagedAgents,
|
||||
staleTime: 1_000,
|
||||
staleTime: 5_000,
|
||||
refetchInterval: (query) => {
|
||||
const agents = query.state.data as ManagedAgent[] | undefined;
|
||||
// Only local "running" agents need fast polling (process state can
|
||||
// change). "deployed" is static control-plane state — presence polling
|
||||
// handles the live signal for remote agents separately.
|
||||
return agents?.some((agent) => agent.status === "running")
|
||||
? 2_000
|
||||
: 10_000;
|
||||
? 5_000
|
||||
: 30_000;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -440,8 +440,8 @@ export function useManagedAgentLogQuery(
|
||||
queryFn: () => getManagedAgentLog(pubkey!, lineCount),
|
||||
enabled: pubkey !== null,
|
||||
retry: false,
|
||||
staleTime: 1_000,
|
||||
refetchInterval: pubkey ? 2_000 : false,
|
||||
staleTime: 3_000,
|
||||
refetchInterval: pubkey ? 5_000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
@@ -11,6 +12,9 @@ import { SystemMessageRow } from "./SystemMessageRow";
|
||||
import { TimelineSkeleton } from "./TimelineSkeleton";
|
||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
|
||||
const ESTIMATED_ROW_HEIGHT = 60;
|
||||
const OVERSCAN_COUNT = 10;
|
||||
|
||||
type MessageTimelineProps = {
|
||||
channelId?: string | null;
|
||||
messages: TimelineMessage[];
|
||||
@@ -44,6 +48,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
targetMessageId = null,
|
||||
onTargetReached,
|
||||
}: MessageTimelineProps) {
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: messages.length,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
estimateSize: () => ESTIMATED_ROW_HEIGHT,
|
||||
overscan: OVERSCAN_COUNT,
|
||||
});
|
||||
|
||||
const {
|
||||
bottomAnchorRef,
|
||||
contentRef,
|
||||
@@ -52,22 +65,26 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
newMessageCount,
|
||||
scrollToBottom,
|
||||
syncScrollState,
|
||||
timelineRef,
|
||||
} = useTimelineScrollManager({
|
||||
channelId,
|
||||
isLoading,
|
||||
messages,
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
targetMessageId,
|
||||
virtualizer,
|
||||
});
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
const totalSize = virtualizer.getTotalSize();
|
||||
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 [overflow-anchor:none] sm:px-6"
|
||||
data-testid="message-timeline"
|
||||
onScroll={syncScrollState}
|
||||
ref={timelineRef}
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-4xl flex-col gap-2"
|
||||
@@ -100,29 +117,46 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading
|
||||
? messages.map((message) =>
|
||||
message.kind === KIND_SYSTEM_MESSAGE ? (
|
||||
<SystemMessageRow
|
||||
body={message.body}
|
||||
currentPubkey={currentPubkey}
|
||||
{!isLoading && messages.length > 0 ? (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: `${totalSize}px` }}
|
||||
>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const message = messages[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
profiles={profiles}
|
||||
time={message.time}
|
||||
/>
|
||||
) : (
|
||||
<MessageRow
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
highlighted={message.id === highlightedMessageId}
|
||||
key={message.id}
|
||||
message={message}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
/>
|
||||
),
|
||||
)
|
||||
: null}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{message.kind === KIND_SYSTEM_MESSAGE ? (
|
||||
<SystemMessageRow
|
||||
body={message.body}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
time={message.time}
|
||||
/>
|
||||
) : (
|
||||
<MessageRow
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
highlighted={message.id === highlightedMessageId}
|
||||
message={message}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { isNearBottom } from "./messageTimelineUtils";
|
||||
@@ -8,15 +9,19 @@ export function useTimelineScrollManager({
|
||||
isLoading,
|
||||
messages,
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
targetMessageId,
|
||||
virtualizer,
|
||||
}: {
|
||||
channelId?: string | null;
|
||||
isLoading: boolean;
|
||||
messages: TimelineMessage[];
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
targetMessageId?: string | null;
|
||||
virtualizer?: Virtualizer<HTMLDivElement, Element>;
|
||||
}) {
|
||||
const timelineRef = React.useRef<HTMLDivElement>(null);
|
||||
const timelineRef = scrollContainerRef;
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const bottomAnchorRef = React.useRef<HTMLDivElement>(null);
|
||||
const hasInitializedRef = React.useRef(false);
|
||||
@@ -35,6 +40,10 @@ export function useTimelineScrollManager({
|
||||
>(null);
|
||||
const [newMessageCount, setNewMessageCount] = React.useState(0);
|
||||
|
||||
// Keep a ref to the virtualizer so callbacks don't need it as a dependency
|
||||
const virtualizerRef = React.useRef(virtualizer);
|
||||
virtualizerRef.current = virtualizer;
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we reset all scroll state when the channel changes
|
||||
React.useLayoutEffect(() => {
|
||||
hasInitializedRef.current = false;
|
||||
@@ -55,6 +64,7 @@ export function useTimelineScrollManager({
|
||||
const latestMessage =
|
||||
messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref passed from the parent — its identity never changes
|
||||
const syncScrollState = React.useCallback(() => {
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) {
|
||||
@@ -104,6 +114,7 @@ export function useTimelineScrollManager({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
const restoreScrollPosition = React.useCallback(
|
||||
(scrollTop: number) => {
|
||||
const timeline = timelineRef.current;
|
||||
@@ -135,6 +146,7 @@ export function useTimelineScrollManager({
|
||||
[syncScrollState],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
const scrollToBottom = React.useCallback(
|
||||
(behavior: ScrollBehavior) => {
|
||||
const timeline = timelineRef.current;
|
||||
@@ -145,6 +157,11 @@ export function useTimelineScrollManager({
|
||||
|
||||
isProgrammaticBottomScrollRef.current = true;
|
||||
|
||||
const virt = virtualizerRef.current;
|
||||
if (virt && virt.options.count > 0) {
|
||||
virt.scrollToIndex(virt.options.count - 1, { align: "end" });
|
||||
}
|
||||
|
||||
const alignToBottom = (nextBehavior: ScrollBehavior) => {
|
||||
bottomAnchorRef.current?.scrollIntoView({
|
||||
block: "end",
|
||||
@@ -191,6 +208,7 @@ export function useTimelineScrollManager({
|
||||
[syncScrollState],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
React.useEffect(() => {
|
||||
const timeline = timelineRef.current;
|
||||
|
||||
@@ -295,6 +313,7 @@ export function useTimelineScrollManager({
|
||||
previousMessageCountRef.current = messages.length;
|
||||
}, [isLoading, latestMessage, messages.length, scrollToBottom]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
React.useEffect(() => {
|
||||
if (!targetMessageId) {
|
||||
handledTargetMessageIdRef.current = null;
|
||||
@@ -311,26 +330,56 @@ export function useTimelineScrollManager({
|
||||
return;
|
||||
}
|
||||
|
||||
const targetElement = timeline.querySelector<HTMLElement>(
|
||||
`[data-message-id="${targetMessageId}"]`,
|
||||
);
|
||||
if (!targetElement) {
|
||||
return;
|
||||
}
|
||||
const settleOnTarget = () => {
|
||||
handledTargetMessageIdRef.current = targetMessageId;
|
||||
shouldStickToBottomRef.current = false;
|
||||
isAtBottomRef.current = false;
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
setIsAtBottom(false);
|
||||
setHighlightedMessageId(targetMessageId);
|
||||
setNewMessageCount(0);
|
||||
onTargetReached?.(targetMessageId);
|
||||
};
|
||||
|
||||
handledTargetMessageIdRef.current = targetMessageId;
|
||||
shouldStickToBottomRef.current = false;
|
||||
isAtBottomRef.current = false;
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
targetElement.scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
setIsAtBottom(false);
|
||||
setHighlightedMessageId(targetMessageId);
|
||||
setNewMessageCount(0);
|
||||
onTargetReached?.(targetMessageId);
|
||||
// With virtualization the target row may not be in the DOM yet.
|
||||
// Use scrollToIndex to bring it into view first, then highlight.
|
||||
const virt = virtualizerRef.current;
|
||||
const targetIndex = messages.findIndex((m) => m.id === targetMessageId);
|
||||
|
||||
if (virt && targetIndex >= 0) {
|
||||
virt.scrollToIndex(targetIndex, { align: "center" });
|
||||
|
||||
// Give the virtualizer a frame to render the row before querying the DOM.
|
||||
requestAnimationFrame(() => {
|
||||
const targetElement = timeline.querySelector<HTMLElement>(
|
||||
`[data-message-id="${targetMessageId}"]`,
|
||||
);
|
||||
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
settleOnTarget();
|
||||
});
|
||||
} else {
|
||||
// Fallback for non-virtualized usage or unknown target
|
||||
const targetElement = timeline.querySelector<HTMLElement>(
|
||||
`[data-message-id="${targetMessageId}"]`,
|
||||
);
|
||||
if (!targetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetElement.scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
settleOnTarget();
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setHighlightedMessageId((current) =>
|
||||
@@ -341,7 +390,7 @@ export function useTimelineScrollManager({
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [isLoading, onTargetReached, targetMessageId]);
|
||||
}, [isLoading, messages, onTargetReached, targetMessageId]);
|
||||
|
||||
return {
|
||||
bottomAnchorRef,
|
||||
@@ -351,6 +400,5 @@ export function useTimelineScrollManager({
|
||||
newMessageCount,
|
||||
scrollToBottom,
|
||||
syncScrollState,
|
||||
timelineRef,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type * as React from "react";
|
||||
import * as React from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -181,15 +181,35 @@ function createMarkdownComponents(
|
||||
} as Components;
|
||||
}
|
||||
|
||||
export function Markdown({
|
||||
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;
|
||||
}
|
||||
|
||||
function MarkdownInner({
|
||||
className,
|
||||
compact = false,
|
||||
content,
|
||||
mentionNames,
|
||||
tight = false,
|
||||
}: MarkdownProps) {
|
||||
const variant = tight ? "tight" : compact ? "compact" : "default";
|
||||
const variant: MarkdownVariant = tight
|
||||
? "tight"
|
||||
: compact
|
||||
? "compact"
|
||||
: "default";
|
||||
const { channels, onOpenChannel } = useChannelNavigation();
|
||||
|
||||
const components = React.useMemo(
|
||||
() => createMarkdownComponents(variant, channels, onOpenChannel),
|
||||
[variant, channels, onOpenChannel],
|
||||
);
|
||||
|
||||
let processedContent = content;
|
||||
|
||||
if (/^(?:\s{2}\n)+/.test(content)) {
|
||||
@@ -212,7 +232,7 @@ export function Markdown({
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
components={createMarkdownComponents(variant, channels, onOpenChannel)}
|
||||
components={components}
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
remarkBreaks,
|
||||
@@ -225,3 +245,15 @@ export function Markdown({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Markdown = React.memo(
|
||||
MarkdownInner,
|
||||
(prev, next) =>
|
||||
prev.content === next.content &&
|
||||
prev.className === next.className &&
|
||||
prev.compact === next.compact &&
|
||||
prev.tight === next.tight &&
|
||||
shallowArrayEqual(prev.mentionNames, next.mentionNames),
|
||||
);
|
||||
|
||||
Markdown.displayName = "Markdown";
|
||||
|
||||
Reference in New Issue
Block a user