mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
175
desktop/src/features/channels/ui/MarkdownDocPanel.tsx
Normal file
175
desktop/src/features/channels/ui/MarkdownDocPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export const CHANNEL_SEARCH_KEYS = [
|
||||
"agentSessionChannel",
|
||||
"autoSend",
|
||||
"channelManagement",
|
||||
"doc",
|
||||
"docName",
|
||||
"messageId",
|
||||
"profile",
|
||||
"profileTab",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
62
desktop/src/shared/ui/markdown/markdownDocFile.test.mjs
Normal file
62
desktop/src/shared/ui/markdown/markdownDocFile.test.mjs
Normal 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" });
|
||||
});
|
||||
57
desktop/src/shared/ui/markdown/markdownDocFile.ts
Normal file
57
desktop/src/shared/ui/markdown/markdownDocFile.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
31
desktop/src/shared/ui/markdown/markdownDocViewerContext.ts
Normal file
31
desktop/src/shared/ui/markdown/markdownDocViewerContext.ts
Normal 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);
|
||||
}
|
||||
187
desktop/tests/e2e/markdown-doc-viewer.spec.ts
Normal file
187
desktop/tests/e2e/markdown-doc-viewer.spec.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user