feat(desktop): open markdown attachments in an in-app viewer panel

Shared .md/.markdown/.mdx attachments now open in a right auxiliary
panel with a Preview/Code toggle instead of triggering a download,
mirroring berd's markdown-artifact viewer.

Nostr-specific adaptations vs berd: the relay stores markdown as
application/octet-stream ({sha256}.bin — no magic bytes), so
classification is by imeta filename only; relay media URLs require
Blossom auth (401 in plain browsers), so the panel fetches through the
authenticated fetch_media_bytes Tauri command; content-addressed URLs
allow staleTime: Infinity caching. Panel state is URL-search-param
backed (doc/docName) like the other channel panes. Strict UTF-8 decode
with a 2 MiB cap keeps binary/oversized files on the download path,
and surfaces without a panel host (forum, projects) keep download
behavior via a nullable context.

Signed-off-by: Riley Crane <rileycrane@squareup.com>
Co-authored-by: Fizz <d4ec90109b43092b0ec94cf136850915a3732a29ab3089a1dec43d2ed43dd6eb@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Riley Crane
2026-08-21 16:41:47 -07:00
parent f249710331
commit 332c54ea89
12 changed files with 791 additions and 150 deletions

View File

@@ -61,6 +61,7 @@ export default defineConfig({
"**/doctor-cta-screenshots.spec.ts",
"**/pubkey-display-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/markdown-doc-viewer.spec.ts",
"**/image-attachment-gallery.spec.ts",
"**/composer-image-draw.spec.ts",
"**/video-attachment.spec.ts",

View File

@@ -30,6 +30,7 @@ import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeig
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel";
import { MarkdownDocPanel } from "@/features/channels/ui/MarkdownDocPanel";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import { ThreadViewModeToggle } from "@/features/channels/ui/ThreadViewModeToggle";
import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer";
@@ -149,6 +150,9 @@ export const ChannelPane = React.memo(function ChannelPane({
openAgentSessionPubkey,
onProfilePanelViewChange,
onProfilePanelTabChange,
markdownDocName,
markdownDocUrl,
onCloseMarkdownDoc,
profilePanelPubkey,
profilePanelTab,
profilePanelView,
@@ -538,13 +542,18 @@ export const ChannelPane = React.memo(function ChannelPane({
}),
[agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles],
);
const openMarkdownDoc =
markdownDocUrl && markdownDocName
? { filename: markdownDocName, url: markdownDocUrl }
: null;
const hasSplitAuxiliaryPane =
useSplitAuxiliaryPane &&
(channelManagementOpen ||
Boolean(threadHeadMessage) ||
shouldShowThreadSkeleton ||
Boolean(activeChannel && selectedAgent) ||
Boolean(profilePanelPubkey));
Boolean(profilePanelPubkey) ||
Boolean(openMarkdownDoc));
const wrapAux = (
panel: React.ReactNode,
testId: string,
@@ -999,6 +1008,27 @@ export const ChannelPane = React.memo(function ChannelPane({
);
return wrapAux(panel, "user-profile-panel");
})()
) : openMarkdownDoc && onCloseMarkdownDoc ? (
(() => {
// Lowest-priority pane: a higher-priority pane opened afterwards
// (thread, activity, profile) shows immediately, and the document
// reappears when it closes. Opening a document clears competitors
// in the screen-level handler, so it is never dead on arrival.
const panel = (
<MarkdownDocPanel
filename={openMarkdownDoc.filename}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onClose={onCloseMarkdownDoc}
transparentChrome={useSplitAuxiliaryPane}
url={openMarkdownDoc.url}
widthPx={threadPanelWidthPx}
/>
);
return wrapAux(panel, "markdown-doc-panel");
})()
) : null}
</AnimatePresence>
</div>

View File

@@ -80,6 +80,7 @@ export type ChannelPaneProps = {
onCloseAgentSession: () => void;
onCloseChannelManagement?: () => void;
onChannelManagementDeleted?: () => void;
onCloseMarkdownDoc?: () => void;
onCloseProfilePanel: () => void;
onAddAgent?: (options?: { beforeSend?: () => void }) => void;
onBrowseChannels?: () => void;
@@ -164,6 +165,10 @@ export type ChannelPaneProps = {
tab: ProfilePanelTab,
options?: { replace?: boolean },
) => void;
/** Imeta filename labeling the markdown document viewer panel. */
markdownDocName?: string | null;
/** Relay media URL shown in the markdown document viewer panel. */
markdownDocUrl?: string | null;
profilePanelPubkey?: string | null;
profilePanelTab: ProfilePanelTab;
profilePanelView: ProfilePanelView;

View File

@@ -54,6 +54,10 @@ import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker"
import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation";
import { AgentSessionProvider } from "@/shared/context/AgentSessionContext";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
import {
type MarkdownDocTarget,
MarkdownDocViewerProvider,
} from "@/shared/ui/markdown/markdownDocViewerContext";
import { useMainInsetRef } from "@/shared/layout/MainInsetContext";
import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout";
import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable";
@@ -114,6 +118,10 @@ export function ChannelScreen({
channelManagementOpen,
clearAutoSend,
clearMessageRouteTarget,
closeMarkdownDoc,
markdownDocName,
markdownDocUrl,
openMarkdownDoc,
openAgentSessionChannelId,
openAgentSessionPubkey,
openProfilePanel,
@@ -742,6 +750,28 @@ export function ChannelScreen({
() => setIsMembersSidebarOpen((prev) => !prev),
[],
);
// Opening a document clears competing panes (mirrors handleManageChannel /
// useChannelProfilePanel) so the doc panel — lowest in ChannelPane's pane
// priority chain — is never opened dead behind another pane.
const handleOpenMarkdownDoc = React.useCallback(
(doc: MarkdownDocTarget) => {
setOpenThreadHeadId(null);
setExpandedThreadReplyIds(new Set());
setThreadScrollTargetId(null);
setThreadReplyTargetId(null);
handleCloseAgentSession();
setChannelManagementOpen(false);
setProfilePanelPubkey(null);
openMarkdownDoc(doc.url, doc.filename);
},
[
handleCloseAgentSession,
openMarkdownDoc,
setChannelManagementOpen,
setOpenThreadHeadId,
setProfilePanelPubkey,
],
);
const channelHeader = React.useMemo(
() => (
<ChannelScreenHeader
@@ -839,152 +869,168 @@ export function ChannelScreen({
searchTarget,
)
) : (
<React.Suspense
fallback={
<ChannelScreenLoadingFallback
isHuddleTranscript={isHuddleTranscript}
/>
}
>
{searchForwarding.renderSearchAwareChannel(
<GuardedChannelPane
activeChannel={activeChannel}
activityAgents={channelAgentSessionAgents}
agentPubkeys={agentPubkeys}
agentPubkeysPending={agentPubkeysPending}
agentSessionAgents={agentSessionAgents}
autoSendDraftKey={autoSendDraftKey}
onAutoSendComplete={clearAutoSend}
botTypingEntries={botTypingEntries}
channelManagementOpen={channelManagementOpen}
currentPubkey={currentPubkey}
canResetThreadPanelWidth={canResetThreadPanelWidth}
fetchOlder={fetchOlder}
header={channelHeader}
hasOlderMessages={hasOlderMessages}
historyExhausted={historyExhausted}
onAddAgent={handleOpenAddBot}
onBrowseChannels={openBrowseChannels}
onCreateChannel={openCreateChannel}
onOpenMembers={handleOpenMembersSidebar}
isFetchingOlder={isFetchingOlder}
isHuddleTranscript={isHuddleTranscript}
entranceMessageId={welcomeEntranceMessageId}
onEntranceMessageComplete={handleWelcomeEntranceComplete}
welcomeKickoffStage={welcomeKickoffStage}
welcomeKickoffSettingUp={welcomeKickoffSettingUp}
editTarget={
editTargetMessage
? buildMessageComposerEditTarget(
editTargetMessage,
messageProfiles,
(pubkey) =>
knownAgentPubkeys.has(pubkey) ||
!!messageProfiles?.[pubkey]?.isAgent,
)
: null
}
followThreadById={followThread}
unfollowThreadById={unfollowThread}
isFollowingThreadById={isFollowingThread}
isMessageUnreadById={isMessageUnread}
isFollowingThread={isNotifiedForEffectiveThread}
isSending={sendMessageMutation.isPending}
isSinglePanelView={isSinglePanelView}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
threadSummaries={threadSummaries}
huddleThreadRepliesError={huddleThreadRepliesError}
onRetryHuddleThreadReplies={onRetryHuddleThreadReplies}
onCancelEdit={handleCancelEdit}
onCancelThreadReply={handleCancelThreadReply}
onChannelManagementDeleted={handleChannelManagementDeleted}
onFollowThread={
effectiveOpenThreadHeadId != null &&
!isNotifiedForEffectiveThread
? () => followThread(effectiveOpenThreadHeadId)
: undefined
}
onUnfollowThread={
effectiveOpenThreadHeadId != null &&
isNotifiedForEffectiveThread
? () => unfollowThread(effectiveOpenThreadHeadId)
: undefined
}
onCloseAgentSession={handleCloseAgentSession}
onBackFromAgentSession={
hasAgentSessionReturnTarget
? handleBackFromAgentSession
: undefined
}
onCloseChannelManagement={handleCloseChannelManagement}
onCloseThread={handleCloseThread}
onDelete={
activeChannel?.archivedAt ? undefined : handleDelete
}
onEdit={activeChannel?.archivedAt ? undefined : handleEdit}
onEditSave={
activeChannel?.archivedAt ? undefined : handleEditSave
}
onMarkUnread={handleMessageMarkUnread}
onMarkRead={handleMessageMarkRead}
onExpandThreadReplies={handleExpandThreadReplies}
onOpenAgentSession={handleOpenAgentSession}
onOpenDm={handleOpenDm}
onOpenProfilePanel={handleOpenProfilePanel}
onResetThreadPanelWidth={handleThreadPanelWidthReset}
onCloseProfilePanel={handleCloseProfilePanel}
onOpenThread={handleOpenThreadAndCloseAgentSession}
onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
onSendMessage={handleSendMessage}
onSendToChannel={handleSendToChannel}
onSendVideoReviewComment={effectiveSendVideoReviewComment}
onSendThreadReply={handleSendThreadReply}
onThreadScrollTargetResolved={() =>
setThreadScrollTargetId(null)
}
onThreadPanelResizeStart={handleThreadPanelResizeStart}
onTargetReached={() =>
clearMessageRouteTarget({ replace: true })
}
onToggleReaction={effectiveToggleReaction}
openAgentSessionChannelId={openAgentSessionChannelId}
openAgentSessionPubkey={openAgentSessionPubkey}
openThreadHeadId={effectiveOpenThreadHeadId}
shouldShowThreadSkeleton={shouldShowThreadSkeleton}
onProfilePanelViewChange={setProfilePanelView}
onProfilePanelTabChange={setProfilePanelTab}
profilePanelPubkey={profilePanelPubkey}
profilePanelTab={profilePanelTab}
profilePanelView={profilePanelView}
personaLookup={personaLookup}
profiles={messageProfiles}
ownerProfiles={messageOwnerProfiles}
firstUnreadMessageId={firstUnreadMessageId}
unreadCount={unreadCount}
targetMessageId={mainTimelineTargetMessageId}
threadAllMessages={displayedThreadAllMessages}
threadHeadMessage={displayedThreadHeadMessage}
threadMessages={displayedThreadMessages}
threadMessagesPending={threadRepliesQuery.isPending}
threadMessagesError={threadRepliesQuery.isError}
onRetryThreadReplies={() => {
void threadRepliesQuery.refetch();
}}
threadPanelWidthPx={threadPanelWidthPx}
threadTypingPubkeys={threadTypingPubkeys}
threadReplyTargetMessage={displayedThreadReplyTargetMessage}
threadScrollTargetId={threadScrollTargetId}
threadUnreadCounts={threadUnreadCounts}
threadReplyUnreadCounts={threadReplyUnreadCounts}
threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId}
isJoining={joinChannelMutation.isPending}
onJoinChannel={joinChannelMutation.mutateAsync}
typingPubkeys={humanTypingPubkeys}
/>,
searchTarget,
)}
</React.Suspense>
// Scoped to the pane that hosts the markdown-doc auxiliary
// panel — the forum branch has no host, so its FileCards keep
// download behavior instead of a dead open-in-viewer click.
<MarkdownDocViewerProvider value={handleOpenMarkdownDoc}>
<React.Suspense
fallback={
<ChannelScreenLoadingFallback
isHuddleTranscript={isHuddleTranscript}
/>
}
>
{searchForwarding.renderSearchAwareChannel(
<GuardedChannelPane
activeChannel={activeChannel}
activityAgents={channelAgentSessionAgents}
agentPubkeys={agentPubkeys}
agentPubkeysPending={agentPubkeysPending}
agentSessionAgents={agentSessionAgents}
autoSendDraftKey={autoSendDraftKey}
onAutoSendComplete={clearAutoSend}
botTypingEntries={botTypingEntries}
channelManagementOpen={channelManagementOpen}
currentPubkey={currentPubkey}
canResetThreadPanelWidth={canResetThreadPanelWidth}
fetchOlder={fetchOlder}
header={channelHeader}
hasOlderMessages={hasOlderMessages}
historyExhausted={historyExhausted}
onAddAgent={handleOpenAddBot}
onBrowseChannels={openBrowseChannels}
onCreateChannel={openCreateChannel}
onOpenMembers={handleOpenMembersSidebar}
isFetchingOlder={isFetchingOlder}
isHuddleTranscript={isHuddleTranscript}
entranceMessageId={welcomeEntranceMessageId}
onEntranceMessageComplete={handleWelcomeEntranceComplete}
welcomeKickoffStage={welcomeKickoffStage}
welcomeKickoffSettingUp={welcomeKickoffSettingUp}
editTarget={
editTargetMessage
? buildMessageComposerEditTarget(
editTargetMessage,
messageProfiles,
(pubkey) =>
knownAgentPubkeys.has(pubkey) ||
!!messageProfiles?.[pubkey]?.isAgent,
)
: null
}
followThreadById={followThread}
unfollowThreadById={unfollowThread}
isFollowingThreadById={isFollowingThread}
isMessageUnreadById={isMessageUnread}
isFollowingThread={isNotifiedForEffectiveThread}
isSending={sendMessageMutation.isPending}
isSinglePanelView={isSinglePanelView}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
threadSummaries={threadSummaries}
huddleThreadRepliesError={huddleThreadRepliesError}
onRetryHuddleThreadReplies={onRetryHuddleThreadReplies}
onCancelEdit={handleCancelEdit}
onCancelThreadReply={handleCancelThreadReply}
onChannelManagementDeleted={
handleChannelManagementDeleted
}
onFollowThread={
effectiveOpenThreadHeadId != null &&
!isNotifiedForEffectiveThread
? () => followThread(effectiveOpenThreadHeadId)
: undefined
}
onUnfollowThread={
effectiveOpenThreadHeadId != null &&
isNotifiedForEffectiveThread
? () => unfollowThread(effectiveOpenThreadHeadId)
: undefined
}
onCloseAgentSession={handleCloseAgentSession}
onBackFromAgentSession={
hasAgentSessionReturnTarget
? handleBackFromAgentSession
: undefined
}
onCloseChannelManagement={handleCloseChannelManagement}
onCloseThread={handleCloseThread}
onDelete={
activeChannel?.archivedAt ? undefined : handleDelete
}
onEdit={
activeChannel?.archivedAt ? undefined : handleEdit
}
onEditSave={
activeChannel?.archivedAt ? undefined : handleEditSave
}
onMarkUnread={handleMessageMarkUnread}
onMarkRead={handleMessageMarkRead}
onExpandThreadReplies={handleExpandThreadReplies}
onOpenAgentSession={handleOpenAgentSession}
onOpenDm={handleOpenDm}
onOpenProfilePanel={handleOpenProfilePanel}
onResetThreadPanelWidth={handleThreadPanelWidthReset}
onCloseProfilePanel={handleCloseProfilePanel}
onOpenThread={handleOpenThreadAndCloseAgentSession}
onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
onSendMessage={handleSendMessage}
onSendToChannel={handleSendToChannel}
onSendVideoReviewComment={effectiveSendVideoReviewComment}
onSendThreadReply={handleSendThreadReply}
onThreadScrollTargetResolved={() =>
setThreadScrollTargetId(null)
}
onThreadPanelResizeStart={handleThreadPanelResizeStart}
onTargetReached={() =>
clearMessageRouteTarget({ replace: true })
}
onToggleReaction={effectiveToggleReaction}
openAgentSessionChannelId={openAgentSessionChannelId}
openAgentSessionPubkey={openAgentSessionPubkey}
openThreadHeadId={effectiveOpenThreadHeadId}
shouldShowThreadSkeleton={shouldShowThreadSkeleton}
onProfilePanelViewChange={setProfilePanelView}
onProfilePanelTabChange={setProfilePanelTab}
markdownDocName={markdownDocName}
markdownDocUrl={markdownDocUrl}
onCloseMarkdownDoc={closeMarkdownDoc}
profilePanelPubkey={profilePanelPubkey}
profilePanelTab={profilePanelTab}
profilePanelView={profilePanelView}
personaLookup={personaLookup}
profiles={messageProfiles}
ownerProfiles={messageOwnerProfiles}
firstUnreadMessageId={firstUnreadMessageId}
unreadCount={unreadCount}
targetMessageId={mainTimelineTargetMessageId}
threadAllMessages={displayedThreadAllMessages}
threadHeadMessage={displayedThreadHeadMessage}
threadMessages={displayedThreadMessages}
threadMessagesPending={threadRepliesQuery.isPending}
threadMessagesError={threadRepliesQuery.isError}
onRetryThreadReplies={() => {
void threadRepliesQuery.refetch();
}}
threadPanelWidthPx={threadPanelWidthPx}
threadTypingPubkeys={threadTypingPubkeys}
threadReplyTargetMessage={
displayedThreadReplyTargetMessage
}
threadScrollTargetId={threadScrollTargetId}
threadUnreadCounts={threadUnreadCounts}
threadReplyUnreadCounts={threadReplyUnreadCounts}
threadFirstUnreadReplyId={
displayedThreadFirstUnreadReplyId
}
isJoining={joinChannelMutation.isPending}
onJoinChannel={joinChannelMutation.mutateAsync}
typingPubkeys={humanTypingPubkeys}
/>,
searchTarget,
)}
</React.Suspense>
</MarkdownDocViewerProvider>
)
) : (
<ChannelScreenEmptyState />

View File

@@ -0,0 +1,175 @@
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { Download, FileText, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { invokeTauri } from "@/shared/api/tauri";
import { fetchMediaBytes } from "@/shared/api/tauriMedia";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import {
AuxiliaryPanel,
AuxiliaryPanelBody,
AuxiliaryPanelHeader,
AuxiliaryPanelHeaderActions,
AuxiliaryPanelHeaderGroup,
AuxiliaryPanelHeaderTitleBlock,
} from "@/shared/layout/AuxiliaryPanel";
import { Button } from "@/shared/ui/button";
import { Markdown, SyntaxHighlightedCode } from "@/shared/ui/markdown";
import {
decodeMarkdownDocBytes,
type MarkdownDocDecodeResult,
} from "@/shared/ui/markdown/markdownDocFile";
import { SegmentedControl } from "@/shared/ui/segmented-control";
type MarkdownDocView = "preview" | "code";
type MarkdownDocPanelProps = {
/** Raw relay `/media/` URL of the attachment. */
url: string;
/** Human-readable filename from the imeta `filename` field. */
filename: string;
isSinglePanelView?: boolean;
layout?: "standalone" | "split";
onClose: () => void;
transparentChrome?: boolean;
widthPx: number;
};
const VIEW_OPTIONS = [
{ value: "preview", label: "Preview" },
{ value: "code", label: "Code" },
] as const;
function decodeErrorMessage(kind: "too-large" | "binary"): string {
return kind === "too-large"
? "This file is too large to preview."
: "This file isn't valid text, so it can't be previewed.";
}
/**
* Right auxiliary panel rendering a shared markdown attachment in-app.
*
* Relay media URLs require relay auth (plain browser requests 401), so the
* content is fetched through the authenticated `fetch_media_bytes` Tauri
* command and rendered with the same markdown pipeline chat messages use.
* The Preview/Code toggle switches between rendered markdown and the
* syntax-highlighted source.
*/
export function MarkdownDocPanel({
url,
filename,
isSinglePanelView = false,
layout = "standalone",
onClose,
transparentChrome = false,
widthPx,
}: MarkdownDocPanelProps) {
const isOverlay = useIsThreadPanelOverlay();
useEscapeKey(onClose, isOverlay || isSinglePanelView);
const [view, setView] = React.useState<MarkdownDocView>("preview");
// Blob URLs are content-addressed (`/media/{sha256}.{ext}`), so a fetched
// document never changes under its URL — cache it for the session.
const docQuery = useQuery<MarkdownDocDecodeResult>({
queryKey: ["markdown-doc", url],
queryFn: async () => decodeMarkdownDocBytes(await fetchMediaBytes(url)),
staleTime: Number.POSITIVE_INFINITY,
retry: 1,
});
const handleDownload = React.useCallback(() => {
invokeTauri("download_file", { url, filename }).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "Download failed";
toast.error(msg);
});
}, [url, filename]);
const decoded = docQuery.data;
const errorMessage = docQuery.isError
? "Couldn't load this file from the relay."
: decoded && decoded.kind !== "ok"
? decodeErrorMessage(decoded.kind)
: null;
return (
<AuxiliaryPanel
isSinglePanelView={isSinglePanelView}
layout={layout}
onClose={onClose}
testId="markdown-doc-panel"
transparentChrome={transparentChrome}
widthPx={widthPx}
header={
<AuxiliaryPanelHeader
backdrop={layout !== "split" && !isOverlay}
backdropSurface="soft"
inset={layout !== "split" ? "wide" : "default"}
>
<AuxiliaryPanelHeaderGroup align="start">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<AuxiliaryPanelHeaderTitleBlock title={filename} />
</AuxiliaryPanelHeaderGroup>
<AuxiliaryPanelHeaderActions includeCloseAction>
{decoded?.kind === "ok" ? (
<SegmentedControl
legend="Document view"
onValueChange={setView}
optionTestIdPrefix="markdown-doc-view"
options={VIEW_OPTIONS}
size="compact"
testId="markdown-doc-view-toggle"
value={view}
/>
) : null}
<Button
aria-label={`Download ${filename}`}
data-testid="markdown-doc-download"
onClick={handleDownload}
size="icon"
variant="ghost"
>
<Download className="h-4 w-4" />
</Button>
</AuxiliaryPanelHeaderActions>
</AuxiliaryPanelHeader>
}
>
<AuxiliaryPanelBody className="overflow-y-auto px-4 pb-6" panelPadding>
{docQuery.isPending ? (
<div
className="flex items-center justify-center py-12"
data-testid="markdown-doc-loading"
>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground/70" />
</div>
) : errorMessage !== null ? (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<p className="text-sm text-muted-foreground">{errorMessage}</p>
<Button onClick={handleDownload} size="sm" variant="secondary">
<Download className="mr-1.5 h-4 w-4" />
Download file
</Button>
</div>
) : decoded?.kind === "ok" ? (
view === "preview" ? (
<Markdown
blockCode
className="pt-3 text-sm"
content={decoded.text}
hardLineBreaks={false}
/>
) : (
<pre
className="overflow-x-auto pt-3 text-xs leading-relaxed"
data-testid="markdown-doc-code"
>
<SyntaxHighlightedCode code={decoded.text} language="markdown" />
</pre>
)
) : null}
</AuxiliaryPanelBody>
</AuxiliaryPanel>
);
}

View File

@@ -10,6 +10,8 @@ export const CHANNEL_SEARCH_KEYS = [
"agentSessionChannel",
"autoSend",
"channelManagement",
"doc",
"docName",
"messageId",
"profile",
"profileTab",

View File

@@ -29,7 +29,8 @@ export type { ChannelSearchKey } from "./channelSearchKeys";
* (presence flag for the channel-management panel — open/closed only, so it
* carries a sentinel `"1"` rather than an id), `autoSend` (draft auto-submit
* trigger — cleared surgically after the auto-submit fires so `thread` and
* all other panel state are preserved).
* all other panel state are preserved), `doc` + `docName` (markdown document
* viewer panel — relay media URL and imeta filename).
*/
export type PanelSetterOptions = HistorySearchSetterOptions;
@@ -96,6 +97,21 @@ export function useChannelPanelHistoryState() {
[applyPatch],
);
// `doc` + `docName` travel together: the URL identifies the attachment and
// the imeta filename is the only human-readable name (blob URLs are content
// hashes), so a doc without a name can't label its panel.
const openMarkdownDoc = React.useCallback(
(url: string, filename: string, options?: PanelSetterOptions) =>
applyPatch({ doc: url, docName: filename }, options),
[applyPatch],
);
const closeMarkdownDoc = React.useCallback(
(options?: PanelSetterOptions) =>
applyPatch({ doc: null, docName: null }, options),
[applyPatch],
);
const setChannelManagementOpen = React.useCallback(
(open: boolean, options?: PanelSetterOptions) =>
applyPatch(
@@ -125,6 +141,10 @@ export function useChannelPanelHistoryState() {
channelManagementOpen: values.channelManagement != null,
clearAutoSend,
clearMessageRouteTarget,
closeMarkdownDoc,
markdownDocName: values.docName,
markdownDocUrl: values.doc,
openMarkdownDoc,
openAgentSessionChannelId: values.agentSessionChannel,
openAgentSessionPubkey: values.agentSession,
openProfilePanel,

View File

@@ -1,10 +1,15 @@
import * as React from "react";
import { Download, FileText } from "lucide-react";
import { Download, FileText, PanelRight } from "lucide-react";
import { toast } from "sonner";
import { invokeTauri } from "@/shared/api/tauri";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import { isRelayDownloadable } from "./mediaEntry";
import { isMarkdownDocFilename } from "./markdownDocFile";
import { useMarkdownDocViewer } from "./markdownDocViewerContext";
import { useMarkdownRuntime } from "./runtimeContext";
/** Human-readable byte size: "820 B", "12.4 KB", "3.1 MB". */
function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "";
@@ -28,6 +33,11 @@ function formatFileSize(bytes: number): string {
* link navigates the webview to the blob URL, which escapes to the OS browser
* and gets bounced to a corporate CDN interstitial ("browser not supported").
* The native command mirrors the image-download path.
*
* Markdown attachments (`.md`/`.markdown`/`.mdx` by imeta filename) open the
* in-app markdown viewer panel instead when a hosting surface provides one —
* relay-hosted only, because the viewer fetches through the authenticated
* `fetch_media_bytes` command, which accepts relay `/media/` origins alone.
*/
export function FileCard({
href,
@@ -41,12 +51,22 @@ export function FileCard({
const cardRef = React.useRef<HTMLButtonElement | null>(null);
const sizeLabel = size != null ? formatFileSize(size) : "";
useSmoothCorners(cardRef);
const openMarkdownDoc = useMarkdownDocViewer();
const { relayOrigin } = useMarkdownRuntime();
const opensInViewer =
openMarkdownDoc !== null &&
isMarkdownDocFilename(filename) &&
isRelayDownloadable(href, relayOrigin ?? undefined);
return (
<button
ref={cardRef}
type="button"
onClick={() => {
if (opensInViewer) {
openMarkdownDoc({ url: href, filename });
return;
}
invokeTauri("download_file", { url: href, filename }).catch(
(err: unknown) => {
const msg = err instanceof Error ? err.message : "Download failed";
@@ -54,6 +74,7 @@ export function FileCard({
},
);
}}
aria-label={opensInViewer ? `Open ${filename}` : `Download ${filename}`}
data-testid="file-card"
className="my-1 inline-flex max-w-sm items-center gap-3 rounded-2xl border border-border/70 bg-muted/40 px-3 py-2 text-left no-underline transition-colors hover:bg-muted/70"
style={{ borderRadius: "1rem" }}
@@ -71,7 +92,11 @@ export function FileCard({
</span>
) : null}
</span>
<Download className="h-4 w-4 shrink-0 text-muted-foreground" />
{opensInViewer ? (
<PanelRight className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Download className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
</button>
);
}

View File

@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
decodeMarkdownDocBytes,
isMarkdownDocFilename,
MAX_MARKDOWN_DOC_BYTES,
} from "./markdownDocFile.ts";
// ── isMarkdownDocFilename ─────────────────────────────────────────────────
test("isMarkdownDocFilename: accepts .md, .markdown, .mdx", () => {
assert.equal(isMarkdownDocFilename("README.md"), true);
assert.equal(isMarkdownDocFilename("notes.markdown"), true);
assert.equal(isMarkdownDocFilename("page.mdx"), true);
});
test("isMarkdownDocFilename: case-insensitive and whitespace-tolerant", () => {
assert.equal(isMarkdownDocFilename("PLAN.MD"), true);
assert.equal(isMarkdownDocFilename(" design.Md "), true);
});
test("isMarkdownDocFilename: rejects other extensions", () => {
assert.equal(isMarkdownDocFilename("report.pdf"), false);
assert.equal(isMarkdownDocFilename("archive.zip"), false);
assert.equal(isMarkdownDocFilename("script.mjs"), false);
// Extension must be a suffix with a stem, not the whole name.
assert.equal(isMarkdownDocFilename(".md"), false);
assert.equal(isMarkdownDocFilename(""), false);
});
test("isMarkdownDocFilename: does not match mid-name extensions", () => {
assert.equal(isMarkdownDocFilename("notes.md.zip"), false);
assert.equal(isMarkdownDocFilename("mdfile.txt"), false);
});
// ── decodeMarkdownDocBytes ────────────────────────────────────────────────
test("decodeMarkdownDocBytes: decodes UTF-8 text", () => {
const bytes = new TextEncoder().encode("# Hello 🐝\n\n- item");
assert.deepEqual(decodeMarkdownDocBytes(bytes), {
kind: "ok",
text: "# Hello 🐝\n\n- item",
});
});
test("decodeMarkdownDocBytes: rejects oversized payloads", () => {
const bytes = new Uint8Array(MAX_MARKDOWN_DOC_BYTES + 1);
assert.deepEqual(decodeMarkdownDocBytes(bytes), { kind: "too-large" });
});
test("decodeMarkdownDocBytes: accepts a payload exactly at the cap", () => {
const bytes = new Uint8Array(MAX_MARKDOWN_DOC_BYTES).fill(0x61);
const result = decodeMarkdownDocBytes(bytes);
assert.equal(result.kind, "ok");
});
test("decodeMarkdownDocBytes: strict decode reports binary content", () => {
// 0xFF is never valid in UTF-8.
const bytes = new Uint8Array([0x23, 0x20, 0xff, 0xfe, 0x00]);
assert.deepEqual(decodeMarkdownDocBytes(bytes), { kind: "binary" });
});

View File

@@ -0,0 +1,57 @@
/**
* Pure classification and decoding for viewable markdown document
* attachments.
*
* A markdown file uploaded to the relay has no magic bytes, so Blossom
* stores it as `application/octet-stream` under a `{sha256}.bin` blob key —
* the original `.md` name survives only in the message's imeta `filename`
* field. Classification therefore keys off the imeta filename, never the
* blob-URL extension or the MIME type.
*
* Kept DOM-free (TextDecoder is available in both the webview and Node)
* so the branch logic is unit-testable without a webview.
*/
/** Filename extensions rendered by the in-app markdown viewer. */
const MARKDOWN_DOC_EXTENSIONS = [".md", ".markdown", ".mdx"] as const;
/**
* Maximum attachment size the viewer will render. Larger files fall back
* to the download card path. Matches the ceiling proven workable by other
* in-app text viewers; well under the 50 MiB `fetch_media_bytes` cap.
*/
export const MAX_MARKDOWN_DOC_BYTES = 2 * 1024 * 1024;
/** Whether an imeta filename should open in the in-app markdown viewer. */
export function isMarkdownDocFilename(filename: string): boolean {
const lower = filename.trim().toLowerCase();
return MARKDOWN_DOC_EXTENSIONS.some(
(extension) => lower.endsWith(extension) && lower.length > extension.length,
);
}
export type MarkdownDocDecodeResult =
| { kind: "ok"; text: string }
| { kind: "too-large" }
| { kind: "binary" };
/**
* Decode fetched attachment bytes for the viewer.
*
* Strict UTF-8: a file that merely *claims* to be markdown by name but is
* actually binary fails decoding and reports `binary`, so the panel can fall
* back to the download action instead of rendering mojibake.
*/
export function decodeMarkdownDocBytes(
bytes: Uint8Array,
): MarkdownDocDecodeResult {
if (bytes.byteLength > MAX_MARKDOWN_DOC_BYTES) {
return { kind: "too-large" };
}
try {
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
return { kind: "ok", text };
} catch {
return { kind: "binary" };
}
}

View File

@@ -0,0 +1,31 @@
import * as React from "react";
/** A relay-hosted markdown attachment the in-app viewer can open. */
export type MarkdownDocTarget = {
/** Raw relay `/media/` URL of the attachment (pre-proxy-rewrite). */
url: string;
/** Human-readable filename from the message's imeta `filename` field. */
filename: string;
};
/**
* Open-in-viewer callback for markdown document attachments.
*
* Provided by surfaces that host a markdown-doc auxiliary panel (the channel
* screen). Where no provider exists — project PR/issue bodies, read-only
* previews — the context is `null` and `FileCard` keeps its default
* download-only behavior, so the viewer affordance can never appear
* somewhere it has no panel to open.
*/
const MarkdownDocViewerContext = React.createContext<
((doc: MarkdownDocTarget) => void) | null
>(null);
export const MarkdownDocViewerProvider = MarkdownDocViewerContext.Provider;
/** The active surface's open-in-viewer callback, or null when unhosted. */
export function useMarkdownDocViewer():
| ((doc: MarkdownDocTarget) => void)
| null {
return React.useContext(MarkdownDocViewerContext);
}

View File

@@ -0,0 +1,187 @@
import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// Exercises the markdown-attachment viewer end-to-end through the mock Tauri
// bridge: upload a `.md` file → send → FileCard opens the in-app markdown
// viewer panel (not the download dialog) → Preview renders, Code shows the
// source, Download still works from the panel header.
//
// The attachment URL deliberately mirrors production shape: the relay stores
// extension-less text as `{sha256}.bin` (markdown has no magic bytes), so the
// `.md` identity lives only in the imeta filename. The mock upload descriptor
// reproduces that.
const RELAY_HTTP_URL =
process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000";
const DOC_SHA = "b".repeat(64);
const DOC_URL = `${RELAY_HTTP_URL}/media/${DOC_SHA}.bin`;
const DOC_MARKDOWN = [
"# Release Notes",
"",
"Some **bold** text and a table:",
"",
"| Feature | Works |",
"| --- | --- |",
"| Headings | Yes |",
"",
"```js",
'console.log("hi");',
"```",
"",
].join("\n");
test.beforeEach(async ({ page }) => {
await installMockBridge(page, {
// Route attach through the DOM file input (Playwright's filechooser)
// instead of the native pick_and_upload_media dialog path.
deferredComposerUploads: true,
uploadDescriptors: [
{
url: DOC_URL,
sha256: DOC_SHA,
size: DOC_MARKDOWN.length,
type: "application/octet-stream",
uploaded: Math.floor(Date.now() / 1000),
filename: "release-notes.md",
},
],
});
// The bridge's `fetch_media_bytes` mock fetches the URL in-browser; serve
// the document body from the spec instead of a real relay.
await page.route(`**/media/${DOC_SHA}.bin`, (route) =>
route.fulfill({
body: DOC_MARKDOWN,
contentType: "application/octet-stream",
}),
);
});
async function sendMarkdownAttachment(page: Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByRole("button", { name: "Attach file" }).click(),
]);
await chooser.setFiles({
buffer: Buffer.from(DOC_MARKDOWN),
mimeType: "text/markdown",
name: "release-notes.md",
});
await expect(page.getByTestId("message-composer")).toContainText(
"release-notes.md",
);
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
}
test("markdown attachment opens the in-app viewer with Preview/Code toggle", async ({
page,
}) => {
await sendMarkdownAttachment(page);
// The card advertises open-in-viewer, not download.
const card = page.getByTestId("file-card").last();
await expect(card).toContainText("release-notes.md");
await expect(card).toHaveAttribute("aria-label", "Open release-notes.md");
await card.click();
// The viewer panel opens with the rendered document (no download dialog).
const panel = page.getByTestId("markdown-doc-panel");
await expect(panel).toBeVisible();
await expect(panel).toContainText("release-notes.md");
await expect(
panel.getByRole("heading", { name: "Release Notes" }),
).toBeVisible();
// GFM table rendered as a real table, not pipes.
await expect(panel.locator("table")).toContainText("Headings");
const commands = () =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
);
expect(await commands()).not.toContain("download_file");
// Code view shows the raw source.
await page.getByTestId("markdown-doc-view-code").click();
await expect(page.getByTestId("markdown-doc-code")).toContainText(
"# Release Notes",
);
await page.getByTestId("markdown-doc-view-preview").click();
await expect(
panel.getByRole("heading", { name: "Release Notes" }),
).toBeVisible();
// Download stays available from the panel header.
await page.getByTestId("markdown-doc-download").click();
await expect.poll(commands).toContain("download_file");
// Close returns to the plain channel view.
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0);
});
test("non-markdown attachments keep the download-card behavior", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
// Re-point the mock upload at a PDF: same flow, no viewer affordance.
await page.evaluate(() => {
const e2e = (
window as Window & {
__BUZZ_E2E__?: {
mock?: { uploadDescriptors?: Array<Record<string, unknown>> };
};
}
).__BUZZ_E2E__;
if (e2e?.mock) {
e2e.mock.uploadDescriptors = [
{
url: `http://localhost:3000/media/${"c".repeat(64)}.pdf`,
sha256: "c".repeat(64),
size: 128,
type: "application/pdf",
uploaded: 1_700_000_000,
filename: "report.pdf",
},
];
}
});
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByRole("button", { name: "Attach file" }).click(),
]);
await chooser.setFiles({
buffer: Buffer.from("pdf bytes"),
mimeType: "application/pdf",
name: "report.pdf",
});
await expect(page.getByTestId("message-composer")).toContainText(
"report.pdf",
);
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
const card = page.getByTestId("file-card").last();
await expect(card).toContainText("report.pdf");
await expect(card).toHaveAttribute("aria-label", "Download report.pdf");
await card.click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("download_file");
await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0);
});