diff --git a/desktop/src/features/channels/ui/ChannelMarkdownDocPanels.tsx b/desktop/src/features/channels/ui/ChannelMarkdownDocPanels.tsx new file mode 100644 index 0000000000..64fa04b46a --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelMarkdownDocPanels.tsx @@ -0,0 +1,47 @@ +import type * as React from "react"; + +import { MarkdownDocAuxiliaryPanel } from "@/features/channels/ui/MarkdownDocAuxiliaryPanel"; +import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; +import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext"; + +type ChannelMarkdownDocPanelProps = { + canResetWidth: boolean; + doc: MarkdownDocTarget; + onClose: () => void; + onResetWidth: () => void; + onResizeStart: (event: React.PointerEvent) => void; + stacked?: boolean; + widthPx: number; +}; + +/** Document pane shown beside, or stacked over, a still-mounted thread. */ +export function ChannelMarkdownDocPanel({ + canResetWidth, + doc, + onClose, + onResetWidth, + onResizeStart, + stacked = false, + widthPx, +}: ChannelMarkdownDocPanelProps) { + return ( + + + + ); +} diff --git a/desktop/src/features/channels/ui/ChannelMarkdownDocSurfaces.tsx b/desktop/src/features/channels/ui/ChannelMarkdownDocSurfaces.tsx new file mode 100644 index 0000000000..0e7cf6f26c --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelMarkdownDocSurfaces.tsx @@ -0,0 +1,51 @@ +import type * as React from "react"; +import { AnimatePresence } from "motion/react"; + +import { ChannelMarkdownDocPanel } from "@/features/channels/ui/ChannelMarkdownDocPanels"; +import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext"; + +type Props = { + canResetThreadPanelWidth: boolean; + idleAuxiliarySurface: React.ReactNode; + onCloseMarkdownDoc?: () => void; + onResetThreadPanelWidth: () => void; + onThreadPanelResizeStart: ( + event: React.PointerEvent, + ) => void; + openMarkdownDoc: MarkdownDocTarget | null; + showIdleAuxiliaryOverThread: boolean; + showMarkdownBesideThread: boolean; + threadPanelWidthPx: number; + threadSurface: { markExitComplete: () => void }; + useStackedMarkdownPanel: boolean; +}; + +/** Presence boundaries for responsive Markdown panes around an open thread. */ +export function ChannelMarkdownDocSurfaces(props: Props) { + const renderPanel = (stacked = false) => + props.openMarkdownDoc && props.onCloseMarkdownDoc ? ( + + ) : null; + return ( + <> + + {props.showMarkdownBesideThread ? renderPanel() : null} + + + {props.useStackedMarkdownPanel + ? renderPanel(true) + : props.showIdleAuxiliaryOverThread + ? props.idleAuxiliarySurface + : null} + + + ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index f3df229d9a..c3e3817a4d 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -30,6 +30,7 @@ import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThre import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel"; import { IdleAuxiliaryPanel } from "@/features/channels/ui/IdleAuxiliaryPanel"; import { MarkdownDocAuxiliaryPanel } from "@/features/channels/ui/MarkdownDocAuxiliaryPanel"; +import { ChannelMarkdownDocSurfaces } from "@/features/channels/ui/ChannelMarkdownDocSurfaces"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import { createChannelPaneAuxiliaryLayout } from "@/features/channels/ui/channelPaneAuxiliaryLayout"; import { @@ -43,6 +44,7 @@ import { getThreadPanelLayout } from "@/features/channels/lib/threadPanelLayout" import { useThreadViewMode } from "@/features/channels/lib/threadViewModePreference"; import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch"; import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence"; +import { AUXILIARY_PANEL_MIN_WIDTH_PX } from "@/shared/layout/AuxiliaryPanel"; import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal"; import { useCardMintJobs } from "@/features/agents/cardMintStore"; import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; @@ -85,6 +87,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onAutoSendComplete = null, botTypingEntries, channelManagementOpen = false, + channelContentWidthPx, currentPubkey, editTarget = null, fetchOlder, @@ -437,10 +440,15 @@ export const ChannelPane = React.memo(function ChannelPane({ priorityIdleAuxiliary, replaceThreadWithIdleAuxiliary, showIdleAuxiliaryOverThread, + showMarkdownBesideThread, useFocusIdleDrawer, useFocusThreadDrawer, + useStackedMarkdownPanel, useSplitAuxiliaryPane, } = createChannelPaneAuxiliaryLayout({ + canFitThirdPanel: + channelContentWidthPx >= + threadPanelWidthPx * 2 + AUXILIARY_PANEL_MIN_WIDTH_PX, channelManagementOpen, hasAgentSession: Boolean(activeChannel && selectedAgent), hasIdleAuxiliaryPanel: Boolean(idleAuxiliaryPanel), @@ -456,14 +464,16 @@ export const ChannelPane = React.memo(function ChannelPane({ }); const { channelIsCovered, markExitComplete } = useFocusDrawerPresence( useFocusThreadDrawer || useFocusIdleDrawer, - priorityIdleAuxiliary - ? (onCloseIdleAuxiliaryPanel ?? onCloseThread) - : useFocusThreadDrawer - ? onCloseThread - : (onCloseIdleAuxiliaryPanel ?? onCloseThread), + useStackedMarkdownPanel && onCloseMarkdownDoc + ? onCloseMarkdownDoc + : priorityIdleAuxiliary + ? (onCloseIdleAuxiliaryPanel ?? onCloseThread) + : useFocusThreadDrawer + ? onCloseThread + : (onCloseIdleAuxiliaryPanel ?? onCloseThread), ); const threadSurface = useThreadPanelSurface( - showIdleAuxiliaryOverThread, + showIdleAuxiliaryOverThread || useStackedMarkdownPanel, markExitComplete, ); const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = @@ -989,9 +999,8 @@ export const ChannelPane = React.memo(function ChannelPane({ idleAuxiliarySurface )} - - {showIdleAuxiliaryOverThread ? idleAuxiliarySurface : null} - + {/* biome-ignore format: line-count ratchet in this legacy component */} + ); }); diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index cb4f3d65bd..902335e8ac 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -36,6 +36,8 @@ export type ChannelPaneProps = { onAutoSendComplete?: (() => void) | null; botTypingEntries: TypingIndicatorEntry[]; channelManagementOpen?: boolean; + /** Width of the channel content container, used for responsive pane topology. */ + channelContentWidthPx: number; currentPubkey?: string; editTarget?: MessageComposerEditTarget | null; fetchOlder?: () => Promise; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index e5b0e86a20..26c4192886 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -833,6 +833,7 @@ export function ChannelScreen({ void; useSplitAuxiliaryPane: boolean; @@ -21,6 +23,7 @@ type MarkdownDocAuxiliaryPanelProps = { */ export function MarkdownDocAuxiliaryPanel({ doc, + isFocusDrawer = false, isSinglePanelView, onClose, useSplitAuxiliaryPane, @@ -32,10 +35,13 @@ export function MarkdownDocAuxiliaryPanel({ diff --git a/desktop/src/features/channels/ui/MarkdownDocPanel.tsx b/desktop/src/features/channels/ui/MarkdownDocPanel.tsx index 50cdc2f39a..f20f247aa5 100644 --- a/desktop/src/features/channels/ui/MarkdownDocPanel.tsx +++ b/desktop/src/features/channels/ui/MarkdownDocPanel.tsx @@ -14,6 +14,7 @@ import { } from "@/features/channels/ui/markdownDocFocus"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; +import { cn } from "@/shared/lib/cn"; import { AuxiliaryPanel, AuxiliaryPanelBody, @@ -37,6 +38,8 @@ type MarkdownDocPanelProps = { url: string; /** Human-readable filename from the imeta `filename` field. */ filename: string; + /** Fill a parent focus drawer and center the document reading column. */ + isFocusMode?: boolean; isSinglePanelView?: boolean; layout?: "standalone" | "split"; onClose: () => void; @@ -68,6 +71,7 @@ function decodeErrorMessage(kind: "too-large" | "binary"): string { export function MarkdownDocPanel({ url, filename, + isFocusMode = false, isSinglePanelView = false, layout = "standalone", onClose, @@ -125,7 +129,8 @@ export function MarkdownDocPanel({ return ( } > - + {/* The view picker gets its own pinned row below the title: sharing the title row squeezed the filename out, and the header chrome band overlays anything placed directly after it in the header slot — so the row lives inside the chrome-padded body instead. */} {decoded?.kind === "ok" ? ( -
+
) : null}
- {docQuery.isPending ? ( -
- -
- ) : errorMessage !== null ? ( -
-

{errorMessage}

- -
- ) : decoded?.kind === "ok" ? ( - view === "preview" ? ( - - ) : ( -
+            {docQuery.isPending ? (
+              
- {/* Shiki's synchronous-tokenization guard caps highlighting at - 150 lines; longer documents render as plain text here. */} - +
+ ) : errorMessage !== null ? ( +
+

{errorMessage}

+ +
+ ) : decoded?.kind === "ok" ? ( + view === "preview" ? ( + -
- ) - ) : null} + ) : ( +
+                  {/* Shiki's synchronous-tokenization guard caps highlighting at
+                  150 lines; longer documents render as plain text here. */}
+                  
+                
+ ) + ) : null} +
diff --git a/desktop/src/features/channels/ui/channelPaneAuxiliaryLayout.test.mjs b/desktop/src/features/channels/ui/channelPaneAuxiliaryLayout.test.mjs index c9b0938ba0..00bc34d620 100644 --- a/desktop/src/features/channels/ui/channelPaneAuxiliaryLayout.test.mjs +++ b/desktop/src/features/channels/ui/channelPaneAuxiliaryLayout.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { createChannelPaneAuxiliaryLayout } from "./channelPaneAuxiliaryLayout.ts"; const base = { + canFitThirdPanel: false, channelManagementOpen: false, hasAgentSession: false, hasIdleAuxiliaryPanel: true, @@ -28,6 +29,35 @@ test("an open document suppresses idle-drawer coverage when the idle pane yields assert.equal(layout.useFocusIdleDrawer, false); }); +test("an open document stacks over an existing split thread", () => { + const layout = createChannelPaneAuxiliaryLayout({ + ...base, + hasThreadSurface: true, + }); + + assert.equal(layout.useStackedMarkdownPanel, true); + assert.deepEqual(layout.openMarkdownDoc, { + filename: "notes.md", + url: "http://localhost/media/notes.bin", + }); +}); + +test("a wide layout shows the document beside the existing thread", () => { + const layout = createChannelPaneAuxiliaryLayout({ + ...base, + canFitThirdPanel: true, + hasThreadSurface: true, + }); + + assert.equal(layout.showMarkdownBesideThread, true); + assert.equal(layout.useStackedMarkdownPanel, false); +}); + +test("a document without a thread remains in the ordinary auxiliary pane", () => { + const layout = createChannelPaneAuxiliaryLayout(base); + assert.equal(layout.useStackedMarkdownPanel, false); +}); + test("an explicitly selected idle pane wins rendering and owns drawer coverage", () => { const layout = createChannelPaneAuxiliaryLayout({ ...base, diff --git a/desktop/src/features/channels/ui/channelPaneAuxiliaryLayout.ts b/desktop/src/features/channels/ui/channelPaneAuxiliaryLayout.ts index 9e7d5d4171..2824535e93 100644 --- a/desktop/src/features/channels/ui/channelPaneAuxiliaryLayout.ts +++ b/desktop/src/features/channels/ui/channelPaneAuxiliaryLayout.ts @@ -4,6 +4,7 @@ import { } from "./ChannelPane.helpers"; type ChannelPaneAuxiliaryLayoutOptions = { + canFitThirdPanel: boolean; channelManagementOpen: boolean; hasAgentSession: boolean; hasIdleAuxiliaryPanel: boolean; @@ -19,6 +20,7 @@ type ChannelPaneAuxiliaryLayoutOptions = { }; export function createChannelPaneAuxiliaryLayout({ + canFitThirdPanel, channelManagementOpen, hasAgentSession, hasIdleAuxiliaryPanel, @@ -61,6 +63,18 @@ export function createChannelPaneAuxiliaryLayout({ }); const displayedMarkdownDoc = priorityIdleAuxiliary ? null : openMarkdownDoc; + const showMarkdownBesideThread = Boolean( + displayedMarkdownDoc && + hasThreadSurface && + useSplitAuxiliaryPane && + canFitThirdPanel, + ); + const useStackedMarkdownPanel = Boolean( + displayedMarkdownDoc && + hasThreadSurface && + useSplitAuxiliaryPane && + !showMarkdownBesideThread, + ); const hasSplitAuxiliaryPane = useSplitAuxiliaryPane && (channelManagementOpen || @@ -75,8 +89,10 @@ export function createChannelPaneAuxiliaryLayout({ replaceThreadWithIdleAuxiliary, showIdleAuxiliaryOverThread: overlayIdleAuxiliaryOverThread && useFocusIdleDrawer, + showMarkdownBesideThread, useFocusIdleDrawer, useFocusThreadDrawer, + useStackedMarkdownPanel, useSplitAuxiliaryPane, }; } diff --git a/desktop/src/features/channels/ui/useChannelPaneOpeners.ts b/desktop/src/features/channels/ui/useChannelPaneOpeners.ts index b1ecb45e0a..b399b970b1 100644 --- a/desktop/src/features/channels/ui/useChannelPaneOpeners.ts +++ b/desktop/src/features/channels/ui/useChannelPaneOpeners.ts @@ -80,22 +80,37 @@ export function useChannelPaneOpeners({ const handleOpenMarkdownDoc = React.useCallback( (doc: MarkdownDocTarget) => { - // Opening a doc closes the thread pane, so an in-progress thread edit - // must be resolved first — same contract as the sibling pane openers. - if (!requireThreadEditResolution()) return; - // Capture the invoking card's identity before the panel swap unmounts - // it, so close can restore focus to this card and not merely the first - // card sharing the document URL. + // Capture the invoking card's identity before a narrow-layout panel swap + // can unmount it. In wide layouts an open thread remains mounted beneath + // the document focus drawer, preserving its scroll and reply state. recordMarkdownDocOpener(doc.url, doc.opener ?? null); - clearCompetingPanes(); + // A document opened from the center timeline takes the ordinary right + // pane, replacing any open thread. A thread card (or URL-restored Inbox + // context, which has no live opener element) preserves that thread so + // ChannelPane can choose side-by-side vs stacked responsively. + if ( + doc.opener && + !doc.opener.closest('[data-testid="message-thread-panel"]') + ) { + setOpenThreadHeadId(null); + setExpandedThreadReplyIds(new Set()); + setThreadScrollTargetId(null); + setThreadReplyTargetId(null); + } + closeAgentSession(); + setProfilePanelPubkey(null); setChannelManagementOpen(false); openMarkdownDoc(doc.url, doc.filename); }, [ - clearCompetingPanes, + closeAgentSession, openMarkdownDoc, - requireThreadEditResolution, setChannelManagementOpen, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, ], ); diff --git a/desktop/src/features/home/ui/HomeMarkdownDocPanel.tsx b/desktop/src/features/home/ui/HomeMarkdownDocPanel.tsx new file mode 100644 index 0000000000..7613b4e721 --- /dev/null +++ b/desktop/src/features/home/ui/HomeMarkdownDocPanel.tsx @@ -0,0 +1,48 @@ +import type * as React from "react"; + +import { MarkdownDocAuxiliaryPanel } from "@/features/channels/ui/MarkdownDocAuxiliaryPanel"; +import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; +import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext"; + +type HomeMarkdownDocPanelProps = { + canResetWidth: boolean; + className?: string; + doc: MarkdownDocTarget; + onClose: () => void; + onResetWidth: () => void; + onResizeStart: (event: React.PointerEvent) => void; + testId: string; + widthPx: number; +}; + +/** Inbox-local Markdown viewer, rendered either as a third pane or a stack. */ +export function HomeMarkdownDocPanel({ + canResetWidth, + className, + doc, + onClose, + onResetWidth, + onResizeStart, + testId, + widthPx, +}: HomeMarkdownDocPanelProps) { + return ( + + + + ); +} diff --git a/desktop/src/features/home/ui/HomeMarkdownDocSurfaces.tsx b/desktop/src/features/home/ui/HomeMarkdownDocSurfaces.tsx new file mode 100644 index 0000000000..c3dba8796e --- /dev/null +++ b/desktop/src/features/home/ui/HomeMarkdownDocSurfaces.tsx @@ -0,0 +1,42 @@ +import type * as React from "react"; + +import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext"; +import { HomeMarkdownDocPanel } from "./HomeMarkdownDocPanel"; + +type Props = { + besideDetail: boolean; + canResetWidth: boolean; + doc: MarkdownDocTarget | null; + onClose: () => void; + onResetWidth: () => void; + onResizeStart: (event: React.PointerEvent) => void; + showDetail: boolean; + widthPx: number; +}; + +/** Responsive Inbox document surfaces: third pane when roomy, stack otherwise. */ +export function HomeMarkdownDocSurfaces(props: Props) { + if (!props.doc) return null; + return props.besideDetail ? ( + + ) : props.showDetail ? ( + + ) : null; +} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 0a16f27c4d..2dfebcd86f 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import { RefreshCcw } from "lucide-react"; - import { useAppShell } from "@/app/AppShellContext"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { useChannelsQuery } from "@/features/channels/hooks"; @@ -27,6 +26,7 @@ import { useHomeInboxAutoSelection } from "@/features/home/useHomeInboxAutoSelec import { useHomeInboxContextMessages } from "@/features/home/useHomeInboxContextMessages"; import { useHomePersonalInbox } from "@/features/home/useHomePersonalInbox"; import { useInboxThreadContext } from "@/features/home/useInboxThreadContext"; +import { useInboxMarkdownDoc } from "@/features/home/useInboxMarkdownDoc"; import { useHiddenDmInboxNavigation } from "@/features/home/useHiddenDmInboxNavigation"; import { type ProfilePanelTab, @@ -47,6 +47,7 @@ import { HomeLoadingState } from "@/features/home/ui/HomeLoadingState"; import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane"; import { InboxListPane } from "@/features/home/ui/InboxListPane"; import { HomePersonalInboxDetail } from "@/features/home/ui/HomePersonalInboxDetail"; +import { HomeMarkdownDocSurfaces } from "@/features/home/ui/HomeMarkdownDocSurfaces"; import { useChannelMessagesQuery, useToggleReactionMutation, @@ -73,14 +74,12 @@ import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { Button } from "@/shared/ui/button"; import { HomeMembersSidebarOverlay } from "./HomeMembersSidebarOverlay"; - const INBOX_SEARCH_KEYS = [ "item", "profile", "profileTab", "profileView", ] as const; - type HomeViewProps = { feed?: HomeFeedResponse; isLoading?: boolean; @@ -94,7 +93,6 @@ type HomeViewProps = { ) => void; onRefresh: () => void; }; - export function HomeView({ feed, isLoading = false, @@ -253,7 +251,6 @@ export function HomeView({ selectedEventId, availableChannelIds, }); - const threadContextFeedItem = activeLatchedItem; // Derive the default composer parent from the active anchor's own tags so // that InboxDetailPane can recover the original reply target even when the @@ -282,13 +279,6 @@ export function HomeView({ return channels.find((channel) => channel.id === managedChannelId) ?? null; }, [channels, managedChannelId]); const isChannelManagementOpen = managedChannel !== null; - const hasAuxiliaryPane = - isChannelManagementOpen || profilePanelPubkey !== null; - const isSinglePanelAuxiliaryView = - hasAuxiliaryPane && - homeInboxWidthPx > 0 && - homeInboxWidthPx < AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX; - const channelMessagesQuery = useChannelMessagesQuery(selectedChannel); const toggleReactionMutation = useToggleReactionMutation(); const channelMessages = channelMessagesQuery.data; @@ -307,7 +297,6 @@ export function HomeView({ selectedChannel, threadContext.refreshStructuralEvents, ); - const feedProfilePubkeys = React.useMemo( () => [ ...new Set([ @@ -349,13 +338,11 @@ export function HomeView({ const communityAgentPubkeys = useKnownAgentPubkeys(); const inboxAgentPubkeys = React.useMemo(() => { const pubkeys = new Set(communityAgentPubkeys); - for (const [pubkey, profile] of Object.entries(feedProfiles ?? {})) { if (profile.isAgent) { pubkeys.add(normalizePubkey(pubkey)); } } - return pubkeys; }, [feedProfiles, communityAgentPubkeys]); // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates the stable getChannelReadAt callback @@ -417,7 +404,25 @@ export function HomeView({ : null; const selectedConversationId = selectedItemFromAll?.conversationId ?? latchedConversationId; - + const { + besideDetail: inboxDocCanFitBesideDetail, + close: closeMarkdownDoc, + doc: markdownDoc, + open: openMarkdownDoc, + } = useInboxMarkdownDoc({ + conversationId: selectedConversationId, + homeWidthPx: homeInboxWidthPx, + inboxListWidthPx, + panelWidthPx: threadPanelWidthPx, + }); + const hasAuxiliaryPane = + isChannelManagementOpen || + profilePanelPubkey !== null || + inboxDocCanFitBesideDetail; + const isSinglePanelAuxiliaryView = + hasAuxiliaryPane && + homeInboxWidthPx > 0 && + homeInboxWidthPx < AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX; const filteredItems = React.useMemo(() => { return inboxItems.filter( (item) => @@ -523,7 +528,6 @@ export function HomeView({ setAutoSelectedEventId, urlSelectedItemId, }); - React.useEffect(() => { void selectedConversationId; setEmptyDeleteId(null); @@ -531,7 +535,6 @@ export function HomeView({ setIsDeletingMessage(false); setIsSendingReply(false); }, [selectedConversationId]); - const handleFilterChange = React.useCallback( (nextFilter: InboxFilter) => { const nextItems = inboxItems.filter( @@ -546,12 +549,10 @@ export function HomeView({ items: nextItems, selectedConversationId, }); - setUnreadBoundary(null); setSelectedDraftKey(null); setSelectedReminderId(null); setFilter(nextFilter); - if ( nextFilter === "reminders" || nextFilter === "drafts" || @@ -563,7 +564,6 @@ export function HomeView({ } return; } - applyInboxSearchPatch({ item: null }); setAutoSelectedEventId(selection.autoSelectedEventId); }, @@ -579,11 +579,9 @@ export function HomeView({ unreadOnly, ], ); - if (isLoading && !feed) { return ; } - if (!feed) { return (
@@ -604,7 +602,6 @@ export function HomeView({
); } - const { canDelete, canReact, canReply, disabledReplyReason } = getHomeMessageCapabilities( selectedItem, @@ -642,7 +639,6 @@ export function HomeView({ selectedReminder: selectedReminder !== null, threadPanelWidthPx, }); - return ( ) : null} - {showListPane ? ( ) : null} - - {showDetailPane && detailMode === "messages" ? ( ) : null} +
void; + onOpenMarkdownDoc: (doc: MarkdownDocTarget) => void; /** True while the selected hidden DM is being reopened on the relay. */ reopenPending?: boolean; /** True when the last reopen of the selected hidden DM failed. */ @@ -157,7 +162,9 @@ export function InboxDetailPane(props: InboxDetailPaneProps) { return ( - + + + ); } diff --git a/desktop/src/features/home/useInboxMarkdownDoc.ts b/desktop/src/features/home/useInboxMarkdownDoc.ts new file mode 100644 index 0000000000..696ffea4cd --- /dev/null +++ b/desktop/src/features/home/useInboxMarkdownDoc.ts @@ -0,0 +1,37 @@ +import * as React from "react"; + +import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext"; +import { AUXILIARY_PANEL_MIN_WIDTH_PX } from "@/shared/layout/AuxiliaryPanel"; + +/** Owns Inbox-local document state and its responsive third-pane threshold. */ +export function useInboxMarkdownDoc({ + conversationId, + homeWidthPx, + inboxListWidthPx, + panelWidthPx, +}: { + conversationId: string | null; + homeWidthPx: number; + inboxListWidthPx: number; + panelWidthPx: number; +}) { + const [state, setState] = React.useState<{ + conversationId: string | null; + doc: MarkdownDocTarget | null; + }>({ conversationId, doc: null }); + const doc = state.conversationId === conversationId ? state.doc : null; + const open = React.useCallback( + (next: MarkdownDocTarget | null) => setState({ conversationId, doc: next }), + [conversationId], + ); + const besideDetail = + doc !== null && + homeWidthPx >= + inboxListWidthPx + panelWidthPx + AUXILIARY_PANEL_MIN_WIDTH_PX; + return { + besideDetail, + close: React.useCallback(() => open(null), [open]), + doc, + open, + }; +} diff --git a/desktop/src/shared/ui/markdown/FileCard.tsx b/desktop/src/shared/ui/markdown/FileCard.tsx index 764a2bc4c3..b96cf28c93 100644 --- a/desktop/src/shared/ui/markdown/FileCard.tsx +++ b/desktop/src/shared/ui/markdown/FileCard.tsx @@ -5,13 +5,11 @@ import { toast } from "sonner"; import { invokeTauri } from "@/shared/api/tauri"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; -import { isRelayDownloadable } from "./mediaEntry"; import { isMarkdownDocFilename, MAX_MARKDOWN_DOC_BYTES, } 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 { @@ -38,10 +36,10 @@ function formatFileSize(bytes: number): string { * 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_markdown_doc_bytes` command (native 2 MiB cap), which accepts - * relay `/media/` origins alone. + * in-app markdown viewer panel instead when a hosting surface provides one. + * The authenticated `fetch_markdown_doc_bytes` command owns the authoritative + * relay-origin check and native 2 MiB cap; duplicating its origin check here + * makes the affordance depend on asynchronous frontend cache timing. */ export function FileCard({ href, @@ -56,14 +54,17 @@ export function FileCard({ const sizeLabel = size != null ? formatFileSize(size) : ""; useSmoothCorners(cardRef); const openMarkdownDoc = useMarkdownDocViewer(); - const { relayOrigin } = useMarkdownRuntime(); + const isMarkdownDoc = isMarkdownDocFilename(filename); + const isWithinPreviewSize = size == null || size <= MAX_MARKDOWN_DOC_BYTES; const opensInViewer = - openMarkdownDoc !== null && - isMarkdownDocFilename(filename) && - // Advertised-size pre-gate: skip fetching a doc the viewer would refuse - // anyway. The imeta size is untrusted, so the decoder re-checks post-fetch. - (size == null || size <= MAX_MARKDOWN_DOC_BYTES) && - isRelayDownloadable(href, relayOrigin ?? undefined); + openMarkdownDoc !== null && isMarkdownDoc && isWithinPreviewSize; + const downloadReason = !isMarkdownDoc + ? "not-markdown" + : !isWithinPreviewSize + ? "too-large" + : openMarkdownDoc === null + ? "viewer-unavailable" + : undefined; return (