diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index 42027d3e49..6dac19d385 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -34,8 +34,6 @@ import { } from 'expo-audio'; import { addScreenshotListener, - conversationShareNativeRendererAvailable, - renderConversationShareHtmlToPng, } from 'xdt-screenshot-monitor'; import { useFocusEffect, useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; import { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode, type RefObject, type SetStateAction } from 'react'; @@ -102,15 +100,16 @@ import { type ShareableMessageViewport, } from '@/session/MessageRenderer'; import { - ConversationShareWebView, - bundledAssetToDataUri, deleteConversationSharePngTemp, writeConversationSharePngTemp, - type ConversationShareWebViewHandle, } from '@/session/ConversationShareWebView'; import { - buildConversationShareHtml, + ConversationShareSvg, + type ConversationShareSvgHandle, +} from '@/session/ConversationShareSvg'; +import { type ConversationShareMessage, + type ConversationShareWebViewColors, } from '@/session/conversationShareWebViewHtml'; import { collectConversationShareBlockIds, @@ -609,14 +608,6 @@ const REOPEN_MESSAGE_WINDOW_LIMITS = [20, 10, 5, 1] as const; const TAIL_RETRY_HIDE_TIMEOUT_MS = 15_000; const SCREENSHOT_SHARE_ACTIVATION_DEBOUNCE_MS = 1_200; -// 分享图页脚资源转成 data URI 后再交给 WebView,避免 foreignObject 导出空白。 -// eslint-disable-next-line @typescript-eslint/no-require-imports -const shareCharacterAsset = require('../../assets/share/cindy-share-character.jpg'); -// eslint-disable-next-line @typescript-eslint/no-require-imports -const shareLogoLightAsset = require('../../assets/login/login-wordmark.png'); -// eslint-disable-next-line @typescript-eslint/no-require-imports -const shareLogoDarkAsset = require('../../assets/login/login-wordmark-dark.png'); - /** * 排队消息「复用 composer 编辑」的会话内状态:clientId 定位队列条目, * stashed* 暂存进入编辑前用户的草稿与附件托盘(保存/放弃/条目消失时恢复)。 @@ -942,7 +933,7 @@ export default function SessionScreen() { composerDocumentRef.current = nextScope.document; draftRef.current = nextDraft; } - const conversationShareWebViewRef = useRef(null); + const conversationShareSvgRef = useRef(null); const topOverlayRef = useRef(null); const bottomOverlayRef = useRef(null); const visibleShareableMessageIdsReaderRef = useRef<( @@ -958,9 +949,6 @@ export default function SessionScreen() { const shareOperationSeqRef = useRef(0); const [conversationShareBusy, setConversationShareBusy] = useState(false); const [shareSelectionTriggeredByScreenshot, setShareSelectionTriggeredByScreenshot] = useState(false); - const [shareCharacterSrc, setShareCharacterSrc] = useState(null); - const [shareLogoSrc, setShareLogoSrc] = useState(null); - const shareLogoModeRef = useRef(null); // chat-text-quote:待随下一条消息发送的选中文字引用(全局 store,消息流选区 // 按钮 / 文件预览页写入;发送时拼进正文,命中本地命令时保留)。 const quotes = useSessionQuotes(sessionId); @@ -1040,28 +1028,6 @@ export default function SessionScreen() { }; }, [sessionId]), ); - useEffect(() => { - if (!shareSelectionActive) return undefined; - let cancelled = false; - const logoNeedsLoad = shareLogoModeRef.current !== mode || !shareLogoSrc; - void Promise.all([ - shareCharacterSrc - ? Promise.resolve(shareCharacterSrc) - : bundledAssetToDataUri(shareCharacterAsset, 'image/jpeg'), - logoNeedsLoad - ? bundledAssetToDataUri( - mode === 'dark' ? shareLogoDarkAsset : shareLogoLightAsset, - 'image/png', - ) - : Promise.resolve(shareLogoSrc), - ]).then(([character, logo]) => { - if (cancelled) return; - shareLogoModeRef.current = mode; - setShareCharacterSrc(character); - setShareLogoSrc(logo); - }); - return () => { cancelled = true; }; - }, [mode, shareCharacterSrc, shareLogoSrc, shareSelectionActive]); const [composerFocused, setComposerFocused] = useState(false); const [composerInputContentHeight, setComposerInputContentHeight] = useState(COMPOSER_INPUT_SINGLE_LINE_CONTENT_HEIGHT); const [voiceDraftCaretFrame, setVoiceDraftCaretFrame] = useState({ left: 0, top: 0 }); @@ -5872,45 +5838,26 @@ export default function SessionScreen() { .map((clientId) => shareMessageById.get(clientId)) .filter((message): message is ConversationShareMessage => message !== undefined); }, [allShareableIds, shareMessageById, shareSelectionActive, shareSelectionRevision]); - const conversationShareHtml = useMemo(() => { - if (!shareSelectionActive || selectedShareMessages.length === 0) return ''; - return buildConversationShareHtml({ - allShareableIds, - characterSrc: shareCharacterSrc ?? undefined, - colors: { - background: colors.surface, - border: colors.border, - codeSurface: colors.chatCodeSurface, - inlineCode: colors.chatInlineCodeText, - surfaceChip: colors.surfaceChip, - surfaceElevated: colors.surfaceElevated, - syntax: { - comment: colors.syntaxComment, - function: colors.syntaxFunction, - keyword: colors.syntaxKeyword, - number: colors.syntaxNumber, - property: colors.syntaxProperty, - string: colors.syntaxString, - }, - textPrimary: colors.textPrimary, - textSecondary: colors.textSecondary, - textTertiary: colors.textTertiary, - dark: mode === 'dark', - }, - contentWidth: windowDimensions.width, - logoSrc: shareLogoModeRef.current === mode ? shareLogoSrc ?? undefined : undefined, - selectedMessages: selectedShareMessages, - }); - }, [ - allShareableIds, - colors, - mode, - selectedShareMessages, - shareCharacterSrc, - shareLogoSrc, - shareSelectionActive, - windowDimensions.width, - ]); + const conversationShareColors = useMemo(() => ({ + background: colors.surface, + border: colors.border, + codeSurface: colors.chatCodeSurface, + inlineCode: colors.chatInlineCodeText, + surfaceChip: colors.surfaceChip, + surfaceElevated: colors.surfaceElevated, + syntax: { + comment: colors.syntaxComment, + function: colors.syntaxFunction, + keyword: colors.syntaxKeyword, + number: colors.syntaxNumber, + property: colors.syntaxProperty, + string: colors.syntaxString, + }, + textPrimary: colors.textPrimary, + textSecondary: colors.textSecondary, + textTertiary: colors.textTertiary, + dark: mode === 'dark', + }), [colors, mode]); const enterShareSelection = useCallback((clientId: string) => { Keyboard.dismiss(); setShareSelectionTriggeredByScreenshot(false); @@ -5922,18 +5869,11 @@ export default function SessionScreen() { setShareSelectionTriggeredByScreenshot(false); shareSelectionStore.exit(); }, []); - const exportConversationSharePng = useCallback(async (scale = 2) => { - if (!conversationShareHtml) throw new Error('conversation share html is empty'); - const nativeBase64 = await renderConversationShareHtmlToPng({ - html: conversationShareHtml, - scale, - width: windowDimensions.width, - }); - if (nativeBase64) return nativeBase64; - const webView = conversationShareWebViewRef.current; - if (!webView) throw new Error('conversation share renderer is unavailable'); - return webView.exportPng({ scale }); - }, [conversationShareHtml, windowDimensions.width]); + const exportConversationSharePng = useCallback(async () => { + const svg = conversationShareSvgRef.current; + if (!svg) throw new Error('conversation share svg renderer is unavailable'); + return svg.exportPng(); + }, []); const shareSelectedConversation = useCallback(async () => { if ( conversationShareBusy @@ -5972,7 +5912,7 @@ export default function SessionScreen() { } if (shareOperationSeqRef.current === operationSeq) setConversationShareBusy(false); } - }, [conversationShareBusy, conversationShareHtml, exportConversationSharePng, selectedShareMessages.length, shareSelectionActive, shareSelectionRevision, t]); + }, [conversationShareBusy, exportConversationSharePng, selectedShareMessages.length, shareSelectionActive, shareSelectionRevision, t]); // 解禁唤醒:会话参数就绪(fresh 元数据到达 / 新建管线收口)的那一帧重新 pump,把 // 未就绪期间攒下的待发消息按 FIFO 发出去。渲染态判据与 outboxDispatchBlockedNow // 同构(那个读 store,供异步循环用;这个供 effect 依赖比较用)。 @@ -9515,11 +9455,13 @@ export default function SessionScreen() { - {shareSelectionActive && conversationShareHtml && !conversationShareNativeRendererAvailable ? ( - 0 ? ( + ) : null} {wideSessionNav.enabled || sessionListDrawerOverlayMounted ? ( diff --git a/apps/mobile/src/__tests__/conversationShareSvg.test.ts b/apps/mobile/src/__tests__/conversationShareSvg.test.ts new file mode 100644 index 0000000000..2a88607a73 --- /dev/null +++ b/apps/mobile/src/__tests__/conversationShareSvg.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; + +import { i18n } from "@/i18n"; +import { createConversationShareFooterAssetGate } from "@/session/conversationShareAssetGate"; +import { + buildConversationShareSvgLayout, + conversationShareSvgRenderSize, + wrapSvgText, +} from "@/session/conversationShareSvgLayout"; + +const colors = { + background: "#ffffff", + border: "#cccccc", + codeSurface: "#eeeeee", + inlineCode: "#111111", + surfaceChip: "#eeeeee", + surfaceElevated: "#f5f5f5", + syntax: { + comment: "#777777", + function: "#111111", + keyword: "#111111", + number: "#111111", + property: "#111111", + string: "#111111", + }, + textPrimary: "#111111", + textSecondary: "#666666", + textTertiary: "#999999", +}; + +describe("ConversationShareSvg", () => { + it("wraps Chinese and Latin text within the available width", () => { + expect( + wrapSvgText("这是很长的一段中文消息", 60, 15).length, + ).toBeGreaterThan(1); + expect(wrapSvgText("long latin message", 60, 15).length).toBeGreaterThan(1); + }); + + it("wraps long runs of wide Arial glyphs before they can be clipped", () => { + const maxWidth = 263; + const fontSize = 15; + const cases: Array<[glyph: string, maxGlyphsPerLine: number]> = [ + ["W", 17], + ["@", 16], + ["%", 17], + ["M", 19], + ["m", 19], + ]; + + for (const [glyph, maxGlyphsPerLine] of cases) { + const text = glyph.repeat(30); + const lines = wrapSvgText(text, maxWidth, fontSize); + expect(lines.join("")).toBe(text); + expect(Math.max(...lines.map((line) => line.length))).toBeLessThanOrEqual( + maxGlyphsPerLine, + ); + } + }); + + it("lays out user and assistant messages with a footer", () => { + const layout = buildConversationShareSvgLayout({ + allShareableIds: ["u", "skipped", "a"], + colors, + messages: [ + { body: "hello", clientId: "u", kind: "user" }, + { body: "world", clientId: "a", kind: "assistant" }, + ], + width: 390, + }); + + expect(layout.width).toBe(390); + expect(layout.bubbles).toHaveLength(2); + expect(layout.gaps).toHaveLength(1); + expect(layout.bubbles[0]?.x).toBeGreaterThan(layout.bubbles[1]?.x ?? 0); + expect(layout.height).toBeGreaterThan(layout.footerY); + expect(conversationShareSvgRenderSize(layout)).toMatchObject({ + scale: 2, + sourceTooLarge: false, + width: 780, + }); + }); + + it("redacts metadata before drawing it", () => { + const layout = buildConversationShareSvgLayout({ + allShareableIds: ["a"], + colors, + messages: [ + { + attachments: [{ kind: "file", name: "token: sk-12345678" }], + automationOriginLabel: "token: sk-12345678", + body: "hello", + clientId: "a", + kind: "assistant", + }, + ], + width: 390, + }); + const renderedText = + layout.bubbles[0]?.textBlocks.flatMap((block) => block.lines).join(" ") ?? + ""; + expect(renderedText).not.toContain("sk-12345678"); + expect(renderedText).toContain("[REDACTED]"); + }); + + it("keeps image-only Markdown visible without exposing its source URL", () => { + const secretUrl = "https://example.com/image.png?token=private-value"; + const layout = buildConversationShareSvgLayout({ + allShareableIds: ["empty-alt", "html", "named-alt"], + colors, + messages: [ + { + body: `![](${secretUrl})`, + clientId: "empty-alt", + kind: "assistant", + }, + { + body: ``, + clientId: "html", + kind: "assistant", + }, + { + body: `![Screenshot](${secretUrl})`, + clientId: "named-alt", + kind: "assistant", + }, + ], + width: 390, + }); + const renderedText = layout.bubbles.map((bubble) => + bubble.textBlocks.flatMap((block) => block.lines).join(" "), + ); + + expect(renderedText).toEqual([ + i18n.t("message.renderer.imageFallbackTitle"), + i18n.t("message.renderer.imageFallbackTitle"), + "Screenshot", + ]); + expect(renderedText.join(" ")).not.toContain(secretUrl); + }); + + it("preserves list markers and task state in the exported text", () => { + const layout = buildConversationShareSvgLayout({ + allShareableIds: ["list"], + colors, + messages: [ + { + body: "- [x] shipped\n- [ ] pending\n1. first\n* bullet\n2. [x] ordered done\n3. [ ] ordered pending", + clientId: "list", + kind: "assistant", + }, + ], + width: 390, + }); + + expect(layout.bubbles[0]?.textBlocks[0]?.lines).toEqual([ + "[x] shipped", + "[ ] pending", + "1. first", + "* bullet", + "2. [x] ordered done", + "3. [ ] ordered pending", + ]); + }); + + it("preserves semantic plaintext markers without altering chip labels", () => { + const layout = buildConversationShareSvgLayout({ + allShareableIds: ["chips", "markdown"], + colors, + messages: [ + { + body: "ignored when structured parts are present", + bodyParts: [ + { kind: "quote", label: "quoted context" }, + { kind: "pasted", label: "pasted text" }, + { kind: "slash", label: "/review" }, + ], + clientId: "chips", + kind: "user", + }, + { + body: "> do not deploy\n> until reviewed\n\nUse v2, ~~not v1~~", + clientId: "markdown", + kind: "assistant", + }, + ], + width: 390, + }); + + expect(layout.bubbles[0]?.textBlocks[0]?.lines).toEqual([ + "quoted context", + "pasted text", + "/review", + ]); + expect(layout.bubbles[1]?.textBlocks[0]?.lines).toEqual([ + "> do not deploy", + "> until reviewed", + "Use v2, ~~not v1~~", + ]); + }); + + it("waits for both footer assets before allowing export", async () => { + const gate = createConversationShareFooterAssetGate(); + let ready = false; + const wait = gate.waitUntilReady().then(() => { + ready = true; + }); + + gate.markReady("character"); + gate.markReady("character"); + await Promise.resolve(); + expect(ready).toBe(false); + + gate.markReady("logo"); + await wait; + expect(ready).toBe(true); + }); + + it("refuses oversized source layouts before mounting a large SVG", () => { + expect( + conversationShareSvgRenderSize({ height: 40_000, width: 390 }), + ).toEqual({ height: 1, scale: 1, sourceTooLarge: true, width: 1 }); + }); +}); diff --git a/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts b/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts index b1483dab1d..3e654e8ce9 100644 --- a/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts +++ b/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts @@ -175,7 +175,7 @@ describe('buildConversationShareHtml 富内容导出', () => { 'await deleteConversationSharePngTemp(file.uri);', ); expect(sessionSource).toContain("localUri && Platform.OS !== 'android'"); - expect(sessionSource).toContain('key={conversationShareHtml}'); + expect(sessionSource).toContain(' { @@ -183,11 +183,23 @@ describe('buildConversationShareHtml 富内容导出', () => { resolve(process.cwd(), '../../docs/design-rules/DESIGN.md'), 'utf8', ); + const svgSource = readFileSync( + resolve(process.cwd(), 'src/session/ConversationShareSvg.tsx'), + 'utf8', + ); const html = buildRichConversationHtml(); expect(designSource).toContain('Mobile approved 2026-08-08'); + expect(designSource).toContain('src/session/ConversationShareSvg.tsx'); expect(designSource).toContain('22×22px (6px radius)'); expect(designSource).toContain('18px-high wordmark with a 6px gap'); + expect(svgSource).toContain('const SHARE_CHARACTER_SIZE = 22;'); + expect(svgSource).toContain('const SHARE_LOGO_HEIGHT = 18;'); + expect(svgSource).toContain('const SHARE_LOCKUP_GAP = 6;'); + expect(svgSource).toContain('rx={6}'); + expect(svgSource).toContain('footerAssetGate.waitUntilReady()'); + expect(svgSource).toContain('footerAssetGate.markReady("character")'); + expect(svgSource).toContain('footerAssetGate.markReady("logo")'); expect(html).toContain('width: 22px;'); expect(html).toContain('height: 18px;'); expect(html).toContain('gap: 6px;'); diff --git a/apps/mobile/src/session/ConversationShareSvg.tsx b/apps/mobile/src/session/ConversationShareSvg.tsx new file mode 100644 index 0000000000..aaabf02291 --- /dev/null +++ b/apps/mobile/src/session/ConversationShareSvg.tsx @@ -0,0 +1,254 @@ +import { forwardRef, useImperativeHandle, useMemo, useRef } from "react"; +import { Image as NativeImage, StyleSheet, View } from "react-native"; +import Svg, { + ClipPath, + Defs, + Image as SvgImage, + Rect, + Text as SvgText, + TSpan, +} from "react-native-svg"; + +import type { + ConversationShareMessage, + ConversationShareWebViewColors, +} from "@/session/conversationShareWebViewHtml"; +import { createConversationShareFooterAssetGate } from "@/session/conversationShareAssetGate"; +import { + buildConversationShareSvgLayout, + conversationShareSvgRenderSize, + type ConversationShareSvgBubble, +} from "@/session/conversationShareSvgLayout"; +import { typeScale } from "@/theme"; + +export interface ConversationShareSvgHandle { + exportPng(): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const shareCharacterAsset = require("../../assets/share/cindy-share-character.jpg"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const shareLogoLightAsset = require("../../assets/login/login-wordmark.png"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const shareLogoDarkAsset = require("../../assets/login/login-wordmark-dark.png"); +const SHARE_CHARACTER_SIZE = 22; +const SHARE_LOGO_HEIGHT = 18; +const SHARE_LOCKUP_GAP = 6; +const SHARE_EXPORT_TIMEOUT_MS = 20_000; +// Export-card geometry: this is a 1px bubble border in the SVG coordinate +// system, not a Lucide icon stroke (whose thinnest token is intentionally 1.75). +const SHARE_BUBBLE_STROKE_WIDTH = 1; + +export const ConversationShareSvg = forwardRef< + ConversationShareSvgHandle, + { + allShareableIds: readonly string[]; + colors: ConversationShareWebViewColors; + messages: readonly ConversationShareMessage[]; + width: number; + } +>(function ConversationShareSvg( + { allShareableIds, colors, messages, width }, + ref, +) { + const svgRef = useRef(null); + const layout = useMemo( + () => + buildConversationShareSvgLayout({ + allShareableIds, + colors, + messages, + width, + }), + [allShareableIds, colors, messages, width], + ); + const renderSize = useMemo( + () => conversationShareSvgRenderSize(layout), + [layout], + ); + const logoAsset = colors.dark ? shareLogoDarkAsset : shareLogoLightAsset; + const footerAssetGate = useMemo( + () => createConversationShareFooterAssetGate(), + [logoAsset], + ); + const logoSource = NativeImage.resolveAssetSource(logoAsset); + const logoWidth = (SHARE_LOGO_HEIGHT * logoSource.width) / logoSource.height; + const lockupWidth = SHARE_CHARACTER_SIZE + SHARE_LOCKUP_GAP + logoWidth; + const lockupX = (layout.width - lockupWidth) / 2; + + useImperativeHandle( + ref, + () => ({ + exportPng() { + if (renderSize.sourceTooLarge) { + return Promise.reject( + new Error("conversation share content is too large"), + ); + } + return new Promise((resolve, reject) => { + let settled = false; + const fail = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }; + const timer = setTimeout(() => { + fail(new Error("conversation share svg export timed out")); + }, SHARE_EXPORT_TIMEOUT_MS); + void footerAssetGate.waitUntilReady().then(() => { + if (settled) return; + const svg = svgRef.current; + if (!svg) { + fail(new Error("conversation share svg renderer is unavailable")); + return; + } + try { + svg.toDataURL((base64) => { + if (settled) return; + if (!base64) { + fail(new Error("conversation share svg export was empty")); + return; + } + settled = true; + clearTimeout(timer); + resolve(base64); + }); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + }); + }); + }, + }), + [footerAssetGate, renderSize.sourceTooLarge], + ); + + return ( + + + + {!renderSize.sourceTooLarge ? ( + <> + {layout.bubbles.map((bubble, bubbleIndex) => ( + + ))} + {layout.gaps.map((gap, gapIndex) => ( + + ⋯ + + ))} + + + + + + footerAssetGate.markReady("character")} + preserveAspectRatio="xMidYMid slice" + width={SHARE_CHARACTER_SIZE} + x={lockupX} + y={layout.footerY} + /> + footerAssetGate.markReady("logo")} + preserveAspectRatio="xMinYMid meet" + width={logoWidth} + x={lockupX + SHARE_CHARACTER_SIZE + SHARE_LOCKUP_GAP} + y={ + layout.footerY + (SHARE_CHARACTER_SIZE - SHARE_LOGO_HEIGHT) / 2 + } + /> + + ) : null} + + + ); +}); + +function SvgBubbleView({ bubble }: { bubble: ConversationShareSvgBubble }) { + return ( + <> + {bubble.fill || bubble.stroke ? ( + + ) : null} + {bubble.textBlocks.map((block, blockIndex) => ( + + {block.lines.map((line, lineIndex) => ( + + {line || " "} + + ))} + + ))} + + ); +} + +const styles = StyleSheet.create({ + hidden: { + left: 0, + opacity: 0, + position: "absolute", + top: 0, + }, +}); diff --git a/apps/mobile/src/session/conversationShareAssetGate.ts b/apps/mobile/src/session/conversationShareAssetGate.ts new file mode 100644 index 0000000000..e872651681 --- /dev/null +++ b/apps/mobile/src/session/conversationShareAssetGate.ts @@ -0,0 +1,29 @@ +export type ConversationShareFooterAsset = "character" | "logo"; + +export interface ConversationShareFooterAssetGate { + markReady(asset: ConversationShareFooterAsset): void; + waitUntilReady(): Promise; +} + +/** + * The SVG footer contains two independently decoded bundled images. Keep the + * readiness latch separate from React so export can wait for both assets and + * the ordering/duplicate-load behavior stays unit-testable. + */ +export function createConversationShareFooterAssetGate(): ConversationShareFooterAssetGate { + const pending = new Set(["character", "logo"]); + let resolveReady = () => {}; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + + return { + markReady(asset) { + pending.delete(asset); + if (pending.size === 0) resolveReady(); + }, + waitUntilReady() { + return ready; + }, + }; +} diff --git a/apps/mobile/src/session/conversationShareSvgLayout.ts b/apps/mobile/src/session/conversationShareSvgLayout.ts new file mode 100644 index 0000000000..85a93f5601 --- /dev/null +++ b/apps/mobile/src/session/conversationShareSvgLayout.ts @@ -0,0 +1,296 @@ +import { redactSensitiveText } from "@cindy/maker-shared/error-redaction"; + +import { i18n } from "@/i18n"; +import { + parseMobileMarkdown, + type MobileMarkdownBlock, + type MobileMarkdownInline, +} from "@/session/messageMarkdown"; +import type { + ConversationShareMessage, + ConversationShareWebViewColors, +} from "@/session/conversationShareWebViewHtml"; + +const PADDING = 28; +const MESSAGE_GAP = 16; +const TEXT_FONT_SIZE = 15; +const TEXT_LINE_HEIGHT = 22; +const META_FONT_SIZE = 12; +const META_LINE_HEIGHT = 18; +const MAX_OUTPUT_PIXELS = 12_000_000; +const DEFAULT_EXPORT_SCALE = 2; + +export interface ConversationShareSvgTextBlock { + color: string; + fontSize: number; + lineHeight: number; + lines: string[]; + x: number; + y: number; +} + +export interface ConversationShareSvgBubble { + fill?: string; + height: number; + stroke?: string; + textBlocks: ConversationShareSvgTextBlock[]; + width: number; + x: number; + y: number; +} + +export interface ConversationShareSvgLayout { + bubbles: ConversationShareSvgBubble[]; + footerY: number; + gaps: Array<{ color: string; y: number }>; + height: number; + width: number; +} + +export function conversationShareSvgRenderSize( + layout: Pick, +): { height: number; scale: number; sourceTooLarge: boolean; width: number } { + const sourceTooLarge = layout.width * layout.height > MAX_OUTPUT_PIXELS; + if (sourceTooLarge) { + return { height: 1, scale: 1, sourceTooLarge, width: 1 }; + } + const scale = Math.min( + DEFAULT_EXPORT_SCALE, + Math.sqrt(MAX_OUTPUT_PIXELS / Math.max(1, layout.width * layout.height)), + ); + return { + height: Math.max(1, Math.ceil(layout.height * scale)), + scale, + sourceTooLarge, + width: Math.max(1, Math.ceil(layout.width * scale)), + }; +} + +export function buildConversationShareSvgLayout({ + allShareableIds, + colors, + messages, + width, +}: { + allShareableIds: readonly string[]; + colors: ConversationShareWebViewColors; + messages: readonly ConversationShareMessage[]; + width: number; +}): ConversationShareSvgLayout { + const canvasWidth = Math.max(280, Math.round(width)); + const contentWidth = canvasWidth - PADDING * 2; + const bubbles: ConversationShareSvgBubble[] = []; + const gaps: Array<{ color: string; y: number }> = []; + const messageIndex = new Map(allShareableIds.map((id, index) => [id, index])); + let previousIndex: number | null = null; + let cursorY = PADDING; + + for (const message of messages) { + const currentIndex = messageIndex.get(message.clientId) ?? null; + if ( + previousIndex !== null && + currentIndex !== null && + currentIndex - previousIndex > 1 + ) { + gaps.push({ color: colors.textTertiary, y: cursorY + 12 }); + cursorY += 28; + } + const user = message.kind === "user"; + const bubbleWidth = user ? Math.round(contentWidth * 0.86) : contentWidth; + const bubbleX = user ? canvasWidth - PADDING - bubbleWidth : PADDING; + const horizontalPadding = user ? 12 : 0; + const textWidth = bubbleWidth - horizontalPadding * 2; + const blocks: Array<{ + color: string; + fontSize: number; + lineHeight: number; + text: string; + }> = []; + + if (message.automationOriginLabel) { + blocks.push({ + color: colors.textTertiary, + fontSize: META_FONT_SIZE, + lineHeight: META_LINE_HEIGHT, + text: redactSensitiveText(message.automationOriginLabel).trim(), + }); + } + for (const attachment of message.attachments ?? []) { + blocks.push({ + color: colors.textSecondary, + fontSize: META_FONT_SIZE, + lineHeight: META_LINE_HEIGHT, + text: `${attachment.kind === "image" ? "▧" : "▤"} ${redactSensitiveText(attachment.name).trim()}`, + }); + } + const body = plainConversationShareText(message); + if (body) { + blocks.push({ + color: colors.textPrimary, + fontSize: TEXT_FONT_SIZE, + lineHeight: TEXT_LINE_HEIGHT, + text: body, + }); + } + + const textBlocks: ConversationShareSvgTextBlock[] = []; + let innerY = user ? 12 : 4; + for (const block of blocks) { + const lines = wrapSvgText(block.text, textWidth, block.fontSize); + textBlocks.push({ + color: block.color, + fontSize: block.fontSize, + lineHeight: block.lineHeight, + lines, + x: bubbleX + horizontalPadding, + y: cursorY + innerY + block.fontSize, + }); + innerY += lines.length * block.lineHeight + 5; + } + if (blocks.length > 0) innerY -= 5; + const bubbleHeight = Math.max(user ? 44 : 30, innerY + (user ? 12 : 4)); + bubbles.push({ + fill: user ? colors.surfaceElevated : undefined, + height: bubbleHeight, + stroke: user ? colors.textSecondary : undefined, + textBlocks, + width: bubbleWidth, + x: bubbleX, + y: cursorY, + }); + cursorY += bubbleHeight + MESSAGE_GAP; + previousIndex = currentIndex; + } + + const footerY = cursorY + 36; + return { + bubbles, + footerY, + gaps, + height: footerY + 22 + PADDING, + width: canvasWidth, + }; +} + +function plainConversationShareText(message: ConversationShareMessage): string { + const parts = message.bodyParts + ? message.bodyParts + .map((part) => (part.kind === "text" ? part.text : part.label)) + .join("\n") + : message.body; + const combined = [parts, message.secondaryBody].filter(Boolean).join("\n"); + return redactSensitiveText(plainMarkdownText(combined)); +} + +function plainMarkdownText(markdown: string): string { + const blocks = parseMobileMarkdown(markdown); + return blocks + .map(plainMarkdownBlockText) + .filter(Boolean) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function plainMarkdownBlockText(block: MobileMarkdownBlock): string { + switch (block.type) { + case "paragraph": + case "heading": + return plainMarkdownInlineText(block.inlines); + case "blockquote": + return plainMarkdownInlineText(block.inlines) + .split("\n") + .map((line) => `> ${line}`) + .join("\n"); + case "list_item": { + const taskMarker = + typeof block.checked === "boolean" + ? block.checked + ? "[x]" + : "[ ]" + : null; + const marker = taskMarker + ? block.ordered + ? `${block.marker} ${taskMarker}` + : taskMarker + : block.marker; + return `${marker} ${plainMarkdownInlineText(block.inlines)}`; + } + case "table": { + const rows = [block.header, ...block.rows.map((row) => row.cells)]; + return rows + .map((cells) => cells.map(plainMarkdownInlineText).join(" | ")) + .join("\n"); + } + case "code": + case "math": + case "mermaid": + return block.text; + } +} + +function plainMarkdownInlineText( + inlines: readonly MobileMarkdownInline[], +): string { + return inlines + .map((inline) => { + if (inline.type === "image") { + return ( + inline.alt.trim() || i18n.t("message.renderer.imageFallbackTitle") + ); + } + if (inline.type === "strikethrough") { + return `~~${inline.text}~~`; + } + return inline.text; + }) + .join(""); +} + +export function wrapSvgText( + text: string, + maxWidth: number, + fontSize: number, +): string[] { + const maxUnits = Math.max(1, maxWidth / fontSize); + const lines: string[] = []; + for (const paragraph of text.replace(/\r\n?/g, "\n").split("\n")) { + if (!paragraph) { + lines.push(""); + continue; + } + let line = ""; + let units = 0; + for (const character of Array.from(paragraph)) { + const characterUnits = conservativeArialGlyphWidthEm(character); + if (line && units + characterUnits > maxUnits) { + lines.push(line.trimEnd()); + line = ""; + units = 0; + } + line += character; + units += characterUnits; + } + lines.push(line.trimEnd()); + } + return lines.length > 0 ? lines : [""]; +} + +function conservativeArialGlyphWidthEm(character: string): number { + // react-native-svg does not expose synchronous glyph measurement while this + // pure layout is built. These Arial-like buckets intentionally round wide + // glyphs up so an exported line wraps early instead of being clipped. + if (character === " ") return 0.33; + if (character.codePointAt(0)! > 0x7f) return 1; + if (character === "@") return 1.05; + if ("W%".includes(character)) return 1; + if ("Mm".includes(character)) return 0.9; + if ("CGOQw".includes(character)) return 0.82; + if ("ABDGHKNRUVXY&".includes(character)) return 0.75; + if ("EFLPSTZ".includes(character)) return 0.68; + if ("0123456789#?$+=<>^_~abdeghnopqu".includes(character)) return 0.62; + if ("Jckrsvxyz".includes(character)) return 0.55; + if ("(){}[]ft*".includes(character)) return 0.4; + if (`!"',.:;\`il|/\\-`.includes(character)) return 0.36; + return 0.68; +} diff --git a/docs/design-rules/DESIGN.md b/docs/design-rules/DESIGN.md index ca08204aae..aafa80eb2a 100644 --- a/docs/design-rules/DESIGN.md +++ b/docs/design-rules/DESIGN.md @@ -1083,7 +1083,7 @@ The splash wordmark is a separate asset pair (`assets/splash/wordmark.png`, whit **Sanctioned brand surface — conversation-share export footer (Desktop approved 2026-08-06; Mobile approved 2026-08-08).** -- **Where**: the footer of conversation-share PNG images generated by Desktop `renderer/lib/shareConversationImage.ts` or Mobile `src/session/conversationShareWebViewHtml.ts` only. The live conversation, selection mode, message stream, composer, and other working-UI surfaces remain neutral and must not display the character artwork. +- **Where**: the footer of conversation-share PNG images generated by Desktop `renderer/lib/shareConversationImage.ts` or Mobile `src/session/conversationShareWebViewHtml.ts` / `src/session/ConversationShareSvg.tsx` only. The live conversation, selection mode, message stream, composer, and other working-UI surfaces remain neutral and must not display the character artwork. - **Lockup**: Desktop uses one static 40×40px product-approved Cindy character crop (8px radius), followed by the active theme's 24px-high wordmark with an 8px gap. Mobile uses the same crop at 22×22px (6px radius), followed by an 18px-high wordmark with a 6px gap, keeping the narrower export understated. Do not append a website or regional host. The character keeps the source asset's original color and opacity without component-authored filtering. - **Constraints**: no animation, shadow, decorative background, additional brand color, enlarged hero treatment, or alternate character composition. This approval identifies the source of an exported Cindy conversation; it is not precedent for adding mascots to cards, dialogs, tool output, or other share-adjacent UI. - **Theme boundary**: Light and Dark use their matching wordmark assets. The same static character crop may be used in both modes because it is an exported brand asset, not a UI color surface.