diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 1280f1f..af13b65 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -41,7 +41,7 @@ if (projectId) { const config: ExpoConfig = { name: appName, slug, - version: "0.1.4", + version: "0.1.5", newArchEnabled: true, platforms: ["ios", "android"], icon: "./assets/icon.png", @@ -54,7 +54,7 @@ const config: ExpoConfig = { : { enabled: false }, ios: { bundleIdentifier: iosBundleIdentifier, - buildNumber: "7", + buildNumber: "8", supportsTablet: true, config: { usesNonExemptEncryption: false, @@ -70,7 +70,7 @@ const config: ExpoConfig = { android: { package: androidPackage, ...(googleServicesFile ? { googleServicesFile } : {}), - versionCode: 6, + versionCode: 7, allowBackup: false, predictiveBackGestureEnabled: false, softwareKeyboardLayoutMode: "resize", diff --git a/apps/mobile/modules/transcript-selection/expo-module.config.json b/apps/mobile/modules/transcript-selection/expo-module.config.json new file mode 100644 index 0000000..53405db --- /dev/null +++ b/apps/mobile/modules/transcript-selection/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["TranscriptSelectionModule"] + } +} diff --git a/apps/mobile/modules/transcript-selection/ios/TranscriptSelection.podspec b/apps/mobile/modules/transcript-selection/ios/TranscriptSelection.podspec new file mode 100644 index 0000000..c2bd5ea --- /dev/null +++ b/apps/mobile/modules/transcript-selection/ios/TranscriptSelection.podspec @@ -0,0 +1,15 @@ +Pod::Spec.new do |s| + s.name = 'TranscriptSelection' + s.version = '1.0.0' + s.summary = 'Native selectable transcript text' + s.description = 'Read-only attributed UITextView with native range selection.' + s.license = { :type => 'MIT' } + s.author = 'Omnicus' + s.homepage = 'https://github.com/omnicus/opencode2-mobile' + s.platforms = { :ios => '15.1' } + s.source = { :git => 'https://github.com/omnicus/opencode2-mobile.git' } + s.static_framework = true + s.dependency 'ExpoModulesCore' + s.swift_version = '5.9' + s.source_files = '**/*.{h,m,mm,swift}' +end diff --git a/apps/mobile/modules/transcript-selection/ios/TranscriptSelectionModule.swift b/apps/mobile/modules/transcript-selection/ios/TranscriptSelectionModule.swift new file mode 100644 index 0000000..823d1a1 --- /dev/null +++ b/apps/mobile/modules/transcript-selection/ios/TranscriptSelectionModule.swift @@ -0,0 +1,34 @@ +import ExpoModulesCore + +public class TranscriptSelectionModule: Module { + public func definition() -> ModuleDefinition { + Name("TranscriptSelection") + View(TranscriptSelectionView.self) { + Events("onMeasure", "onLink") + Prop("runs") { (view: TranscriptSelectionView, runs: [SelectionRun]) in + view.runs = runs + } + Prop("unwrapped") { (view: TranscriptSelectionView, value: Bool) in + view.unwrapped = value + } + OnViewDidUpdateProps { (view: TranscriptSelectionView) in + view.updateText() + } + } + } +} + +struct SelectionRun: Record { + @Field var text: String = "" + @Field var color: Double = 4294967295 + @Field var background: Double? = nil + @Field var fontSize: Double = 17 + @Field var lineHeight: Double = 26 + @Field var bold: Bool = false + @Field var italic: Bool = false + @Field var mono: Bool = false + @Field var strike: Bool = false + @Field var underline: Bool = false + @Field var link: Bool = false + @Field var alignment: String = "left" +} diff --git a/apps/mobile/modules/transcript-selection/ios/TranscriptSelectionView.swift b/apps/mobile/modules/transcript-selection/ios/TranscriptSelectionView.swift new file mode 100644 index 0000000..e5f3568 --- /dev/null +++ b/apps/mobile/modules/transcript-selection/ios/TranscriptSelectionView.swift @@ -0,0 +1,99 @@ +import ExpoModulesCore +import UIKit + +final class TranscriptSelectionView: ExpoView, UITextViewDelegate { + let onMeasure = EventDispatcher() + let onLink = EventDispatcher() + var runs: [SelectionRun] = [] + var unwrapped = false + private let textView = SelectionTextView() + private var lastSize = CGSize.zero + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + textView.isEditable = false + textView.isSelectable = true + textView.isScrollEnabled = false + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.dataDetectorTypes = [] + textView.linkTextAttributes = [:] + textView.delegate = self + addSubview(textView) + } + + func updateText() { + let attributed = NSMutableAttributedString(string: "") + for (index, run) in runs.enumerated() { + let size = CGFloat(run.fontSize) + let weight: UIFont.Weight = run.bold ? .semibold : .regular + var font = run.mono ? UIFont.monospacedSystemFont(ofSize: size, weight: weight) + : UIFont.systemFont(ofSize: size, weight: weight) + if run.italic, let descriptor = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(.traitItalic)) { + font = UIFont(descriptor: descriptor, size: size) + } + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = CGFloat(run.lineHeight) + paragraph.maximumLineHeight = CGFloat(run.lineHeight) + paragraph.alignment = run.alignment == "right" ? .right : run.alignment == "center" ? .center : .natural + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, .foregroundColor: color(run.color), .paragraphStyle: paragraph + ] + if let background = run.background { attributes[.backgroundColor] = color(background) } + if run.strike { attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue } + if run.underline { attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue } + if run.link { attributes[.link] = URL(string: "transcript-link://run/\(index)") } + attributed.append(NSAttributedString(string: run.text, attributes: attributes)) + } + // Unrelated React updates must not clear an active selection. Preserve its range + // when streaming appends text; clamp only if the server shortens the content. + if textView.attributedText?.isEqual(to: attributed) != true { + let selected = textView.selectedRange + textView.attributedText = attributed + let location = min(selected.location, attributed.length) + textView.selectedRange = NSRange(location: location, length: min(selected.length, attributed.length - location)) + } + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + guard bounds.width > 0 else { return } + let width = unwrapped ? CGFloat(1_000_000) : bounds.width + let fitted = textView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) + let size = CGSize(width: unwrapped ? ceil(fitted.width) : bounds.width, height: ceil(fitted.height)) + textView.frame = CGRect(origin: .zero, size: size) + if size != lastSize { + lastSize = size + onMeasure(["width": size.width, "height": size.height]) + } + } + + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + // Route taps through the existing confirmation handler. Never open links here. + if interaction == .invokeDefaultAction, let index = Int(URL.lastPathComponent) { + onLink(["index": index]) + } + return false + } + + private func color(_ value: Double) -> UIColor { + let bits = UInt32(truncatingIfNeeded: Int64(value)) + return UIColor(red: CGFloat((bits >> 16) & 255) / 255, + green: CGFloat((bits >> 8) & 255) / 255, + blue: CGFloat(bits & 255) / 255, + alpha: CGFloat((bits >> 24) & 255) / 255) + } +} + +private final class SelectionTextView: UITextView { + override func copy(_ sender: Any?) { + let source = (text ?? "") as NSString + let range = selectedRange + guard range.location != NSNotFound, range.length > 0, + range.location <= source.length, range.length <= source.length - range.location else { return } + // Keep native range selection, but do not copy internal link-dispatch URLs. + UIPasteboard.general.string = source.substring(with: range) + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 0547f78..86a37dd 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@opencode2-mobile/mobile", - "version": "0.1.4", + "version": "0.1.5", "private": true, "main": "index.ts", "scripts": { diff --git a/apps/mobile/src/components/copy-text-button.test.tsx b/apps/mobile/src/components/copy-text-button.test.tsx new file mode 100644 index 0000000..d81ff4f --- /dev/null +++ b/apps/mobile/src/components/copy-text-button.test.tsx @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, expect, jest, test } from "@jest/globals"; +import { act, fireEvent, render, screen } from "@testing-library/react-native"; +import * as Clipboard from "expo-clipboard"; + +import { CopyTextButton } from "./copy-text-button"; + +jest.mock("expo-clipboard", () => ({ setStringAsync: jest.fn() })); + +beforeEach(() => { + jest.mocked(Clipboard.setStringAsync).mockReset().mockResolvedValue(true); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +test("restores the copy label two seconds after a successful copy", async () => { + jest.useFakeTimers(); + render(); + await act(async () => fireEvent.press(screen.getByRole("button", { name: "Copy code" }))); + expect(screen.getByText("Copied")).toBeOnTheScreen(); + act(() => jest.advanceTimersByTime(1_999)); + expect(screen.getByText("Copied")).toBeOnTheScreen(); + act(() => jest.advanceTimersByTime(1)); + expect(screen.queryByText("Copied")).toBeNull(); + expect(screen.getByText("Copy code")).toBeOnTheScreen(); +}); + +test("copies exact text and reports a failed write without claiming success", async () => { + jest.mocked(Clipboard.setStringAsync).mockResolvedValueOnce(false); + render(); + fireEvent.press(screen.getByRole("button", { name: "Copy text" })); + await screen.findByText("Retry copy"); + expect(screen.queryByText("Copied")).toBeNull(); + fireEvent.press(screen.getByRole("button", { name: "Copy text" })); + await screen.findByText("Copied"); + expect(Clipboard.setStringAsync).toHaveBeenLastCalledWith(" first\n\nsecond\n"); +}); diff --git a/apps/mobile/src/components/copy-text-button.tsx b/apps/mobile/src/components/copy-text-button.tsx new file mode 100644 index 0000000..30e315b --- /dev/null +++ b/apps/mobile/src/components/copy-text-button.tsx @@ -0,0 +1,68 @@ +import * as Clipboard from "expo-clipboard"; +import { useEffect, useRef, useState } from "react"; +import { AccessibilityInfo, Pressable, StyleSheet, Text } from "react-native"; + +import { palette, space, typography } from "../theme"; + +export function CopyTextButton({ + text, + label = "Copy text", + iconOnly = false, +}: { + text: string; + label?: string; + iconOnly?: boolean; +}) { + const [result, setResult] = useState<{ text: string; status: "copied" | "failed" }>(); + const pending = useRef(false); + const status = result?.text === text ? result.status : undefined; + useEffect(() => { + if (result?.status !== "copied") return; + const timer = setTimeout(() => setResult(undefined), 2_000); + return () => clearTimeout(timer); + }, [result]); + async function copy() { + if (pending.current) return; + pending.current = true; + try { + const copied = await Clipboard.setStringAsync(text); + if (!copied) throw new Error("CLIPBOARD_WRITE_FAILED"); + setResult({ text, status: "copied" }); + AccessibilityInfo.announceForAccessibility("Copied to clipboard"); + } catch { + setResult({ text, status: "failed" }); + AccessibilityInfo.announceForAccessibility("Could not copy. Try again."); + } finally { + pending.current = false; + } + } + return ( + void copy()} + style={styles.action} + > + {iconOnly && !status ? ( + + ) : ( + + {status === "copied" ? "Copied" : status === "failed" ? "Retry copy" : label} + + )} + + ); +} + +const styles = StyleSheet.create({ + action: { + minHeight: 44, + minWidth: 44, + justifyContent: "center", + alignSelf: "flex-start", + paddingHorizontal: space.xs, + }, + label: { ...typography.caption, color: palette.dim }, +}); + +import Feather from "@expo/vector-icons/Feather"; diff --git a/apps/mobile/src/components/selectable-transcript-text.test.tsx b/apps/mobile/src/components/selectable-transcript-text.test.tsx new file mode 100644 index 0000000..643a98b --- /dev/null +++ b/apps/mobile/src/components/selectable-transcript-text.test.tsx @@ -0,0 +1,63 @@ +import { afterEach, expect, jest, test } from "@jest/globals"; +import { fireEvent, render, screen } from "@testing-library/react-native"; +import { createElement as mockCreateElement } from "react"; +import { View as MockView, Platform, Text } from "react-native"; +import { TranscriptMarkdown } from "../screens/transcript-markdown"; +import { SelectableTranscriptText, selectionRuns } from "./selectable-transcript-text"; + +jest.mock("expo", () => ({ + requireOptionalNativeModule: () => ({}), + requireNativeView: () => (props: object) => + mockCreateElement(MockView, { ...props, testID: "native-selection" }), +})); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test("builds attributed runs with nested formatting, dynamic type and safe link callbacks", () => { + const open = jest.fn(); + const { runs, actions } = selectionRuns( + <> + Normal{" "} + + bold nested + + link + , + { color: "#ffffff", fontSize: 18, lineHeight: 27 }, + 1.5, + ); + expect(runs.map((run) => run.text).join("")).toBe("Normal bold nestedlink"); + expect(runs[0]).toMatchObject({ fontSize: 27, lineHeight: 40.5, bold: false, link: false }); + expect(runs.find((run) => run.text === "nested")).toMatchObject({ bold: true, italic: true }); + expect(runs.find((run) => run.text === "link")).toMatchObject({ link: true }); + actions[runs.findIndex((run) => run.link)]?.(); + expect(open).toHaveBeenCalledTimes(1); +}); + +test("the Markdown call site supplies actual inline content to the iOS view", () => { + const open = jest.fn(); + render( + , + ); + const view = screen.getByTestId("native-selection"); + const runs = view.props.runs as ReturnType["runs"]; + expect(runs.map((run) => run.text).join("")).toBe("Hello bold and Docs."); + expect(runs.find((run) => run.text === "bold")?.bold).toBe(true); + fireEvent(view, "link", { nativeEvent: { index: runs.findIndex((run) => run.link) } }); + expect(open).toHaveBeenCalledWith("https://example.test/docs"); + fireEvent(view, "measure", { nativeEvent: { width: 320, height: 78 } }); + expect(view).toHaveStyle({ height: 78 }); +}); + +test("Android retains its native selectable Text renderer", () => { + jest.replaceProperty(Platform, "OS", "android"); + render(Mark part of this text); + expect(screen.queryByTestId("native-selection")).toBeNull(); + expect(screen.getByText("Mark part of this text")).toHaveProp("selectable", true); +}); diff --git a/apps/mobile/src/components/selectable-transcript-text.tsx b/apps/mobile/src/components/selectable-transcript-text.tsx new file mode 100644 index 0000000..32f87b0 --- /dev/null +++ b/apps/mobile/src/components/selectable-transcript-text.tsx @@ -0,0 +1,132 @@ +import { requireNativeView, requireOptionalNativeModule } from "expo"; +import { Children, isValidElement, type ReactNode, useMemo, useState } from "react"; +import { + Platform, + processColor, + StyleSheet, + Text, + type TextProps, + type TextStyle, + useWindowDimensions, + type ViewProps, +} from "react-native"; + +type Run = { + text: string; + color: number; + background?: number; + fontSize: number; + lineHeight: number; + bold: boolean; + italic: boolean; + mono: boolean; + strike: boolean; + underline: boolean; + link: boolean; + alignment: string; +}; +type NativeProps = ViewProps & { + runs: Run[]; + unwrapped: boolean; + onMeasure: (event: { nativeEvent: { width: number; height: number } }) => void; + onLink: (event: { nativeEvent: { index: number } }) => void; +}; +// Older installed builds and Expo Go retain the standard renderer until rebuilt. +const NativeText = + Platform.OS === "ios" && requireOptionalNativeModule("TranscriptSelection") + ? requireNativeView("TranscriptSelection") + : undefined; + +export function selectionRuns(children: ReactNode, base: TextStyle, scale: number) { + const runs: Run[] = []; + const actions: Array<(() => void) | undefined> = []; + function visit(content: ReactNode, style: TextStyle, action?: () => void) { + Children.forEach(content, (child) => { + if (typeof child === "string" || typeof child === "number") { + const fontSize = Number(style.fontSize ?? 17) * scale; + const color = processColor(style.color ?? "#ffffff"); + const background = processColor(style.backgroundColor); + runs.push({ + text: String(child), + color: typeof color === "number" ? color : 0xffffffff, + ...(typeof background === "number" ? { background } : {}), + fontSize, + lineHeight: Number(style.lineHeight ?? Number(style.fontSize ?? 17) * 1.5) * scale, + bold: style.fontWeight === "bold" || Number(style.fontWeight) >= 600, + italic: style.fontStyle === "italic", + mono: /menlo|monospace|courier/i.test(style.fontFamily ?? ""), + strike: style.textDecorationLine?.includes("line-through") ?? false, + underline: style.textDecorationLine?.includes("underline") ?? false, + link: Boolean(action), + alignment: style.textAlign ?? "left", + }); + actions.push(action); + } else if ( + isValidElement<{ children?: ReactNode; style?: TextProps["style"]; onPress?: () => void }>( + child, + ) + ) { + visit( + child.props.children, + { ...style, ...StyleSheet.flatten(child.props.style) }, + child.props.onPress ?? action, + ); + } + }); + } + visit(children, base); + return { runs, actions }; +} + +export function SelectableTranscriptText({ + children, + style, + unwrapped = false, + ...props +}: TextProps & { unwrapped?: boolean }) { + const { fontScale } = useWindowDimensions(); + const [size, setSize] = useState<{ width: number; height: number }>(); + const flatStyle = StyleSheet.flatten(style) ?? {}; + const { runs, actions } = useMemo( + () => + selectionRuns( + children, + StyleSheet.flatten(style) ?? {}, + props.allowFontScaling === false ? 1 : fontScale, + ), + [children, style, fontScale, props.allowFontScaling], + ); + if (Platform.OS !== "ios" || !NativeText) + return ( + + {children} + + ); + return ( + { + if ( + Number.isFinite(nativeEvent.height) && + nativeEvent.height >= 0 && + Number.isFinite(nativeEvent.width) + ) { + setSize((current) => + current?.width === nativeEvent.width && current.height === nativeEvent.height + ? current + : nativeEvent, + ); + } + }} + onLink={({ nativeEvent }) => actions[nativeEvent.index]?.()} + style={{ + ...flatStyle, + height: size?.height ?? Number(flatStyle.lineHeight ?? 26) * fontScale, + ...(unwrapped ? { width: size?.width ?? 1 } : {}), + }} + /> + ); +} diff --git a/apps/mobile/src/screens/session-transcript.test.tsx b/apps/mobile/src/screens/session-transcript.test.tsx index 6d9c8d7..7a9fe3e 100644 --- a/apps/mobile/src/screens/session-transcript.test.tsx +++ b/apps/mobile/src/screens/session-transcript.test.tsx @@ -1,6 +1,7 @@ import { afterEach, expect, jest, test } from "@jest/globals"; import type { SessionMessageInfo } from "@opencode2-mobile/opencode-adapter"; -import { fireEvent, render, screen } from "@testing-library/react-native"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react-native"; +import * as Clipboard from "expo-clipboard"; import { Alert, Linking, View } from "react-native"; import { resetTranscriptPerformanceMetrics } from "../state/transcript-performance"; @@ -13,6 +14,32 @@ import { afterEach(resetTranscriptPerformanceMetrics); +jest.mock("expo-clipboard", () => ({ setStringAsync: jest.fn(async () => true) })); + +test("one response copy action combines prose parts without reasoning or model metadata", async () => { + const message = messages.find((item) => item.type === "assistant"); + if (!message) throw new Error("fixture"); + render( + , + ); + expect(screen.getAllByRole("button", { name: "Copy response" })).toHaveLength(1); + fireEvent.press(screen.getByRole("button", { name: "Copy response" })); + await waitFor(() => + expect(Clipboard.setStringAsync).toHaveBeenLastCalledWith( + "First paragraph\n\nSecond paragraph", + ), + ); +}); + test("inline code preserves literal markup and does not create links inside code", () => { const message = messages.find((item) => item.type === "assistant"); if (!message) throw new Error("fixture"); @@ -38,6 +65,20 @@ test("inline code preserves literal markup and does not create links inside code expect(screen.getByText("Important")).toHaveStyle({ fontWeight: "700" }); }); +test("message text supports native selection without separate copy or selection controls", () => { + const message = messages.find((item) => item.type === "assistant"); + if (!message) throw new Error("fixture"); + const text = "First paragraph\n\nSecond paragraph"; + render(); + for (const paragraph of ["First paragraph", "Second paragraph"]) { + let node = screen.getByText(paragraph); + while (!node.props.selectable && node.parent) node = node.parent; + expect(node.props.selectable).toBe(true); + } + expect(screen.queryByRole("button", { name: "Select text" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Copy text" })).toBeNull(); +}); + test("activity summaries count operations rather than inventing file counts", () => { const message = messages.find((item) => item.type === "assistant"); if (!message) throw new Error("fixture"); diff --git a/apps/mobile/src/screens/session-transcript.tsx b/apps/mobile/src/screens/session-transcript.tsx index afab1b1..372ccc5 100644 --- a/apps/mobile/src/screens/session-transcript.tsx +++ b/apps/mobile/src/screens/session-transcript.tsx @@ -3,6 +3,8 @@ import { memo, useEffect, useState } from "react"; import { Alert, Linking, Pressable, StyleSheet, Text, View } from "react-native"; import { applicationName } from "../application-name"; +import { CopyTextButton } from "../components/copy-text-button"; +import { SelectableTranscriptText } from "../components/selectable-transcript-text"; import { recordTranscriptRowCommit } from "../state/transcript-performance"; import { markdownPalette, palette, radius, space, typeRamp, typography } from "../theme"; import { @@ -202,6 +204,19 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ ); case "assistant": { const visibleContent = message.content.slice(0, maxAssistantParts); + const responseParts = visibleContent.flatMap((part) => + part.type === "text" ? [part.text] : [], + ); + let copyTruncated = message.content + .slice(maxAssistantParts) + .some((part) => part.type === "text"); + const responseText = responseParts + .map((text) => { + const safe = sanitizeTranscriptText(text.slice(0, maxSanitizedInput), maxVisibleText + 1); + copyTruncated ||= safe.length > maxVisibleText || text.length > maxSanitizedInput; + return safe.slice(0, maxVisibleText); + }) + .join("\n\n"); return ( {(detailed @@ -300,6 +315,13 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ {hasNarrativeContent(message) ? ( ) : null} + {responseText ? ( + + ) : null} ); } @@ -606,20 +628,20 @@ function ToolDisclosure({ /> {expanded ? presentation.files.map((file) => ( - {file} - + )) : null} {expanded && presentation.command ? ( - + {`$ ${presentation.command}`} - + ) : null} {expanded ? keyToolContent(visibleContent).map(({ item, key }) => @@ -630,12 +652,16 @@ function ToolDisclosure({ text={parseSubagentProtocolText(item.text).text} /> ) : ( - + {sanitizeTranscriptText( item.name?.trim() ? basename(item.name.trim()) : "File result", 256, )} - + ), ) : null} @@ -732,9 +758,9 @@ function ShellDisclosure({ largeText, message }: { largeText: boolean; message: onPress={() => setExpanded((current) => !current)} /> {expanded ? ( - + {`$ ${message.command}`} - + ) : null} {expanded && message.output?.output ? ( @@ -1031,9 +1057,9 @@ function InlineMarkdownText({ function LinkifiedText({ style, text }: { style: object; text: string }) { return ( - - - + + {LinkifiedTextContent({ text })} + ); } diff --git a/apps/mobile/src/screens/transcript-markdown.test.tsx b/apps/mobile/src/screens/transcript-markdown.test.tsx index 33647fc..2ebc461 100644 --- a/apps/mobile/src/screens/transcript-markdown.test.tsx +++ b/apps/mobile/src/screens/transcript-markdown.test.tsx @@ -1,9 +1,32 @@ import { expect, jest, test } from "@jest/globals"; -import { fireEvent, render, screen } from "@testing-library/react-native"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react-native"; +import * as Clipboard from "expo-clipboard"; import { TranscriptMarkdown } from "./transcript-markdown"; const body = { fontSize: 17, lineHeight: 26 }; +jest.mock("expo-clipboard", () => ({ setStringAsync: jest.fn(async () => true) })); + +test("copies only the selected code block, preserving indentation and newlines", async () => { + render( + , + ); + const copy = screen.getAllByRole("button", { name: "Copy code" })[0]; + if (!copy) throw new Error("Missing code copy action"); + fireEvent.press(copy); + await waitFor(() => + expect(Clipboard.setStringAsync).toHaveBeenLastCalledWith(" first();\n\n second();\n"), + ); + expect(screen.getByText(" first();\n\n second();", { exact: true })).toHaveProp( + "selectable", + true, + ); +}); + test("keeps emphasis local to its nested content and paragraph", () => { render( + {prefix ? {`${prefix} `} : null} - - + {InlineContent({ tokens, onOpenLink: props.onOpenLink })} + ); } @@ -149,12 +150,14 @@ function Blocks({ blocks, ...props }: Omit & { blocks: Node[] }) switch (token.type) { case "inline": return ( - + + {InlineContent({ tokens: token.children ?? [], onOpenLink: props.onOpenLink })} + ); case "paragraph_open": case "heading_open": return ( - & { blocks: Node[] }) /h[4-6]/.test(token.tag) && styles.headingSmall, ]} > - - + {Blocks({ blocks: children, ...props })} + ); case "fence": case "code_block": { @@ -179,15 +182,22 @@ function Blocks({ blocks, ...props }: Omit & { blocks: Node[] }) accessibilityLabel={language ? `Code block, ${language}` : "Code block"} style={styles.codeBlock} > - {language ? ( - - {language.toLocaleUpperCase()} - - ) : null} + + {language ? ( + + {language.toLocaleUpperCase()} + + ) : null} + + - + {token.content.replace(/\n$/, "")} - + ); @@ -222,9 +232,9 @@ function Blocks({ blocks, ...props }: Omit & { blocks: Node[] }) return ; default: return ( - + {token.content} - + ); } }); @@ -245,7 +255,7 @@ function MarkdownTable({ sections, ...props }: Omit & { sections: {row.children.map((cell) => ( - & { sections: cell.token.attrGet("style") === "text-align:center" && styles.alignCenter, ]} > - - + {Blocks({ blocks: cell.children, ...props })} + ))} @@ -292,6 +302,13 @@ const styles = StyleSheet.create({ gap: space.xs, }, codeLanguage: { ...typography.label, color: palette.dim }, + codeHeader: { + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "space-between", + alignItems: "center", + gap: space.sm, + }, codeText: { ...typography.code, color: palette.ink }, list: { gap: space.sm }, listRow: { flexDirection: "row", gap: space.sm }, diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 5454216..140dcaa 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -12,6 +12,24 @@ the fail-closed database backup-exclusion startup guard. Statements marked pending in older dated entries describe the status at the time of that probe; later entries supersede them. +## 2026-09-28: transcript range selection, pending native verification + +The user reported that long-pressing transcript text on iOS offered Copy without +selection handles. The installed React Native iOS paragraph implementation exposes +a whole-text copy menu rather than range selection. + +An Apple-only local Expo view now uses a non-editable, selectable `UITextView` for +transcript text, preserving attributed formatting and routing link taps through +the existing confirmation callback. Android continues to use selectable native +React Native Text. Selection is scoped to each rendered text block or table cell. + +The iOS module requires a new native build and runtime fingerprint; OTA alone does +not install it. Older builds fall back to their existing text renderer. Native +compilation and physical-device verification are pending. Verify selection handles, +copying a partial range, scrolling during selection, link confirmation, streaming, +large text, and VoiceOver/TalkBack before claiming device support. JavaScript tests +and Hermes exports do not establish these native behaviors. + ## 2026-09-27: iOS multiline composer sizing A physical iPhone recording showed typed text wrapping beyond the visible input