diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index be15c75587..f4b6033c43 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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", diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 89c61bdf60..87242f8b2c 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -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 = ( + + ); + return wrapAux(panel, "markdown-doc-panel"); + })() ) : null} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index d1b88ae3a6..adc9706c81 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -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; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index fe0c4d23b7..8f99e1f9f9 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -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( () => ( - } - > - {searchForwarding.renderSearchAwareChannel( - - 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, - )} - + // 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. + + + } + > + {searchForwarding.renderSearchAwareChannel( + + 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, + )} + + ) ) : ( diff --git a/desktop/src/features/channels/ui/MarkdownDocPanel.tsx b/desktop/src/features/channels/ui/MarkdownDocPanel.tsx new file mode 100644 index 0000000000..37721d2c52 --- /dev/null +++ b/desktop/src/features/channels/ui/MarkdownDocPanel.tsx @@ -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("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({ + 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 ( + + + + + + + {decoded?.kind === "ok" ? ( + + ) : null} + + + + } + > + + {docQuery.isPending ? ( +
+ +
+ ) : errorMessage !== null ? ( +
+

{errorMessage}

+ +
+ ) : decoded?.kind === "ok" ? ( + view === "preview" ? ( + + ) : ( +
+              
+            
+ ) + ) : null} +
+
+ ); +} diff --git a/desktop/src/features/channels/ui/channelSearchKeys.ts b/desktop/src/features/channels/ui/channelSearchKeys.ts index 0828d9fec6..25b763a7c0 100644 --- a/desktop/src/features/channels/ui/channelSearchKeys.ts +++ b/desktop/src/features/channels/ui/channelSearchKeys.ts @@ -10,6 +10,8 @@ export const CHANNEL_SEARCH_KEYS = [ "agentSessionChannel", "autoSend", "channelManagement", + "doc", + "docName", "messageId", "profile", "profileTab", diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts index 60e3a291b2..4481d61160 100644 --- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts +++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts @@ -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, diff --git a/desktop/src/shared/ui/markdown/FileCard.tsx b/desktop/src/shared/ui/markdown/FileCard.tsx index 889847193e..38ee068be9 100644 --- a/desktop/src/shared/ui/markdown/FileCard.tsx +++ b/desktop/src/shared/ui/markdown/FileCard.tsx @@ -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(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 ( ); } diff --git a/desktop/src/shared/ui/markdown/markdownDocFile.test.mjs b/desktop/src/shared/ui/markdown/markdownDocFile.test.mjs new file mode 100644 index 0000000000..58c7e05e91 --- /dev/null +++ b/desktop/src/shared/ui/markdown/markdownDocFile.test.mjs @@ -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" }); +}); diff --git a/desktop/src/shared/ui/markdown/markdownDocFile.ts b/desktop/src/shared/ui/markdown/markdownDocFile.ts new file mode 100644 index 0000000000..7e9dbf06aa --- /dev/null +++ b/desktop/src/shared/ui/markdown/markdownDocFile.ts @@ -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" }; + } +} diff --git a/desktop/src/shared/ui/markdown/markdownDocViewerContext.ts b/desktop/src/shared/ui/markdown/markdownDocViewerContext.ts new file mode 100644 index 0000000000..8195d93f35 --- /dev/null +++ b/desktop/src/shared/ui/markdown/markdownDocViewerContext.ts @@ -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); +} diff --git a/desktop/tests/e2e/markdown-doc-viewer.spec.ts b/desktop/tests/e2e/markdown-doc-viewer.spec.ts new file mode 100644 index 0000000000..ecc1ec6191 --- /dev/null +++ b/desktop/tests/e2e/markdown-doc-viewer.spec.ts @@ -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> }; + }; + } + ).__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); +});