Files
multica/packages/views/editor/attachment.tsx
Bohan Jiang 06a100d612 fix(editor): render proxy-mode inline images from an authenticated byte fetch (MUL-5445) (#6091)
Inline media re-sign is a two-step repair: detect that a URL is auth-gated
(fixed in #6029), then swap in a URL a native <img> can load. The second step
only worked where the server had a signed URL to give — CloudFront signing, or
presign mode with a DownloadPresigner. In proxy mode GetAttachmentByID returns
`/api/attachments/<id>/download` again, the renderer rejected it and kept the
URL it already knew 401s, so the image stayed broken and the metadata request
was pure overhead.

Proxy is the default for self-hosted storage on an internal host: `auto` mode
forces it for a dotless hostname (docker-compose MinIO at `http://minio:9000`),
localhost, .local/.internal/.lan/.docker suffixes, and private/loopback IPs.
Combined with a client that cannot ride the session cookie on a native resource
fetch — Desktop's file:// renderer, the mobile webview, split-origin web
(cookies are SameSite=Strict) — every inline image in such a deployment fails.

When the refreshed metadata confirms there is no signed URL, pull the bytes
through the authenticated API client and paint them from an object URL. The
metadata request stops being wasted: it is the per-attachment probe that
decides signed-URL vs bytes, so presign/CloudFront clients never double-fetch.

- getAttachmentBlob goes through fetchRaw, inheriting auth headers, 401
  recovery and the ApiError shape, mirroring getAttachmentTextContent.
- The byte fetch is gated on the image branch; a file card only needs a link
  and must not pull a large archive into renderer memory.
- The object URL is revoked on unmount, and the blob query is capped with a
  5 minute gcTime so an image-heavy thread does not pin every screenshot.
- Copy Link keeps handing out the durable URL — a blob: URL resolves only
  inside this renderer session.

Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:45:11 +08:00

672 lines
26 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 { useEffect, useState } from "react";
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;
// How long fetched image bytes stay in the query cache after the last <img>
// using them unmounts. The bytes themselves never go stale (an attachment id
// maps to an immutable storage object), so the blob query uses
// `staleTime: Infinity`; this bound exists purely to keep a long scroll
// through an image-heavy thread from pinning every decoded screenshot in
// renderer memory forever.
const INLINE_BLOB_GC_MS = 5 * 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 when it is genuinely same-origin. Desktop's file:// renderer, the
// mobile webview, and split-origin web deployments cannot rely on that: no
// cookie is attached and the Bearer token cannot be put on a native resource
// fetch, so the image 401s. Desktop/mobile expose a non-empty
// `api.getBaseUrl()`; web can also hit this path when the server emits an
// absolute markdown URL whose origin differs from the current page.
//
// 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`.
//
// The server only has a signed URL to offer under CloudFront signing or
// presign mode. In **proxy** download mode `GetAttachmentByID` hands back the
// auth-gated API path again, and before MUL-5445 the renderer simply kept the
// original URL — the image stayed broken and the metadata request was pure
// overhead. Proxy is not an exotic setting: the default `auto` mode forces it
// whenever the storage URL points at an internal host, which is exactly the
// docker-compose MinIO (`http://minio:9000`) self-host shape. So when the
// refreshed metadata confirms there is no signed URL, fall back to pulling the
// bytes through the authenticated client and rendering them from an object
// URL. `blobFallback` gates that on the caller actually rendering a native
// `<img>`: a file card only needs a link, and downloading a 100 MB archive
// into renderer memory to draw a chip would be a bad trade.
function useResignedInlineMediaURL(
attachmentId: string | undefined,
pickedUrl: string,
blobFallback: boolean,
): string {
const idFromPickedUrl = attachmentIdFromDownloadURL(pickedUrl);
const resignAttachmentId = attachmentId ?? idFromPickedUrl;
const isCrossOriginWebURL = (() => {
if (!/^https?:\/\//i.test(pickedUrl) || typeof window === "undefined") {
return false;
}
try {
return new URL(pickedUrl).origin !== window.location.origin;
} catch {
return false;
}
})();
const needsResign =
!!resignAttachmentId &&
!!pickedUrl &&
idFromPickedUrl !== undefined &&
((api.getBaseUrl?.() ?? "") !== "" || isCrossOriginWebURL);
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,
});
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).
const signedUrl =
/^https?:\/\//i.test(dl) && attachmentIdFromDownloadURL(dl) === undefined
? dl
: "";
// Only after `fresh` has landed do we know this deployment has nothing
// signed to give — firing the byte fetch earlier would double-download on
// every CloudFront / presign client.
const { data: blob } = useQuery({
queryKey: ["attachment-inline-blob", resignAttachmentId],
queryFn: () => api.getAttachmentBlob(resignAttachmentId as string),
enabled: needsResign && blobFallback && !!fresh && signedUrl === "",
staleTime: Infinity,
gcTime: INLINE_BLOB_GC_MS,
});
const blobUrl = useObjectURL(blob);
if (!needsResign) return pickedUrl;
if (signedUrl) return signedUrl;
return blobUrl || pickedUrl;
}
// useObjectURL turns a Blob into a `blob:` URL for the lifetime of the calling
// component, revoking it on unmount / replacement so the bytes are released
// once nothing renders them. Returns "" while there is no blob (and during
// SSR, where createObjectURL does not exist).
function useObjectURL(blob: Blob | undefined): string {
const [url, setUrl] = useState("");
useEffect(() => {
if (!blob || typeof URL.createObjectURL !== "function") {
setUrl("");
return;
}
const next = URL.createObjectURL(blob);
setUrl(next);
return () => {
URL.revokeObjectURL(next);
};
}, [blob]);
return url;
}
// isObjectURL flags a src that only resolves inside this renderer session —
// safe to paint, wrong to expose through Copy Link or persist anywhere.
function isObjectURL(rawUrl: string): boolean {
return /^blob:/i.test(rawUrl);
}
// ---------------------------------------------------------------------------
// 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);
const forceKind =
attachment.kind === "url" ? attachment.forceKind : undefined;
const kind =
forceKind ??
(state.filename || state.contentType
? getPreviewKind(state.contentType, state.filename)
: null);
// 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, or —
// on deployments that have no signed URL to give — to an object URL built
// from the authenticated byte fetch. Only the image branch renders a native
// resource load, so only it opts into that byte fetch.
const mediaUrl = useResignedInlineMediaURL(
state.attachmentId,
state.url,
kind === "image",
);
// Object URLs are session-local, so anything that hands a URL to the user or
// to another surface keeps the durable pick instead.
const shareUrl = isObjectURL(mediaUrl) ? state.url : mediaUrl;
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 (shareUrl) openByUrl(shareUrl);
};
if (kind === "image") {
return (
<>
<ImageAttachmentView
src={mediaUrl}
linkUrl={shareUrl}
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={shareUrl || 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;
/**
* URL handed to the user by Copy Link. Splits from `src` only when `src` is
* a session-local object URL (proxy-mode byte fallback) — pasting a `blob:`
* URL anywhere outside this renderer resolves to nothing.
*/
linkUrl: string;
alt: string;
uploading: boolean;
width?: number;
height?: number;
editable?: boolean;
selected?: boolean;
onView: () => void;
onDownload: () => void;
onDelete?: () => void;
className?: string;
}
function ImageAttachmentView({
src,
linkUrl,
alt,
uploading,
width,
height,
editable,
selected,
onView,
onDownload,
onDelete,
className,
}: ImageAttachmentViewProps) {
const { t } = useT("editor");
const handleCopyLink = async () => {
if (await copyText(linkUrl)) {
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>
);
}