Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -54,7 +54,7 @@ const config: ExpoConfig = {
: { enabled: false },
ios: {
bundleIdentifier: iosBundleIdentifier,
buildNumber: "7",
buildNumber: "8",
supportsTablet: true,
config: {
usesNonExemptEncryption: false,
Expand All @@ -70,7 +70,7 @@ const config: ExpoConfig = {
android: {
package: androidPackage,
...(googleServicesFile ? { googleServicesFile } : {}),
versionCode: 6,
versionCode: 7,
allowBackup: false,
predictiveBackGestureEnabled: false,
softwareKeyboardLayoutMode: "resize",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["TranscriptSelectionModule"]
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opencode2-mobile/mobile",
"version": "0.1.4",
"version": "0.1.5",
"private": true,
"main": "index.ts",
"scripts": {
Expand Down
38 changes: 38 additions & 0 deletions apps/mobile/src/components/copy-text-button.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CopyTextButton text="example" label="Copy code" />);
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(<CopyTextButton text={" first\n\nsecond\n"} />);
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");
});
68 changes: 68 additions & 0 deletions apps/mobile/src/components/copy-text-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={() => void copy()}
style={styles.action}
>
{iconOnly && !status ? (
<Feather accessible={false} name="copy" size={20} color={palette.dim} />
) : (
<Text style={styles.label}>
{status === "copied" ? "Copied" : status === "failed" ? "Retry copy" : label}
</Text>
)}
</Pressable>
);
}

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";
63 changes: 63 additions & 0 deletions apps/mobile/src/components/selectable-transcript-text.test.tsx
Original file line number Diff line number Diff line change
@@ -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{" "}
<Text style={{ fontWeight: "700" }}>
bold <Text style={{ fontStyle: "italic" }}>nested</Text>
</Text>
<Text onPress={open}>link</Text>
</>,
{ 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(
<TranscriptMarkdown
style={{ fontSize: 17 }}
onOpenLink={open}
text="Hello **bold** and [Docs](https://example.test/docs)."
/>,
);
const view = screen.getByTestId("native-selection");
const runs = view.props.runs as ReturnType<typeof selectionRuns>["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(<SelectableTranscriptText>Mark part of this text</SelectableTranscriptText>);
expect(screen.queryByTestId("native-selection")).toBeNull();
expect(screen.getByText("Mark part of this text")).toHaveProp("selectable", true);
});
Loading
Loading