Files
multica/packages/views/editor/attachment.tsx
Naiyuan Qing 5a11232c47 feat(rich-content): unify Chat and Issue/Comment on one RichContent renderer (MUL-4922) (#5578)
* refactor(markdown): single canonical sanitize schema for both renderers (MUL-4922)

Phase 1 of the RichContent convergence: collapse the duplicated security
base shared by the two product-level Markdown chains.

Chat (packages/ui/markdown/Markdown.tsx) and Issue/Comment
(packages/views/editor/readonly-content.tsx) each carried a verbatim fork
of the rehype-sanitize schema and urlTransform, and the forks had already
drifted: readonly whitelisted <mark> for `==highlight==`, chat did not.
A security-relevant allow-list maintained in two places means every future
XSS fix has to land twice, and missing one is a hole — this is the hardest
reason for the sweep, ahead of the user-visible feature drift.

- Extract markdownSanitizeSchema + markdownUrlTransform into
  packages/ui/markdown/sanitize.ts and export from the package index.
  Both chains now import the single copy; no local forks remain.
- The canonical schema is the union, so chat gains the <mark> tag name.
  This is the one intentional behavior delta: <mark> is inert and admits
  no attributes, and chat needs it anyway once ==highlight== converges.
- Annotate the schema as rehype-sanitize's Options: exporting it makes the
  previously-inferred hast-util-sanitize type unnameable across packages.

Adds a cross-surface contract test that runs one set of security fixtures
(script, event handlers, javascript: href, data:image vs data:text/html,
mark, slash://) through BOTH surfaces and asserts identical outcomes —
the mechanism that stops a third fork from growing back.

Code-block rendering is deliberately not asserted cross-surface yet: chat
highlights with Shiki, readonly with lowlight, so emitted class tokens
still differ. Converging them is the RichCodeBlock phase and needs the
highlight-engine decision first; only the schema-level allow-list is
shared here.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
pnpm vitest run in packages/views (228 files, 2665 tests, all passing).

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(rich-content): one RichContent renderer for Chat and Issue/Comment (MUL-4922)

Phase 2+3 of the convergence. Chat now renders agent output through the same
product renderer as Issue descriptions and Comments, so a ```mermaid fence an
agent emits is a diagram in Chat — not a dead code block.

Before this there were two product Markdown chains. Issue/Comment had Mermaid,
HTML preview, lowlight code, product Mention/Attachment/LinkHover; Chat went
through a generic renderer with no Mermaid/HTML dispatcher at all. Chat is
where agents emit the most diagrams, so the gap was most visible exactly where
it mattered.

Architecture
- packages/views/rich-content/ holds the ONE product renderer: a single
  ReactMarkdown pipeline, one sanitize config, one components map, one fenced
  -code dispatcher. Public API is content/attachments/density/phase — no
  `surface` prop, no renderMention override, no custom code-renderer hook,
  because each is a door a per-surface fork walks back through.
- `density` is CSS only; `phase` is lifecycle only. Neither switches parser,
  plugins or the semantic DOM, so all surfaces emit the same blocks.
- ReadonlyContent is now a ~40-line compatibility wrapper (was 501 lines);
  its consumers are untouched. views/common/markdown.tsx is deleted.
- Leaf components (Mermaid, HTML preview, lowlight code) stay in editor/ and
  are imported by direct path, so Tiptap keeps reusing them and Chat does not
  pull in the editor's Tiptap graph. Moving them is a later mechanical commit.

Streaming fence gate
Rich blocks upgrade only when the fence is CLOSED, judged by a real CommonMark
parse (mdast) rather than a `startsWith("```")` scan. A half-streamed fence
renders as source, so Mermaid never parses a partial diagram and no iframe is
created for HTML still arriving. Closedness — not `phase` — is the gate, so a
settled-but-malformed fence stays source too. The gate returns offsets only; it
never renders, because a gate that rendered would be the second renderer this
change exists to delete. Verified that source offsets survive rehype-raw +
sanitize + katex before relying on them.

Live -> persisted row identity
The live timeline moved out of Virtuoso's Footer into a real row keyed
`task:<taskId>`, and persisted assistant rows key on the same task instead of
`message.id`. Completion is now an in-place data swap through one
AssistantMessage component, so an already-rendered diagram or iframe stays
mounted and is not re-run. The process fold previously relied on that remount
to re-collapse, so the collapse is now expressed directly.

Notes
- Chat code blocks move from Shiki to lowlight (Howard's ratified choice),
  matching Tiptap/Readonly and the existing hljs CSS. Ligature suppression
  carries over via code.css instead of utility classes.
- Chat message tests now stub react-virtuoso: real Virtuoso renders its Footer
  but no data rows under jsdom's zero-height viewport, and the live timeline is
  a row now.

Tests: five-surface Mermaid parity (Chat user / live / persisted, Issue
description, Comment), streaming open->closed->settled with mount counting,
live->persisted no-remount, htmlbars/mermaidx not misdispatched, sandbox and
data-URI security regressions, CommonMark fence edge cases (shorter closer,
tilde, indented, nested, in-list), and source-level import boundary guards.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 240 files / 2743 tests all passing.

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(rich-content): drop dangling export, add export guard + lazy rich blocks (MUL-4922)

Two review follow-ups.

1. Dangling package export
`packages/views` still exported "./common/markdown" after the file was
deleted in the RichContent convergence. TypeScript resolves against the
source tree so typecheck and unit tests both passed; the subpath would only
fail when a consuming app bundled it. No consumer imports it, so this was
latent rather than broken in practice.

Removes the entry and adds packages/views/rich-content/package-exports.test.ts,
which walks every workspace package.json and asserts each export target
exists (wildcards checked by directory prefix). Scoped repo-wide because the
failure mode belongs to package.json, not to this package. Verified the guard
actually fails by re-adding the dangling entry before committing.

2. Near-viewport lazy shell for Mermaid / HTML
Implements the deferred performance contract rather than requesting an
exemption. LazyRichBlock defers each rich leaf until it is within 800px of
the viewport, then latches: once mounted it is never unmounted, so scrolling
past does not re-run Mermaid, rebuild the sandboxed iframe, or discard the
viewer's pan/zoom state.

The stable-size requirement is handled by reserving the block's expected
height before AND after mount, so a block never measures 0px off-screen and
then jumps — the churn that makes a virtualized list mis-estimate item sizes.
The reservation is not a local guess: reservedMermaidHeightPx() reuses the
existing session layout cache (real height on a cache hit, else the documented
280px skeleton) and HTML_BLOCK_PREVIEW_HEIGHT_PX is the pixel twin of the
preview iframe's fixed h-[480px]. Both are exported from the leaves so there
is one source of truth per block type.

Environments without IntersectionObserver (jsdom, SSR) mount eagerly, which is
today's behaviour; rendering nothing would not be.

Verified in real Chromium (jsdom has no IntersectionObserver, so unit tests
only exercise the eager fallback):
- Non-virtualized Issue/Comment with 25 diagrams: 3 mounted, 22 deferred on
  load; after scrolling, mounted grows 3 -> 6 and never drops, confirming the
  latch. This is where the win is real.
- Chat gains less: Virtuoso already caps the DOM to ~4 rows, so only 1 of 4
  shells was deferred. Stated rather than overclaimed.
- Live -> persisted handoff re-checked with the shell in place: scrollTop
  220 -> 220 (delta 0), the tagged diagram DOM node survived, and the run
  reached the persisted state. Contract 1 is not regressed.

Tests: 6 lazy-shell cases (defer, mount-on-approach, latch/no-unmount, equal
reserved height before and after mount, root margin exceeding the chat list's
own overscan, no-IntersectionObserver fallback) plus 3 export-guard cases.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 242 files / 2752 tests all passing.

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(rich-content): SSR-deterministic lazy state, mention a11y, drop type casts (MUL-4922)

Three review blockers.

1. Hydration mismatch in the lazy shell
LazyRichBlock derived its initial `mounted` state from feature detection
inside useState. On the server (no window) that resolved true and rendered
the whole Mermaid/HTML subtree; in the browser (IntersectionObserver present)
the hydration pass resolved false and rendered a placeholder — different
markup for the same component, and an SSR path that silently bypassed the
lazy gate. `"use client"` does not opt a component out of Next's server
render, so this was reachable.

Initial state is now unconditionally false on both sides. The eager fallback
for environments without IntersectionObserver moved into the effect, which
never runs on the server, so the first committed frame is identical
everywhere and the latch is unchanged.

The first version of the SSR test passed against the buggy component: jsdom
always provides `window`, so server and client took the same detection branch
and the mismatch was unobservable. The suite now removes IntersectionObserver
for the duration of renderToString to reproduce the real asymmetry. Verified
by reinstating the bug: 2 of the 3 SSR tests fail and React raises its own
"Hydration failed" error.

2. Project mention lost keyboard access
The unified renderer inherited the readonly surface's `<span onClick>` around
ProjectChip, while Chat had previously used AppLink — so converging the
surfaces regressed Chat from a focusable anchor to a mouse-only span. A span
and an anchor are visually identical and behave the same under a mouse, which
is why only an assertion on the emitted element catches it.

Now rendered through AppLink, which also owns plain-click, modifier-click and
the desktop new-tab adapter, so none of that is reimplemented. The wrapper
keeps only stopPropagation, matching IssueMentionCard.

Adds project-mention-a11y.test.tsx using the REAL AppLink and
NavigationProvider — mocking AppLink to emit an anchor would assert the mock.
Covers anchor + href, chip's nearest interactive ancestor, Tab focus, Enter
activation, click, and modifier-click labelling. All 6 fail on the old span.

3. Type suppressions in the production renderer
`as never` on the plugin lists and `as NonNullable<Components[...]>` on the
code/pre overrides silenced real type errors, against the repo's strict-TS
rule. Replaced with react-markdown's own types: RichCode/RichPre now derive
their props from `ComponentPropsWithoutRef<tag> & ExtraProps`, and the plugin
lists use `satisfies NonNullable<Options["remarkPlugins" | "rehypePlugins"]>`
so a bad plugin tuple still fails to compile.

Also removed the remaining casts this exposed: hast property reads went
through `as string` even though hast property values are a union, so a
non-string `data-*` attribute would have been typed as a lie — replaced with
a runtime-narrowing helper. `toHtml()` already returns string; its cast was
redundant. Node position reads are narrowed in one helper. No cast or
ts-ignore remains in production rich-content.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 243 files / 2780 tests all passing.

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(rich-content): cached-height hydration, scroll-root + recycle latch, CDN reactivity (MUL-4922)

Three review blockers.

1. Cached reserved height still reached the first frame
The previous fix made `mounted` deterministic but left the *height* reading
sessionStorage during render: RichFenceBlock called reservedMermaidHeightPx()
inline. Server has no sessionStorage so it emitted 280px, while a browser with
a warm cache emitted the real height — a differing style="min-height:…" on the
frame React hydrates, which React reports as an attribute mismatch and does
not repair. Same bug class as the one already fixed, one layer up.

RichFenceBlock now splits into Mermaid/HTML components (so the height hook is
never conditional) and reserves the skeleton default on the first frame,
adopting the cached height in an effect. Zero-shift on a warm cache is kept;
only the read moves after hydration.

The earlier SSR suite passed a fixed reservedHeightPx straight to the lazy
shell, bypassing this path — it could not have caught this. New tests drive the
real RichFenceBlock with a real prefilled sessionStorage entry, and simulate
the server by removing sessionStorage for the renderToString call (jsdom
provides one, so without that the "server" takes the browser branch and the
mismatch is invisible).

2. Wrong observer root, and a latch that died with the row
The IntersectionObserver set only rootMargin, so it clipped against the
viewport — but Chat scrolls inside its own element (Virtuoso
customScrollParent). Expanding the viewport box says nothing about a nested
scroller, so Chat blocks only loaded once already visible and the preload was
effectively dead there. Surfaces now publish their scroll container through
RichContentScrollRootProvider and the observer uses it as `root`; page-scrolled
surfaces keep the viewport root.

Separately, the mount latch was component state, so Virtuoso recycling a row
discarded it and scrolling back re-ran Mermaid, rebuilt the iframe and dropped
viewer pan/zoom — the per-pass cost the shell exists to prevent. The latch moved
to a module-level registry keyed by a hash of the content, bounded at 500
entries with oldest-first eviction. Restore happens in a layout effect (before
paint, so no placeholder flash) and never in initial state, keeping the
server/client first frame identical. Scope was not narrowed.

3. CDN config arriving late never reprocessed content
preprocessMarkdown read configStore.getState().cdnDomain imperatively and
RichContent's memo depended only on `content`, so content rendered before the
async config landed kept legacy CDN links as plain anchors permanently.
cdnDomain is now an explicit parameter: RichContent subscribes via
useConfigStore and puts it in the memo deps, while the Tiptap editor — which
genuinely preprocesses once at load — reads the store at its own call sites. No
fallback branch.

Each fix was verified by reinstating its bug and confirming the new tests fail
(2/5, 2/14 and 1/3 respectively) rather than trusting a green run.

Also fixes a false positive in the boundary guard: the Tiptap check read raw
file text instead of using the suite's stripComments helper, so a doc comment
mentioning RichContent counted as a violation.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 245 files / 2793 tests all passing.

Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:40:00 +08:00

581 lines
22 KiB
TypeScript

"use client";
/**
* Attachment — single unified renderer for every attachment surface.
*
* Takes one attachment-shaped input (a full record, a URL-only reference, or
* an in-flight upload) and dispatches by PreviewKind:
*
* - image → ImageAttachmentView (figure + hover toolbar + lightbox via
* the shared AttachmentPreviewModal)
* - html → HtmlAttachmentPreview (inline iframe + hover toolbar)
* - others → AttachmentCard (icon + filename + Eye/Download row)
*
* Call sites:
* - extensions/file-card.tsx FileCardView (Tiptap NodeView)
* - extensions/image-view.tsx ImageView (Tiptap NodeView)
* - rich-content/rich-content.tsx (markdown img + fileCard div renderers,
* serving Chat, Issue descriptions and Comments through one renderer)
* - issues/components/comment-card.tsx AttachmentList (standalone fallback)
*
* The component owns its own preview modal and download dispatcher — callers
* just pass `attachment` and (for editor surfaces) optional editor chrome
* hints (selected, editable, onDelete).
*/
import {
Download,
Link as LinkIcon,
Maximize2,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { useQuery } from "@tanstack/react-query";
import { api } from "@multica/core/api";
import { useConfigStore } from "@multica/core/config";
import type { Attachment as AttachmentRecord } from "@multica/core/types";
import { attachmentIdFromDownloadURL } from "@multica/core/types/attachment-url";
import { useT } from "../i18n";
import { useAttachmentDownloadResolver } from "./attachment-download-context";
import { useAttachmentPreview } from "./attachment-preview-modal";
import { useDownloadAttachment } from "./use-download-attachment";
import { AttachmentCard } from "./attachment-card";
import { HtmlAttachmentPreview } from "./html-attachment-preview";
import { getPreviewKind, type PreviewKind } from "./utils/preview";
import "./styles/attachment.css";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export type AttachmentInput =
// Server response in hand — full record. Used by AttachmentList and any
// caller iterating a server-returned attachments[] array.
| { kind: "record"; attachment: AttachmentRecord }
// Markdown / Tiptap inline: only a URL + filename. Resolves to a full
// record via the surrounding AttachmentDownloadProvider when available;
// otherwise renders in URL-only mode (media types still preview from URL,
// text types fall back to a download CTA).
| {
kind: "url";
url: string;
filename: string;
contentType?: string;
/** Editor in-flight state. Renders a loader placeholder. */
uploading?: boolean;
/**
* Intrinsic pixel dimensions. Rendered as `<img width height>` so the
* browser reserves the box before the image decodes — prevents the
* layout shift that would otherwise push the caret out of view on paste.
*/
width?: number;
height?: number;
/**
* Structural hint from the call site: "this slot is definitionally an
* image / file / ...". Bypasses `getPreviewKind` autodetect, which
* needs a filename or content-type and falls back to the file-card
* chrome when neither is available. Required for callers that KNOW
* the kind from context (markdown `![]()` is always an image; Tiptap
* image NodeView is always an image) but receive only a URL with an
* empty `alt`/`filename`.
*/
forceKind?: PreviewKind;
};
export interface AttachmentProps {
attachment: AttachmentInput;
/** Editor hint — when true, the image toolbar exposes Trash. */
editable?: boolean;
/** Editor hint — applies the "selected" visual to the image figure. */
selected?: boolean;
/** Editor hint — wired to Tiptap deleteNode(). */
onDelete?: () => void;
className?: string;
}
interface Normalized {
filename: string;
contentType: string;
url: string;
attachmentId?: string;
record?: AttachmentRecord;
uploading: boolean;
width?: number;
height?: number;
}
function normalize(
input: AttachmentInput,
resolve: (url: string) => AttachmentRecord | undefined,
cdnDomain: string,
cdnSigned: boolean,
): Normalized {
if (input.kind === "record") {
return {
filename: input.attachment.filename,
contentType: input.attachment.content_type,
url: absolutizeMediaURL(
pickInlineMediaURL(input.attachment, input.attachment.url, cdnDomain, cdnSigned),
),
attachmentId: input.attachment.id,
record: input.attachment,
uploading: false,
};
}
const record = input.url ? resolve(input.url) : undefined;
return {
filename: input.filename || record?.filename || "",
contentType: input.contentType || record?.content_type || "",
// When the markdown URL resolved to an attachment record, swap to
// the record's freshly-loadable URL. The persisted markdown URL
// (`/api/attachments/<id>/download` for new content; raw stored URL
// for legacy) is correct as a stable reference but doesn't
// necessarily load as a native <img>/<video> resource for every
// client — token-mode clients can't attach an Authorization header
// to bare /api/* fetches, and a CloudFront-signed `download_url`
// is the only working media src in that mode. `pickInlineMediaURL`
// picks the URL with embedded credentials when one exists and
// falls back to the input URL otherwise so legacy / unresolved
// markdown stays on its existing path. See MUL-3130 review.
//
// After picking the credential-bearing URL we run the absolutize
// pass so a site-relative `/api/attachments/...` or `/uploads/...`
// path becomes a proper origin-bearing URL when the renderer's
// document origin doesn't proxy /api or /uploads to the API host
// (Electron desktop, mobile webview). Web with a same-origin
// proxy keeps `apiBaseUrl=""` and the helper is a no-op there.
// See MUL-3192 — quick-create modal regressed because the freshly-
// uploaded image URL stayed site-relative and Electron's renderer
// origin (file://) couldn't load it.
url: absolutizeMediaURL(
record ? pickInlineMediaURL(record, input.url, cdnDomain, cdnSigned) : input.url,
),
attachmentId: record?.id,
record,
uploading: !!input.uploading,
width: input.width,
height: input.height,
};
}
// absolutizeMediaURL is the legacy-compat fallback for old markdown bodies
// that persisted a site-relative `/api/attachments/<id>/download` or
// `/uploads/<key>` URL.
//
// The current (post-MUL-3192) write path persists an absolute URL chosen
// server-side by `buildMarkdownURL` (see server/internal/handler/file.go),
// so new content already loads natively on every client. This helper only
// matters for content written BEFORE MUL-3192 — those bodies still carry
// the old relative shape, and rendering them on a surface whose document
// origin is NOT the API host (Electron desktop, mobile webview) needs the
// API base URL pinned in at render time.
//
// On web, `api.getBaseUrl()` is empty (the Next.js rewrite proxies /api/*
// to the API host server-side), so this is a no-op there.
//
// http(s)://, blob:, and data: URLs are passed through unchanged — they
// already carry their own origin.
function absolutizeMediaURL(rawUrl: string): string {
if (!rawUrl) return rawUrl;
if (/^https?:\/\//i.test(rawUrl)) return rawUrl;
if (/^blob:/i.test(rawUrl) || /^data:/i.test(rawUrl)) return rawUrl;
if (!rawUrl.startsWith("/")) return rawUrl;
// The api singleton is a Proxy that returns `undefined` for any property
// access before `setApiInstance()` runs (boot ordering, SSR). Optional
// chaining lets us cope with that without throwing — pre-init renders
// simply keep the site-relative path.
const baseUrl = (api.getBaseUrl?.() ?? "").replace(/\/+$/, "");
if (!baseUrl) return rawUrl;
return `${baseUrl}${rawUrl}`;
}
// pickInlineMediaURL returns the URL most likely to load successfully
// inside a native <img>/<video>/<iframe> resource fetch — i.e. without
// the calling client attaching an Authorization header.
//
// The metadata response carries three URL fields per attachment row,
// each with a different lifetime / accessibility:
//
// - `record.download_url` — this-response click-time URL. In
// CloudFront-signed mode this is the
// signed redirect (works as a native img
// src for the duration of the TTL); in
// other modes it's the bare API endpoint
// (`/api/attachments/<id>/download`) which
// requires per-request auth and does NOT
// load as a native img on a non-same-site
// origin like Desktop's file://.
// - `record.markdown_url` — the durable URL the server picked for
// persistence (MUL-3192 / `buildMarkdownURL`):
// public CDN passthrough when the storage is
// public-readable, or `MULTICA_PUBLIC_URL +
// /api/attachments/<id>/download` for
// private-bucket modes. Aligned with the
// server-side policy by construction, so it
// beats `record.url` whenever both exist.
// - `record.url` — raw storage URL. May be private (S3 /
// CloudFront-signed, R2, MinIO) and unable
// to load directly. Last-resort fallback
// for legacy responses that omit
// `markdown_url`.
//
// Order:
//
// 1. Signed `download_url` — when CloudFront has minted a signed
// redirect for THIS response, use it; the TTL means the signed URL
// beats `markdown_url` on first paint (no extra hop through the
// API endpoint), and the renderer doesn't persist it so the TTL is
// not a problem.
// 2. Known CDN `record.url` — when `/api/config` exposes the same CDN
// host as the attachment record, the browser can load the object
// directly (public CDN, or CloudFront cookie mode). Prefer it over
// an API-shaped `markdown_url` so the rendered `<img src>` and Copy
// Link affordance expose the CDN URL while the persisted markdown
// can remain the stable attachment endpoint. Skipped when the server
// reports `cdn_signed` — in CloudFront signed-URL mode the same
// domain serves PRIVATE content and a raw (unsigned) storage URL is
// a guaranteed 403 (MUL-3254).
// 3. Local disk `record.url` — self-host LocalStorage without
// LOCAL_UPLOAD_BASE_URL stores a site-relative `/uploads/...` path.
// It is the direct static object URL and is loadable once
// `absolutizeMediaURL` prefixes apiBaseUrl in split-origin clients.
// 4. `record.markdown_url` — the durable, server-policy-aligned URL.
// Beats raw `record.url` because it never points at a private
// bucket (must-fix 2 from MUL-3192 review), except for the explicit
// site-relative local upload path above.
// 5. `record.url` — legacy fallback for responses that omit
// `markdown_url` (a backend old enough to predate MUL-3192).
// 6. The input URL — when there's no record at all.
function pickInlineMediaURL(
record: AttachmentRecord,
fallback: string,
cdnDomain: string,
cdnSigned: boolean,
): string {
const dl = record.download_url ?? "";
if (
/^https?:\/\//i.test(dl) &&
/[?&](Signature|X-Amz-Signature|Key-Pair-Id|Expires|X-Amz-Expires)=/i.test(dl)
) {
return dl;
}
if (!cdnSigned && storageURLMatchesCdnDomain(record.url, cdnDomain)) return record.url;
if (isSiteRelativeLocalUploadURL(record.url)) return record.url;
if (record.markdown_url) return record.markdown_url;
if (record.url) return record.url;
return fallback;
}
function isSiteRelativeLocalUploadURL(rawURL: string): boolean {
if (!rawURL || !rawURL.startsWith("/")) return false;
const path = rawURL.split(/[?#]/, 1)[0] ?? "";
return path === "/uploads" || path.startsWith("/uploads/");
}
function storageURLMatchesCdnDomain(rawURL: string, cdnDomain: string): boolean {
const expected = normalizeHost(cdnDomain);
if (!rawURL || !expected) return false;
try {
const u = new URL(rawURL);
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
if (normalizeHost(u.hostname) !== expected) return false;
return !hasExpiringSignatureQuery(u.searchParams);
} catch {
return false;
}
}
function normalizeHost(host: string): string {
return host.trim().toLowerCase().replace(/\.$/, "");
}
function hasExpiringSignatureQuery(q: URLSearchParams): boolean {
for (const key of [
"Signature",
"X-Amz-Signature",
"Key-Pair-Id",
"Expires",
"X-Amz-Expires",
]) {
if (q.has(key)) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Inline media re-sign (MUL-3254)
// ---------------------------------------------------------------------------
// Keep refetches well inside the server's signed-URL TTL (30 min default,
// server/internal/handler/file.go) so a re-render never serves an expired
// signature from the query cache.
const RESIGN_STALE_MS = 20 * 60 * 1000;
// useResignedInlineMediaURL upgrades an auth-gated media URL to a freshly
// signed one for clients that cannot load `/api/attachments/<id>/download`
// natively.
//
// The picked inline URL can end up being the stable per-attachment API
// endpoint (e.g. a reopened issue draft, whose persisted record deliberately
// strips the short-lived signed `download_url`). That endpoint needs
// credentials: web loads it because the session cookie rides on the <img>
// request (same-site), but Desktop's file:// renderer and the mobile webview
// are cross-site — no cookie is attached and the Bearer token cannot be put
// on a native resource fetch, so the image 401s. Those clients are exactly
// the ones with a non-empty `api.getBaseUrl()` (no same-origin /api proxy),
// which is the existing platform signal `absolutizeMediaURL` keys off.
//
// For them, fetch fresh attachment metadata through the authenticated API —
// the same re-sign the click-time download path already does — and swap in
// the response's signed `download_url`. When the server has no signed URL to
// offer (non-CloudFront deployments return the API path again), keep the
// original URL rather than looping.
function useResignedInlineMediaURL(
attachmentId: string | undefined,
pickedUrl: string,
): string {
const idFromPickedUrl = attachmentIdFromDownloadURL(pickedUrl);
const resignAttachmentId = attachmentId ?? idFromPickedUrl;
const needsResign =
!!resignAttachmentId &&
!!pickedUrl &&
idFromPickedUrl !== undefined &&
(api.getBaseUrl?.() ?? "") !== "";
const { data: fresh } = useQuery({
queryKey: ["attachment-inline-resign", resignAttachmentId],
queryFn: () => api.getAttachment(resignAttachmentId as string),
enabled: needsResign,
staleTime: RESIGN_STALE_MS,
gcTime: RESIGN_STALE_MS,
});
if (!needsResign) return pickedUrl;
const dl = fresh?.download_url ?? "";
// Accept the fresh URL only when it is an actual upgrade — absolute and no
// longer the auth-gated API shape (i.e. a signed storage URL the renderer
// can load natively).
if (/^https?:\/\//i.test(dl) && attachmentIdFromDownloadURL(dl) === undefined) {
return dl;
}
return pickedUrl;
}
// ---------------------------------------------------------------------------
// Dispatcher
// ---------------------------------------------------------------------------
export function Attachment({
attachment,
editable,
selected,
onDelete,
className,
}: AttachmentProps) {
const { resolveAttachment, openByUrl } = useAttachmentDownloadResolver();
const cdnDomain = useConfigStore((s) => s.cdnDomain);
const cdnSigned = useConfigStore((s) => s.cdnSigned);
const download = useDownloadAttachment();
const preview = useAttachmentPreview();
const state = normalize(attachment, resolveAttachment, cdnDomain, cdnSigned);
// The picked URL may still be the auth-gated API endpoint (reopened drafts
// whose persisted record has no signed download_url). Upgrade it to a
// freshly signed URL on clients that can't load the endpoint natively.
const mediaUrl = useResignedInlineMediaURL(state.attachmentId, state.url);
const forceKind =
attachment.kind === "url" ? attachment.forceKind : undefined;
const kind =
forceKind ??
(state.filename || state.contentType
? getPreviewKind(state.contentType, state.filename)
: null);
const openPreview = () => {
if (state.record) {
preview.tryOpen({
kind: "full",
attachment: {
...state.record,
download_url: mediaUrl || state.record.download_url,
},
});
return;
}
if (mediaUrl) {
preview.tryOpen({
kind: "url",
url: mediaUrl,
filename: state.filename,
});
}
};
const handleDownload = () => {
if (state.attachmentId) {
download(state.attachmentId);
return;
}
if (mediaUrl) openByUrl(mediaUrl);
};
if (kind === "image") {
return (
<>
<ImageAttachmentView
src={mediaUrl}
alt={state.filename}
uploading={state.uploading}
width={state.width}
height={state.height}
editable={editable}
selected={selected}
onView={openPreview}
onDownload={handleDownload}
onDelete={onDelete}
className={className}
/>
{preview.modal}
</>
);
}
if (kind === "html" && state.attachmentId && !state.uploading) {
return (
<>
<HtmlAttachmentPreview
attachmentId={state.attachmentId}
filename={state.filename}
onPreview={openPreview}
onDownload={handleDownload}
onDelete={editable ? onDelete : undefined}
/>
{preview.modal}
</>
);
}
return (
<>
<AttachmentCard
filename={state.filename}
contentType={state.contentType}
attachmentId={state.attachmentId}
href={mediaUrl || undefined}
uploading={state.uploading}
onPreview={openPreview}
onDownload={handleDownload}
onDelete={editable ? onDelete : undefined}
/>
{preview.modal}
</>
);
}
// ---------------------------------------------------------------------------
// ImageAttachmentView — inline image with hover toolbar
// ---------------------------------------------------------------------------
//
// DOM and styling are intentionally a direct port of the original
// extensions/image-view.tsx <figure> structure. Shared visual styles live in
// styles/attachment.css under `.image-figure / .image-content / .image-toolbar`
// so standalone surfaces (chat messages, AttachmentList) get identical visuals
// without depending on the editor stylesheet being imported elsewhere.
interface ImageAttachmentViewProps {
src: string;
alt: string;
uploading: boolean;
width?: number;
height?: number;
editable?: boolean;
selected?: boolean;
onView: () => void;
onDownload: () => void;
onDelete?: () => void;
className?: string;
}
function ImageAttachmentView({
src,
alt,
uploading,
width,
height,
editable,
selected,
onView,
onDownload,
onDelete,
className,
}: ImageAttachmentViewProps) {
const { t } = useT("editor");
const handleCopyLink = async () => {
if (await copyText(src)) {
toast.success(t(($) => $.image.link_copied));
} else {
toast.error(t(($) => $.image.copy_link_failed));
}
};
// Click on figure opens the preview only in non-editor / non-uploading
// surfaces — inside the editor we let ProseMirror own the click for
// selection / cursor placement and route preview through the explicit
// Maximize button. The CSS rule `.image-figure[data-clickable="true"] {
// cursor: zoom-in }` keys off this same flag for the cursor affordance.
const clickable = !editable && !uploading;
// DOM mirrors the original ReadonlyImage (span-only chain so it stays
// valid HTML5 when rendered inside a markdown <p>). In editor surfaces
// the NodeViewWrapper still emits its own outer .image-node div around
// this — the duplicate `image-node` class is harmless.
return (
<span className="image-node">
<span
className={cn(
"image-figure",
selected && editable && "image-selected",
className,
)}
data-clickable={clickable || undefined}
contentEditable={false}
onClick={clickable ? onView : undefined}
>
<img
src={src || undefined}
alt={alt}
width={width}
height={height}
className={cn("image-content", uploading && "image-uploading")}
draggable={false}
/>
{!uploading && src && (
<span
className="image-toolbar"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button type="button" onClick={onView} title={t(($) => $.image.view)}>
<Maximize2 className="size-3.5" />
</button>
<button type="button" onClick={onDownload} title={t(($) => $.image.download)}>
<Download className="size-3.5" />
</button>
<button type="button" onClick={handleCopyLink} title={t(($) => $.image.copy_link)}>
<LinkIcon className="size-3.5" />
</button>
{editable && onDelete && (
<button type="button" onClick={onDelete} title={t(($) => $.image.delete)}>
<Trash2 className="size-3.5" />
</button>
)}
</span>
)}
</span>
</span>
);
}