| author | |
| committer | |
| log | 689a882045384f9f20e0c016b27fab20b7d76969 |
| tree | 93b8a06239b1b591e39058122a2a8d8d98ae4dd4 |
| parent | 85fe62d4021dfabe14572b7a7ef55e3f4f1144f4 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>148 files changed, 8550 insertions(+), 715 deletions(-)
apps/mobile/clerk-theme.json+2-2| ... | ... | @@ -13,7 +13,7 @@ |
| 13 | 13 | "neutral": "#F5F5F5", |
| 14 | 14 | "border": "#E5E5EA", |
| 15 | 15 | "ring": "#A3A3A3", |
| 16 | "muted": "#F5F5F5", | |
| 16 | "muted": "#F2F2F7", | |
| 17 | 17 | "shadow": "#000000" |
| 18 | 18 | }, |
| 19 | 19 | "darkColors": { |
| ... | ... | @@ -30,7 +30,7 @@ |
| 30 | 30 | "neutral": "#1C1C1C", |
| 31 | 31 | "border": "#2A2A2A", |
| 32 | 32 | "ring": "#525252", |
| 33 | "muted": "#1C1C1C", | |
| 33 | "muted": "#0E0E0E", | |
| 34 | 34 | "shadow": "#000000" |
| 35 | 35 | }, |
| 36 | 36 | "design": { |
apps/mobile/global.css+15-5| ... | ... | @@ -18,7 +18,7 @@ |
| 18 | 18 | --color-foreground: #262626; |
| 19 | 19 | --color-foreground-secondary: #525252; |
| 20 | 20 | --color-foreground-muted: #737373; |
| 21 | --color-foreground-tertiary: #a3a3a3; | |
| 21 | --color-foreground-tertiary: #8e8e93; | |
| 22 | 22 | |
| 23 | 23 | /* Borders & separators */ |
| 24 | 24 | --color-border: rgba(0, 0, 0, 0.08); |
| ... | ... | @@ -28,6 +28,9 @@ |
| 28 | 28 | /* Subtle backgrounds (badges, pills, overlays) */ |
| 29 | 29 | --color-subtle: rgba(0, 0, 0, 0.04); |
| 30 | 30 | --color-subtle-strong: rgba(0, 0, 0, 0.08); |
| 31 | --color-inline-skill-background: rgba(217, 70, 239, 0.12); | |
| 32 | --color-inline-skill-border: rgba(217, 70, 239, 0.25); | |
| 33 | --color-inline-skill-foreground: #a21caf; | |
| 31 | 34 | |
| 32 | 35 | /* Primary action */ |
| 33 | 36 | --color-primary: #262626; |
| ... | ... | @@ -58,6 +61,8 @@ |
| 58 | 61 | /* Header / glass chrome */ |
| 59 | 62 | --color-header: rgba(255, 255, 255, 0.97); |
| 60 | 63 | --color-header-border: rgba(0, 0, 0, 0.06); |
| 64 | --color-glass-surface: rgba(255, 255, 255, 0.72); | |
| 65 | --color-glass-tint: rgba(255, 255, 255, 0.18); | |
| 61 | 66 | |
| 62 | 67 | /* StatusBar */ |
| 63 | 68 | --color-status-bar: #f2f2f7; |
| ... | ... | @@ -105,8 +110,8 @@ |
| 105 | 110 | /* Text */ |
| 106 | 111 | --color-foreground: #f5f5f5; |
| 107 | 112 | --color-foreground-secondary: #a3a3a3; |
| 108 | --color-foreground-muted: #737373; | |
| 109 | --color-foreground-tertiary: #525252; | |
| 113 | --color-foreground-muted: #8e8e93; | |
| 114 | --color-foreground-tertiary: #636366; | |
| 110 | 115 | |
| 111 | 116 | /* Borders & separators */ |
| 112 | 117 | --color-border: rgba(255, 255, 255, 0.06); |
| ... | ... | @@ -116,6 +121,9 @@ |
| 116 | 121 | /* Subtle backgrounds (badges, pills, overlays) */ |
| 117 | 122 | --color-subtle: rgba(255, 255, 255, 0.04); |
| 118 | 123 | --color-subtle-strong: rgba(255, 255, 255, 0.08); |
| 124 | --color-inline-skill-background: rgba(217, 70, 239, 0.12); | |
| 125 | --color-inline-skill-border: rgba(217, 70, 239, 0.25); | |
| 126 | --color-inline-skill-foreground: #f0abfc; | |
| 119 | 127 | |
| 120 | 128 | /* Primary action */ |
| 121 | 129 | --color-primary: #f5f5f5; |
| ... | ... | @@ -136,16 +144,18 @@ |
| 136 | 144 | /* Inputs */ |
| 137 | 145 | --color-input: #141414; |
| 138 | 146 | --color-input-border: rgba(255, 255, 255, 0.08); |
| 139 | --color-placeholder: #737373; | |
| 147 | --color-placeholder: #8e8e93; | |
| 140 | 148 | |
| 141 | 149 | /* Icons */ |
| 142 | 150 | --color-icon: #f5f5f5; |
| 143 | 151 | --color-icon-muted: #a3a3a3; |
| 144 | --color-icon-subtle: #737373; | |
| 152 | --color-icon-subtle: #8e8e93; | |
| 145 | 153 | |
| 146 | 154 | /* Header / glass chrome */ |
| 147 | 155 | --color-header: rgba(10, 10, 10, 0.97); |
| 148 | 156 | --color-header-border: rgba(255, 255, 255, 0.06); |
| 157 | --color-glass-surface: rgba(23, 23, 23, 0.78); | |
| 158 | --color-glass-tint: rgba(23, 23, 23, 0.24); | |
| 149 | 159 | |
| 150 | 160 | /* StatusBar */ |
| 151 | 161 | --color-status-bar: #0a0a0a; |
apps/mobile/modules/t3-composer-editor/LICENSE created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | The MIT License (MIT) | |
| 2 | ||
| 3 | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | |
| 4 | ||
| 5 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | of this software and associated documentation files (the "Software"), to deal | |
| 7 | in the Software without restriction, including without limitation the rights | |
| 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | copies of the Software, and to permit persons to whom the Software is | |
| 10 | furnished to do so, subject to the following conditions: | |
| 11 | ||
| 12 | The above copyright notice and this permission notice shall be included in all | |
| 13 | copies or substantial portions of the Software. | |
| 14 | ||
| 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | SOFTWARE. |
apps/mobile/modules/t3-composer-editor/expo-module.config.json created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | { | |
| 2 | "platforms": ["apple"], | |
| 3 | "apple": { | |
| 4 | "modules": ["T3ComposerEditorModule"] | |
| 5 | } | |
| 6 | } |
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | Pod::Spec.new do |s| | |
| 2 | s.name = 'T3ComposerEditor' | |
| 3 | s.version = '1.0.0' | |
| 4 | s.summary = 'Native attributed composer editor for T3 Code mobile.' | |
| 5 | s.description = 'UIKit-backed rich text composer with atomic skill and file tokens.' | |
| 6 | s.author = 'T3 Tools' | |
| 7 | s.homepage = 'https://t3tools.com' | |
| 8 | s.platforms = { | |
| 9 | :ios => '16.4', | |
| 10 | } | |
| 11 | s.source = { :path => '.' } | |
| 12 | s.static_framework = true | |
| 13 | ||
| 14 | s.dependency 'ExpoModulesCore' | |
| 15 | # Swift/Objective-C compatibility | |
| 16 | s.pod_target_xcconfig = { | |
| 17 | 'DEFINES_MODULE' => 'YES', | |
| 18 | } | |
| 19 | ||
| 20 | s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" | |
| 21 | end |
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift created+71| ... | ... | @@ -0,0 +1,71 @@ |
| 1 | import ExpoModulesCore | |
| 2 | ||
| 3 | public class T3ComposerEditorModule: Module { | |
| 4 | public func definition() -> ModuleDefinition { | |
| 5 | Name("T3ComposerEditor") | |
| 6 | ||
| 7 | View(T3ComposerEditorView.self) { | |
| 8 | Prop("value") { (view: T3ComposerEditorView, value: String) in | |
| 9 | view.setValue(value) | |
| 10 | } | |
| 11 | Prop("tokensJson") { (view: T3ComposerEditorView, tokensJson: String) in | |
| 12 | view.setTokensJson(tokensJson) | |
| 13 | } | |
| 14 | Prop("selectionJson") { (view: T3ComposerEditorView, selectionJson: String) in | |
| 15 | view.setSelectionJson(selectionJson) | |
| 16 | } | |
| 17 | Prop("themeJson") { (view: T3ComposerEditorView, themeJson: String) in | |
| 18 | view.setThemeJson(themeJson) | |
| 19 | } | |
| 20 | Prop("placeholder") { (view: T3ComposerEditorView, placeholder: String) in | |
| 21 | view.setPlaceholder(placeholder) | |
| 22 | } | |
| 23 | Prop("fontFamily") { (view: T3ComposerEditorView, fontFamily: String) in | |
| 24 | view.setFontFamily(fontFamily) | |
| 25 | } | |
| 26 | Prop("fontSize") { (view: T3ComposerEditorView, fontSize: Double) in | |
| 27 | view.setFontSize(CGFloat(fontSize)) | |
| 28 | } | |
| 29 | Prop("lineHeight") { (view: T3ComposerEditorView, lineHeight: Double) in | |
| 30 | view.setLineHeight(CGFloat(lineHeight)) | |
| 31 | } | |
| 32 | Prop("contentInsetVertical") { (view: T3ComposerEditorView, contentInsetVertical: Double) in | |
| 33 | view.setContentInsetVertical(CGFloat(contentInsetVertical)) | |
| 34 | } | |
| 35 | Prop("editable") { (view: T3ComposerEditorView, editable: Bool) in | |
| 36 | view.setEditable(editable) | |
| 37 | } | |
| 38 | Prop("scrollEnabled") { (view: T3ComposerEditorView, scrollEnabled: Bool) in | |
| 39 | view.setScrollEnabled(scrollEnabled) | |
| 40 | } | |
| 41 | Prop("autoFocus") { (view: T3ComposerEditorView, autoFocus: Bool) in | |
| 42 | view.setAutoFocus(autoFocus) | |
| 43 | } | |
| 44 | Prop("autoCorrect") { (view: T3ComposerEditorView, autoCorrect: Bool) in | |
| 45 | view.setAutoCorrect(autoCorrect) | |
| 46 | } | |
| 47 | Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in | |
| 48 | view.setSpellCheck(spellCheck) | |
| 49 | } | |
| 50 | ||
| 51 | Events( | |
| 52 | "onComposerChange", | |
| 53 | "onComposerSelectionChange", | |
| 54 | "onComposerFocus", | |
| 55 | "onComposerBlur", | |
| 56 | "onComposerPasteImages", | |
| 57 | "onComposerContentSizeChange" | |
| 58 | ) | |
| 59 | ||
| 60 | AsyncFunction("focus") { (view: T3ComposerEditorView) in | |
| 61 | view.focusEditor() | |
| 62 | } | |
| 63 | AsyncFunction("blur") { (view: T3ComposerEditorView) in | |
| 64 | view.blurEditor() | |
| 65 | } | |
| 66 | AsyncFunction("setSelection") { (view: T3ComposerEditorView, start: Int, end: Int) in | |
| 67 | view.setSelection(start: start, end: end) | |
| 68 | } | |
| 69 | } | |
| 70 | } | |
| 71 | } |
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift created+819| ... | ... | @@ -0,0 +1,819 @@ |
| 1 | import ExpoModulesCore | |
| 2 | import UIKit | |
| 3 | ||
| 4 | private struct ComposerTokenPayload: Decodable { | |
| 5 | let type: String | |
| 6 | let source: String | |
| 7 | let label: String | |
| 8 | let iconUri: String? | |
| 9 | let start: Int | |
| 10 | let end: Int | |
| 11 | } | |
| 12 | ||
| 13 | private struct ComposerSelectionPayload: Decodable { | |
| 14 | let start: Int | |
| 15 | let end: Int | |
| 16 | } | |
| 17 | ||
| 18 | private struct ComposerThemePayload: Decodable { | |
| 19 | let text: String | |
| 20 | let placeholder: String | |
| 21 | let chipBackground: String | |
| 22 | let chipBorder: String | |
| 23 | let chipText: String | |
| 24 | let skillBackground: String | |
| 25 | let skillBorder: String | |
| 26 | let skillText: String | |
| 27 | let fileTint: String | |
| 28 | } | |
| 29 | ||
| 30 | private struct ComposerChipStyle { | |
| 31 | let tint: UIColor | |
| 32 | let backgroundColor: UIColor | |
| 33 | let borderColor: UIColor | |
| 34 | let textColor: UIColor | |
| 35 | } | |
| 36 | ||
| 37 | private final class ComposerTextAttachment: NSTextAttachment { | |
| 38 | let source: String | |
| 39 | ||
| 40 | init(source: String, image: UIImage, size: CGSize, baselineOffset: CGFloat) { | |
| 41 | self.source = source | |
| 42 | super.init(data: nil, ofType: nil) | |
| 43 | self.image = image | |
| 44 | bounds = CGRect(x: 0, y: baselineOffset, width: size.width, height: size.height) | |
| 45 | } | |
| 46 | ||
| 47 | required init?(coder: NSCoder) { | |
| 48 | nil | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | private final class ComposerTextView: UITextView { | |
| 53 | private static let pastedImageDirectoryName = "t3-composer-paste" | |
| 54 | private static let stalePastedImageAge: TimeInterval = 60 * 60 | |
| 55 | ||
| 56 | var onPasteImages: (([String]) -> Void)? | |
| 57 | var onAttributedMutation: (() -> Void)? | |
| 58 | ||
| 59 | override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { | |
| 60 | if action == #selector(paste(_:)) { | |
| 61 | let pasteboard = UIPasteboard.general | |
| 62 | if pasteboard.hasImages || | |
| 63 | pasteboard.itemProviders.contains(where: { | |
| 64 | $0.canLoadObject(ofClass: UIImage.self) | |
| 65 | }) { | |
| 66 | return true | |
| 67 | } | |
| 68 | } | |
| 69 | return super.canPerformAction(action, withSender: sender) | |
| 70 | } | |
| 71 | ||
| 72 | override func paste(_ sender: Any?) { | |
| 73 | let pasteboard = UIPasteboard.general | |
| 74 | let imageProviders = pasteboard.itemProviders.filter { | |
| 75 | $0.canLoadObject(ofClass: UIImage.self) | |
| 76 | } | |
| 77 | if !imageProviders.isEmpty { | |
| 78 | loadImages(from: imageProviders) | |
| 79 | return | |
| 80 | } | |
| 81 | ||
| 82 | let images = pasteboard.images ?? [] | |
| 83 | if !images.isEmpty { | |
| 84 | let urls = images.compactMap(Self.writeTemporaryImage) | |
| 85 | if !urls.isEmpty { | |
| 86 | onPasteImages?(urls) | |
| 87 | return | |
| 88 | } | |
| 89 | } | |
| 90 | super.paste(sender) | |
| 91 | } | |
| 92 | ||
| 93 | override func deleteBackward() { | |
| 94 | guard selectedRange.length == 0, selectedRange.location > 0 else { | |
| 95 | super.deleteBackward() | |
| 96 | return | |
| 97 | } | |
| 98 | ||
| 99 | let previousOffset = selectedRange.location - 1 | |
| 100 | if textStorage.attribute(.attachment, at: previousOffset, effectiveRange: nil) | |
| 101 | is ComposerTextAttachment { | |
| 102 | replaceDisplayRange(NSRange(location: previousOffset, length: 1)) | |
| 103 | return | |
| 104 | } | |
| 105 | ||
| 106 | super.deleteBackward() | |
| 107 | } | |
| 108 | ||
| 109 | private func replaceDisplayRange(_ range: NSRange) { | |
| 110 | guard let start = position(from: beginningOfDocument, offset: range.location), | |
| 111 | let end = position(from: start, offset: range.length), | |
| 112 | let textRange = textRange(from: start, to: end) else { | |
| 113 | return | |
| 114 | } | |
| 115 | replace(textRange, withText: "") | |
| 116 | } | |
| 117 | ||
| 118 | private func loadImages(from providers: [NSItemProvider]) { | |
| 119 | let group = DispatchGroup() | |
| 120 | let lock = NSLock() | |
| 121 | var images = [UIImage?](repeating: nil, count: providers.count) | |
| 122 | ||
| 123 | for (index, provider) in providers.enumerated() { | |
| 124 | group.enter() | |
| 125 | provider.loadObject(ofClass: UIImage.self) { object, _ in | |
| 126 | defer { group.leave() } | |
| 127 | guard let image = object as? UIImage else { | |
| 128 | return | |
| 129 | } | |
| 130 | lock.lock() | |
| 131 | images[index] = image | |
| 132 | lock.unlock() | |
| 133 | } | |
| 134 | } | |
| 135 | ||
| 136 | group.notify(queue: .main) { [weak self] in | |
| 137 | let urls = images.compactMap { $0 }.compactMap(Self.writeTemporaryImage) | |
| 138 | if !urls.isEmpty { | |
| 139 | self?.onPasteImages?(urls) | |
| 140 | } | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | override func copy(_ sender: Any?) { | |
| 145 | guard selectedRange.length > 0 else { | |
| 146 | return super.copy(sender) | |
| 147 | } | |
| 148 | UIPasteboard.general.string = serializedText(in: selectedRange) | |
| 149 | } | |
| 150 | ||
| 151 | override func cut(_ sender: Any?) { | |
| 152 | guard isEditable, selectedRange.length > 0 else { | |
| 153 | return super.cut(sender) | |
| 154 | } | |
| 155 | copy(sender) | |
| 156 | textStorage.replaceCharacters(in: selectedRange, with: "") | |
| 157 | selectedRange = NSRange(location: selectedRange.location, length: 0) | |
| 158 | onAttributedMutation?() | |
| 159 | } | |
| 160 | ||
| 161 | func serializedText() -> String { | |
| 162 | serializedText(in: NSRange(location: 0, length: attributedText.length)) | |
| 163 | } | |
| 164 | ||
| 165 | func serializedText(in range: NSRange) -> String { | |
| 166 | guard range.length > 0 else { | |
| 167 | return "" | |
| 168 | } | |
| 169 | ||
| 170 | let source = NSMutableString() | |
| 171 | let nsString = attributedText.string as NSString | |
| 172 | var cursor = range.location | |
| 173 | let end = NSMaxRange(range) | |
| 174 | attributedText.enumerateAttribute(.attachment, in: range) { value, attachmentRange, _ in | |
| 175 | if attachmentRange.location > cursor { | |
| 176 | source.append( | |
| 177 | nsString.substring( | |
| 178 | with: NSRange(location: cursor, length: attachmentRange.location - cursor) | |
| 179 | ) | |
| 180 | ) | |
| 181 | } | |
| 182 | if let attachment = value as? ComposerTextAttachment { | |
| 183 | source.append(attachment.source) | |
| 184 | } else { | |
| 185 | source.append(nsString.substring(with: attachmentRange)) | |
| 186 | } | |
| 187 | cursor = NSMaxRange(attachmentRange) | |
| 188 | } | |
| 189 | if cursor < end { | |
| 190 | source.append(nsString.substring(with: NSRange(location: cursor, length: end - cursor))) | |
| 191 | } | |
| 192 | return source as String | |
| 193 | } | |
| 194 | ||
| 195 | func sourceOffset(forDisplayOffset displayOffset: Int) -> Int { | |
| 196 | let boundedOffset = max(0, min(attributedText.length, displayOffset)) | |
| 197 | if boundedOffset == 0 { | |
| 198 | return 0 | |
| 199 | } | |
| 200 | ||
| 201 | var sourceOffset = 0 | |
| 202 | let range = NSRange(location: 0, length: boundedOffset) | |
| 203 | attributedText.enumerateAttribute(.attachment, in: range) { value, attributeRange, _ in | |
| 204 | if let attachment = value as? ComposerTextAttachment { | |
| 205 | sourceOffset += (attachment.source as NSString).length | |
| 206 | } else { | |
| 207 | sourceOffset += attributeRange.length | |
| 208 | } | |
| 209 | } | |
| 210 | return sourceOffset | |
| 211 | } | |
| 212 | ||
| 213 | private static func writeTemporaryImage(_ image: UIImage) -> String? { | |
| 214 | guard let data = image.pngData() else { | |
| 215 | return nil | |
| 216 | } | |
| 217 | let directory = FileManager.default.temporaryDirectory | |
| 218 | .appendingPathComponent(pastedImageDirectoryName, isDirectory: true) | |
| 219 | do { | |
| 220 | try FileManager.default.createDirectory( | |
| 221 | at: directory, | |
| 222 | withIntermediateDirectories: true | |
| 223 | ) | |
| 224 | removeStaleTemporaryImages(in: directory) | |
| 225 | let url = directory.appendingPathComponent("\(UUID().uuidString).png") | |
| 226 | try data.write(to: url, options: .atomic) | |
| 227 | return url.absoluteString | |
| 228 | } catch { | |
| 229 | return nil | |
| 230 | } | |
| 231 | } | |
| 232 | ||
| 233 | private static func removeStaleTemporaryImages(in directory: URL) { | |
| 234 | let cutoff = Date().addingTimeInterval(-stalePastedImageAge) | |
| 235 | guard let urls = try? FileManager.default.contentsOfDirectory( | |
| 236 | at: directory, | |
| 237 | includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], | |
| 238 | options: [.skipsHiddenFiles] | |
| 239 | ) else { | |
| 240 | return | |
| 241 | } | |
| 242 | ||
| 243 | for url in urls { | |
| 244 | guard | |
| 245 | let values = try? url.resourceValues( | |
| 246 | forKeys: [.contentModificationDateKey, .isRegularFileKey] | |
| 247 | ), | |
| 248 | values.isRegularFile == true, | |
| 249 | let modifiedAt = values.contentModificationDate, | |
| 250 | modifiedAt < cutoff | |
| 251 | else { | |
| 252 | continue | |
| 253 | } | |
| 254 | try? FileManager.default.removeItem(at: url) | |
| 255 | } | |
| 256 | } | |
| 257 | } | |
| 258 | ||
| 259 | public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { | |
| 260 | private let textView = ComposerTextView() | |
| 261 | private let placeholderLabel = UILabel() | |
| 262 | private var value = "" | |
| 263 | private var tokensJson = "[]" | |
| 264 | private var tokens: [ComposerTokenPayload] = [] | |
| 265 | private var requestedSelection: ComposerSelectionPayload? | |
| 266 | private var theme = ComposerThemePayload( | |
| 267 | text: "#262626", | |
| 268 | placeholder: "#8e8e93", | |
| 269 | chipBackground: "#f2f2f7", | |
| 270 | chipBorder: "#dedee3", | |
| 271 | chipText: "#262626", | |
| 272 | skillBackground: "#f9e8fb", | |
| 273 | skillBorder: "#e5a6eb", | |
| 274 | skillText: "#a21caf", | |
| 275 | fileTint: "#737373" | |
| 276 | ) | |
| 277 | private var fontFamily = "DMSans_400Regular" | |
| 278 | private var fontSize: CGFloat = 15 | |
| 279 | private var lineHeight: CGFloat = 22 | |
| 280 | private var contentInsetVertical: CGFloat = 0 | |
| 281 | private var shouldAutoFocus = false | |
| 282 | private var didAutoFocus = false | |
| 283 | private var isApplyingControlledValue = false | |
| 284 | private var lastContentSize = CGSize.zero | |
| 285 | private var iconImages: [String: UIImage] = [:] | |
| 286 | private var pendingIconUris = Set<String>() | |
| 287 | private var tokensNeedRebuild = false | |
| 288 | ||
| 289 | let onComposerChange = EventDispatcher() | |
| 290 | let onComposerSelectionChange = EventDispatcher() | |
| 291 | let onComposerFocus = EventDispatcher() | |
| 292 | let onComposerBlur = EventDispatcher() | |
| 293 | let onComposerPasteImages = EventDispatcher() | |
| 294 | let onComposerContentSizeChange = EventDispatcher() | |
| 295 | ||
| 296 | public required init(appContext: AppContext? = nil) { | |
| 297 | super.init(appContext: appContext) | |
| 298 | ||
| 299 | clipsToBounds = false | |
| 300 | textView.delegate = self | |
| 301 | textView.backgroundColor = .clear | |
| 302 | textView.textContainerInset = .zero | |
| 303 | textView.textContainer.lineFragmentPadding = 0 | |
| 304 | textView.keyboardDismissMode = .interactive | |
| 305 | textView.alwaysBounceVertical = false | |
| 306 | textView.showsVerticalScrollIndicator = true | |
| 307 | textView.adjustsFontForContentSizeCategory = true | |
| 308 | textView.onPasteImages = { [weak self] urls in | |
| 309 | self?.onComposerPasteImages(["uris": urls]) | |
| 310 | } | |
| 311 | textView.onAttributedMutation = { [weak self] in | |
| 312 | self?.emitTextChange() | |
| 313 | } | |
| 314 | addSubview(textView) | |
| 315 | ||
| 316 | placeholderLabel.numberOfLines = 0 | |
| 317 | placeholderLabel.adjustsFontForContentSizeCategory = true | |
| 318 | addSubview(placeholderLabel) | |
| 319 | applyTypography() | |
| 320 | applyTheme() | |
| 321 | } | |
| 322 | ||
| 323 | public override func layoutSubviews() { | |
| 324 | super.layoutSubviews() | |
| 325 | textView.frame = bounds | |
| 326 | let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding | |
| 327 | let placeholderY = textView.textContainerInset.top | |
| 328 | let placeholderWidth = max( | |
| 329 | 0, | |
| 330 | bounds.width - placeholderX - textView.textContainerInset.right - | |
| 331 | textView.textContainer.lineFragmentPadding | |
| 332 | ) | |
| 333 | placeholderLabel.frame = CGRect( | |
| 334 | x: placeholderX, | |
| 335 | y: placeholderY, | |
| 336 | width: placeholderWidth, | |
| 337 | height: max(lineHeight, placeholderLabel.font.lineHeight) | |
| 338 | ) | |
| 339 | emitContentSizeIfNeeded() | |
| 340 | } | |
| 341 | ||
| 342 | public override func didMoveToWindow() { | |
| 343 | super.didMoveToWindow() | |
| 344 | guard window != nil, shouldAutoFocus, !didAutoFocus else { | |
| 345 | return | |
| 346 | } | |
| 347 | didAutoFocus = true | |
| 348 | DispatchQueue.main.async { [weak self] in | |
| 349 | self?.textView.becomeFirstResponder() | |
| 350 | } | |
| 351 | } | |
| 352 | ||
| 353 | func setValue(_ value: String) { | |
| 354 | self.value = value | |
| 355 | applyControlledDocument(force: tokensNeedRebuild) | |
| 356 | if tokensMatchCurrentValue() { | |
| 357 | tokensNeedRebuild = false | |
| 358 | } | |
| 359 | } | |
| 360 | ||
| 361 | func setTokensJson(_ tokensJson: String) { | |
| 362 | guard self.tokensJson != tokensJson else { | |
| 363 | return | |
| 364 | } | |
| 365 | self.tokensJson = tokensJson | |
| 366 | tokens = decode([ComposerTokenPayload].self, from: tokensJson) ?? [] | |
| 367 | tokensNeedRebuild = true | |
| 368 | applyControlledDocument(force: true) | |
| 369 | if tokensMatchCurrentValue() { | |
| 370 | tokensNeedRebuild = false | |
| 371 | } | |
| 372 | } | |
| 373 | ||
| 374 | func setSelectionJson(_ selectionJson: String) { | |
| 375 | requestedSelection = decode(ComposerSelectionPayload.self, from: selectionJson) | |
| 376 | applyRequestedSelection() | |
| 377 | } | |
| 378 | ||
| 379 | func setThemeJson(_ themeJson: String) { | |
| 380 | guard let nextTheme = decode(ComposerThemePayload.self, from: themeJson) else { | |
| 381 | return | |
| 382 | } | |
| 383 | theme = nextTheme | |
| 384 | applyTheme() | |
| 385 | applyControlledDocument(force: true) | |
| 386 | } | |
| 387 | ||
| 388 | func setPlaceholder(_ placeholder: String) { | |
| 389 | placeholderLabel.text = placeholder | |
| 390 | setNeedsLayout() | |
| 391 | } | |
| 392 | ||
| 393 | func setFontFamily(_ fontFamily: String) { | |
| 394 | self.fontFamily = fontFamily | |
| 395 | applyTypography() | |
| 396 | applyControlledDocument(force: true) | |
| 397 | } | |
| 398 | ||
| 399 | func setFontSize(_ fontSize: CGFloat) { | |
| 400 | self.fontSize = fontSize | |
| 401 | applyTypography() | |
| 402 | applyControlledDocument(force: true) | |
| 403 | } | |
| 404 | ||
| 405 | func setLineHeight(_ lineHeight: CGFloat) { | |
| 406 | self.lineHeight = lineHeight | |
| 407 | applyTypography() | |
| 408 | applyControlledDocument(force: true) | |
| 409 | } | |
| 410 | ||
| 411 | func setContentInsetVertical(_ contentInsetVertical: CGFloat) { | |
| 412 | self.contentInsetVertical = contentInsetVertical | |
| 413 | textView.textContainerInset = UIEdgeInsets( | |
| 414 | top: contentInsetVertical, | |
| 415 | left: 0, | |
| 416 | bottom: contentInsetVertical, | |
| 417 | right: 0 | |
| 418 | ) | |
| 419 | setNeedsLayout() | |
| 420 | } | |
| 421 | ||
| 422 | func setEditable(_ editable: Bool) { | |
| 423 | textView.isEditable = editable | |
| 424 | } | |
| 425 | ||
| 426 | func setScrollEnabled(_ scrollEnabled: Bool) { | |
| 427 | textView.isScrollEnabled = scrollEnabled | |
| 428 | } | |
| 429 | ||
| 430 | func setAutoFocus(_ autoFocus: Bool) { | |
| 431 | shouldAutoFocus = autoFocus | |
| 432 | } | |
| 433 | ||
| 434 | func setAutoCorrect(_ autoCorrect: Bool) { | |
| 435 | textView.autocorrectionType = autoCorrect ? .yes : .no | |
| 436 | } | |
| 437 | ||
| 438 | func setSpellCheck(_ spellCheck: Bool) { | |
| 439 | textView.spellCheckingType = spellCheck ? .yes : .no | |
| 440 | } | |
| 441 | ||
| 442 | func focusEditor() { | |
| 443 | textView.becomeFirstResponder() | |
| 444 | } | |
| 445 | ||
| 446 | func blurEditor() { | |
| 447 | textView.resignFirstResponder() | |
| 448 | } | |
| 449 | ||
| 450 | func setSelection(start: Int, end: Int) { | |
| 451 | requestedSelection = ComposerSelectionPayload(start: start, end: end) | |
| 452 | applyRequestedSelection() | |
| 453 | } | |
| 454 | ||
| 455 | public func textViewDidChange(_ textView: UITextView) { | |
| 456 | emitTextChange() | |
| 457 | } | |
| 458 | ||
| 459 | public func textViewDidChangeSelection(_ textView: UITextView) { | |
| 460 | guard !isApplyingControlledValue else { | |
| 461 | return | |
| 462 | } | |
| 463 | emitSelection() | |
| 464 | } | |
| 465 | ||
| 466 | public func textViewDidBeginEditing(_ textView: UITextView) { | |
| 467 | onComposerFocus() | |
| 468 | } | |
| 469 | ||
| 470 | public func textViewDidEndEditing(_ textView: UITextView) { | |
| 471 | onComposerBlur() | |
| 472 | } | |
| 473 | ||
| 474 | private func applyControlledDocument(force: Bool = false) { | |
| 475 | let currentSource = textView.serializedText() | |
| 476 | guard force || currentSource != value || !documentMatchesExpectedTokens() else { | |
| 477 | updatePlaceholderVisibility() | |
| 478 | return | |
| 479 | } | |
| 480 | ||
| 481 | let previousSelection = sourceSelection() | |
| 482 | isApplyingControlledValue = true | |
| 483 | textView.attributedText = makeAttributedDocument() | |
| 484 | let targetSelection = requestedSelection ?? previousSelection | |
| 485 | requestedSelection = nil | |
| 486 | textView.selectedRange = displayRange(for: targetSelection) | |
| 487 | isApplyingControlledValue = false | |
| 488 | updatePlaceholderVisibility() | |
| 489 | emitContentSizeIfNeeded() | |
| 490 | } | |
| 491 | ||
| 492 | private func makeAttributedDocument() -> NSAttributedString { | |
| 493 | let result = NSMutableAttributedString() | |
| 494 | let source = value as NSString | |
| 495 | var cursor = 0 | |
| 496 | let validTokens = tokens.filter { | |
| 497 | $0.start >= cursor && | |
| 498 | $0.end > $0.start && | |
| 499 | $0.end <= source.length && | |
| 500 | source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source | |
| 501 | } | |
| 502 | ||
| 503 | for token in validTokens { | |
| 504 | if token.start < cursor { | |
| 505 | continue | |
| 506 | } | |
| 507 | if token.start > cursor { | |
| 508 | appendPlainText( | |
| 509 | source.substring(with: NSRange(location: cursor, length: token.start - cursor)), | |
| 510 | to: result | |
| 511 | ) | |
| 512 | } | |
| 513 | result.append(makeAttachmentString(token)) | |
| 514 | cursor = token.end | |
| 515 | } | |
| 516 | if cursor < source.length { | |
| 517 | appendPlainText( | |
| 518 | source.substring(with: NSRange(location: cursor, length: source.length - cursor)), | |
| 519 | to: result | |
| 520 | ) | |
| 521 | } | |
| 522 | return result | |
| 523 | } | |
| 524 | ||
| 525 | private func appendPlainText(_ text: String, to result: NSMutableAttributedString) { | |
| 526 | result.append(NSAttributedString(string: text, attributes: baseAttributes())) | |
| 527 | } | |
| 528 | ||
| 529 | private func makeAttachmentString(_ token: ComposerTokenPayload) -> NSAttributedString { | |
| 530 | let isSkill = token.type == "skill" | |
| 531 | let tint = UIColor(composerHex: isSkill ? theme.skillText : theme.fileTint) ?? .secondaryLabel | |
| 532 | let iconName = isSkill ? "cube" : "doc" | |
| 533 | let iconImage = token.iconUri.flatMap(iconImage(for:)) | |
| 534 | let style = ComposerChipStyle( | |
| 535 | tint: tint, | |
| 536 | backgroundColor: UIColor( | |
| 537 | composerHex: isSkill ? theme.skillBackground : theme.chipBackground | |
| 538 | ) ?? .secondarySystemFill, | |
| 539 | borderColor: UIColor( | |
| 540 | composerHex: isSkill ? theme.skillBorder : theme.chipBorder | |
| 541 | ) ?? .separator, | |
| 542 | textColor: UIColor(composerHex: isSkill ? theme.skillText : theme.chipText) ?? .label | |
| 543 | ) | |
| 544 | let image = renderChip( | |
| 545 | label: token.label, | |
| 546 | iconName: iconName, | |
| 547 | iconImage: iconImage, | |
| 548 | style: style | |
| 549 | ) | |
| 550 | let font = UIFont(name: fontFamily, size: fontSize) | |
| 551 | ?? UIFont.systemFont(ofSize: fontSize) | |
| 552 | let baselineOffset = floor((font.capHeight - image.size.height) / 2) | |
| 553 | let attachment = ComposerTextAttachment( | |
| 554 | source: token.source, | |
| 555 | image: image, | |
| 556 | size: image.size, | |
| 557 | baselineOffset: baselineOffset | |
| 558 | ) | |
| 559 | return NSAttributedString(attachment: attachment) | |
| 560 | } | |
| 561 | ||
| 562 | private func renderChip( | |
| 563 | label: String, | |
| 564 | iconName: String, | |
| 565 | iconImage: UIImage?, | |
| 566 | style: ComposerChipStyle | |
| 567 | ) -> UIImage { | |
| 568 | let font = UIFont(name: "DMSans_500Medium", size: max(12, fontSize - 2)) | |
| 569 | ?? UIFont.systemFont(ofSize: max(12, fontSize - 2), weight: .medium) | |
| 570 | let fallbackIcon = UIImage( | |
| 571 | systemName: iconName, | |
| 572 | withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium) | |
| 573 | ) | |
| 574 | let icon = iconImage ?? fallbackIcon | |
| 575 | let textSize = (label as NSString).size(withAttributes: [.font: font]) | |
| 576 | let iconWidth = icon == nil ? 0 : 14 | |
| 577 | let iconGap = icon == nil ? 0 : 5 | |
| 578 | let height: CGFloat = 24 | |
| 579 | let width = ceil(9 + CGFloat(iconWidth + iconGap) + textSize.width + 9) | |
| 580 | let format = UIGraphicsImageRendererFormat.preferred() | |
| 581 | format.opaque = false | |
| 582 | let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format) | |
| 583 | return renderer.image { context in | |
| 584 | let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height)) | |
| 585 | let path = UIBezierPath(roundedRect: rect.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 7) | |
| 586 | style.backgroundColor.setFill() | |
| 587 | path.fill() | |
| 588 | style.borderColor.setStroke() | |
| 589 | path.lineWidth = 1 | |
| 590 | path.stroke() | |
| 591 | ||
| 592 | var x: CGFloat = 9 | |
| 593 | if let icon { | |
| 594 | let renderedIcon = iconImage == nil | |
| 595 | ? icon.withTintColor(style.tint, renderingMode: .alwaysOriginal) | |
| 596 | : icon | |
| 597 | renderedIcon.draw( | |
| 598 | in: CGRect(x: x, y: 5, width: 14, height: 14) | |
| 599 | ) | |
| 600 | x += 19 | |
| 601 | } | |
| 602 | let paragraph = NSMutableParagraphStyle() | |
| 603 | paragraph.alignment = .left | |
| 604 | (label as NSString).draw( | |
| 605 | in: CGRect(x: x, y: 3, width: textSize.width + 1, height: 18), | |
| 606 | withAttributes: [ | |
| 607 | .font: font, | |
| 608 | .foregroundColor: style.textColor, | |
| 609 | .paragraphStyle: paragraph, | |
| 610 | ] | |
| 611 | ) | |
| 612 | context.cgContext.setAllowsAntialiasing(true) | |
| 613 | } | |
| 614 | } | |
| 615 | ||
| 616 | private func iconImage(for uri: String) -> UIImage? { | |
| 617 | if let image = iconImages[uri] { | |
| 618 | return image | |
| 619 | } | |
| 620 | guard !pendingIconUris.contains(uri), let url = URL(string: uri) else { | |
| 621 | return nil | |
| 622 | } | |
| 623 | ||
| 624 | if url.isFileURL, let image = UIImage(contentsOfFile: url.path) { | |
| 625 | iconImages[uri] = image | |
| 626 | return image | |
| 627 | } | |
| 628 | ||
| 629 | pendingIconUris.insert(uri) | |
| 630 | URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in | |
| 631 | guard let self, let data, let image = UIImage(data: data) else { | |
| 632 | DispatchQueue.main.async { | |
| 633 | self?.pendingIconUris.remove(uri) | |
| 634 | } | |
| 635 | return | |
| 636 | } | |
| 637 | DispatchQueue.main.async { | |
| 638 | self.pendingIconUris.remove(uri) | |
| 639 | self.iconImages[uri] = image | |
| 640 | self.applyControlledDocument(force: true) | |
| 641 | } | |
| 642 | }.resume() | |
| 643 | return nil | |
| 644 | } | |
| 645 | ||
| 646 | private func baseAttributes() -> [NSAttributedString.Key: Any] { | |
| 647 | let font = UIFont(name: fontFamily, size: fontSize) | |
| 648 | ?? UIFont.systemFont(ofSize: fontSize) | |
| 649 | let paragraph = NSMutableParagraphStyle() | |
| 650 | paragraph.minimumLineHeight = lineHeight | |
| 651 | paragraph.maximumLineHeight = lineHeight | |
| 652 | return [ | |
| 653 | .font: font, | |
| 654 | .foregroundColor: UIColor(composerHex: theme.text) ?? .label, | |
| 655 | .paragraphStyle: paragraph, | |
| 656 | ] | |
| 657 | } | |
| 658 | ||
| 659 | private func applyTypography() { | |
| 660 | let font = UIFont(name: fontFamily, size: fontSize) | |
| 661 | ?? UIFont.systemFont(ofSize: fontSize) | |
| 662 | textView.font = font | |
| 663 | textView.typingAttributes = baseAttributes() | |
| 664 | placeholderLabel.font = font | |
| 665 | setNeedsLayout() | |
| 666 | } | |
| 667 | ||
| 668 | private func applyTheme() { | |
| 669 | textView.textColor = UIColor(composerHex: theme.text) ?? .label | |
| 670 | placeholderLabel.textColor = UIColor(composerHex: theme.placeholder) ?? .placeholderText | |
| 671 | tintColor = UIColor.systemBlue | |
| 672 | } | |
| 673 | ||
| 674 | private func emitTextChange() { | |
| 675 | guard !isApplyingControlledValue else { | |
| 676 | return | |
| 677 | } | |
| 678 | value = textView.serializedText() | |
| 679 | let selection = sourceSelection() | |
| 680 | onComposerChange([ | |
| 681 | "value": value, | |
| 682 | "selection": ["start": selection.start, "end": selection.end], | |
| 683 | ]) | |
| 684 | updatePlaceholderVisibility() | |
| 685 | emitContentSizeIfNeeded() | |
| 686 | } | |
| 687 | ||
| 688 | private func emitSelection() { | |
| 689 | let selection = sourceSelection() | |
| 690 | onComposerSelectionChange([ | |
| 691 | "selection": ["start": selection.start, "end": selection.end], | |
| 692 | ]) | |
| 693 | } | |
| 694 | ||
| 695 | private func sourceSelection() -> ComposerSelectionPayload { | |
| 696 | ComposerSelectionPayload( | |
| 697 | start: textView.sourceOffset(forDisplayOffset: textView.selectedRange.location), | |
| 698 | end: textView.sourceOffset(forDisplayOffset: NSMaxRange(textView.selectedRange)) | |
| 699 | ) | |
| 700 | } | |
| 701 | ||
| 702 | private func displayRange(for selection: ComposerSelectionPayload) -> NSRange { | |
| 703 | let start = displayOffset(forSourceOffset: selection.start) | |
| 704 | let end = displayOffset(forSourceOffset: selection.end) | |
| 705 | return NSRange(location: start, length: max(0, end - start)) | |
| 706 | } | |
| 707 | ||
| 708 | private func displayOffset(forSourceOffset sourceOffset: Int) -> Int { | |
| 709 | let boundedOffset = max(0, min((value as NSString).length, sourceOffset)) | |
| 710 | var collapsedLength = 0 | |
| 711 | for token in tokens where token.end <= boundedOffset { | |
| 712 | collapsedLength += max(0, token.end - token.start - 1) | |
| 713 | } | |
| 714 | if let token = tokens.first(where: { $0.start < boundedOffset && boundedOffset < $0.end }) { | |
| 715 | return token.start - collapsedLength + 1 | |
| 716 | } | |
| 717 | return boundedOffset - collapsedLength | |
| 718 | } | |
| 719 | ||
| 720 | private func applyRequestedSelection() { | |
| 721 | guard let requestedSelection else { | |
| 722 | return | |
| 723 | } | |
| 724 | let nextRange = displayRange(for: requestedSelection) | |
| 725 | guard nextRange.location <= textView.attributedText.length, | |
| 726 | NSMaxRange(nextRange) <= textView.attributedText.length else { | |
| 727 | return | |
| 728 | } | |
| 729 | isApplyingControlledValue = true | |
| 730 | textView.selectedRange = nextRange | |
| 731 | isApplyingControlledValue = false | |
| 732 | } | |
| 733 | ||
| 734 | private func updatePlaceholderVisibility() { | |
| 735 | placeholderLabel.isHidden = !value.isEmpty | |
| 736 | } | |
| 737 | ||
| 738 | private func emitContentSizeIfNeeded() { | |
| 739 | let nextSize = textView.contentSize | |
| 740 | guard abs(nextSize.width - lastContentSize.width) > 0.5 || | |
| 741 | abs(nextSize.height - lastContentSize.height) > 0.5 else { | |
| 742 | return | |
| 743 | } | |
| 744 | lastContentSize = nextSize | |
| 745 | onComposerContentSizeChange(["width": nextSize.width, "height": nextSize.height]) | |
| 746 | } | |
| 747 | ||
| 748 | private func decode<T: Decodable>(_ type: T.Type, from json: String) -> T? { | |
| 749 | guard let data = json.data(using: .utf8) else { | |
| 750 | return nil | |
| 751 | } | |
| 752 | return try? JSONDecoder().decode(type, from: data) | |
| 753 | } | |
| 754 | ||
| 755 | private func tokensMatchCurrentValue() -> Bool { | |
| 756 | let source = value as NSString | |
| 757 | return tokens.allSatisfy { | |
| 758 | $0.start >= 0 && | |
| 759 | $0.end > $0.start && | |
| 760 | $0.end <= source.length && | |
| 761 | source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source | |
| 762 | } | |
| 763 | } | |
| 764 | ||
| 765 | private func documentMatchesExpectedTokens() -> Bool { | |
| 766 | let source = value as NSString | |
| 767 | let expectedSources = tokens.compactMap { token -> String? in | |
| 768 | guard token.start >= 0, | |
| 769 | token.end > token.start, | |
| 770 | token.end <= source.length, | |
| 771 | source.substring( | |
| 772 | with: NSRange(location: token.start, length: token.end - token.start) | |
| 773 | ) == token.source else { | |
| 774 | return nil | |
| 775 | } | |
| 776 | return token.source | |
| 777 | } | |
| 778 | var renderedSources: [String] = [] | |
| 779 | textView.attributedText.enumerateAttribute( | |
| 780 | .attachment, | |
| 781 | in: NSRange(location: 0, length: textView.attributedText.length) | |
| 782 | ) { value, _, _ in | |
| 783 | if let attachment = value as? ComposerTextAttachment { | |
| 784 | renderedSources.append(attachment.source) | |
| 785 | } | |
| 786 | } | |
| 787 | return renderedSources == expectedSources | |
| 788 | } | |
| 789 | } | |
| 790 | ||
| 791 | private extension UIColor { | |
| 792 | convenience init?(composerHex hex: String?) { | |
| 793 | guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { | |
| 794 | return nil | |
| 795 | } | |
| 796 | if value.hasPrefix("#") { | |
| 797 | value.removeFirst() | |
| 798 | } | |
| 799 | guard value.count == 6 || value.count == 8, | |
| 800 | let raw = UInt64(value, radix: 16) else { | |
| 801 | return nil | |
| 802 | } | |
| 803 | if value.count == 8 { | |
| 804 | self.init( | |
| 805 | red: CGFloat((raw >> 24) & 0xff) / 255, | |
| 806 | green: CGFloat((raw >> 16) & 0xff) / 255, | |
| 807 | blue: CGFloat((raw >> 8) & 0xff) / 255, | |
| 808 | alpha: CGFloat(raw & 0xff) / 255 | |
| 809 | ) | |
| 810 | } else { | |
| 811 | self.init( | |
| 812 | red: CGFloat((raw >> 16) & 0xff) / 255, | |
| 813 | green: CGFloat((raw >> 8) & 0xff) / 255, | |
| 814 | blue: CGFloat(raw & 0xff) / 255, | |
| 815 | alpha: 1 | |
| 816 | ) | |
| 817 | } | |
| 818 | } | |
| 819 | } |
apps/mobile/modules/t3-markdown-text/LICENSE created+20| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | MIT License | |
| 2 | ||
| 3 | Copyright (c) 2024-25 Bluesky PBC | |
| 4 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 5 | of this software and associated documentation files (the "Software"), to deal | |
| 6 | in the Software without restriction, including without limitation the rights | |
| 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 8 | copies of the Software, and to permit persons to whom the Software is | |
| 9 | furnished to do so, subject to the following conditions: | |
| 10 | ||
| 11 | The above copyright notice and this permission notice shall be included in all | |
| 12 | copies or substantial portions of the Software. | |
| 13 | ||
| 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 20 | SOFTWARE. |
apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | require "json" | |
| 2 | ||
| 3 | package = JSON.parse(File.read(File.join(__dir__, "package.json"))) | |
| 4 | new_arch_enabled = ENV["RCT_NEW_ARCH_ENABLED"] == "1" | |
| 5 | ||
| 6 | Pod::Spec.new do |s| | |
| 7 | s.name = "T3MarkdownText" | |
| 8 | s.version = package["version"] | |
| 9 | s.summary = "Native selectable markdown renderer for T3 Code mobile." | |
| 10 | s.description = "Fabric-backed attributed text and markdown rendering primitives owned by T3 Code." | |
| 11 | s.homepage = "https://t3tools.com" | |
| 12 | s.license = { :type => "MIT", :file => "LICENSE" } | |
| 13 | s.author = { "T3 Tools" => "hello@t3tools.com" } | |
| 14 | s.platforms = { :ios => min_ios_version_supported } | |
| 15 | s.source = { :path => "." } | |
| 16 | s.source_files = "ios/**/*.{h,m,mm,cpp}" | |
| 17 | ||
| 18 | install_modules_dependencies(s) | |
| 19 | ||
| 20 | if ENV["USE_FRAMEWORKS"] != nil && new_arch_enabled | |
| 21 | add_dependency(s, "React-FabricComponents", :additional_framework_paths => [ | |
| 22 | "react/renderer/textlayoutmanager/platform/ios", | |
| 23 | ]) | |
| 24 | end | |
| 25 | end |
apps/mobile/modules/t3-markdown-text/UPSTREAM.md created+12| ... | ... | @@ -0,0 +1,12 @@ |
| 1 | # Upstream Attribution | |
| 2 | ||
| 3 | The Fabric attributed-text component in this module originated from | |
| 4 | [`bluesky-social/react-native-uitextview`](https://github.com/bluesky-social/react-native-uitextview), | |
| 5 | version `2.2.0`, commit `addc08fea303608f070fe1eeba4bc075f181c4af`. | |
| 6 | ||
| 7 | The upstream project is Copyright (c) 2024-25 Bluesky PBC and licensed under | |
| 8 | the MIT License included in this directory. | |
| 9 | ||
| 10 | T3 Code has substantially modified and renamed the implementation, integrated | |
| 11 | its markdown renderer, and owns the resulting module going forward. This is not | |
| 12 | an upstream package dependency or a compatibility fork. |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png created| Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png differ |
apps/mobile/modules/t3-markdown-text/index.ts created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | export { markdownFileIconSource } from "./src/markdownFileIcons"; | |
| 2 | export { | |
| 3 | resolveMarkdownFileIcon, | |
| 4 | resolveMarkdownLinkPresentation, | |
| 5 | type MarkdownFileIcon, | |
| 6 | type MarkdownLinkPresentation, | |
| 7 | } from "./src/markdownLinks"; | |
| 8 | export { | |
| 9 | nativeMarkdownChunkSpacing, | |
| 10 | nativeMarkdownDocumentChunks, | |
| 11 | nativeMarkdownDocumentRuns, | |
| 12 | nativeMarkdownListItemBlocks, | |
| 13 | nativeMarkdownTextRuns, | |
| 14 | type NativeMarkdownDocumentChunk, | |
| 15 | type NativeMarkdownTextRun, | |
| 16 | } from "./src/nativeMarkdownText"; | |
| 17 | export { MarkdownTextPrimitive } from "./src/MarkdownTextPrimitive"; | |
| 18 | export { | |
| 19 | SelectableMarkdownText, | |
| 20 | type MarkdownCodeHighlighter, | |
| 21 | type MarkdownHighlightedToken, | |
| 22 | } from "./src/SelectableMarkdownText"; | |
| 23 | export type { | |
| 24 | NativeMarkdownTextStyle, | |
| 25 | SelectableMarkdownSkill, | |
| 26 | SelectableMarkdownTextProps, | |
| 27 | } from "./src/SelectableMarkdownText.types"; |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | #import <React/RCTViewComponentView.h> | |
| 2 | #import <UIKit/UIKit.h> | |
| 3 | ||
| 4 | #ifndef T3MarkdownTextNativeComponent_h | |
| 5 | #define T3MarkdownTextNativeComponent_h | |
| 6 | ||
| 7 | NS_ASSUME_NONNULL_BEGIN | |
| 8 | @interface T3MarkdownText : RCTViewComponentView | |
| 9 | @end | |
| 10 | ||
| 11 | NS_ASSUME_NONNULL_END | |
| 12 | ||
| 13 | #endif /* UitextviewViewNativeComponent_h */ |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm created+688| ... | ... | @@ -0,0 +1,688 @@ |
| 1 | #import "T3MarkdownText.h" | |
| 2 | #import "T3MarkdownTextShadowNode.h" | |
| 3 | #import "T3MarkdownTextComponentDescriptor.h" | |
| 4 | #import "T3MarkdownTextRun.h" | |
| 5 | #import <React/RCTConversions.h> | |
| 6 | #import <objc/runtime.h> | |
| 7 | ||
| 8 | #import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h> | |
| 9 | #import <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h> | |
| 10 | #import <react/renderer/components/T3MarkdownTextSpec/Props.h> | |
| 11 | #import <react/renderer/components/T3MarkdownTextSpec/RCTComponentViewHelpers.h> | |
| 12 | #import "RCTFabricComponentsPlugins.h" | |
| 13 | ||
| 14 | using namespace facebook::react; | |
| 15 | ||
| 16 | static void T3MarkdownTextApplyParagraphStyles( | |
| 17 | NSMutableAttributedString *attributedString, | |
| 18 | const std::vector<T3MarkdownTextParagraphStyleRange> &styleRanges) | |
| 19 | { | |
| 20 | for (const auto &styleRange : styleRanges) { | |
| 21 | if (styleRange.length == 0 || styleRange.location >= attributedString.length) { | |
| 22 | continue; | |
| 23 | } | |
| 24 | ||
| 25 | const NSRange markerRange = NSMakeRange( | |
| 26 | styleRange.location, | |
| 27 | MIN(styleRange.length, attributedString.length - styleRange.location)); | |
| 28 | const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; | |
| 29 | const NSParagraphStyle *existingStyle = | |
| 30 | [attributedString attribute:NSParagraphStyleAttributeName | |
| 31 | atIndex:paragraphRange.location | |
| 32 | effectiveRange:nil]; | |
| 33 | NSMutableParagraphStyle *paragraphStyle = | |
| 34 | existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; | |
| 35 | paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; | |
| 36 | paragraphStyle.headIndent = styleRange.headIndent; | |
| 37 | paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; | |
| 38 | paragraphStyle.tabStops = @[ | |
| 39 | [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft | |
| 40 | location:styleRange.headIndent | |
| 41 | options:@{}] | |
| 42 | ]; | |
| 43 | paragraphStyle.defaultTabInterval = styleRange.headIndent; | |
| 44 | [attributedString addAttribute:NSParagraphStyleAttributeName | |
| 45 | value:paragraphStyle | |
| 46 | range:paragraphRange]; | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | static void T3MarkdownTextApplyAttachments( | |
| 51 | NSMutableAttributedString *attributedString, | |
| 52 | const std::vector<T3MarkdownTextAttachmentRange> &attachmentRanges, | |
| 53 | NSDictionary<NSString *, UIImage *> *images) | |
| 54 | { | |
| 55 | for (const auto &attachmentRange : attachmentRanges) { | |
| 56 | if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { | |
| 57 | continue; | |
| 58 | } | |
| 59 | ||
| 60 | NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; | |
| 61 | NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; | |
| 62 | UIImage *image = images[imageUri]; | |
| 63 | if ([imageUri hasPrefix:@"sf:"]) { | |
| 64 | NSString *symbolName = [imageUri substringFromIndex:3]; | |
| 65 | UIColor *foregroundColor = | |
| 66 | [attributedString attribute:NSForegroundColorAttributeName | |
| 67 | atIndex:attachmentRange.location | |
| 68 | effectiveRange:nil] ?: UIColor.labelColor; | |
| 69 | image = [[UIImage systemImageNamed:symbolName] imageWithTintColor:foregroundColor | |
| 70 | renderingMode:UIImageRenderingModeAlwaysOriginal]; | |
| 71 | } | |
| 72 | attachment.image = image ?: [[UIImage alloc] init]; | |
| 73 | attachment.bounds = CGRectMake(0, -0.5, 10, 10); | |
| 74 | const NSRange range = NSMakeRange( | |
| 75 | attachmentRange.location, | |
| 76 | MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); | |
| 77 | NSAttributedString *attachmentString = | |
| 78 | [NSAttributedString attributedStringWithAttachment:attachment]; | |
| 79 | [attributedString replaceCharactersInRange:range withAttributedString:attachmentString]; | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | static NSArray<NSDictionary<NSString *, id> *> *T3MarkdownTextExtractChipBackgrounds( | |
| 84 | NSMutableAttributedString *attributedString, | |
| 85 | const std::vector<T3MarkdownTextChipRange> &chipRanges) | |
| 86 | { | |
| 87 | NSMutableArray<NSDictionary<NSString *, id> *> *backgrounds = [NSMutableArray array]; | |
| 88 | for (const auto &chipRange : chipRanges) { | |
| 89 | if (chipRange.length == 0 || chipRange.location >= attributedString.length) { | |
| 90 | continue; | |
| 91 | } | |
| 92 | ||
| 93 | const NSRange range = NSMakeRange( | |
| 94 | chipRange.location, | |
| 95 | MIN(chipRange.length, attributedString.length - chipRange.location)); | |
| 96 | UIColor *color = [attributedString attribute:NSBackgroundColorAttributeName | |
| 97 | atIndex:range.location | |
| 98 | effectiveRange:nil]; | |
| 99 | UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName | |
| 100 | atIndex:range.location | |
| 101 | effectiveRange:nil]; | |
| 102 | if (color == nil) { | |
| 103 | continue; | |
| 104 | } | |
| 105 | [backgrounds addObject:@{ | |
| 106 | @"range": [NSValue valueWithRange:range], | |
| 107 | @"color": color, | |
| 108 | @"strokeColor": [foregroundColor | |
| 109 | colorWithAlphaComponent:chipRange.isSkill ? 0.25 : 0.1] ?: UIColor.clearColor, | |
| 110 | }]; | |
| 111 | [attributedString removeAttribute:NSBackgroundColorAttributeName range:range]; | |
| 112 | } | |
| 113 | return backgrounds; | |
| 114 | } | |
| 115 | ||
| 116 | @interface T3MarkdownTextBackingView : UITextView | |
| 117 | @property(nonatomic, copy) NSArray<NSDictionary<NSString *, id> *> *chipBackgrounds; | |
| 118 | @end | |
| 119 | ||
| 120 | @implementation T3MarkdownTextBackingView | |
| 121 | ||
| 122 | - (void)drawRect:(CGRect)rect | |
| 123 | { | |
| 124 | [self.layoutManager ensureLayoutForTextContainer:self.textContainer]; | |
| 125 | CGContextRef context = UIGraphicsGetCurrentContext(); | |
| 126 | if (context != nil) { | |
| 127 | CGContextSaveGState(context); | |
| 128 | CGContextResetClip(context); | |
| 129 | CGContextClipToRect(context, self.bounds); | |
| 130 | } | |
| 131 | for (NSDictionary<NSString *, id> *background in self.chipBackgrounds) { | |
| 132 | const NSRange characterRange = [background[@"range"] rangeValue]; | |
| 133 | UIColor *color = background[@"color"]; | |
| 134 | UIColor *strokeColor = background[@"strokeColor"]; | |
| 135 | if (characterRange.length == 0 || NSMaxRange(characterRange) > self.textStorage.length) { | |
| 136 | continue; | |
| 137 | } | |
| 138 | ||
| 139 | const NSRange glyphRange = | |
| 140 | [self.layoutManager glyphRangeForCharacterRange:characterRange actualCharacterRange:nil]; | |
| 141 | [color setFill]; | |
| 142 | [self.layoutManager | |
| 143 | enumerateEnclosingRectsForGlyphRange:glyphRange | |
| 144 | withinSelectedGlyphRange:NSMakeRange(NSNotFound, 0) | |
| 145 | inTextContainer:self.textContainer | |
| 146 | usingBlock:^(CGRect glyphRect, BOOL *stop) { | |
| 147 | const CGFloat chipHeight = 22; | |
| 148 | CGRect chipRect = CGRectMake( | |
| 149 | glyphRect.origin.x - 4, | |
| 150 | CGRectGetMidY(glyphRect) - chipHeight / 2, | |
| 151 | glyphRect.size.width + 8, | |
| 152 | chipHeight); | |
| 153 | chipRect.origin.x += self.textContainerInset.left; | |
| 154 | chipRect.origin.y += self.textContainerInset.top; | |
| 155 | const CGFloat minimumX = self.textContainerInset.left + 0.5; | |
| 156 | const CGFloat maximumX = | |
| 157 | CGRectGetWidth(self.bounds) - self.textContainerInset.right - 0.5; | |
| 158 | if (chipRect.origin.x < minimumX) { | |
| 159 | chipRect.size.width -= minimumX - chipRect.origin.x; | |
| 160 | chipRect.origin.x = minimumX; | |
| 161 | } | |
| 162 | if (CGRectGetMaxX(chipRect) > maximumX) { | |
| 163 | chipRect.size.width = MAX(0, maximumX - chipRect.origin.x); | |
| 164 | } | |
| 165 | UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:chipRect cornerRadius:6]; | |
| 166 | [path fill]; | |
| 167 | [strokeColor setStroke]; | |
| 168 | path.lineWidth = 1; | |
| 169 | [path stroke]; | |
| 170 | }]; | |
| 171 | } | |
| 172 | if (context != nil) { | |
| 173 | CGContextRestoreGState(context); | |
| 174 | } | |
| 175 | ||
| 176 | [super drawRect:rect]; | |
| 177 | } | |
| 178 | ||
| 179 | @end | |
| 180 | ||
| 181 | @protocol T3MarkdownOutsideTapTarget <NSObject> | |
| 182 | - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView; | |
| 183 | @end | |
| 184 | ||
| 185 | @interface T3MarkdownOutsideTapCoordinator : NSObject <UIGestureRecognizerDelegate> | |
| 186 | ||
| 187 | - (instancetype)initWithWindow:(UIWindow *)window; | |
| 188 | - (void)addTarget:(id<T3MarkdownOutsideTapTarget>)target; | |
| 189 | - (void)removeTarget:(id<T3MarkdownOutsideTapTarget>)target; | |
| 190 | ||
| 191 | @end | |
| 192 | ||
| 193 | static const void *T3MarkdownOutsideTapCoordinatorKey = | |
| 194 | &T3MarkdownOutsideTapCoordinatorKey; | |
| 195 | ||
| 196 | @implementation T3MarkdownOutsideTapCoordinator { | |
| 197 | __weak UIWindow *_window; | |
| 198 | UITapGestureRecognizer *_recognizer; | |
| 199 | NSHashTable<id<T3MarkdownOutsideTapTarget>> *_targets; | |
| 200 | } | |
| 201 | ||
| 202 | - (instancetype)initWithWindow:(UIWindow *)window | |
| 203 | { | |
| 204 | if (self = [super init]) { | |
| 205 | _window = window; | |
| 206 | _targets = [NSHashTable weakObjectsHashTable]; | |
| 207 | _recognizer = [[UITapGestureRecognizer alloc] initWithTarget:self | |
| 208 | action:@selector(handleTap:)]; | |
| 209 | _recognizer.cancelsTouchesInView = NO; | |
| 210 | _recognizer.delegate = self; | |
| 211 | [window addGestureRecognizer:_recognizer]; | |
| 212 | } | |
| 213 | return self; | |
| 214 | } | |
| 215 | ||
| 216 | - (void)addTarget:(id<T3MarkdownOutsideTapTarget>)target | |
| 217 | { | |
| 218 | [_targets addObject:target]; | |
| 219 | } | |
| 220 | ||
| 221 | - (void)removeTarget:(id<T3MarkdownOutsideTapTarget>)target | |
| 222 | { | |
| 223 | [_targets removeObject:target]; | |
| 224 | if (_targets.count > 0) { | |
| 225 | return; | |
| 226 | } | |
| 227 | ||
| 228 | UIWindow *window = _window; | |
| 229 | [window removeGestureRecognizer:_recognizer]; | |
| 230 | if (objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey) == self) { | |
| 231 | objc_setAssociatedObject( | |
| 232 | window, | |
| 233 | T3MarkdownOutsideTapCoordinatorKey, | |
| 234 | nil, | |
| 235 | OBJC_ASSOCIATION_RETAIN_NONATOMIC); | |
| 236 | } | |
| 237 | } | |
| 238 | ||
| 239 | - (void)handleTap:(UITapGestureRecognizer *)sender | |
| 240 | { | |
| 241 | UIWindow *window = _window; | |
| 242 | if (window == nil) { | |
| 243 | return; | |
| 244 | } | |
| 245 | ||
| 246 | UIView *hitView = [window hitTest:[sender locationInView:window] withEvent:nil]; | |
| 247 | if (hitView == nil) { | |
| 248 | return; | |
| 249 | } | |
| 250 | for (id<T3MarkdownOutsideTapTarget> target in _targets.allObjects) { | |
| 251 | [target clearSelectionForOutsideTapWithHitView:hitView]; | |
| 252 | } | |
| 253 | } | |
| 254 | ||
| 255 | - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer | |
| 256 | shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer | |
| 257 | { | |
| 258 | return YES; | |
| 259 | } | |
| 260 | ||
| 261 | @end | |
| 262 | ||
| 263 | static T3MarkdownOutsideTapCoordinator * | |
| 264 | T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) | |
| 265 | { | |
| 266 | T3MarkdownOutsideTapCoordinator *coordinator = | |
| 267 | objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey); | |
| 268 | if (coordinator == nil) { | |
| 269 | coordinator = [[T3MarkdownOutsideTapCoordinator alloc] initWithWindow:window]; | |
| 270 | objc_setAssociatedObject( | |
| 271 | window, | |
| 272 | T3MarkdownOutsideTapCoordinatorKey, | |
| 273 | coordinator, | |
| 274 | OBJC_ASSOCIATION_RETAIN_NONATOMIC); | |
| 275 | } | |
| 276 | return coordinator; | |
| 277 | } | |
| 278 | ||
| 279 | @interface T3MarkdownText () <RCTT3MarkdownTextViewProtocol, UIGestureRecognizerDelegate, UITextViewDelegate> | |
| 280 | ||
| 281 | @end | |
| 282 | ||
| 283 | @interface T3MarkdownText () <T3MarkdownOutsideTapTarget> | |
| 284 | @end | |
| 285 | ||
| 286 | @implementation T3MarkdownText { | |
| 287 | UIView * _view; | |
| 288 | T3MarkdownTextBackingView * _textView; | |
| 289 | T3MarkdownTextShadowNode::ConcreteState::Shared _state; | |
| 290 | __weak UIWindow * _outsideTapWindow; | |
| 291 | BOOL _suppressSelectionChange; | |
| 292 | NSMutableDictionary<NSString *, UIImage *> * _attachmentImages; | |
| 293 | NSMutableSet<NSString *> * _pendingAttachmentUris; | |
| 294 | } | |
| 295 | ||
| 296 | + (ComponentDescriptorProvider)componentDescriptorProvider | |
| 297 | { | |
| 298 | return concreteComponentDescriptorProvider<T3MarkdownTextComponentDescriptor>(); | |
| 299 | } | |
| 300 | ||
| 301 | - (instancetype)initWithFrame:(CGRect)frame | |
| 302 | { | |
| 303 | if (self = [super initWithFrame:frame]) { | |
| 304 | static const auto defaultProps = std::make_shared<const T3MarkdownTextProps>(); | |
| 305 | _props = defaultProps; | |
| 306 | ||
| 307 | _view = [[UIView alloc] init]; | |
| 308 | self.contentView = _view; | |
| 309 | self.clipsToBounds = true; | |
| 310 | ||
| 311 | _textView = [[T3MarkdownTextBackingView alloc] init]; | |
| 312 | _attachmentImages = [[NSMutableDictionary alloc] init]; | |
| 313 | _pendingAttachmentUris = [[NSMutableSet alloc] init]; | |
| 314 | _textView.scrollEnabled = false; | |
| 315 | _textView.editable = false; | |
| 316 | _textView.textContainerInset = UIEdgeInsetsZero; | |
| 317 | _textView.textContainer.lineFragmentPadding = 0; | |
| 318 | _textView.delegate = self; | |
| 319 | // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. | |
| 320 | _textView.layoutManager.usesFontLeading = NO; | |
| 321 | [self addSubview:_textView]; | |
| 322 | ||
| 323 | const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self | |
| 324 | action:@selector(handleLongPressIfNecessary:)]; | |
| 325 | longPressGestureRecognizer.delegate = self; | |
| 326 | ||
| 327 | const auto pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self | |
| 328 | action:@selector(handlePressIfNecessary:)]; | |
| 329 | pressGestureRecognizer.delegate = self; | |
| 330 | [pressGestureRecognizer requireGestureRecognizerToFail:longPressGestureRecognizer]; | |
| 331 | ||
| 332 | [_textView addGestureRecognizer:pressGestureRecognizer]; | |
| 333 | [_textView addGestureRecognizer:longPressGestureRecognizer]; | |
| 334 | } | |
| 335 | ||
| 336 | return self; | |
| 337 | } | |
| 338 | ||
| 339 | - (void)didMoveToWindow | |
| 340 | { | |
| 341 | [super didMoveToWindow]; | |
| 342 | if (_outsideTapWindow == self.window) { | |
| 343 | return; | |
| 344 | } | |
| 345 | if (_outsideTapWindow != nil) { | |
| 346 | T3MarkdownOutsideTapCoordinator *coordinator = | |
| 347 | objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); | |
| 348 | [coordinator removeTarget:self]; | |
| 349 | } | |
| 350 | _outsideTapWindow = self.window; | |
| 351 | if (_outsideTapWindow != nil) { | |
| 352 | [T3MarkdownOutsideTapCoordinatorForWindow(_outsideTapWindow) addTarget:self]; | |
| 353 | } | |
| 354 | } | |
| 355 | ||
| 356 | - (void)dealloc | |
| 357 | { | |
| 358 | T3MarkdownOutsideTapCoordinator *coordinator = | |
| 359 | objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); | |
| 360 | [coordinator removeTarget:self]; | |
| 361 | } | |
| 362 | ||
| 363 | // See RCTParagraphComponentView | |
| 364 | - (void)prepareForRecycle | |
| 365 | { | |
| 366 | [super prepareForRecycle]; | |
| 367 | T3MarkdownOutsideTapCoordinator *coordinator = | |
| 368 | objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); | |
| 369 | [coordinator removeTarget:self]; | |
| 370 | _outsideTapWindow = nil; | |
| 371 | _state.reset(); | |
| 372 | ||
| 373 | // Reset the frame to zero so that when it properly lays out on the next use | |
| 374 | _textView.frame = CGRectZero; | |
| 375 | _textView.attributedText = nil; | |
| 376 | } | |
| 377 | ||
| 378 | - (void)layoutSubviews | |
| 379 | { | |
| 380 | [super layoutSubviews]; | |
| 381 | // _textView's frame is assigned inside drawRect, which only fires when | |
| 382 | // state changes. Trigger a redraw whenever the host frame moves out from | |
| 383 | // under it (rotation, parent relayout) so the text view resizes and | |
| 384 | // onTextLayout re-fires with the new line wrapping. | |
| 385 | if (!CGRectEqualToRect(_textView.frame, _view.frame)) { | |
| 386 | [self setNeedsDisplay]; | |
| 387 | } | |
| 388 | } | |
| 389 | ||
| 390 | - (void)drawRect:(CGRect)rect | |
| 391 | { | |
| 392 | if (!_state) { | |
| 393 | return; | |
| 394 | } | |
| 395 | ||
| 396 | const auto &props = *std::static_pointer_cast<T3MarkdownTextProps const>(_props); | |
| 397 | ||
| 398 | const auto attrString = _state->getData().attributedString; | |
| 399 | NSMutableAttributedString *convertedAttrString = | |
| 400 | [RCTNSAttributedStringFromAttributedString(attrString) mutableCopy]; | |
| 401 | T3MarkdownTextApplyParagraphStyles( | |
| 402 | convertedAttrString, | |
| 403 | _state->getData().paragraphStyleRanges); | |
| 404 | T3MarkdownTextApplyAttachments( | |
| 405 | convertedAttrString, | |
| 406 | _state->getData().attachmentRanges, | |
| 407 | _attachmentImages); | |
| 408 | _textView.chipBackgrounds = T3MarkdownTextExtractChipBackgrounds( | |
| 409 | convertedAttrString, | |
| 410 | _state->getData().chipRanges); | |
| 411 | [self loadAttachmentImages:_state->getData().attachmentRanges]; | |
| 412 | ||
| 413 | // Setting attributedText clears any active text selection, and re-assigning | |
| 414 | // the frame triggers a layout flush that has the same effect. Bail out | |
| 415 | // entirely when nothing actually changed so a JS-side state update made in | |
| 416 | // response to onSelectionChange doesn't deselect what the user is selecting. | |
| 417 | const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; | |
| 418 | const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); | |
| 419 | if (!textChanged && !frameChanged) { | |
| 420 | return; | |
| 421 | } | |
| 422 | if (textChanged) { | |
| 423 | // Reassigning attributedText clears any active selection. Save it and | |
| 424 | // restore after, while suppressing the synthetic textViewDidChangeSelection | |
| 425 | // events the clear-then-restore would otherwise produce — those would | |
| 426 | // round-trip to JS and re-trigger this same path, causing a loop. | |
| 427 | const NSRange savedRange = _textView.selectedRange; | |
| 428 | _suppressSelectionChange = YES; | |
| 429 | _textView.attributedText = convertedAttrString; | |
| 430 | if (savedRange.length > 0 && NSMaxRange(savedRange) <= _textView.attributedText.length) { | |
| 431 | _textView.selectedRange = savedRange; | |
| 432 | } | |
| 433 | _suppressSelectionChange = NO; | |
| 434 | } | |
| 435 | if (frameChanged) { | |
| 436 | _textView.frame = _view.frame; | |
| 437 | } | |
| 438 | ||
| 439 | __block std::vector<std::string> lines; | |
| 440 | const int maxLines = props.numberOfLines; | |
| 441 | [_textView.layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, convertedAttrString.string.length) usingBlock:^(CGRect rect, | |
| 442 | CGRect usedRect, | |
| 443 | NSTextContainer * _Nonnull textContainer, | |
| 444 | NSRange glyphRange, | |
| 445 | BOOL * _Nonnull stop) { | |
| 446 | const auto charRange = [self->_textView.layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nil]; | |
| 447 | const auto line = [self->_textView.text substringWithRange:charRange]; | |
| 448 | lines.push_back(line.UTF8String); | |
| 449 | // enumerateLineFragments overshoots maximumNumberOfLines by one on iOS | |
| 450 | // 18, so cap explicitly. | |
| 451 | if (maxLines > 0 && lines.size() >= (size_t)maxLines) { | |
| 452 | *stop = YES; | |
| 453 | } | |
| 454 | }]; | |
| 455 | ||
| 456 | if (_eventEmitter != nullptr) { | |
| 457 | std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextEventEmitter>(_eventEmitter) | |
| 458 | ->onTextLayout(facebook::react::T3MarkdownTextEventEmitter::OnTextLayout{static_cast<int>(self.tag), lines}); | |
| 459 | }; | |
| 460 | } | |
| 461 | ||
| 462 | - (void)loadAttachmentImages:(const std::vector<T3MarkdownTextAttachmentRange> &)attachmentRanges | |
| 463 | { | |
| 464 | for (const auto &attachmentRange : attachmentRanges) { | |
| 465 | NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; | |
| 466 | if ([imageUri hasPrefix:@"sf:"]) { | |
| 467 | continue; | |
| 468 | } | |
| 469 | if (_attachmentImages[imageUri] != nil || [_pendingAttachmentUris containsObject:imageUri]) { | |
| 470 | continue; | |
| 471 | } | |
| 472 | ||
| 473 | NSURL *url = [NSURL URLWithString:imageUri]; | |
| 474 | if (url == nil) { | |
| 475 | continue; | |
| 476 | } | |
| 477 | if (url.isFileURL) { | |
| 478 | UIImage *image = [UIImage imageWithContentsOfFile:url.path]; | |
| 479 | if (image != nil) { | |
| 480 | _attachmentImages[imageUri] = image; | |
| 481 | dispatch_async(dispatch_get_main_queue(), ^{ | |
| 482 | [self refreshDisplayedAttachments]; | |
| 483 | }); | |
| 484 | } | |
| 485 | continue; | |
| 486 | } | |
| 487 | ||
| 488 | [_pendingAttachmentUris addObject:imageUri]; | |
| 489 | [[[NSURLSession sharedSession] dataTaskWithURL:url | |
| 490 | completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { | |
| 491 | UIImage *image = data == nil ? nil : [UIImage imageWithData:data]; | |
| 492 | dispatch_async(dispatch_get_main_queue(), ^{ | |
| 493 | [self->_pendingAttachmentUris removeObject:imageUri]; | |
| 494 | if (image != nil) { | |
| 495 | self->_attachmentImages[imageUri] = image; | |
| 496 | [self refreshDisplayedAttachments]; | |
| 497 | } | |
| 498 | }); | |
| 499 | }] resume]; | |
| 500 | } | |
| 501 | } | |
| 502 | ||
| 503 | - (void)refreshDisplayedAttachments | |
| 504 | { | |
| 505 | if (!_state || _textView.attributedText == nil) { | |
| 506 | return; | |
| 507 | } | |
| 508 | ||
| 509 | NSMutableAttributedString *attributedText = [_textView.attributedText mutableCopy]; | |
| 510 | T3MarkdownTextApplyAttachments( | |
| 511 | attributedText, | |
| 512 | _state->getData().attachmentRanges, | |
| 513 | _attachmentImages); | |
| 514 | ||
| 515 | const NSRange savedRange = _textView.selectedRange; | |
| 516 | _suppressSelectionChange = YES; | |
| 517 | _textView.attributedText = attributedText; | |
| 518 | if (savedRange.location != NSNotFound && | |
| 519 | NSMaxRange(savedRange) <= _textView.attributedText.length) { | |
| 520 | _textView.selectedRange = savedRange; | |
| 521 | } | |
| 522 | _suppressSelectionChange = NO; | |
| 523 | [_textView setNeedsDisplay]; | |
| 524 | } | |
| 525 | ||
| 526 | - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps | |
| 527 | { | |
| 528 | const auto &oldViewProps = *std::static_pointer_cast<T3MarkdownTextProps const>(_props); | |
| 529 | const auto &newViewProps = *std::static_pointer_cast<T3MarkdownTextProps const>(props); | |
| 530 | ||
| 531 | if (oldViewProps.numberOfLines != newViewProps.numberOfLines) { | |
| 532 | _textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines; | |
| 533 | } | |
| 534 | ||
| 535 | if (oldViewProps.selectable != newViewProps.selectable) { | |
| 536 | _textView.selectable = newViewProps.selectable; | |
| 537 | } | |
| 538 | ||
| 539 | if (oldViewProps.allowFontScaling != newViewProps.allowFontScaling) { | |
| 540 | if (@available(iOS 11.0, *)) { | |
| 541 | _textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling; | |
| 542 | } | |
| 543 | } | |
| 544 | ||
| 545 | if (oldViewProps.ellipsizeMode != newViewProps.ellipsizeMode) { | |
| 546 | if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { | |
| 547 | _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingHead; | |
| 548 | } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { | |
| 549 | _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingMiddle; | |
| 550 | } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { | |
| 551 | _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingTail; | |
| 552 | } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Clip) { | |
| 553 | _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByClipping; | |
| 554 | } | |
| 555 | } | |
| 556 | ||
| 557 | ||
| 558 | // I'm not sure if this is really the right way to handle this style. This means that the entire _view_ the text | |
| 559 | // is in will have this background color applied. To apply it just to a particular part of a string, you'd need | |
| 560 | // to do <Text><Text style={{backgroundColor: 'blue'}}>Hello</Text></Text>. | |
| 561 | // This is how the base <Text> component works though, so we'll go with it for now. Can change later if we want. | |
| 562 | if (oldViewProps.backgroundColor != newViewProps.backgroundColor) { | |
| 563 | _textView.backgroundColor = RCTUIColorFromSharedColor(newViewProps.backgroundColor); | |
| 564 | } | |
| 565 | ||
| 566 | [super updateProps:props oldProps:oldProps]; | |
| 567 | } | |
| 568 | ||
| 569 | // See RCTParagraphComponentView | |
| 570 | - (void)updateState:(const facebook::react::State::Shared &)state oldState:(const facebook::react::State::Shared &)oldState | |
| 571 | { | |
| 572 | _state = std::static_pointer_cast<const T3MarkdownTextShadowNode::ConcreteState>(state); | |
| 573 | [self setNeedsDisplay]; | |
| 574 | } | |
| 575 | ||
| 576 | // MARK: - UIGestureRecognizerDelegate | |
| 577 | ||
| 578 | - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer | |
| 579 | { | |
| 580 | return YES; | |
| 581 | } | |
| 582 | ||
| 583 | - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch | |
| 584 | { | |
| 585 | return YES; | |
| 586 | } | |
| 587 | ||
| 588 | - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView | |
| 589 | { | |
| 590 | if ([hitView isDescendantOfView:self]) { | |
| 591 | return; | |
| 592 | } | |
| 593 | // Defer past the current event loop turn so any in-flight edit-menu action | |
| 594 | // (Copy / Define / Look Up / …) reads the live selection before we clear it. | |
| 595 | UITextView *textView = _textView; | |
| 596 | dispatch_async(dispatch_get_main_queue(), ^{ | |
| 597 | UITextRange *range = textView.selectedTextRange; | |
| 598 | if (range != nil && !range.isEmpty) { | |
| 599 | textView.selectedTextRange = nil; | |
| 600 | } | |
| 601 | }); | |
| 602 | } | |
| 603 | ||
| 604 | // MARK: - Touch handling | |
| 605 | ||
| 606 | - (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender | |
| 607 | { | |
| 608 | return [sender locationInView:_textView]; | |
| 609 | } | |
| 610 | ||
| 611 | - (T3MarkdownTextRun*)getTouchChild:(CGPoint)location | |
| 612 | { | |
| 613 | const auto charIndex = [_textView.layoutManager characterIndexForPoint:location | |
| 614 | inTextContainer:_textView.textContainer | |
| 615 | fractionOfDistanceBetweenInsertionPoints:nil | |
| 616 | ]; | |
| 617 | ||
| 618 | int currIndex = -1; | |
| 619 | for (UIView* child in self.subviews) { | |
| 620 | if (![child isKindOfClass:[T3MarkdownTextRun class]]) { | |
| 621 | continue; | |
| 622 | } | |
| 623 | ||
| 624 | T3MarkdownTextRun* textChild = (T3MarkdownTextRun*)child; | |
| 625 | ||
| 626 | // This is UTF16 code units!! | |
| 627 | currIndex += textChild.text.length; | |
| 628 | ||
| 629 | if (charIndex <= currIndex) { | |
| 630 | return textChild; | |
| 631 | } | |
| 632 | } | |
| 633 | ||
| 634 | return nil; | |
| 635 | } | |
| 636 | ||
| 637 | - (void)handlePressIfNecessary:(UITapGestureRecognizer*)sender | |
| 638 | { | |
| 639 | const auto location = [self getLocationOfPress:sender]; | |
| 640 | const auto child = [self getTouchChild:location]; | |
| 641 | ||
| 642 | if (child) { | |
| 643 | [child onPress]; | |
| 644 | } | |
| 645 | } | |
| 646 | ||
| 647 | - (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender | |
| 648 | { | |
| 649 | const auto location = [self getLocationOfPress:sender]; | |
| 650 | const auto child = [self getTouchChild:location]; | |
| 651 | ||
| 652 | if (child) { | |
| 653 | [child onLongPress]; | |
| 654 | } | |
| 655 | } | |
| 656 | ||
| 657 | // MARK: - UITextViewDelegate | |
| 658 | ||
| 659 | - (void)textViewDidChangeSelection:(UITextView *)textView | |
| 660 | { | |
| 661 | if (_suppressSelectionChange) { | |
| 662 | return; | |
| 663 | } | |
| 664 | if (_eventEmitter == nullptr) { | |
| 665 | return; | |
| 666 | } | |
| 667 | ||
| 668 | const NSRange selectedRange = textView.selectedRange; | |
| 669 | if (selectedRange.location == NSNotFound) { | |
| 670 | return; | |
| 671 | } | |
| 672 | ||
| 673 | // Fires on programmatic selection changes too (e.g. the outside-tap clear | |
| 674 | // in handleOutsideTap:), so JS will see a synthetic empty-range event then. | |
| 675 | std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextEventEmitter>(_eventEmitter) | |
| 676 | ->onSelectionChange(facebook::react::T3MarkdownTextEventEmitter::OnSelectionChange{ | |
| 677 | static_cast<int>(self.tag), | |
| 678 | static_cast<int>(selectedRange.location), | |
| 679 | static_cast<int>(selectedRange.location + selectedRange.length), | |
| 680 | }); | |
| 681 | } | |
| 682 | ||
| 683 | Class<RCTComponentViewProtocol> T3MarkdownTextCls(void) | |
| 684 | { | |
| 685 | return T3MarkdownText.class; | |
| 686 | } | |
| 687 | ||
| 688 | @end |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | #pragma once | |
| 2 | ||
| 3 | #include "T3MarkdownTextShadowNode.h" | |
| 4 | ||
| 5 | #include <react/renderer/core/ConcreteComponentDescriptor.h> | |
| 6 | #include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h> | |
| 7 | ||
| 8 | namespace facebook::react { | |
| 9 | using T3MarkdownTextComponentDescriptor = ConcreteComponentDescriptor<T3MarkdownTextShadowNode>; | |
| 10 | ||
| 11 | void T3MarkdownTextSpec_registerComponentDescriptorsFromCodegen( | |
| 12 | std::shared_ptr<const ComponentDescriptorProviderRegistry> registry); | |
| 13 | } |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | #import <React/RCTViewManager.h> | |
| 2 | #import <React/RCTUIManager.h> | |
| 3 | #import "RCTBridge.h" | |
| 4 | #import "Utils.h" | |
| 5 | ||
| 6 | @interface T3MarkdownTextManager : RCTViewManager | |
| 7 | @end | |
| 8 | ||
| 9 | @implementation T3MarkdownTextManager | |
| 10 | ||
| 11 | RCT_EXPORT_MODULE(T3MarkdownText) | |
| 12 | ||
| 13 | - (UIView *)view | |
| 14 | { | |
| 15 | return [[UIView alloc] init]; | |
| 16 | } | |
| 17 | ||
| 18 | RCT_CUSTOM_VIEW_PROPERTY(color, NSString, UIView) | |
| 19 | { | |
| 20 | } | |
| 21 | ||
| 22 | @end | |
| 23 | ||
| 24 | @interface T3MarkdownTextRunManager : RCTViewManager | |
| 25 | @end | |
| 26 | ||
| 27 | @implementation T3MarkdownTextRunManager | |
| 28 | ||
| 29 | RCT_EXPORT_MODULE(T3MarkdownTextRun) | |
| 30 | ||
| 31 | - (UIView *)view | |
| 32 | { | |
| 33 | return nil; | |
| 34 | } | |
| 35 | ||
| 36 | @end |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h created+24| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | // This guard prevent this file to be compiled in the old architecture. | |
| 2 | #ifdef RCT_NEW_ARCH_ENABLED | |
| 3 | #import <React/RCTViewComponentView.h> | |
| 4 | #import <React/RCTComponent.h> | |
| 5 | #import <UIKit/UIKit.h> | |
| 6 | ||
| 7 | #ifndef T3MarkdownTextRunNativeComponent_h | |
| 8 | #define T3MarkdownTextRunNativeComponent_h | |
| 9 | ||
| 10 | NS_ASSUME_NONNULL_BEGIN | |
| 11 | ||
| 12 | @interface T3MarkdownTextRun : RCTViewComponentView | |
| 13 | ||
| 14 | @property (nonatomic, copy, nullable) NSString *text; | |
| 15 | ||
| 16 | - (void)onPress; | |
| 17 | - (void)onLongPress; | |
| 18 | ||
| 19 | @end | |
| 20 | ||
| 21 | NS_ASSUME_NONNULL_END | |
| 22 | ||
| 23 | #endif /* UitextviewViewNativeComponent_h */ | |
| 24 | #endif /* RCT_NEW_ARCH_ENABLED */ |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm created+72| ... | ... | @@ -0,0 +1,72 @@ |
| 1 | #import "T3MarkdownTextRun.h" | |
| 2 | #import "T3MarkdownText.h" | |
| 3 | #import "T3MarkdownTextRunComponentDescriptor.h" | |
| 4 | #import <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h> | |
| 5 | #import <react/renderer/components/T3MarkdownTextSpec/Props.h> | |
| 6 | #import <react/renderer/components/T3MarkdownTextSpec/RCTComponentViewHelpers.h> | |
| 7 | #import "RCTFabricComponentsPlugins.h" | |
| 8 | #import "Utils.h" | |
| 9 | ||
| 10 | using namespace facebook::react; | |
| 11 | ||
| 12 | @interface T3MarkdownTextRun () <RCTT3MarkdownTextRunViewProtocol> | |
| 13 | ||
| 14 | @end | |
| 15 | ||
| 16 | @implementation T3MarkdownTextRun { | |
| 17 | NSString * _text; | |
| 18 | RCTBubblingEventBlock _onPress; | |
| 19 | RCTBubblingEventBlock _onLongPress; | |
| 20 | } | |
| 21 | ||
| 22 | + (ComponentDescriptorProvider)componentDescriptorProvider | |
| 23 | { | |
| 24 | return concreteComponentDescriptorProvider<T3MarkdownTextRunComponentDescriptor>(); | |
| 25 | } | |
| 26 | ||
| 27 | - (instancetype)initWithFrame:(CGRect)frame | |
| 28 | { | |
| 29 | if (self = [super initWithFrame:frame]) { | |
| 30 | static const auto defaultProps = std::make_shared<const T3MarkdownTextRunProps>(); | |
| 31 | _props = defaultProps; | |
| 32 | } | |
| 33 | return self; | |
| 34 | } | |
| 35 | ||
| 36 | - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps | |
| 37 | { | |
| 38 | const auto &oldViewProps = *std::static_pointer_cast<T3MarkdownTextRunProps const>(_props); | |
| 39 | const auto &newViewProps = *std::static_pointer_cast<T3MarkdownTextRunProps const>(props); | |
| 40 | ||
| 41 | if (newViewProps.text != oldViewProps.text) { | |
| 42 | NSString *text = [NSString stringWithUTF8String:newViewProps.text.c_str()]; | |
| 43 | _text = text; | |
| 44 | } | |
| 45 | ||
| 46 | [super updateProps:props oldProps:oldProps]; | |
| 47 | } | |
| 48 | ||
| 49 | - (void)onPress { | |
| 50 | if (_eventEmitter != nullptr) { | |
| 51 | std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter) | |
| 52 | ->onPress(facebook::react::T3MarkdownTextRunEventEmitter::OnPress{}); | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | - (void)onLongPress { | |
| 57 | if (_eventEmitter != nullptr) { | |
| 58 | std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter) | |
| 59 | ->onLongPress(facebook::react::T3MarkdownTextRunEventEmitter::OnLongPress{}); | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | + (BOOL)shouldBeRecycled { | |
| 64 | return NO; | |
| 65 | } | |
| 66 | ||
| 67 | Class<RCTComponentViewProtocol> T3MarkdownTextRunCls(void) | |
| 68 | { | |
| 69 | return T3MarkdownTextRun.class; | |
| 70 | } | |
| 71 | ||
| 72 | @end |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | #pragma once | |
| 2 | ||
| 3 | #include "T3MarkdownTextRunShadowNode.h" | |
| 4 | ||
| 5 | #include <react/renderer/core/ConcreteComponentDescriptor.h> | |
| 6 | #include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h> | |
| 7 | ||
| 8 | namespace facebook::react { | |
| 9 | using T3MarkdownTextRunComponentDescriptor = ConcreteComponentDescriptor<T3MarkdownTextRunShadowNode>; | |
| 10 | ||
| 11 | void T3MarkdownTextRunSpec_registerComponentDescriptorsFromCodegen( | |
| 12 | std::shared_ptr<const ComponentDescriptorProviderRegistry> registry); | |
| 13 | } |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | #include "T3MarkdownTextRunShadowNode.h" | |
| 2 | ||
| 3 | namespace facebook::react { | |
| 4 | ||
| 5 | extern const char T3MarkdownTextRunComponentName[] = "T3MarkdownTextRun"; | |
| 6 | } // namespace facebook::react |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h created+16| ... | ... | @@ -0,0 +1,16 @@ |
| 1 | #pragma once | |
| 2 | ||
| 3 | #include <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h> | |
| 4 | #include <react/renderer/components/T3MarkdownTextSpec/Props.h> | |
| 5 | #include <react/renderer/components/T3MarkdownTextSpec/States.h> | |
| 6 | #include <react/renderer/components/view/ConcreteViewShadowNode.h> | |
| 7 | ||
| 8 | namespace facebook::react { | |
| 9 | extern const char T3MarkdownTextRunComponentName[]; | |
| 10 | ||
| 11 | using T3MarkdownTextRunShadowNode = ConcreteViewShadowNode< | |
| 12 | T3MarkdownTextRunComponentName, | |
| 13 | T3MarkdownTextRunProps, | |
| 14 | T3MarkdownTextRunEventEmitter, | |
| 15 | T3MarkdownTextRunState>; | |
| 16 | } |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h created+77| ... | ... | @@ -0,0 +1,77 @@ |
| 1 | #pragma once | |
| 2 | ||
| 3 | #include <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h> | |
| 4 | #include <react/renderer/components/T3MarkdownTextSpec/Props.h> | |
| 5 | #include <react/renderer/components/view/ConcreteViewShadowNode.h> | |
| 6 | #include <react/renderer/textlayoutmanager/TextLayoutManager.h> | |
| 7 | #include <react/renderer/core/LayoutContext.h> | |
| 8 | #include <react/renderer/core/ShadowNode.h> | |
| 9 | ||
| 10 | #include <string> | |
| 11 | #include <vector> | |
| 12 | ||
| 13 | namespace facebook::react { | |
| 14 | ||
| 15 | extern const char T3MarkdownTextComponentName[]; | |
| 16 | ||
| 17 | struct T3MarkdownTextParagraphStyleRange { | |
| 18 | size_t location; | |
| 19 | size_t length; | |
| 20 | Float firstLineHeadIndent; | |
| 21 | Float headIndent; | |
| 22 | Float paragraphSpacing; | |
| 23 | }; | |
| 24 | ||
| 25 | struct T3MarkdownTextAttachmentRange { | |
| 26 | size_t location; | |
| 27 | size_t length; | |
| 28 | std::string imageUri; | |
| 29 | }; | |
| 30 | ||
| 31 | struct T3MarkdownTextChipRange { | |
| 32 | size_t location; | |
| 33 | size_t length; | |
| 34 | bool isSkill; | |
| 35 | }; | |
| 36 | ||
| 37 | class T3MarkdownTextStateReal final { | |
| 38 | public: | |
| 39 | AttributedString attributedString; | |
| 40 | std::vector<T3MarkdownTextParagraphStyleRange> paragraphStyleRanges; | |
| 41 | std::vector<T3MarkdownTextAttachmentRange> attachmentRanges; | |
| 42 | std::vector<T3MarkdownTextChipRange> chipRanges; | |
| 43 | }; | |
| 44 | ||
| 45 | class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode< | |
| 46 | T3MarkdownTextComponentName, | |
| 47 | T3MarkdownTextProps, | |
| 48 | T3MarkdownTextEventEmitter, | |
| 49 | T3MarkdownTextStateReal> { | |
| 50 | public: | |
| 51 | using ConcreteViewShadowNode::ConcreteViewShadowNode; | |
| 52 | ||
| 53 | T3MarkdownTextShadowNode( | |
| 54 | const ShadowNode& sourceShadowNode, | |
| 55 | const ShadowNodeFragment& fragment | |
| 56 | ); | |
| 57 | ||
| 58 | static ShadowNodeTraits BaseTraits() { | |
| 59 | auto traits = ConcreteViewShadowNode::BaseTraits(); | |
| 60 | traits.set(ShadowNodeTraits::Trait::LeafYogaNode); | |
| 61 | traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); | |
| 62 | return traits; | |
| 63 | } | |
| 64 | ||
| 65 | void layout(LayoutContext layoutContext) override; | |
| 66 | ||
| 67 | Size measureContent( | |
| 68 | const LayoutContext& layoutContext, | |
| 69 | const LayoutConstraints& layoutConstraints) const override; | |
| 70 | ||
| 71 | private: | |
| 72 | mutable AttributedString _attributedString; | |
| 73 | mutable std::vector<T3MarkdownTextParagraphStyleRange> _paragraphStyleRanges; | |
| 74 | mutable std::vector<T3MarkdownTextAttachmentRange> _attachmentRanges; | |
| 75 | mutable std::vector<T3MarkdownTextChipRange> _chipRanges; | |
| 76 | }; | |
| 77 | } // namespace facebook::React |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm created+269| ... | ... | @@ -0,0 +1,269 @@ |
| 1 | #include "T3MarkdownTextShadowNode.h" | |
| 2 | #include "T3MarkdownTextRunShadowNode.h" | |
| 3 | #include <react/renderer/components/view/ViewShadowNode.h> | |
| 4 | #import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h> | |
| 5 | ||
| 6 | #include <algorithm> | |
| 7 | #include <cmath> | |
| 8 | ||
| 9 | namespace facebook::react { | |
| 10 | ||
| 11 | static constexpr Float ParagraphStyleEncodingOffset = 1000; | |
| 12 | static constexpr auto ChipNativeIdPrefix = "t3-chip-"; | |
| 13 | static constexpr auto FileChipNativeIdPrefix = "t3-chip-file:"; | |
| 14 | static constexpr auto SkillChipNativeIdPrefix = "t3-chip-skill:"; | |
| 15 | ||
| 16 | static void applyParagraphStyles( | |
| 17 | NSMutableAttributedString *attributedString, | |
| 18 | const std::vector<T3MarkdownTextParagraphStyleRange> &styleRanges) | |
| 19 | { | |
| 20 | for (const auto &styleRange : styleRanges) { | |
| 21 | if (styleRange.length == 0 || styleRange.location >= attributedString.length) { | |
| 22 | continue; | |
| 23 | } | |
| 24 | ||
| 25 | const NSRange markerRange = NSMakeRange( | |
| 26 | styleRange.location, | |
| 27 | MIN(styleRange.length, attributedString.length - styleRange.location)); | |
| 28 | const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; | |
| 29 | const NSParagraphStyle *existingStyle = | |
| 30 | [attributedString attribute:NSParagraphStyleAttributeName | |
| 31 | atIndex:paragraphRange.location | |
| 32 | effectiveRange:nil]; | |
| 33 | NSMutableParagraphStyle *paragraphStyle = | |
| 34 | existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; | |
| 35 | paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; | |
| 36 | paragraphStyle.headIndent = styleRange.headIndent; | |
| 37 | paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; | |
| 38 | paragraphStyle.tabStops = @[ | |
| 39 | [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft | |
| 40 | location:styleRange.headIndent | |
| 41 | options:@{}] | |
| 42 | ]; | |
| 43 | paragraphStyle.defaultTabInterval = styleRange.headIndent; | |
| 44 | [attributedString addAttribute:NSParagraphStyleAttributeName | |
| 45 | value:paragraphStyle | |
| 46 | range:paragraphRange]; | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | static void applyAttachments( | |
| 51 | NSMutableAttributedString *attributedString, | |
| 52 | const std::vector<T3MarkdownTextAttachmentRange> &attachmentRanges) | |
| 53 | { | |
| 54 | for (const auto &attachmentRange : attachmentRanges) { | |
| 55 | if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { | |
| 56 | continue; | |
| 57 | } | |
| 58 | ||
| 59 | NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; | |
| 60 | attachment.image = [[UIImage alloc] init]; | |
| 61 | attachment.bounds = CGRectMake(0, -0.5, 10, 10); | |
| 62 | const NSRange range = NSMakeRange( | |
| 63 | attachmentRange.location, | |
| 64 | MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); | |
| 65 | NSAttributedString *attachmentString = | |
| 66 | [NSAttributedString attributedStringWithAttachment:attachment]; | |
| 67 | [attributedString replaceCharactersInRange:range withAttributedString:attachmentString]; | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | T3MarkdownTextShadowNode::T3MarkdownTextShadowNode( | |
| 72 | const ShadowNode& sourceShadowNode, | |
| 73 | const ShadowNodeFragment& fragment | |
| 74 | ) : ConcreteViewShadowNode(sourceShadowNode, fragment) { | |
| 75 | }; | |
| 76 | ||
| 77 | Size T3MarkdownTextShadowNode::measureContent( | |
| 78 | const LayoutContext& layoutContext, | |
| 79 | const LayoutConstraints& layoutConstraints) const { | |
| 80 | const auto &baseProps = getConcreteProps(); | |
| 81 | ||
| 82 | auto baseTextAttributes = TextAttributes::defaultTextAttributes(); | |
| 83 | baseTextAttributes.backgroundColor = baseProps.backgroundColor; | |
| 84 | baseTextAttributes.allowFontScaling = baseProps.allowFontScaling; | |
| 85 | ||
| 86 | Float fontSizeMultiplier = 1.0; | |
| 87 | if (baseTextAttributes.allowFontScaling) { | |
| 88 | fontSizeMultiplier = layoutContext.fontSizeMultiplier; | |
| 89 | } | |
| 90 | ||
| 91 | auto baseAttributedString = AttributedString{}; | |
| 92 | auto paragraphStyleRanges = std::vector<T3MarkdownTextParagraphStyleRange>{}; | |
| 93 | auto attachmentRanges = std::vector<T3MarkdownTextAttachmentRange>{}; | |
| 94 | auto chipRanges = std::vector<T3MarkdownTextChipRange>{}; | |
| 95 | size_t utf16Offset = 0; | |
| 96 | const auto &children = getChildren(); | |
| 97 | for (size_t i = 0; i < children.size(); i++) { | |
| 98 | const auto child = children[i].get(); | |
| 99 | if (auto textViewChild = dynamic_cast<const T3MarkdownTextRunShadowNode *>(child)) { | |
| 100 | auto &props = textViewChild->getConcreteProps(); | |
| 101 | auto fragment = AttributedString::Fragment{}; | |
| 102 | auto textAttributes = TextAttributes::defaultTextAttributes(); | |
| 103 | ||
| 104 | textAttributes.allowFontScaling = baseProps.allowFontScaling; | |
| 105 | textAttributes.backgroundColor = props.backgroundColor; | |
| 106 | textAttributes.fontSize = props.fontSize * fontSizeMultiplier; | |
| 107 | textAttributes.lineHeight = props.lineHeight * fontSizeMultiplier; | |
| 108 | textAttributes.foregroundColor = props.color; | |
| 109 | const bool hasParagraphStyle = props.shadowRadius >= ParagraphStyleEncodingOffset; | |
| 110 | if (!hasParagraphStyle) { | |
| 111 | textAttributes.textShadowColor = props.shadowColor; | |
| 112 | textAttributes.textShadowOffset = props.shadowOffset; | |
| 113 | textAttributes.textShadowRadius = props.shadowRadius; | |
| 114 | } | |
| 115 | textAttributes.letterSpacing = props.letterSpacing; | |
| 116 | textAttributes.textDecorationColor = props.textDecorationColor; | |
| 117 | textAttributes.fontFamily = props.fontFamily; | |
| 118 | ||
| 119 | if (props.fontStyle == T3MarkdownTextRunFontStyle::Italic) { | |
| 120 | textAttributes.fontStyle = FontStyle::Italic; | |
| 121 | } else { | |
| 122 | textAttributes.fontStyle = FontStyle::Normal; | |
| 123 | } | |
| 124 | ||
| 125 | if (props.fontWeight == T3MarkdownTextRunFontWeight::Bold) { | |
| 126 | textAttributes.fontWeight = FontWeight::Bold; | |
| 127 | } else if (props.fontWeight == T3MarkdownTextRunFontWeight::UltraLight) { | |
| 128 | textAttributes.fontWeight = FontWeight::UltraLight; | |
| 129 | } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Light) { | |
| 130 | textAttributes.fontWeight = FontWeight::Light; | |
| 131 | } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Medium) { | |
| 132 | textAttributes.fontWeight = FontWeight::Medium; | |
| 133 | } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Semibold) { | |
| 134 | textAttributes.fontWeight = FontWeight::Semibold; | |
| 135 | } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Heavy) { | |
| 136 | textAttributes.fontWeight = FontWeight::Heavy; | |
| 137 | } else { | |
| 138 | textAttributes.fontWeight = FontWeight::Regular; | |
| 139 | } | |
| 140 | ||
| 141 | if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::LineThrough) { | |
| 142 | textAttributes.textDecorationLineType = TextDecorationLineType::Strikethrough; | |
| 143 | } else if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::Underline) { | |
| 144 | textAttributes.textDecorationLineType = TextDecorationLineType::Underline; | |
| 145 | } else { | |
| 146 | textAttributes.textDecorationLineType = TextDecorationLineType::None; | |
| 147 | } | |
| 148 | ||
| 149 | if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Solid) { | |
| 150 | textAttributes.textDecorationStyle = TextDecorationStyle::Solid; | |
| 151 | } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dotted) { | |
| 152 | textAttributes.textDecorationStyle = TextDecorationStyle::Dotted; | |
| 153 | } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dashed) { | |
| 154 | textAttributes.textDecorationStyle = TextDecorationStyle::Dashed; | |
| 155 | } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Double) { | |
| 156 | textAttributes.textDecorationStyle = TextDecorationStyle::Double; | |
| 157 | } | |
| 158 | ||
| 159 | if (props.textAlign == T3MarkdownTextRunTextAlign::Left) { | |
| 160 | textAttributes.alignment = TextAlignment::Left; | |
| 161 | } else if (props.textAlign == T3MarkdownTextRunTextAlign::Right) { | |
| 162 | textAttributes.alignment = TextAlignment::Right; | |
| 163 | } else if (props.textAlign == T3MarkdownTextRunTextAlign::Center) { | |
| 164 | textAttributes.alignment = TextAlignment::Center; | |
| 165 | } else if (props.textAlign == T3MarkdownTextRunTextAlign::Justify) { | |
| 166 | textAttributes.alignment = TextAlignment::Justified; | |
| 167 | } else if (props.textAlign == T3MarkdownTextRunTextAlign::Auto) { | |
| 168 | textAttributes.alignment = TextAlignment::Natural; | |
| 169 | } | |
| 170 | ||
| 171 | textAttributes.backgroundColor = props.backgroundColor; | |
| 172 | ||
| 173 | fragment.string = props.text; | |
| 174 | fragment.textAttributes = textAttributes; | |
| 175 | ||
| 176 | NSString *fragmentText = [NSString stringWithUTF8String:props.text.c_str()]; | |
| 177 | const size_t fragmentLength = fragmentText.length; | |
| 178 | if (hasParagraphStyle) { | |
| 179 | paragraphStyleRanges.push_back(T3MarkdownTextParagraphStyleRange{ | |
| 180 | utf16Offset, | |
| 181 | fragmentLength, | |
| 182 | props.shadowOffset.width, | |
| 183 | props.shadowOffset.height, | |
| 184 | props.shadowRadius - ParagraphStyleEncodingOffset, | |
| 185 | }); | |
| 186 | } | |
| 187 | if (props.nativeId.rfind(ChipNativeIdPrefix, 0) == 0 && fragmentLength > 0) { | |
| 188 | chipRanges.push_back(T3MarkdownTextChipRange{ | |
| 189 | utf16Offset, | |
| 190 | fragmentLength, | |
| 191 | props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0, | |
| 192 | }); | |
| 193 | } | |
| 194 | if (props.nativeId.rfind(FileChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) { | |
| 195 | attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ | |
| 196 | utf16Offset + 1, | |
| 197 | 1, | |
| 198 | props.nativeId.substr(std::char_traits<char>::length(FileChipNativeIdPrefix)), | |
| 199 | }); | |
| 200 | } else if ( | |
| 201 | props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) { | |
| 202 | attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ | |
| 203 | utf16Offset + 1, | |
| 204 | 1, | |
| 205 | props.nativeId.substr(std::char_traits<char>::length(SkillChipNativeIdPrefix)), | |
| 206 | }); | |
| 207 | } | |
| 208 | utf16Offset += fragmentLength; | |
| 209 | baseAttributedString.appendFragment(std::move(fragment)); | |
| 210 | } | |
| 211 | } | |
| 212 | ||
| 213 | _attributedString = baseAttributedString; | |
| 214 | _paragraphStyleRanges = paragraphStyleRanges; | |
| 215 | _attachmentRanges = attachmentRanges; | |
| 216 | _chipRanges = chipRanges; | |
| 217 | ||
| 218 | NSMutableAttributedString *convertedAttributedString = | |
| 219 | [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; | |
| 220 | applyParagraphStyles(convertedAttributedString, paragraphStyleRanges); | |
| 221 | applyAttachments(convertedAttributedString, attachmentRanges); | |
| 222 | ||
| 223 | const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width) | |
| 224 | ? layoutConstraints.maximumSize.width | |
| 225 | : CGFLOAT_MAX; | |
| 226 | NSTextStorage *textStorage = | |
| 227 | [[NSTextStorage alloc] initWithAttributedString:convertedAttributedString]; | |
| 228 | NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; | |
| 229 | layoutManager.usesFontLeading = NO; | |
| 230 | NSTextContainer *textContainer = | |
| 231 | [[NSTextContainer alloc] initWithSize:CGSizeMake(maximumWidth, CGFLOAT_MAX)]; | |
| 232 | textContainer.lineFragmentPadding = 0; | |
| 233 | textContainer.maximumNumberOfLines = baseProps.numberOfLines; | |
| 234 | if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { | |
| 235 | textContainer.lineBreakMode = NSLineBreakByTruncatingHead; | |
| 236 | } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { | |
| 237 | textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle; | |
| 238 | } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { | |
| 239 | textContainer.lineBreakMode = NSLineBreakByTruncatingTail; | |
| 240 | } else { | |
| 241 | textContainer.lineBreakMode = NSLineBreakByClipping; | |
| 242 | } | |
| 243 | [layoutManager addTextContainer:textContainer]; | |
| 244 | [textStorage addLayoutManager:layoutManager]; | |
| 245 | [layoutManager ensureLayoutForTextContainer:textContainer]; | |
| 246 | const CGRect usedRect = [layoutManager usedRectForTextContainer:textContainer]; | |
| 247 | ||
| 248 | return { | |
| 249 | std::clamp( | |
| 250 | static_cast<Float>(std::ceil(usedRect.size.width)), | |
| 251 | layoutConstraints.minimumSize.width, | |
| 252 | layoutConstraints.maximumSize.width), | |
| 253 | std::clamp( | |
| 254 | static_cast<Float>(std::ceil(usedRect.size.height)), | |
| 255 | layoutConstraints.minimumSize.height, | |
| 256 | layoutConstraints.maximumSize.height), | |
| 257 | }; | |
| 258 | } | |
| 259 | ||
| 260 | void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { | |
| 261 | ensureUnsealed(); | |
| 262 | setStateData(T3MarkdownTextStateReal{ | |
| 263 | _attributedString, | |
| 264 | _paragraphStyleRanges, | |
| 265 | _attachmentRanges, | |
| 266 | _chipRanges, | |
| 267 | }); | |
| 268 | } | |
| 269 | } |
apps/mobile/modules/t3-markdown-text/package.json created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | { | |
| 2 | "name": "@t3tools/mobile-markdown-text", | |
| 3 | "version": "0.0.0", | |
| 4 | "private": true, | |
| 5 | "source": "./index.ts", | |
| 6 | "files": [ | |
| 7 | "assets", | |
| 8 | "ios", | |
| 9 | "src", | |
| 10 | "index.ts", | |
| 11 | "LICENSE", | |
| 12 | "UPSTREAM.md", | |
| 13 | "T3MarkdownText.podspec", | |
| 14 | "react-native.config.js" | |
| 15 | ], | |
| 16 | "main": "./index.ts", | |
| 17 | "types": "./index.ts", | |
| 18 | "react-native": "./index.ts", | |
| 19 | "exports": { | |
| 20 | ".": "./index.ts", | |
| 21 | "./file-icons": "./src/markdownFileIcons.ts", | |
| 22 | "./links": "./src/markdownLinks.ts", | |
| 23 | "./markdown": "./src/nativeMarkdownText.ts", | |
| 24 | "./primitive": "./src/MarkdownTextPrimitive.tsx", | |
| 25 | "./renderer": "./src/SelectableMarkdownText.ios.tsx", | |
| 26 | "./types": "./src/SelectableMarkdownText.types.ts" | |
| 27 | }, | |
| 28 | "peerDependencies": { | |
| 29 | "expo-asset": "*", | |
| 30 | "expo-clipboard": "*", | |
| 31 | "expo-haptics": "*", | |
| 32 | "expo-symbols": "*", | |
| 33 | "react": "*", | |
| 34 | "react-native": "*", | |
| 35 | "react-native-nitro-markdown": "*" | |
| 36 | }, | |
| 37 | "codegenConfig": { | |
| 38 | "name": "T3MarkdownTextSpec", | |
| 39 | "type": "all", | |
| 40 | "jsSrcsDir": "src", | |
| 41 | "ios": { | |
| 42 | "componentProvider": { | |
| 43 | "T3MarkdownText": "T3MarkdownText", | |
| 44 | "T3MarkdownTextRun": "T3MarkdownTextRun" | |
| 45 | } | |
| 46 | }, | |
| 47 | "outputDir": { | |
| 48 | "ios": "ios/generated" | |
| 49 | } | |
| 50 | } | |
| 51 | } |
apps/mobile/modules/t3-markdown-text/react-native.config.js created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | module.exports = { | |
| 2 | dependency: { | |
| 3 | platforms: { | |
| 4 | ios: { | |
| 5 | podspecPath: "T3MarkdownText.podspec", | |
| 6 | }, | |
| 7 | android: null, | |
| 8 | }, | |
| 9 | }, | |
| 10 | }; |
apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs created+133| ... | ... | @@ -0,0 +1,133 @@ |
| 1 | import { execFileSync } from "node:child_process"; | |
| 2 | import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 3 | import { dirname, join, resolve } from "node:path"; | |
| 4 | import { fileURLToPath } from "node:url"; | |
| 5 | ||
| 6 | import { getBuiltInSpriteSheet } from "@pierre/trees"; | |
| 7 | ||
| 8 | const scriptDirectory = dirname(fileURLToPath(import.meta.url)); | |
| 9 | const moduleDirectory = resolve(scriptDirectory, ".."); | |
| 10 | const repositoryRoot = resolve(moduleDirectory, "../../../.."); | |
| 11 | const outputDirectory = join(moduleDirectory, "assets/file-icons"); | |
| 12 | const generatedModulePath = join(moduleDirectory, "src/markdownFileIcons.generated.ts"); | |
| 13 | const webIconSource = readFileSync(join(repositoryRoot, "apps/web/src/pierre-icons.ts"), "utf8"); | |
| 14 | const customSprite = webIconSource.match(/const T3_FILE_ICON_SPRITE = `([\s\S]*?)`;/)?.[1]; | |
| 15 | ||
| 16 | if (!customSprite) { | |
| 17 | throw new Error("Could not read the T3 Pierre icon sprite from apps/web/src/pierre-icons.ts"); | |
| 18 | } | |
| 19 | ||
| 20 | const colors = { | |
| 21 | astro: "#a631be", | |
| 22 | babel: "#d5a910", | |
| 23 | bash: "#199f43", | |
| 24 | biome: "#1a85d4", | |
| 25 | bootstrap: "#693acf", | |
| 26 | browserslist: "#d5a910", | |
| 27 | bun: "#594c5b", | |
| 28 | c: "#1a85d4", | |
| 29 | claude: "#d47628", | |
| 30 | cpp: "#1a85d4", | |
| 31 | css: "#693acf", | |
| 32 | database: "#a631be", | |
| 33 | default: "#84848a", | |
| 34 | docker: "#1a85d4", | |
| 35 | eslint: "#693acf", | |
| 36 | font: "#84848a", | |
| 37 | git: "#ff8c5b", | |
| 38 | go: "#1ca1c7", | |
| 39 | graphql: "#d32a61", | |
| 40 | html: "#d47628", | |
| 41 | image: "#d32a61", | |
| 42 | javascript: "#d5a910", | |
| 43 | json: "#d47628", | |
| 44 | markdown: "#199f43", | |
| 45 | mcp: "#17a5af", | |
| 46 | nextjs: "#84848a", | |
| 47 | npm: "#d52c36", | |
| 48 | oxc: "#1ca1c7", | |
| 49 | postcss: "#d52c36", | |
| 50 | prettier: "#17a5af", | |
| 51 | python: "#1a85d4", | |
| 52 | react: "#1ca1c7", | |
| 53 | ruby: "#d52c36", | |
| 54 | rust: "#d47628", | |
| 55 | sass: "#d32a61", | |
| 56 | stylelint: "#84848a", | |
| 57 | svelte: "#d52c36", | |
| 58 | svg: "#d47628", | |
| 59 | svgo: "#199f43", | |
| 60 | swift: "#d47628", | |
| 61 | table: "#17a5af", | |
| 62 | tailwind: "#1ca1c7", | |
| 63 | terraform: "#693acf", | |
| 64 | text: "#84848a", | |
| 65 | typescript: "#1a85d4", | |
| 66 | vite: "#a631be", | |
| 67 | vscode: "#1a85d4", | |
| 68 | vue: "#199f43", | |
| 69 | wasm: "#693acf", | |
| 70 | webpack: "#1a85d4", | |
| 71 | yml: "#d52c36", | |
| 72 | zig: "#d47628", | |
| 73 | zip: "#d47628", | |
| 74 | }; | |
| 75 | ||
| 76 | const customIcons = { | |
| 77 | agents: "t3-file-icon-agents", | |
| 78 | claude: "t3-file-icon-claude", | |
| 79 | package: "t3-file-icon-package-json", | |
| 80 | pnpm: "t3-file-icon-pnpm", | |
| 81 | readme: "t3-file-icon-readme", | |
| 82 | tsconfig: "t3-file-icon-tsconfig", | |
| 83 | }; | |
| 84 | ||
| 85 | function symbolFromSprite(sprite, id) { | |
| 86 | const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| 87 | const match = sprite.match( | |
| 88 | new RegExp(`<symbol id="${escapedId}"([^>]*)>([\\s\\S]*?)<\\/symbol>`), | |
| 89 | ); | |
| 90 | if (!match) throw new Error(`Missing Pierre icon symbol: ${id}`); | |
| 91 | return { | |
| 92 | body: match[2], | |
| 93 | viewBox: match[1].match(/viewBox="([^"]+)"/)?.[1] ?? "0 0 16 16", | |
| 94 | }; | |
| 95 | } | |
| 96 | ||
| 97 | function renderIcon(token, symbol, color) { | |
| 98 | const svgPath = join(outputDirectory, `.pierre-${token}.svg`); | |
| 99 | const pngPath = join(outputDirectory, `pierre_${token}.png`); | |
| 100 | writeFileSync( | |
| 101 | svgPath, | |
| 102 | `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="${symbol.viewBox}" style="color:${color}">${symbol.body}</svg>`, | |
| 103 | ); | |
| 104 | execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], { | |
| 105 | stdio: "ignore", | |
| 106 | }); | |
| 107 | rmSync(svgPath); | |
| 108 | } | |
| 109 | ||
| 110 | rmSync(outputDirectory, { recursive: true, force: true }); | |
| 111 | mkdirSync(outputDirectory, { recursive: true }); | |
| 112 | ||
| 113 | const builtInSprite = getBuiltInSpriteSheet("complete"); | |
| 114 | const builtInTokens = [...builtInSprite.matchAll(/<symbol id="file-tree-builtin-([^"]+)"/g)] | |
| 115 | .map((match) => match[1]) | |
| 116 | .sort(); | |
| 117 | ||
| 118 | for (const token of builtInTokens) { | |
| 119 | renderIcon( | |
| 120 | token, | |
| 121 | symbolFromSprite(builtInSprite, `file-tree-builtin-${token}`), | |
| 122 | colors[token] ?? colors.default, | |
| 123 | ); | |
| 124 | } | |
| 125 | for (const [token, symbolId] of Object.entries(customIcons)) { | |
| 126 | renderIcon(token, symbolFromSprite(customSprite, symbolId), colors[token] ?? colors.default); | |
| 127 | } | |
| 128 | ||
| 129 | const tokens = [...new Set([...builtInTokens, ...Object.keys(customIcons)])].sort(); | |
| 130 | const generatedSource = `import type { ImageSourcePropType } from "react-native";\n\nexport const MARKDOWN_FILE_ICON_SOURCES = {\n${tokens | |
| 131 | .map((token) => ` ${token}: require("../assets/file-icons/pierre_${token}.png"),`) | |
| 132 | .join("\n")}\n} as const satisfies Readonly<Record<string, ImageSourcePropType>>;\n`; | |
| 133 | writeFileSync(generatedModulePath, generatedSource); |
apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx created+73| ... | ... | @@ -0,0 +1,73 @@ |
| 1 | import { SymbolView } from "expo-symbols"; | |
| 2 | import * as Clipboard from "expo-clipboard"; | |
| 3 | import * as Haptics from "expo-haptics"; | |
| 4 | import { memo, useEffect, useRef, useState } from "react"; | |
| 5 | import { Pressable, type ColorValue } from "react-native"; | |
| 6 | ||
| 7 | const COPY_FEEDBACK_DURATION_MS = 1200; | |
| 8 | ||
| 9 | function copyTextWithHaptic(value: string): void { | |
| 10 | void Clipboard.setStringAsync(value); | |
| 11 | void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); | |
| 12 | } | |
| 13 | ||
| 14 | export const CopyTextButton = memo(function CopyTextButton(props: { | |
| 15 | readonly accessibilityLabel: string; | |
| 16 | readonly text: string; | |
| 17 | readonly tintColor: ColorValue; | |
| 18 | readonly copiedTintColor?: ColorValue; | |
| 19 | readonly backgroundColor?: ColorValue; | |
| 20 | readonly borderColor?: ColorValue; | |
| 21 | readonly iconSize?: number; | |
| 22 | readonly buttonSize?: number; | |
| 23 | }) { | |
| 24 | const [copied, setCopied] = useState(false); | |
| 25 | const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| 26 | ||
| 27 | useEffect( | |
| 28 | () => () => { | |
| 29 | if (resetTimeoutRef.current) { | |
| 30 | clearTimeout(resetTimeoutRef.current); | |
| 31 | } | |
| 32 | }, | |
| 33 | [], | |
| 34 | ); | |
| 35 | ||
| 36 | return ( | |
| 37 | <Pressable | |
| 38 | accessibilityRole="button" | |
| 39 | accessibilityLabel={copied ? "Copied" : props.accessibilityLabel} | |
| 40 | disabled={props.text.length === 0} | |
| 41 | hitSlop={8} | |
| 42 | onPress={() => { | |
| 43 | copyTextWithHaptic(props.text); | |
| 44 | setCopied(true); | |
| 45 | if (resetTimeoutRef.current) { | |
| 46 | clearTimeout(resetTimeoutRef.current); | |
| 47 | } | |
| 48 | resetTimeoutRef.current = setTimeout(() => { | |
| 49 | setCopied(false); | |
| 50 | resetTimeoutRef.current = null; | |
| 51 | }, COPY_FEEDBACK_DURATION_MS); | |
| 52 | }} | |
| 53 | style={({ pressed }) => ({ | |
| 54 | width: props.buttonSize ?? 30, | |
| 55 | height: props.buttonSize ?? 30, | |
| 56 | alignItems: "center", | |
| 57 | justifyContent: "center", | |
| 58 | borderRadius: 9, | |
| 59 | borderWidth: props.borderColor ? 1 : 0, | |
| 60 | borderColor: props.borderColor, | |
| 61 | backgroundColor: props.backgroundColor, | |
| 62 | opacity: pressed ? 0.52 : 1, | |
| 63 | })} | |
| 64 | > | |
| 65 | <SymbolView | |
| 66 | name={copied ? "checkmark" : "doc.on.doc"} | |
| 67 | size={props.iconSize ?? 13} | |
| 68 | tintColor={copied ? (props.copiedTintColor ?? props.tintColor) : props.tintColor} | |
| 69 | type="monochrome" | |
| 70 | /> | |
| 71 | </Pressable> | |
| 72 | ); | |
| 73 | }); |
apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx created+109| ... | ... | @@ -0,0 +1,109 @@ |
| 1 | import React from "react"; | |
| 2 | import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native"; | |
| 3 | import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; | |
| 4 | import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; | |
| 5 | import { flattenStyles } from "./util"; | |
| 6 | ||
| 7 | const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([ | |
| 8 | false, | |
| 9 | StyleSheet.create({}), | |
| 10 | ]); | |
| 11 | ||
| 12 | const textDefaults: TextProps = { | |
| 13 | allowFontScaling: true, | |
| 14 | selectable: true, | |
| 15 | }; | |
| 16 | ||
| 17 | const useTextAncestorContext = () => React.useContext(TextAncestorContext); | |
| 18 | ||
| 19 | /** | |
| 20 | * Event fired by `onSelectionChange`. `start`/`end` are 0-based UTF-16 indices | |
| 21 | * into the rendered string. `start === end` means the selection was cleared. | |
| 22 | */ | |
| 23 | export type SelectionChangeEvent = { | |
| 24 | nativeEvent: { target: number; start: number; end: number }; | |
| 25 | }; | |
| 26 | ||
| 27 | export type MarkdownTextPrimitiveProps = TextProps & { | |
| 28 | uiTextView?: boolean; | |
| 29 | /** | |
| 30 | * Fired when the native text selection changes. Only fires on iOS when | |
| 31 | * `uiTextView` is true. Note: fires on every selection-edge adjustment | |
| 32 | * (e.g. dragging a selection handle), so consumers driving expensive work | |
| 33 | * off this event should debounce. | |
| 34 | */ | |
| 35 | onSelectionChange?: (event: SelectionChangeEvent) => void; | |
| 36 | }; | |
| 37 | ||
| 38 | function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPrimitiveProps) { | |
| 39 | const [isAncestor, rootStyle] = useTextAncestorContext(); | |
| 40 | ||
| 41 | // Flatten the styles, and apply the root styles when needed | |
| 42 | const flattenedStyle = React.useMemo(() => flattenStyles(rootStyle, style), [rootStyle, style]); | |
| 43 | const contextValue = React.useMemo<[boolean, ViewStyle]>( | |
| 44 | () => [true, flattenedStyle], | |
| 45 | [flattenedStyle], | |
| 46 | ); | |
| 47 | let childPosition = 0; | |
| 48 | const nativeChildren = React.Children.toArray(children).map((child) => { | |
| 49 | const position = childPosition; | |
| 50 | childPosition += 1; | |
| 51 | ||
| 52 | if (React.isValidElement(child)) { | |
| 53 | return child; | |
| 54 | } | |
| 55 | if (typeof child !== "string" && typeof child !== "number") { | |
| 56 | return null; | |
| 57 | } | |
| 58 | ||
| 59 | const text = child.toString(); | |
| 60 | return ( | |
| 61 | // @ts-expect-error The generated run props do not include inherited Text props. | |
| 62 | <T3MarkdownTextRunNativeComponent | |
| 63 | key={`text-${position}-${text.length}-${text}`} | |
| 64 | style={flattenedStyle} | |
| 65 | text={text} | |
| 66 | {...rest} | |
| 67 | /> | |
| 68 | ); | |
| 69 | }); | |
| 70 | ||
| 71 | if (!isAncestor) { | |
| 72 | return ( | |
| 73 | <TextAncestorContext.Provider value={contextValue}> | |
| 74 | <T3MarkdownTextNativeComponent | |
| 75 | {...textDefaults} | |
| 76 | {...rest} | |
| 77 | // ellipsizeMode={rest.ellipsizeMode ?? rest.lineBreakMode ?? 'tail'} | |
| 78 | style={[flattenedStyle]} | |
| 79 | // @ts-expect-error Weirdness | |
| 80 | onPress={undefined} | |
| 81 | onLongPress={undefined} | |
| 82 | > | |
| 83 | {nativeChildren} | |
| 84 | </T3MarkdownTextNativeComponent> | |
| 85 | </TextAncestorContext.Provider> | |
| 86 | ); | |
| 87 | } | |
| 88 | ||
| 89 | return <>{nativeChildren}</>; | |
| 90 | } | |
| 91 | ||
| 92 | function MarkdownTextPrimitiveInner(props: MarkdownTextPrimitiveProps) { | |
| 93 | const [isAncestor] = useTextAncestorContext(); | |
| 94 | ||
| 95 | // Even if the uiTextView prop is set, we can still default to using | |
| 96 | // normal selection (i.e. base RN text) if the text doesn't need to be | |
| 97 | // selectable | |
| 98 | if ((!props.selectable || !props.uiTextView) && !isAncestor) { | |
| 99 | return <RNText {...props} />; | |
| 100 | } | |
| 101 | return <MarkdownTextPrimitiveChild {...props} />; | |
| 102 | } | |
| 103 | ||
| 104 | export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) { | |
| 105 | if (Platform.OS !== "ios") { | |
| 106 | return <RNText {...props} />; | |
| 107 | } | |
| 108 | return <MarkdownTextPrimitiveInner {...props} />; | |
| 109 | } |
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx created+647| ... | ... | @@ -0,0 +1,647 @@ |
| 1 | import { useEffect, useState } from "react"; | |
| 2 | import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; | |
| 3 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | |
| 4 | ||
| 5 | import { CopyTextButton } from "./CopyTextButton"; | |
| 6 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; | |
| 7 | import { | |
| 8 | nativeMarkdownDocumentRuns, | |
| 9 | nativeMarkdownListItemBlocks, | |
| 10 | nativeMarkdownTextRuns, | |
| 11 | } from "./nativeMarkdownText"; | |
| 12 | import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; | |
| 13 | import type { | |
| 14 | MarkdownCodeHighlighter, | |
| 15 | MarkdownHighlightedToken, | |
| 16 | NativeMarkdownTextStyle, | |
| 17 | } from "./SelectableMarkdownText.types"; | |
| 18 | ||
| 19 | type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>; | |
| 20 | ||
| 21 | const highlightedCodeCache = new Map<string, HighlightedCode>(); | |
| 22 | const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>(); | |
| 23 | const HIGHLIGHTED_CODE_CACHE_LIMIT = 64; | |
| 24 | ||
| 25 | function nodeKey(node: MarkdownNode, index: number): string { | |
| 26 | return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; | |
| 27 | } | |
| 28 | ||
| 29 | function nodeText(node: MarkdownNode): string { | |
| 30 | if (node.content !== undefined) { | |
| 31 | return node.content; | |
| 32 | } | |
| 33 | return (node.children ?? []).map(nodeText).join(""); | |
| 34 | } | |
| 35 | ||
| 36 | function documentFor(node: MarkdownNode): MarkdownNode { | |
| 37 | return node.type === "document" ? node : { type: "document", children: [node] }; | |
| 38 | } | |
| 39 | ||
| 40 | function SelectableNode(props: { | |
| 41 | readonly node: MarkdownNode; | |
| 42 | readonly textStyle: NativeMarkdownTextStyle; | |
| 43 | }) { | |
| 44 | return ( | |
| 45 | <NativeMarkdownSelectableText | |
| 46 | runs={nativeMarkdownDocumentRuns(documentFor(props.node))} | |
| 47 | textStyle={props.textStyle} | |
| 48 | /> | |
| 49 | ); | |
| 50 | } | |
| 51 | ||
| 52 | function codeHighlightCacheKey( | |
| 53 | code: string, | |
| 54 | language: string | undefined, | |
| 55 | theme: "light" | "dark", | |
| 56 | ): string { | |
| 57 | return `${theme}:${language ?? "text"}:${code}`; | |
| 58 | } | |
| 59 | ||
| 60 | function cacheHighlightedCode(key: string, tokens: HighlightedCode): void { | |
| 61 | highlightedCodeCache.delete(key); | |
| 62 | highlightedCodeCache.set(key, tokens); | |
| 63 | ||
| 64 | while (highlightedCodeCache.size > HIGHLIGHTED_CODE_CACHE_LIMIT) { | |
| 65 | const oldestKey = highlightedCodeCache.keys().next().value; | |
| 66 | if (oldestKey === undefined) { | |
| 67 | break; | |
| 68 | } | |
| 69 | highlightedCodeCache.delete(oldestKey); | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | function loadHighlightedCode( | |
| 74 | code: string, | |
| 75 | language: string | undefined, | |
| 76 | theme: "light" | "dark", | |
| 77 | highlightCode: MarkdownCodeHighlighter, | |
| 78 | ): Promise<HighlightedCode> { | |
| 79 | const key = codeHighlightCacheKey(code, language, theme); | |
| 80 | const cached = highlightedCodeCache.get(key); | |
| 81 | if (cached) { | |
| 82 | return Promise.resolve(cached); | |
| 83 | } | |
| 84 | ||
| 85 | const pending = highlightedCodePromiseCache.get(key); | |
| 86 | if (pending) { | |
| 87 | return pending; | |
| 88 | } | |
| 89 | ||
| 90 | const promise = highlightCode({ code, language, theme }) | |
| 91 | .then((tokens) => { | |
| 92 | cacheHighlightedCode(key, tokens); | |
| 93 | highlightedCodePromiseCache.delete(key); | |
| 94 | return tokens; | |
| 95 | }) | |
| 96 | .catch((error) => { | |
| 97 | highlightedCodePromiseCache.delete(key); | |
| 98 | throw error; | |
| 99 | }); | |
| 100 | highlightedCodePromiseCache.set(key, promise); | |
| 101 | return promise; | |
| 102 | } | |
| 103 | ||
| 104 | function useHighlightedCode( | |
| 105 | code: string, | |
| 106 | language: string | undefined, | |
| 107 | theme: "light" | "dark", | |
| 108 | highlightCode: MarkdownCodeHighlighter, | |
| 109 | ): HighlightedCode | null { | |
| 110 | const key = codeHighlightCacheKey(code, language, theme); | |
| 111 | const [highlighted, setHighlighted] = useState<{ | |
| 112 | readonly key: string; | |
| 113 | readonly tokens: HighlightedCode | null; | |
| 114 | }>(() => ({ | |
| 115 | key, | |
| 116 | tokens: highlightedCodeCache.get(key) ?? null, | |
| 117 | })); | |
| 118 | ||
| 119 | useEffect(() => { | |
| 120 | let active = true; | |
| 121 | const cached = highlightedCodeCache.get(key); | |
| 122 | if (cached) { | |
| 123 | cacheHighlightedCode(key, cached); | |
| 124 | setHighlighted({ key, tokens: cached }); | |
| 125 | return () => { | |
| 126 | active = false; | |
| 127 | }; | |
| 128 | } | |
| 129 | ||
| 130 | void loadHighlightedCode(code, language, theme, highlightCode) | |
| 131 | .then((tokens) => { | |
| 132 | if (active) { | |
| 133 | setHighlighted({ key, tokens }); | |
| 134 | } | |
| 135 | }) | |
| 136 | .catch(() => { | |
| 137 | if (active) { | |
| 138 | setHighlighted({ key, tokens: null }); | |
| 139 | } | |
| 140 | }); | |
| 141 | return () => { | |
| 142 | active = false; | |
| 143 | }; | |
| 144 | }, [code, highlightCode, key, language, theme]); | |
| 145 | ||
| 146 | return highlighted.key === key ? highlighted.tokens : null; | |
| 147 | } | |
| 148 | ||
| 149 | function HighlightedCodeText(props: { | |
| 150 | readonly content: string; | |
| 151 | readonly highlighted: HighlightedCode | null; | |
| 152 | readonly textStyle: NativeMarkdownTextStyle; | |
| 153 | }) { | |
| 154 | if (!props.highlighted) { | |
| 155 | return ( | |
| 156 | <MarkdownTextPrimitive | |
| 157 | uiTextView | |
| 158 | selectable | |
| 159 | style={{ | |
| 160 | color: props.textStyle.codeColor, | |
| 161 | fontFamily: "ui-monospace", | |
| 162 | fontSize: 12, | |
| 163 | lineHeight: 18, | |
| 164 | }} | |
| 165 | > | |
| 166 | {props.content} | |
| 167 | </MarkdownTextPrimitive> | |
| 168 | ); | |
| 169 | } | |
| 170 | const highlighted = props.highlighted; | |
| 171 | let sourceOffset = 0; | |
| 172 | const keyOccurrences = new Map<string, number>(); | |
| 173 | const keyedLines = highlighted.map((line) => { | |
| 174 | const lineStart = sourceOffset; | |
| 175 | const tokens = line.map((token) => { | |
| 176 | const start = sourceOffset; | |
| 177 | sourceOffset += token.content.length; | |
| 178 | const signature = `${start}:${token.content}:${token.color ?? ""}:${token.fontStyle ?? ""}`; | |
| 179 | const occurrence = keyOccurrences.get(signature) ?? 0; | |
| 180 | keyOccurrences.set(signature, occurrence + 1); | |
| 181 | return { key: `${signature}:${occurrence}`, token }; | |
| 182 | }); | |
| 183 | sourceOffset += 1; | |
| 184 | return { | |
| 185 | key: `line:${lineStart}:${line.map((token) => token.content).join("")}`, | |
| 186 | tokens, | |
| 187 | }; | |
| 188 | }); | |
| 189 | ||
| 190 | return ( | |
| 191 | <MarkdownTextPrimitive | |
| 192 | uiTextView | |
| 193 | selectable | |
| 194 | style={{ | |
| 195 | color: props.textStyle.codeColor, | |
| 196 | fontFamily: "ui-monospace", | |
| 197 | fontSize: 12, | |
| 198 | lineHeight: 18, | |
| 199 | }} | |
| 200 | > | |
| 201 | {keyedLines.map((line, lineIndex) => ( | |
| 202 | <MarkdownTextPrimitive key={line.key}> | |
| 203 | {line.tokens.map(({ key, token }) => ( | |
| 204 | <MarkdownTextPrimitive | |
| 205 | key={key} | |
| 206 | style={{ | |
| 207 | color: token.color ?? props.textStyle.codeColor, | |
| 208 | fontFamily: "ui-monospace", | |
| 209 | fontStyle: | |
| 210 | token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal", | |
| 211 | fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400", | |
| 212 | }} | |
| 213 | > | |
| 214 | {token.content} | |
| 215 | </MarkdownTextPrimitive> | |
| 216 | ))} | |
| 217 | {lineIndex + 1 < keyedLines.length ? "\n" : ""} | |
| 218 | </MarkdownTextPrimitive> | |
| 219 | ))} | |
| 220 | </MarkdownTextPrimitive> | |
| 221 | ); | |
| 222 | } | |
| 223 | ||
| 224 | function NativeCodeBlock(props: { | |
| 225 | readonly node: MarkdownNode; | |
| 226 | readonly textStyle: NativeMarkdownTextStyle; | |
| 227 | readonly highlightCode: MarkdownCodeHighlighter; | |
| 228 | readonly compact?: boolean; | |
| 229 | }) { | |
| 230 | const content = nodeText(props.node).replace(/\n$/, ""); | |
| 231 | const colorScheme = useColorScheme(); | |
| 232 | const theme = colorScheme === "dark" ? "dark" : "light"; | |
| 233 | const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode); | |
| 234 | const languageLabel = props.node.language?.toUpperCase() ?? "CODE"; | |
| 235 | return ( | |
| 236 | <View | |
| 237 | style={{ | |
| 238 | backgroundColor: props.textStyle.codeBlockBackgroundColor, | |
| 239 | borderColor: props.textStyle.dividerColor, | |
| 240 | borderCurve: "continuous", | |
| 241 | borderRadius: 10, | |
| 242 | borderWidth: 1, | |
| 243 | marginVertical: props.compact ? 7 : 0, | |
| 244 | overflow: "hidden", | |
| 245 | }} | |
| 246 | > | |
| 247 | <View | |
| 248 | style={{ | |
| 249 | minHeight: 42, | |
| 250 | borderBottomColor: props.textStyle.dividerColor, | |
| 251 | borderBottomWidth: 1, | |
| 252 | paddingLeft: 14, | |
| 253 | paddingRight: 6, | |
| 254 | flexDirection: "row", | |
| 255 | alignItems: "center", | |
| 256 | justifyContent: "space-between", | |
| 257 | }} | |
| 258 | > | |
| 259 | <Text | |
| 260 | selectable | |
| 261 | style={{ | |
| 262 | flex: 1, | |
| 263 | color: props.textStyle.mutedColor, | |
| 264 | fontFamily: "ui-monospace", | |
| 265 | fontSize: 12, | |
| 266 | }} | |
| 267 | > | |
| 268 | {languageLabel} | |
| 269 | </Text> | |
| 270 | <CopyTextButton | |
| 271 | accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`} | |
| 272 | text={content} | |
| 273 | tintColor={props.textStyle.mutedColor} | |
| 274 | copiedTintColor={props.textStyle.linkColor} | |
| 275 | backgroundColor={props.textStyle.codeBackgroundColor} | |
| 276 | borderColor={props.textStyle.dividerColor} | |
| 277 | buttonSize={34} | |
| 278 | iconSize={14} | |
| 279 | /> | |
| 280 | </View> | |
| 281 | <ScrollView | |
| 282 | horizontal | |
| 283 | bounces={false} | |
| 284 | showsHorizontalScrollIndicator={false} | |
| 285 | contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }} | |
| 286 | > | |
| 287 | <HighlightedCodeText | |
| 288 | content={content} | |
| 289 | highlighted={highlighted} | |
| 290 | textStyle={props.textStyle} | |
| 291 | /> | |
| 292 | </ScrollView> | |
| 293 | </View> | |
| 294 | ); | |
| 295 | } | |
| 296 | ||
| 297 | function collectTableRows(node: MarkdownNode): MarkdownNode[] { | |
| 298 | const rows: MarkdownNode[] = []; | |
| 299 | const visit = (child: MarkdownNode) => { | |
| 300 | if (child.type === "table_row") { | |
| 301 | rows.push(child); | |
| 302 | return; | |
| 303 | } | |
| 304 | for (const nested of child.children ?? []) { | |
| 305 | visit(nested); | |
| 306 | } | |
| 307 | }; | |
| 308 | visit(node); | |
| 309 | return rows; | |
| 310 | } | |
| 311 | ||
| 312 | function NativeTable(props: { | |
| 313 | readonly node: MarkdownNode; | |
| 314 | readonly textStyle: NativeMarkdownTextStyle; | |
| 315 | }) { | |
| 316 | const rows = collectTableRows(props.node); | |
| 317 | return ( | |
| 318 | <ScrollView horizontal bounces={false} showsHorizontalScrollIndicator={false}> | |
| 319 | <View | |
| 320 | style={{ | |
| 321 | borderColor: props.textStyle.dividerColor, | |
| 322 | borderCurve: "continuous", | |
| 323 | borderRadius: 8, | |
| 324 | borderWidth: 1, | |
| 325 | overflow: "hidden", | |
| 326 | }} | |
| 327 | > | |
| 328 | {rows.map((row, rowIndex) => ( | |
| 329 | <View | |
| 330 | key={nodeKey(row, rowIndex)} | |
| 331 | style={{ | |
| 332 | flexDirection: "row", | |
| 333 | backgroundColor: rowIndex === 0 ? props.textStyle.codeBackgroundColor : "transparent", | |
| 334 | borderTopColor: props.textStyle.dividerColor, | |
| 335 | borderTopWidth: rowIndex === 0 ? 0 : 1, | |
| 336 | }} | |
| 337 | > | |
| 338 | {(row.children ?? []).map((cell, cellIndex) => ( | |
| 339 | <View | |
| 340 | key={nodeKey(cell, cellIndex)} | |
| 341 | style={{ | |
| 342 | width: 160, | |
| 343 | borderLeftColor: props.textStyle.dividerColor, | |
| 344 | borderLeftWidth: cellIndex === 0 ? 0 : 1, | |
| 345 | paddingHorizontal: 10, | |
| 346 | paddingVertical: 8, | |
| 347 | }} | |
| 348 | > | |
| 349 | <NativeMarkdownSelectableText | |
| 350 | runs={nativeMarkdownTextRuns(cell).map((run) => | |
| 351 | rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, | |
| 352 | )} | |
| 353 | textStyle={props.textStyle} | |
| 354 | /> | |
| 355 | </View> | |
| 356 | ))} | |
| 357 | </View> | |
| 358 | ))} | |
| 359 | </View> | |
| 360 | </ScrollView> | |
| 361 | ); | |
| 362 | } | |
| 363 | ||
| 364 | function NativeMarkdownImage(props: { | |
| 365 | readonly node: MarkdownNode; | |
| 366 | readonly textStyle: NativeMarkdownTextStyle; | |
| 367 | }) { | |
| 368 | const href = props.node.href; | |
| 369 | if (!href) { | |
| 370 | return <SelectableNode node={props.node} textStyle={props.textStyle} />; | |
| 371 | } | |
| 372 | ||
| 373 | return ( | |
| 374 | <View style={{ gap: 6 }}> | |
| 375 | <Image | |
| 376 | source={{ uri: href }} | |
| 377 | resizeMode="contain" | |
| 378 | accessibilityLabel={props.node.alt ?? props.node.title} | |
| 379 | style={{ | |
| 380 | width: "100%", | |
| 381 | aspectRatio: 16 / 9, | |
| 382 | backgroundColor: props.textStyle.codeBackgroundColor, | |
| 383 | borderRadius: 10, | |
| 384 | }} | |
| 385 | /> | |
| 386 | {props.node.alt ? ( | |
| 387 | <Text | |
| 388 | selectable | |
| 389 | style={{ | |
| 390 | color: props.textStyle.mutedColor, | |
| 391 | fontFamily: props.textStyle.fontFamily, | |
| 392 | fontSize: 12, | |
| 393 | lineHeight: 16, | |
| 394 | }} | |
| 395 | > | |
| 396 | {props.node.alt} | |
| 397 | </Text> | |
| 398 | ) : null} | |
| 399 | </View> | |
| 400 | ); | |
| 401 | } | |
| 402 | ||
| 403 | function inlineGroups(nodes: ReadonlyArray<MarkdownNode>): MarkdownNode[] { | |
| 404 | const groups: MarkdownNode[] = []; | |
| 405 | let inline: MarkdownNode[] = []; | |
| 406 | const flush = () => { | |
| 407 | if (inline.length === 0) { | |
| 408 | return; | |
| 409 | } | |
| 410 | groups.push({ type: "paragraph", children: inline }); | |
| 411 | inline = []; | |
| 412 | }; | |
| 413 | ||
| 414 | for (const node of nodes) { | |
| 415 | if (node.type === "image") { | |
| 416 | flush(); | |
| 417 | groups.push(node); | |
| 418 | } else { | |
| 419 | inline.push(node); | |
| 420 | } | |
| 421 | } | |
| 422 | flush(); | |
| 423 | return groups; | |
| 424 | } | |
| 425 | ||
| 426 | function NativeMixedParagraph(props: { | |
| 427 | readonly node: MarkdownNode; | |
| 428 | readonly textStyle: NativeMarkdownTextStyle; | |
| 429 | }) { | |
| 430 | return ( | |
| 431 | <View style={{ gap: 8 }}> | |
| 432 | {inlineGroups(props.node.children ?? []).map((child, index) => | |
| 433 | child.type === "image" ? ( | |
| 434 | <NativeMarkdownImage | |
| 435 | key={nodeKey(child, index)} | |
| 436 | node={child} | |
| 437 | textStyle={props.textStyle} | |
| 438 | /> | |
| 439 | ) : ( | |
| 440 | <SelectableNode key={nodeKey(child, index)} node={child} textStyle={props.textStyle} /> | |
| 441 | ), | |
| 442 | )} | |
| 443 | </View> | |
| 444 | ); | |
| 445 | } | |
| 446 | ||
| 447 | function NativeList(props: { | |
| 448 | readonly node: MarkdownNode; | |
| 449 | readonly textStyle: NativeMarkdownTextStyle; | |
| 450 | readonly highlightCode: MarkdownCodeHighlighter; | |
| 451 | readonly depth: number; | |
| 452 | }) { | |
| 453 | const ordered = props.node.ordered ?? false; | |
| 454 | const start = props.node.start ?? 1; | |
| 455 | const nested = props.depth > 0; | |
| 456 | return ( | |
| 457 | <View | |
| 458 | style={{ | |
| 459 | gap: nested ? 3 : 5, | |
| 460 | }} | |
| 461 | > | |
| 462 | {(props.node.children ?? []).map((item, index) => { | |
| 463 | const taskMarker = item.type === "task_list_item"; | |
| 464 | const marker = taskMarker | |
| 465 | ? item.checked | |
| 466 | ? "☑︎" | |
| 467 | : "☐︎" | |
| 468 | : ordered | |
| 469 | ? `${start + index}.` | |
| 470 | : props.depth % 3 === 1 | |
| 471 | ? "◦" | |
| 472 | : props.depth % 3 === 2 | |
| 473 | ? "▪︎" | |
| 474 | : "•"; | |
| 475 | const markerWidth = ordered ? 28 : taskMarker ? 20 : 18; | |
| 476 | const markerOffset = taskMarker ? 3 : ordered ? 0 : 2; | |
| 477 | return ( | |
| 478 | <View | |
| 479 | key={nodeKey(item, index)} | |
| 480 | style={{ alignItems: "flex-start", flexDirection: "row" }} | |
| 481 | > | |
| 482 | <View | |
| 483 | style={{ | |
| 484 | width: markerWidth, | |
| 485 | height: props.textStyle.lineHeight, | |
| 486 | marginRight: 6, | |
| 487 | alignItems: ordered ? "flex-end" : "center", | |
| 488 | justifyContent: "flex-start", | |
| 489 | }} | |
| 490 | > | |
| 491 | <Text | |
| 492 | style={{ | |
| 493 | color: props.textStyle.color, | |
| 494 | fontFamily: props.textStyle.fontFamily, | |
| 495 | fontSize: taskMarker ? 14 : props.textStyle.fontSize, | |
| 496 | lineHeight: props.textStyle.lineHeight, | |
| 497 | fontVariant: ordered ? ["tabular-nums"] : undefined, | |
| 498 | transform: [{ translateY: markerOffset }], | |
| 499 | }} | |
| 500 | > | |
| 501 | {marker} | |
| 502 | </Text> | |
| 503 | </View> | |
| 504 | <View style={{ flex: 1, minWidth: 0, gap: 2 }}> | |
| 505 | {nativeMarkdownListItemBlocks(item).map((child, childIndex) => ( | |
| 506 | <NativeMarkdownBlock | |
| 507 | key={nodeKey(child, childIndex)} | |
| 508 | node={child} | |
| 509 | textStyle={props.textStyle} | |
| 510 | highlightCode={props.highlightCode} | |
| 511 | depth={props.depth + 1} | |
| 512 | compact | |
| 513 | /> | |
| 514 | ))} | |
| 515 | </View> | |
| 516 | </View> | |
| 517 | ); | |
| 518 | })} | |
| 519 | </View> | |
| 520 | ); | |
| 521 | } | |
| 522 | ||
| 523 | export function NativeMarkdownBlock(props: { | |
| 524 | readonly node: MarkdownNode; | |
| 525 | readonly textStyle: NativeMarkdownTextStyle; | |
| 526 | readonly highlightCode: MarkdownCodeHighlighter; | |
| 527 | readonly depth?: number; | |
| 528 | readonly compact?: boolean; | |
| 529 | }) { | |
| 530 | const depth = props.depth ?? 0; | |
| 531 | switch (props.node.type) { | |
| 532 | case "document": | |
| 533 | return ( | |
| 534 | <View style={{ gap: 8 }}> | |
| 535 | {(props.node.children ?? []).map((child, index) => ( | |
| 536 | <NativeMarkdownBlock | |
| 537 | key={nodeKey(child, index)} | |
| 538 | node={child} | |
| 539 | textStyle={props.textStyle} | |
| 540 | highlightCode={props.highlightCode} | |
| 541 | depth={depth} | |
| 542 | /> | |
| 543 | ))} | |
| 544 | </View> | |
| 545 | ); | |
| 546 | case "code_block": | |
| 547 | return ( | |
| 548 | <NativeCodeBlock | |
| 549 | node={props.node} | |
| 550 | textStyle={props.textStyle} | |
| 551 | highlightCode={props.highlightCode} | |
| 552 | compact={props.compact} | |
| 553 | /> | |
| 554 | ); | |
| 555 | case "table": | |
| 556 | return <NativeTable node={props.node} textStyle={props.textStyle} />; | |
| 557 | case "image": | |
| 558 | return <NativeMarkdownImage node={props.node} textStyle={props.textStyle} />; | |
| 559 | case "horizontal_rule": | |
| 560 | return ( | |
| 561 | <View | |
| 562 | style={{ | |
| 563 | height: 1, | |
| 564 | backgroundColor: props.textStyle.dividerColor, | |
| 565 | }} | |
| 566 | /> | |
| 567 | ); | |
| 568 | case "blockquote": | |
| 569 | return ( | |
| 570 | <View | |
| 571 | style={{ | |
| 572 | borderLeftColor: props.textStyle.quoteMarkerColor, | |
| 573 | borderLeftWidth: 2, | |
| 574 | marginVertical: props.compact ? 4 : 0, | |
| 575 | paddingLeft: 11, | |
| 576 | paddingVertical: 2, | |
| 577 | gap: 6, | |
| 578 | }} | |
| 579 | > | |
| 580 | {(props.node.children ?? []).map((child, index) => ( | |
| 581 | <NativeMarkdownBlock | |
| 582 | key={nodeKey(child, index)} | |
| 583 | node={child} | |
| 584 | textStyle={props.textStyle} | |
| 585 | highlightCode={props.highlightCode} | |
| 586 | depth={depth} | |
| 587 | compact | |
| 588 | /> | |
| 589 | ))} | |
| 590 | </View> | |
| 591 | ); | |
| 592 | case "list": | |
| 593 | return ( | |
| 594 | <NativeList | |
| 595 | node={props.node} | |
| 596 | textStyle={props.textStyle} | |
| 597 | highlightCode={props.highlightCode} | |
| 598 | depth={depth} | |
| 599 | /> | |
| 600 | ); | |
| 601 | case "paragraph": | |
| 602 | return (props.node.children ?? []).some((child) => child.type === "image") ? ( | |
| 603 | <NativeMixedParagraph node={props.node} textStyle={props.textStyle} /> | |
| 604 | ) : ( | |
| 605 | <SelectableNode node={props.node} textStyle={props.textStyle} /> | |
| 606 | ); | |
| 607 | case "html_block": | |
| 608 | case "math_block": | |
| 609 | return ( | |
| 610 | <View | |
| 611 | style={{ | |
| 612 | marginVertical: props.compact ? 2 : 0, | |
| 613 | paddingHorizontal: props.node.type === "math_block" ? 10 : 0, | |
| 614 | paddingVertical: props.node.type === "math_block" ? 8 : 0, | |
| 615 | backgroundColor: | |
| 616 | props.node.type === "math_block" | |
| 617 | ? props.textStyle.codeBackgroundColor | |
| 618 | : "transparent", | |
| 619 | }} | |
| 620 | > | |
| 621 | <SelectableNode node={props.node} textStyle={props.textStyle} /> | |
| 622 | </View> | |
| 623 | ); | |
| 624 | case "table_head": | |
| 625 | case "table_body": | |
| 626 | case "table_row": | |
| 627 | case "table_cell": | |
| 628 | case "list_item": | |
| 629 | case "task_list_item": | |
| 630 | return ( | |
| 631 | <View style={{ gap: 4 }}> | |
| 632 | {(props.node.children ?? []).map((child, index) => ( | |
| 633 | <NativeMarkdownBlock | |
| 634 | key={nodeKey(child, index)} | |
| 635 | node={child} | |
| 636 | textStyle={props.textStyle} | |
| 637 | highlightCode={props.highlightCode} | |
| 638 | depth={depth} | |
| 639 | compact | |
| 640 | /> | |
| 641 | ))} | |
| 642 | </View> | |
| 643 | ); | |
| 644 | default: | |
| 645 | return <SelectableNode node={props.node} textStyle={props.textStyle} />; | |
| 646 | } | |
| 647 | } |
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx created+257| ... | ... | @@ -0,0 +1,257 @@ |
| 1 | import { useEffect, useMemo, useState } from "react"; | |
| 2 | import { Asset } from "expo-asset"; | |
| 3 | import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; | |
| 4 | ||
| 5 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; | |
| 6 | import { markdownFileIconSource } from "./markdownFileIcons"; | |
| 7 | import type { MarkdownFileIcon } from "./markdownLinks"; | |
| 8 | import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; | |
| 9 | import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; | |
| 10 | ||
| 11 | const EXTERNAL_LINK_PREFIX = "◉ "; | |
| 12 | const FILE_LINK_PREFIX = "\u00A0\uFFFC\u00A0"; | |
| 13 | const CHIP_SUFFIX = "\u00A0"; | |
| 14 | const SKILL_ICON_PLACEHOLDER = "\uFFFC"; | |
| 15 | const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; | |
| 16 | ||
| 17 | function useFileIconUris(runs: ReadonlyArray<NativeMarkdownTextRun>) { | |
| 18 | const iconSignature = JSON.stringify( | |
| 19 | [...new Set(runs.flatMap((run) => (run.fileIcon ? [run.fileIcon] : [])))].sort(), | |
| 20 | ); | |
| 21 | const icons = useMemo( | |
| 22 | () => JSON.parse(iconSignature) as ReadonlyArray<MarkdownFileIcon>, | |
| 23 | [iconSignature], | |
| 24 | ); | |
| 25 | const [uris, setUris] = useState<ReadonlyMap<string, string>>(() => new Map()); | |
| 26 | ||
| 27 | useEffect(() => { | |
| 28 | let cancelled = false; | |
| 29 | ||
| 30 | void Promise.all( | |
| 31 | icons.map(async (icon) => { | |
| 32 | const source = markdownFileIconSource(icon); | |
| 33 | const fallbackUri = Image.resolveAssetSource(source).uri; | |
| 34 | if (typeof source !== "number" && typeof source !== "string") { | |
| 35 | return [icon, fallbackUri] as const; | |
| 36 | } | |
| 37 | try { | |
| 38 | const asset = Asset.fromModule(source); | |
| 39 | await asset.downloadAsync(); | |
| 40 | return [icon, asset.localUri ?? fallbackUri] as const; | |
| 41 | } catch { | |
| 42 | return [icon, fallbackUri] as const; | |
| 43 | } | |
| 44 | }), | |
| 45 | ).then((entries) => { | |
| 46 | if (!cancelled) { | |
| 47 | setUris(new Map(entries)); | |
| 48 | } | |
| 49 | }); | |
| 50 | ||
| 51 | return () => { | |
| 52 | cancelled = true; | |
| 53 | }; | |
| 54 | }, [icons]); | |
| 55 | ||
| 56 | return uris; | |
| 57 | } | |
| 58 | ||
| 59 | function runKeySignature(run: NativeMarkdownTextRun): string { | |
| 60 | return [ | |
| 61 | run.text, | |
| 62 | run.bold, | |
| 63 | run.italic, | |
| 64 | run.strikethrough, | |
| 65 | run.code, | |
| 66 | run.href, | |
| 67 | run.externalHost, | |
| 68 | run.fileIcon, | |
| 69 | run.skillName, | |
| 70 | run.skillLabel, | |
| 71 | run.role, | |
| 72 | run.headingLevel, | |
| 73 | run.depth, | |
| 74 | run.spacing, | |
| 75 | run.firstLineHeadIndent, | |
| 76 | run.headIndent, | |
| 77 | run.paragraphSpacing, | |
| 78 | ].join(":"); | |
| 79 | } | |
| 80 | ||
| 81 | function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { | |
| 82 | const isFile = run.fileIcon != null; | |
| 83 | const isSkill = run.skillName != null; | |
| 84 | const isChip = isFile || isSkill; | |
| 85 | const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); | |
| 86 | const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15; | |
| 87 | const isHeading = run.role === "heading"; | |
| 88 | const isCodeBlock = run.role === "code-block" || run.role === "code-language"; | |
| 89 | const hasParagraphStyle = run.headIndent !== undefined; | |
| 90 | const textDecorationLine = run.strikethrough ? "line-through" : run.href ? "underline" : "none"; | |
| 91 | ||
| 92 | return { | |
| 93 | color: isFile | |
| 94 | ? textStyle.fileTextColor | |
| 95 | : isSkill | |
| 96 | ? textStyle.skillTextColor | |
| 97 | : run.href | |
| 98 | ? textStyle.linkColor | |
| 99 | : isHeading | |
| 100 | ? textStyle.strongColor | |
| 101 | : run.role === "quote-marker" | |
| 102 | ? textStyle.quoteMarkerColor | |
| 103 | : run.role === "divider" | |
| 104 | ? textStyle.dividerColor | |
| 105 | : run.role === "code-language" | |
| 106 | ? textStyle.mutedColor | |
| 107 | : run.role === "list-marker" | |
| 108 | ? textStyle.mutedColor | |
| 109 | : run.code || isFile | |
| 110 | ? textStyle.codeColor | |
| 111 | : run.bold | |
| 112 | ? textStyle.strongColor | |
| 113 | : textStyle.color, | |
| 114 | fontFamily: isChip | |
| 115 | ? "DMSans_500Medium" | |
| 116 | : run.code || isCodeBlock | |
| 117 | ? "ui-monospace" | |
| 118 | : isHeading | |
| 119 | ? textStyle.headingFontFamily | |
| 120 | : run.bold | |
| 121 | ? textStyle.boldFontFamily | |
| 122 | : textStyle.fontFamily, | |
| 123 | fontSize: | |
| 124 | run.role === "spacer" | |
| 125 | ? (run.spacing ?? 10) | |
| 126 | : run.role === "list-break" | |
| 127 | ? textStyle.fontSize | |
| 128 | : isHeading | |
| 129 | ? headingFontSize | |
| 130 | : run.role === "code-language" | |
| 131 | ? 11 | |
| 132 | : run.code || isChip || isCodeBlock | |
| 133 | ? Math.max(12, textStyle.fontSize - 2) | |
| 134 | : textStyle.fontSize, | |
| 135 | lineHeight: | |
| 136 | run.role === "spacer" | |
| 137 | ? (run.spacing ?? 10) | |
| 138 | : run.role === "list-break" | |
| 139 | ? textStyle.lineHeight + (run.spacing ?? 0) | |
| 140 | : isHeading | |
| 141 | ? Math.max(headingFontSize + 6, 20) | |
| 142 | : isCodeBlock | |
| 143 | ? 18 | |
| 144 | : textStyle.lineHeight, | |
| 145 | fontStyle: run.italic ? "italic" : "normal", | |
| 146 | fontWeight: isHeading || run.bold ? "700" : isChip ? "500" : "400", | |
| 147 | textDecorationLine, | |
| 148 | backgroundColor: isCodeBlock | |
| 149 | ? textStyle.codeBlockBackgroundColor | |
| 150 | : isSkill | |
| 151 | ? textStyle.skillBackgroundColor | |
| 152 | : run.code | |
| 153 | ? textStyle.codeBackgroundColor | |
| 154 | : isFile | |
| 155 | ? textStyle.fileBackgroundColor | |
| 156 | : undefined, | |
| 157 | ...(hasParagraphStyle | |
| 158 | ? { | |
| 159 | shadowColor: "transparent", | |
| 160 | shadowOffset: { | |
| 161 | width: run.firstLineHeadIndent ?? 0, | |
| 162 | height: run.headIndent, | |
| 163 | }, | |
| 164 | shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0), | |
| 165 | } | |
| 166 | : {}), | |
| 167 | }; | |
| 168 | } | |
| 169 | ||
| 170 | export function NativeMarkdownSelectableText(props: { | |
| 171 | readonly runs: ReadonlyArray<NativeMarkdownTextRun>; | |
| 172 | readonly textStyle: NativeMarkdownTextStyle; | |
| 173 | }) { | |
| 174 | const colorScheme = useColorScheme(); | |
| 175 | const fileIconUris = useFileIconUris(props.runs); | |
| 176 | const occurrences = new Map<string, number>(); | |
| 177 | const prefixedExternalLinks = new Set<string>(); | |
| 178 | const keyedRuns = props.runs.map((run) => { | |
| 179 | const signature = runKeySignature(run); | |
| 180 | const occurrence = occurrences.get(signature) ?? 0; | |
| 181 | occurrences.set(signature, occurrence + 1); | |
| 182 | ||
| 183 | let text = run.text; | |
| 184 | if (run.fileIcon) { | |
| 185 | text = `${FILE_LINK_PREFIX}${text}${CHIP_SUFFIX}`; | |
| 186 | } else if (run.skillName && run.skillLabel) { | |
| 187 | text = `\u00A0${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}${CHIP_SUFFIX}`; | |
| 188 | } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { | |
| 189 | prefixedExternalLinks.add(run.href); | |
| 190 | text = `${EXTERNAL_LINK_PREFIX}${text}`; | |
| 191 | } | |
| 192 | ||
| 193 | return { key: `${signature}:${occurrence}`, run, text }; | |
| 194 | }); | |
| 195 | // T3MarkdownText only rebuilds its attributed string during native layout. A | |
| 196 | // color-only child update can otherwise leave the previous appearance cached. | |
| 197 | const appearanceKey = [ | |
| 198 | colorScheme ?? "unspecified", | |
| 199 | props.textStyle.color, | |
| 200 | props.textStyle.strongColor, | |
| 201 | props.textStyle.mutedColor, | |
| 202 | props.textStyle.linkColor, | |
| 203 | props.textStyle.codeColor, | |
| 204 | props.textStyle.codeBackgroundColor, | |
| 205 | props.textStyle.codeBlockBackgroundColor, | |
| 206 | props.textStyle.fileBackgroundColor, | |
| 207 | props.textStyle.fileTextColor, | |
| 208 | props.textStyle.skillBackgroundColor, | |
| 209 | props.textStyle.skillTextColor, | |
| 210 | props.textStyle.quoteMarkerColor, | |
| 211 | props.textStyle.dividerColor, | |
| 212 | ].join(":"); | |
| 213 | ||
| 214 | return ( | |
| 215 | <MarkdownTextPrimitive | |
| 216 | key={appearanceKey} | |
| 217 | uiTextView | |
| 218 | selectable | |
| 219 | style={{ | |
| 220 | width: "100%", | |
| 221 | color: props.textStyle.color, | |
| 222 | fontFamily: props.textStyle.fontFamily, | |
| 223 | fontSize: props.textStyle.fontSize, | |
| 224 | lineHeight: props.textStyle.lineHeight, | |
| 225 | }} | |
| 226 | > | |
| 227 | {keyedRuns.map(({ key, run, text }) => { | |
| 228 | const href = run.href; | |
| 229 | return ( | |
| 230 | <MarkdownTextPrimitive | |
| 231 | key={key} | |
| 232 | nativeID={ | |
| 233 | run.fileIcon | |
| 234 | ? `t3-chip-file:${ | |
| 235 | fileIconUris.get(run.fileIcon) ?? | |
| 236 | Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri | |
| 237 | }` | |
| 238 | : run.skillName | |
| 239 | ? "t3-chip-skill:sf:cube" | |
| 240 | : undefined | |
| 241 | } | |
| 242 | style={runStyle(run, props.textStyle)} | |
| 243 | onPress={ | |
| 244 | href | |
| 245 | ? () => { | |
| 246 | void Linking.openURL(href); | |
| 247 | } | |
| 248 | : undefined | |
| 249 | } | |
| 250 | > | |
| 251 | {text} | |
| 252 | </MarkdownTextPrimitive> | |
| 253 | ); | |
| 254 | })} | |
| 255 | </MarkdownTextPrimitive> | |
| 256 | ); | |
| 257 | } |
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import { useMemo } from "react"; | |
| 2 | import { View } from "react-native"; | |
| 3 | import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; | |
| 4 | ||
| 5 | import { | |
| 6 | nativeMarkdownChunkSpacing, | |
| 7 | nativeMarkdownDocumentChunks, | |
| 8 | nativeMarkdownDocumentRuns, | |
| 9 | } from "./nativeMarkdownText"; | |
| 10 | import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; | |
| 11 | import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; | |
| 12 | import type { | |
| 13 | SelectableMarkdownSkill, | |
| 14 | SelectableMarkdownTextProps, | |
| 15 | } from "./SelectableMarkdownText.types"; | |
| 16 | ||
| 17 | const EMPTY_SKILLS: ReadonlyArray<SelectableMarkdownSkill> = []; | |
| 18 | ||
| 19 | export type { | |
| 20 | MarkdownCodeHighlighter, | |
| 21 | MarkdownHighlightedToken, | |
| 22 | NativeMarkdownTextStyle, | |
| 23 | SelectableMarkdownSkill, | |
| 24 | SelectableMarkdownTextProps, | |
| 25 | } from "./SelectableMarkdownText.types"; | |
| 26 | ||
| 27 | export function hasNativeSelectableMarkdownText(): boolean { | |
| 28 | return true; | |
| 29 | } | |
| 30 | ||
| 31 | export function SelectableMarkdownText({ | |
| 32 | markdown, | |
| 33 | skills = EMPTY_SKILLS, | |
| 34 | textStyle, | |
| 35 | highlightCode, | |
| 36 | marginTop = 0, | |
| 37 | marginBottom = 0, | |
| 38 | }: SelectableMarkdownTextProps) { | |
| 39 | const chunks = useMemo(() => { | |
| 40 | const document = parseMarkdownWithOptions(markdown, { | |
| 41 | gfm: true, | |
| 42 | html: true, | |
| 43 | math: false, | |
| 44 | }); | |
| 45 | return nativeMarkdownDocumentChunks(document).map((chunk) => | |
| 46 | chunk.kind === "selectable" | |
| 47 | ? { | |
| 48 | ...chunk, | |
| 49 | runs: nativeMarkdownDocumentRuns(chunk.node, skills), | |
| 50 | } | |
| 51 | : chunk, | |
| 52 | ); | |
| 53 | }, [markdown, skills]); | |
| 54 | ||
| 55 | return ( | |
| 56 | <View style={{ width: "100%", marginTop, marginBottom }}> | |
| 57 | {chunks.map((chunk, index) => { | |
| 58 | const content = | |
| 59 | chunk.kind === "rich" ? ( | |
| 60 | <NativeMarkdownBlock | |
| 61 | node={chunk.node} | |
| 62 | textStyle={textStyle} | |
| 63 | highlightCode={highlightCode} | |
| 64 | /> | |
| 65 | ) : ( | |
| 66 | <NativeMarkdownSelectableText runs={chunk.runs} textStyle={textStyle} /> | |
| 67 | ); | |
| 68 | ||
| 69 | return ( | |
| 70 | <View | |
| 71 | key={chunk.key} | |
| 72 | style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }} | |
| 73 | > | |
| 74 | {content} | |
| 75 | </View> | |
| 76 | ); | |
| 77 | })} | |
| 78 | </View> | |
| 79 | ); | |
| 80 | } |
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; | |
| 2 | ||
| 3 | export type { | |
| 4 | MarkdownCodeHighlighter, | |
| 5 | MarkdownHighlightedToken, | |
| 6 | NativeMarkdownTextStyle, | |
| 7 | SelectableMarkdownSkill, | |
| 8 | SelectableMarkdownTextProps, | |
| 9 | } from "./SelectableMarkdownText.types"; | |
| 10 | ||
| 11 | export function SelectableMarkdownText(_props: SelectableMarkdownTextProps) { | |
| 12 | return null; | |
| 13 | } |
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts created+46| ... | ... | @@ -0,0 +1,46 @@ |
| 1 | export interface NativeMarkdownTextStyle { | |
| 2 | readonly color: string; | |
| 3 | readonly strongColor: string; | |
| 4 | readonly mutedColor: string; | |
| 5 | readonly linkColor: string; | |
| 6 | readonly codeColor: string; | |
| 7 | readonly codeBackgroundColor: string; | |
| 8 | readonly codeBlockBackgroundColor: string; | |
| 9 | readonly fileBackgroundColor: string; | |
| 10 | readonly fileTextColor: string; | |
| 11 | readonly skillBackgroundColor: string; | |
| 12 | readonly skillTextColor: string; | |
| 13 | readonly quoteMarkerColor: string; | |
| 14 | readonly dividerColor: string; | |
| 15 | readonly fontSize: number; | |
| 16 | readonly lineHeight: number; | |
| 17 | readonly fontFamily: string; | |
| 18 | readonly headingFontFamily: string; | |
| 19 | readonly boldFontFamily: string; | |
| 20 | } | |
| 21 | ||
| 22 | export interface MarkdownHighlightedToken { | |
| 23 | readonly content: string; | |
| 24 | readonly color: string | null; | |
| 25 | readonly fontStyle: number | null; | |
| 26 | } | |
| 27 | ||
| 28 | export type MarkdownCodeHighlighter = (input: { | |
| 29 | readonly code: string; | |
| 30 | readonly language?: string | null; | |
| 31 | readonly theme: "light" | "dark"; | |
| 32 | }) => Promise<ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>>; | |
| 33 | ||
| 34 | export interface SelectableMarkdownSkill { | |
| 35 | readonly name: string; | |
| 36 | readonly displayName?: string | null; | |
| 37 | } | |
| 38 | ||
| 39 | export interface SelectableMarkdownTextProps { | |
| 40 | readonly markdown: string; | |
| 41 | readonly textStyle: NativeMarkdownTextStyle; | |
| 42 | readonly highlightCode: MarkdownCodeHighlighter; | |
| 43 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; | |
| 44 | readonly marginTop?: number; | |
| 45 | readonly marginBottom?: number; | |
| 46 | } |
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts created+55| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent"; | |
| 2 | import type { ViewProps } from "react-native"; | |
| 3 | import type { | |
| 4 | BubblingEventHandler, | |
| 5 | Int32, | |
| 6 | WithDefault, | |
| 7 | } from "react-native/Libraries/Types/CodegenTypes"; | |
| 8 | ||
| 9 | interface TargetedEvent { | |
| 10 | target: Int32; | |
| 11 | } | |
| 12 | ||
| 13 | interface TextLayoutEvent extends TargetedEvent { | |
| 14 | lines: string[]; | |
| 15 | } | |
| 16 | ||
| 17 | /** | |
| 18 | * Event fired when text selection changes in the MarkdownTextPrimitive. | |
| 19 | * @property target - The view tag identifier | |
| 20 | * @property start - The start index of the selected range (0-based) | |
| 21 | * @property end - The end index of the selected range (0-based, exclusive) | |
| 22 | */ | |
| 23 | interface SelectionChangeEvent extends TargetedEvent { | |
| 24 | start: Int32; | |
| 25 | end: Int32; | |
| 26 | } | |
| 27 | ||
| 28 | type EllipsizeMode = "head" | "middle" | "tail" | "clip"; | |
| 29 | ||
| 30 | interface NativeProps extends ViewProps { | |
| 31 | numberOfLines?: Int32; | |
| 32 | allowFontScaling?: WithDefault<boolean, true>; | |
| 33 | ellipsizeMode?: WithDefault<EllipsizeMode, "tail">; | |
| 34 | selectable?: boolean; | |
| 35 | onTextLayout?: BubblingEventHandler<TextLayoutEvent>; | |
| 36 | /** | |
| 37 | * Callback fired when the text selection changes. | |
| 38 | * | |
| 39 | * @example | |
| 40 | * ```tsx | |
| 41 | * <MarkdownTextPrimitive | |
| 42 | * onSelectionChange={(event) => { | |
| 43 | * console.log('Selection:', event.nativeEvent.start, event.nativeEvent.end); | |
| 44 | * }} | |
| 45 | * > | |
| 46 | * Selectable text | |
| 47 | * </MarkdownTextPrimitive> | |
| 48 | * ``` | |
| 49 | */ | |
| 50 | onSelectionChange?: BubblingEventHandler<SelectionChangeEvent>; | |
| 51 | } | |
| 52 | ||
| 53 | export default codegenNativeComponent<NativeProps>("T3MarkdownText", { | |
| 54 | excludedPlatforms: ["android"], | |
| 55 | }); |
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | import type { ColorValue, ViewProps } from "react-native"; | |
| 2 | import type { | |
| 3 | BubblingEventHandler, | |
| 4 | Float, | |
| 5 | Int32, | |
| 6 | WithDefault, | |
| 7 | } from "react-native/Libraries/Types/CodegenTypes"; | |
| 8 | import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent"; | |
| 9 | ||
| 10 | interface TargetedEvent { | |
| 11 | target: Int32; | |
| 12 | } | |
| 13 | ||
| 14 | type TextDecorationLine = "none" | "underline" | "line-through"; | |
| 15 | ||
| 16 | type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed"; | |
| 17 | ||
| 18 | export type NativeFontWeight = | |
| 19 | | "normal" | |
| 20 | | "bold" | |
| 21 | | "ultraLight" | |
| 22 | | "light" | |
| 23 | | "medium" | |
| 24 | | "semibold" | |
| 25 | | "heavy"; | |
| 26 | ||
| 27 | type FontStyle = "normal" | "italic"; | |
| 28 | ||
| 29 | type TextAlign = "auto" | "left" | "right" | "center" | "justify"; | |
| 30 | ||
| 31 | interface NativeProps extends ViewProps { | |
| 32 | text: string; | |
| 33 | color?: ColorValue; | |
| 34 | fontSize?: Float; | |
| 35 | fontStyle?: WithDefault<FontStyle, "normal">; | |
| 36 | fontWeight?: WithDefault<NativeFontWeight, "normal">; | |
| 37 | fontFamily?: string; | |
| 38 | letterSpacing?: Float; | |
| 39 | lineHeight?: Float; | |
| 40 | textDecorationLine?: WithDefault<TextDecorationLine, "none">; | |
| 41 | textDecorationStyle?: WithDefault<TextDecorationStyle, "solid">; | |
| 42 | textDecorationColor?: ColorValue; | |
| 43 | textAlign?: WithDefault<TextAlign, "auto">; | |
| 44 | shadowRadius?: WithDefault<Float, 0>; | |
| 45 | onPress?: BubblingEventHandler<TargetedEvent>; | |
| 46 | onLongPress?: BubblingEventHandler<TargetedEvent>; | |
| 47 | } | |
| 48 | ||
| 49 | export default codegenNativeComponent<NativeProps>("T3MarkdownTextRun", { | |
| 50 | excludedPlatforms: ["android"], | |
| 51 | }); |
apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts created+62| ... | ... | @@ -0,0 +1,62 @@ |
| 1 | import type { ImageSourcePropType } from "react-native"; | |
| 2 | ||
| 3 | export const MARKDOWN_FILE_ICON_SOURCES = { | |
| 4 | agents: require("../assets/file-icons/pierre_agents.png"), | |
| 5 | astro: require("../assets/file-icons/pierre_astro.png"), | |
| 6 | babel: require("../assets/file-icons/pierre_babel.png"), | |
| 7 | bash: require("../assets/file-icons/pierre_bash.png"), | |
| 8 | biome: require("../assets/file-icons/pierre_biome.png"), | |
| 9 | bootstrap: require("../assets/file-icons/pierre_bootstrap.png"), | |
| 10 | browserslist: require("../assets/file-icons/pierre_browserslist.png"), | |
| 11 | bun: require("../assets/file-icons/pierre_bun.png"), | |
| 12 | c: require("../assets/file-icons/pierre_c.png"), | |
| 13 | claude: require("../assets/file-icons/pierre_claude.png"), | |
| 14 | cpp: require("../assets/file-icons/pierre_cpp.png"), | |
| 15 | css: require("../assets/file-icons/pierre_css.png"), | |
| 16 | database: require("../assets/file-icons/pierre_database.png"), | |
| 17 | default: require("../assets/file-icons/pierre_default.png"), | |
| 18 | docker: require("../assets/file-icons/pierre_docker.png"), | |
| 19 | eslint: require("../assets/file-icons/pierre_eslint.png"), | |
| 20 | font: require("../assets/file-icons/pierre_font.png"), | |
| 21 | git: require("../assets/file-icons/pierre_git.png"), | |
| 22 | go: require("../assets/file-icons/pierre_go.png"), | |
| 23 | graphql: require("../assets/file-icons/pierre_graphql.png"), | |
| 24 | html: require("../assets/file-icons/pierre_html.png"), | |
| 25 | image: require("../assets/file-icons/pierre_image.png"), | |
| 26 | javascript: require("../assets/file-icons/pierre_javascript.png"), | |
| 27 | json: require("../assets/file-icons/pierre_json.png"), | |
| 28 | markdown: require("../assets/file-icons/pierre_markdown.png"), | |
| 29 | mcp: require("../assets/file-icons/pierre_mcp.png"), | |
| 30 | nextjs: require("../assets/file-icons/pierre_nextjs.png"), | |
| 31 | npm: require("../assets/file-icons/pierre_npm.png"), | |
| 32 | oxc: require("../assets/file-icons/pierre_oxc.png"), | |
| 33 | package: require("../assets/file-icons/pierre_package.png"), | |
| 34 | pnpm: require("../assets/file-icons/pierre_pnpm.png"), | |
| 35 | postcss: require("../assets/file-icons/pierre_postcss.png"), | |
| 36 | prettier: require("../assets/file-icons/pierre_prettier.png"), | |
| 37 | python: require("../assets/file-icons/pierre_python.png"), | |
| 38 | react: require("../assets/file-icons/pierre_react.png"), | |
| 39 | readme: require("../assets/file-icons/pierre_readme.png"), | |
| 40 | ruby: require("../assets/file-icons/pierre_ruby.png"), | |
| 41 | rust: require("../assets/file-icons/pierre_rust.png"), | |
| 42 | sass: require("../assets/file-icons/pierre_sass.png"), | |
| 43 | stylelint: require("../assets/file-icons/pierre_stylelint.png"), | |
| 44 | svelte: require("../assets/file-icons/pierre_svelte.png"), | |
| 45 | svg: require("../assets/file-icons/pierre_svg.png"), | |
| 46 | svgo: require("../assets/file-icons/pierre_svgo.png"), | |
| 47 | swift: require("../assets/file-icons/pierre_swift.png"), | |
| 48 | table: require("../assets/file-icons/pierre_table.png"), | |
| 49 | tailwind: require("../assets/file-icons/pierre_tailwind.png"), | |
| 50 | terraform: require("../assets/file-icons/pierre_terraform.png"), | |
| 51 | text: require("../assets/file-icons/pierre_text.png"), | |
| 52 | tsconfig: require("../assets/file-icons/pierre_tsconfig.png"), | |
| 53 | typescript: require("../assets/file-icons/pierre_typescript.png"), | |
| 54 | vite: require("../assets/file-icons/pierre_vite.png"), | |
| 55 | vscode: require("../assets/file-icons/pierre_vscode.png"), | |
| 56 | vue: require("../assets/file-icons/pierre_vue.png"), | |
| 57 | wasm: require("../assets/file-icons/pierre_wasm.png"), | |
| 58 | webpack: require("../assets/file-icons/pierre_webpack.png"), | |
| 59 | yml: require("../assets/file-icons/pierre_yml.png"), | |
| 60 | zig: require("../assets/file-icons/pierre_zig.png"), | |
| 61 | zip: require("../assets/file-icons/pierre_zip.png"), | |
| 62 | } as const satisfies Readonly<Record<string, ImageSourcePropType>>; |
apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts created+8| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | import type { ImageSourcePropType } from "react-native"; | |
| 2 | ||
| 3 | import type { MarkdownFileIcon } from "./markdownLinks"; | |
| 4 | import { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; | |
| 5 | ||
| 6 | export function markdownFileIconSource(icon: MarkdownFileIcon): ImageSourcePropType { | |
| 7 | return MARKDOWN_FILE_ICON_SOURCES[icon]; | |
| 8 | } |
apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts created+349| ... | ... | @@ -0,0 +1,349 @@ |
| 1 | import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; | |
| 2 | ||
| 3 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; | |
| 4 | const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; | |
| 5 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; | |
| 6 | const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; | |
| 7 | const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; | |
| 8 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | |
| 9 | const POSIX_FILE_ROOT_PREFIXES = [ | |
| 10 | "/Users/", | |
| 11 | "/home/", | |
| 12 | "/tmp/", | |
| 13 | "/var/", | |
| 14 | "/etc/", | |
| 15 | "/opt/", | |
| 16 | "/mnt/", | |
| 17 | "/Volumes/", | |
| 18 | "/private/", | |
| 19 | "/root/", | |
| 20 | ] as const; | |
| 21 | ||
| 22 | export type MarkdownLinkPresentation = | |
| 23 | | { | |
| 24 | readonly kind: "external"; | |
| 25 | readonly href: string; | |
| 26 | readonly host: string; | |
| 27 | } | |
| 28 | | { | |
| 29 | readonly kind: "file"; | |
| 30 | readonly icon: MarkdownFileIcon; | |
| 31 | readonly label: string; | |
| 32 | } | |
| 33 | | { | |
| 34 | readonly kind: "link"; | |
| 35 | readonly href: string | null; | |
| 36 | }; | |
| 37 | ||
| 38 | export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; | |
| 39 | ||
| 40 | const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = { | |
| 41 | ".babelrc": "babel", | |
| 42 | ".babelrc.json": "babel", | |
| 43 | ".bash_profile": "bash", | |
| 44 | ".bashrc": "bash", | |
| 45 | ".browserslistrc": "browserslist", | |
| 46 | ".dockerignore": "docker", | |
| 47 | ".eslintignore": "eslint", | |
| 48 | ".eslintrc": "eslint", | |
| 49 | ".eslintrc.cjs": "eslint", | |
| 50 | ".eslintrc.js": "eslint", | |
| 51 | ".eslintrc.json": "eslint", | |
| 52 | ".eslintrc.yaml": "eslint", | |
| 53 | ".eslintrc.yml": "eslint", | |
| 54 | ".gitattributes": "git", | |
| 55 | ".gitignore": "git", | |
| 56 | ".gitkeep": "git", | |
| 57 | ".gitmodules": "git", | |
| 58 | ".oxlintrc.json": "oxc", | |
| 59 | ".postcssrc": "postcss", | |
| 60 | ".postcssrc.json": "postcss", | |
| 61 | ".postcssrc.yaml": "postcss", | |
| 62 | ".postcssrc.yml": "postcss", | |
| 63 | ".prettierignore": "prettier", | |
| 64 | ".prettierrc": "prettier", | |
| 65 | ".prettierrc.json": "prettier", | |
| 66 | ".prettierrc.cjs": "prettier", | |
| 67 | ".prettierrc.js": "prettier", | |
| 68 | ".prettierrc.mjs": "prettier", | |
| 69 | ".prettierrc.toml": "prettier", | |
| 70 | ".prettierrc.yaml": "prettier", | |
| 71 | ".prettierrc.yml": "prettier", | |
| 72 | ".stylelintignore": "stylelint", | |
| 73 | ".stylelintrc": "stylelint", | |
| 74 | ".stylelintrc.cjs": "stylelint", | |
| 75 | ".stylelintrc.js": "stylelint", | |
| 76 | ".stylelintrc.json": "stylelint", | |
| 77 | ".stylelintrc.mjs": "stylelint", | |
| 78 | ".stylelintrc.yaml": "stylelint", | |
| 79 | ".stylelintrc.yml": "stylelint", | |
| 80 | ".terraform.lock.hcl": "terraform", | |
| 81 | ".zprofile": "bash", | |
| 82 | ".zshenv": "bash", | |
| 83 | ".zshrc": "bash", | |
| 84 | "agents.md": "agents", | |
| 85 | "babel.config.js": "babel", | |
| 86 | "babel.config.cjs": "babel", | |
| 87 | "babel.config.json": "babel", | |
| 88 | "babel.config.mjs": "babel", | |
| 89 | "biome.json": "biome", | |
| 90 | "biome.jsonc": "biome", | |
| 91 | "bun.lock": "bun", | |
| 92 | "bun.lockb": "bun", | |
| 93 | "bunfig.toml": "bun", | |
| 94 | "claude.md": "claude", | |
| 95 | "compose.yaml": "docker", | |
| 96 | "compose.yml": "docker", | |
| 97 | "docker-compose.yaml": "docker", | |
| 98 | "docker-compose.yml": "docker", | |
| 99 | "docker-compose.override.yml": "docker", | |
| 100 | dockerfile: "docker", | |
| 101 | "eslint.config.js": "eslint", | |
| 102 | "eslint.config.cjs": "eslint", | |
| 103 | "eslint.config.mjs": "eslint", | |
| 104 | "eslint.config.mts": "eslint", | |
| 105 | "eslint.config.ts": "eslint", | |
| 106 | gemfile: "ruby", | |
| 107 | "next.config.js": "nextjs", | |
| 108 | "next.config.mjs": "nextjs", | |
| 109 | "next.config.mts": "nextjs", | |
| 110 | "next.config.ts": "nextjs", | |
| 111 | "package.json": "package", | |
| 112 | "pnpm-lock.yaml": "pnpm", | |
| 113 | "pnpm-workspace.yaml": "pnpm", | |
| 114 | "postcss.config.js": "postcss", | |
| 115 | "postcss.config.cjs": "postcss", | |
| 116 | "postcss.config.mjs": "postcss", | |
| 117 | "postcss.config.ts": "postcss", | |
| 118 | "prettier.config.js": "prettier", | |
| 119 | "prettier.config.cjs": "prettier", | |
| 120 | "prettier.config.mjs": "prettier", | |
| 121 | rakefile: "ruby", | |
| 122 | "readme.md": "readme", | |
| 123 | "stylelint.config.js": "stylelint", | |
| 124 | "stylelint.config.cjs": "stylelint", | |
| 125 | "stylelint.config.mjs": "stylelint", | |
| 126 | "svgo.config.js": "svgo", | |
| 127 | "svgo.config.cjs": "svgo", | |
| 128 | "svgo.config.mjs": "svgo", | |
| 129 | "svgo.config.ts": "svgo", | |
| 130 | "tailwind.config.js": "tailwind", | |
| 131 | "tailwind.config.cjs": "tailwind", | |
| 132 | "tailwind.config.mjs": "tailwind", | |
| 133 | "tailwind.config.ts": "tailwind", | |
| 134 | "tsconfig.json": "tsconfig", | |
| 135 | "vite.config.js": "vite", | |
| 136 | "vite.config.mjs": "vite", | |
| 137 | "vite.config.mts": "vite", | |
| 138 | "vite.config.ts": "vite", | |
| 139 | "webpack.config.js": "webpack", | |
| 140 | "webpack.config.babel.js": "webpack", | |
| 141 | "webpack.config.cjs": "webpack", | |
| 142 | "webpack.config.mjs": "webpack", | |
| 143 | "webpack.config.ts": "webpack", | |
| 144 | }; | |
| 145 | ||
| 146 | const FILE_ICON_BY_EXTENSION: Readonly<Record<string, MarkdownFileIcon>> = { | |
| 147 | "7z": "zip", | |
| 148 | astro: "astro", | |
| 149 | avif: "image", | |
| 150 | "code-workspace": "vscode", | |
| 151 | bash: "bash", | |
| 152 | bmp: "image", | |
| 153 | bz2: "zip", | |
| 154 | c: "c", | |
| 155 | cc: "cpp", | |
| 156 | cpp: "cpp", | |
| 157 | cxx: "cpp", | |
| 158 | css: "css", | |
| 159 | csv: "table", | |
| 160 | cts: "typescript", | |
| 161 | db: "database", | |
| 162 | env: "text", | |
| 163 | "env.development": "text", | |
| 164 | "env.local": "text", | |
| 165 | "env.production": "text", | |
| 166 | eot: "font", | |
| 167 | erb: "ruby", | |
| 168 | fish: "bash", | |
| 169 | gif: "image", | |
| 170 | go: "go", | |
| 171 | gql: "graphql", | |
| 172 | graphql: "graphql", | |
| 173 | gz: "zip", | |
| 174 | h: "c", | |
| 175 | hh: "cpp", | |
| 176 | hpp: "cpp", | |
| 177 | hxx: "cpp", | |
| 178 | htm: "html", | |
| 179 | html: "html", | |
| 180 | ico: "image", | |
| 181 | icns: "image", | |
| 182 | ini: "text", | |
| 183 | inl: "cpp", | |
| 184 | jar: "zip", | |
| 185 | jpeg: "image", | |
| 186 | jpg: "image", | |
| 187 | js: "javascript", | |
| 188 | jsx: "react", | |
| 189 | json: "json", | |
| 190 | jsonc: "json", | |
| 191 | less: "css", | |
| 192 | md: "markdown", | |
| 193 | mdx: "markdown", | |
| 194 | "mdx.tsx": "markdown", | |
| 195 | mjs: "javascript", | |
| 196 | mts: "typescript", | |
| 197 | png: "image", | |
| 198 | postcss: "css", | |
| 199 | py: "python", | |
| 200 | pyi: "python", | |
| 201 | pyw: "python", | |
| 202 | pyx: "python", | |
| 203 | rake: "ruby", | |
| 204 | rar: "zip", | |
| 205 | rb: "ruby", | |
| 206 | rs: "rust", | |
| 207 | sass: "sass", | |
| 208 | scss: "sass", | |
| 209 | sh: "bash", | |
| 210 | sql: "database", | |
| 211 | sqlite: "database", | |
| 212 | sqlite3: "database", | |
| 213 | svelte: "svelte", | |
| 214 | svg: "svg", | |
| 215 | swift: "swift", | |
| 216 | tar: "zip", | |
| 217 | tf: "terraform", | |
| 218 | tfstate: "terraform", | |
| 219 | tfvars: "terraform", | |
| 220 | tgz: "zip", | |
| 221 | ts: "typescript", | |
| 222 | tsv: "table", | |
| 223 | tsx: "react", | |
| 224 | txt: "text", | |
| 225 | woff: "font", | |
| 226 | woff2: "font", | |
| 227 | vue: "vue", | |
| 228 | wasm: "wasm", | |
| 229 | webp: "image", | |
| 230 | yml: "yml", | |
| 231 | yaml: "yml", | |
| 232 | zig: "zig", | |
| 233 | zip: "zip", | |
| 234 | zsh: "bash", | |
| 235 | }; | |
| 236 | ||
| 237 | function safeDecode(value: string): string { | |
| 238 | try { | |
| 239 | return decodeURIComponent(value); | |
| 240 | } catch { | |
| 241 | return value; | |
| 242 | } | |
| 243 | } | |
| 244 | ||
| 245 | function normalizeDestination(value: string): string { | |
| 246 | const trimmed = value.trim(); | |
| 247 | return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; | |
| 248 | } | |
| 249 | ||
| 250 | function fileUrlPath(href: string): string | null { | |
| 251 | try { | |
| 252 | const parsed = new URL(href); | |
| 253 | if (parsed.protocol.toLowerCase() !== "file:") { | |
| 254 | return null; | |
| 255 | } | |
| 256 | const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname) | |
| 257 | ? parsed.pathname.slice(1) | |
| 258 | : parsed.pathname; | |
| 259 | const lineMatch = parsed.hash.match(/^#L(\d+)(?:C(\d+))?$/i); | |
| 260 | return `${safeDecode(path)}${ | |
| 261 | lineMatch?.[1] ? `:${lineMatch[1]}${lineMatch[2] ? `:${lineMatch[2]}` : ""}` : "" | |
| 262 | }`; | |
| 263 | } catch { | |
| 264 | return null; | |
| 265 | } | |
| 266 | } | |
| 267 | ||
| 268 | function looksLikePosixFilesystemPath(path: string): boolean { | |
| 269 | if (!path.startsWith("/")) { | |
| 270 | return false; | |
| 271 | } | |
| 272 | if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) { | |
| 273 | return true; | |
| 274 | } | |
| 275 | if (POSITION_SUFFIX_PATTERN.test(path)) { | |
| 276 | return true; | |
| 277 | } | |
| 278 | const basename = path.slice(path.lastIndexOf("/") + 1); | |
| 279 | return /\.[A-Za-z0-9_-]+$/.test(basename); | |
| 280 | } | |
| 281 | ||
| 282 | function looksLikeFilePath(value: string): boolean { | |
| 283 | if (WINDOWS_DRIVE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value)) { | |
| 284 | return true; | |
| 285 | } | |
| 286 | if (RELATIVE_PATH_PREFIX_PATTERN.test(value)) { | |
| 287 | return true; | |
| 288 | } | |
| 289 | if (value.startsWith("/")) { | |
| 290 | return looksLikePosixFilesystemPath(value); | |
| 291 | } | |
| 292 | if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) { | |
| 293 | return true; | |
| 294 | } | |
| 295 | return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value); | |
| 296 | } | |
| 297 | ||
| 298 | function fileLabel(value: string): string { | |
| 299 | const normalized = value.replaceAll("\\", "/"); | |
| 300 | const basename = normalized.slice(normalized.lastIndexOf("/") + 1); | |
| 301 | return basename || normalized; | |
| 302 | } | |
| 303 | ||
| 304 | export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { | |
| 305 | const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); | |
| 306 | const exactIcon = FILE_ICON_BY_NAME[basename]; | |
| 307 | if (exactIcon) return exactIcon; | |
| 308 | if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { | |
| 309 | return "tsconfig"; | |
| 310 | } | |
| 311 | const segments = basename.split("."); | |
| 312 | for (let index = 1; index < segments.length; index += 1) { | |
| 313 | const icon = FILE_ICON_BY_EXTENSION[segments.slice(index).join(".")]; | |
| 314 | if (icon) return icon; | |
| 315 | } | |
| 316 | return "default"; | |
| 317 | } | |
| 318 | ||
| 319 | export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { | |
| 320 | const normalized = normalizeDestination(href); | |
| 321 | try { | |
| 322 | const parsed = new URL(normalized); | |
| 323 | if (parsed.protocol === "http:" || parsed.protocol === "https:") { | |
| 324 | return { | |
| 325 | kind: "external", | |
| 326 | href: parsed.toString(), | |
| 327 | host: parsed.hostname, | |
| 328 | }; | |
| 329 | } | |
| 330 | } catch { | |
| 331 | // Relative paths and non-URL link destinations are handled below. | |
| 332 | } | |
| 333 | ||
| 334 | const fileTarget = normalized.toLowerCase().startsWith("file:") | |
| 335 | ? fileUrlPath(normalized) | |
| 336 | : safeDecode(normalized.split(/[?#]/, 1)[0] ?? normalized); | |
| 337 | if (fileTarget && looksLikeFilePath(fileTarget)) { | |
| 338 | return { | |
| 339 | kind: "file", | |
| 340 | icon: resolveMarkdownFileIcon(fileTarget), | |
| 341 | label: fileLabel(fileTarget), | |
| 342 | }; | |
| 343 | } | |
| 344 | ||
| 345 | return { | |
| 346 | kind: "link", | |
| 347 | href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null, | |
| 348 | }; | |
| 349 | } |
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts created+751| ... | ... | @@ -0,0 +1,751 @@ |
| 1 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | |
| 2 | ||
| 3 | import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; | |
| 4 | import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks"; | |
| 5 | ||
| 6 | export interface NativeMarkdownTextRun { | |
| 7 | readonly text: string; | |
| 8 | readonly bold?: boolean; | |
| 9 | readonly italic?: boolean; | |
| 10 | readonly strikethrough?: boolean; | |
| 11 | readonly code?: boolean; | |
| 12 | readonly href?: string; | |
| 13 | readonly externalHost?: string; | |
| 14 | readonly fileIcon?: MarkdownFileIcon; | |
| 15 | readonly skillName?: string; | |
| 16 | readonly skillLabel?: string; | |
| 17 | readonly role?: | |
| 18 | | "body" | |
| 19 | | "heading" | |
| 20 | | "list-marker" | |
| 21 | | "list-break" | |
| 22 | | "quote-marker" | |
| 23 | | "code-block" | |
| 24 | | "code-language" | |
| 25 | | "divider" | |
| 26 | | "spacer"; | |
| 27 | readonly headingLevel?: number; | |
| 28 | readonly depth?: number; | |
| 29 | readonly spacing?: number; | |
| 30 | readonly firstLineHeadIndent?: number; | |
| 31 | readonly headIndent?: number; | |
| 32 | readonly paragraphSpacing?: number; | |
| 33 | } | |
| 34 | ||
| 35 | export type NativeMarkdownDocumentChunk = | |
| 36 | | { | |
| 37 | readonly kind: "selectable"; | |
| 38 | readonly key: string; | |
| 39 | readonly node: MarkdownNode; | |
| 40 | } | |
| 41 | | { | |
| 42 | readonly kind: "rich"; | |
| 43 | readonly key: string; | |
| 44 | readonly node: MarkdownNode; | |
| 45 | }; | |
| 46 | ||
| 47 | interface RunContext { | |
| 48 | readonly bold: boolean; | |
| 49 | readonly italic: boolean; | |
| 50 | readonly strikethrough: boolean; | |
| 51 | readonly code: boolean; | |
| 52 | readonly href?: string; | |
| 53 | readonly externalHost?: string; | |
| 54 | readonly fileIcon?: MarkdownFileIcon; | |
| 55 | readonly role?: NativeMarkdownTextRun["role"]; | |
| 56 | readonly headingLevel?: number; | |
| 57 | readonly depth?: number; | |
| 58 | readonly spacing?: number; | |
| 59 | readonly firstLineHeadIndent?: number; | |
| 60 | readonly headIndent?: number; | |
| 61 | readonly paragraphSpacing?: number; | |
| 62 | } | |
| 63 | ||
| 64 | const EMPTY_CONTEXT: RunContext = { | |
| 65 | bold: false, | |
| 66 | italic: false, | |
| 67 | strikethrough: false, | |
| 68 | code: false, | |
| 69 | }; | |
| 70 | ||
| 71 | const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi; | |
| 72 | ||
| 73 | function decodeHtmlEntitiesOnce(value: string): string { | |
| 74 | return value.replace( | |
| 75 | /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi, | |
| 76 | (entity, decimal: string | undefined, hexadecimal: string | undefined) => { | |
| 77 | if (decimal) { | |
| 78 | return String.fromCodePoint(Number.parseInt(decimal, 10)); | |
| 79 | } | |
| 80 | if (hexadecimal) { | |
| 81 | return String.fromCodePoint(Number.parseInt(hexadecimal, 16)); | |
| 82 | } | |
| 83 | switch (entity.toLowerCase()) { | |
| 84 | case "&amp;": | |
| 85 | return "&"; | |
| 86 | case "&apos;": | |
| 87 | return "'"; | |
| 88 | case "&gt;": | |
| 89 | return ">"; | |
| 90 | case "&lt;": | |
| 91 | return "<"; | |
| 92 | case "&nbsp;": | |
| 93 | return "\u00a0"; | |
| 94 | case "&quot;": | |
| 95 | return '"'; | |
| 96 | default: | |
| 97 | return entity; | |
| 98 | } | |
| 99 | }, | |
| 100 | ); | |
| 101 | } | |
| 102 | ||
| 103 | function decodeHtmlEntities(value: string): string { | |
| 104 | let decoded = value; | |
| 105 | for (let pass = 0; pass < 2; pass += 1) { | |
| 106 | const next = decodeHtmlEntitiesOnce(decoded); | |
| 107 | if (next === decoded) { | |
| 108 | break; | |
| 109 | } | |
| 110 | decoded = next; | |
| 111 | } | |
| 112 | return decoded; | |
| 113 | } | |
| 114 | ||
| 115 | function textNodeContent(value: string): string { | |
| 116 | return decodeHtmlEntities(value).replace(INLINE_HTML_TAG_PATTERN, ""); | |
| 117 | } | |
| 118 | ||
| 119 | function inlineHtmlText(value: string): string { | |
| 120 | if (/^<br\s*\/?>$/i.test(value.trim())) { | |
| 121 | return "\n"; | |
| 122 | } | |
| 123 | return decodeHtmlEntities(value.replace(/<[^>]+>/g, "")); | |
| 124 | } | |
| 125 | ||
| 126 | function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun): boolean { | |
| 127 | return ( | |
| 128 | left.bold === right.bold && | |
| 129 | left.italic === right.italic && | |
| 130 | left.strikethrough === right.strikethrough && | |
| 131 | left.code === right.code && | |
| 132 | left.href === right.href && | |
| 133 | left.externalHost === right.externalHost && | |
| 134 | left.fileIcon === right.fileIcon && | |
| 135 | left.skillName === right.skillName && | |
| 136 | left.skillLabel === right.skillLabel && | |
| 137 | left.role === right.role && | |
| 138 | left.headingLevel === right.headingLevel && | |
| 139 | left.depth === right.depth && | |
| 140 | left.spacing === right.spacing && | |
| 141 | left.firstLineHeadIndent === right.firstLineHeadIndent && | |
| 142 | left.headIndent === right.headIndent && | |
| 143 | left.paragraphSpacing === right.paragraphSpacing | |
| 144 | ); | |
| 145 | } | |
| 146 | ||
| 147 | function appendRun( | |
| 148 | runs: NativeMarkdownTextRun[], | |
| 149 | text: string, | |
| 150 | context: RunContext, | |
| 151 | ): NativeMarkdownTextRun[] { | |
| 152 | if (text.length === 0) { | |
| 153 | return runs; | |
| 154 | } | |
| 155 | ||
| 156 | const run: NativeMarkdownTextRun = { | |
| 157 | text, | |
| 158 | ...(context.bold ? { bold: true } : {}), | |
| 159 | ...(context.italic ? { italic: true } : {}), | |
| 160 | ...(context.strikethrough ? { strikethrough: true } : {}), | |
| 161 | ...(context.code ? { code: true } : {}), | |
| 162 | ...(context.href ? { href: context.href } : {}), | |
| 163 | ...(context.externalHost ? { externalHost: context.externalHost } : {}), | |
| 164 | ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}), | |
| 165 | ...(context.role ? { role: context.role } : {}), | |
| 166 | ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}), | |
| 167 | ...(context.depth ? { depth: context.depth } : {}), | |
| 168 | ...(context.spacing ? { spacing: context.spacing } : {}), | |
| 169 | ...(context.firstLineHeadIndent !== undefined | |
| 170 | ? { firstLineHeadIndent: context.firstLineHeadIndent } | |
| 171 | : {}), | |
| 172 | ...(context.headIndent !== undefined ? { headIndent: context.headIndent } : {}), | |
| 173 | ...(context.paragraphSpacing !== undefined | |
| 174 | ? { paragraphSpacing: context.paragraphSpacing } | |
| 175 | : {}), | |
| 176 | }; | |
| 177 | const previous = runs.at(-1); | |
| 178 | if (previous && sameRunStyle(previous, run)) { | |
| 179 | runs[runs.length - 1] = { ...previous, text: previous.text + run.text }; | |
| 180 | return runs; | |
| 181 | } | |
| 182 | ||
| 183 | runs.push(run); | |
| 184 | return runs; | |
| 185 | } | |
| 186 | ||
| 187 | const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; | |
| 188 | ||
| 189 | function formatSkillLabel(skill: SelectableMarkdownSkill): string { | |
| 190 | const displayName = skill.displayName?.trim(); | |
| 191 | if (displayName) { | |
| 192 | return displayName; | |
| 193 | } | |
| 194 | return skill.name | |
| 195 | .split(/[\s:_-]+/) | |
| 196 | .filter(Boolean) | |
| 197 | .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) | |
| 198 | .join(" "); | |
| 199 | } | |
| 200 | ||
| 201 | function decorateSkillRuns( | |
| 202 | runs: ReadonlyArray<NativeMarkdownTextRun>, | |
| 203 | skills: ReadonlyArray<SelectableMarkdownSkill>, | |
| 204 | ): ReadonlyArray<NativeMarkdownTextRun> { | |
| 205 | if (skills.length === 0) { | |
| 206 | return runs; | |
| 207 | } | |
| 208 | const skillByName = new Map(skills.map((skill) => [skill.name, skill])); | |
| 209 | const decorated: NativeMarkdownTextRun[] = []; | |
| 210 | ||
| 211 | for (const run of runs) { | |
| 212 | if (run.code || run.href || run.fileIcon || run.role === "code-block") { | |
| 213 | decorated.push(run); | |
| 214 | continue; | |
| 215 | } | |
| 216 | ||
| 217 | let cursor = 0; | |
| 218 | let matched = false; | |
| 219 | for (const match of run.text.matchAll(SKILL_TOKEN_REGEX)) { | |
| 220 | const prefix = match[1] ?? ""; | |
| 221 | const name = match[2] ?? ""; | |
| 222 | const skill = skillByName.get(name); | |
| 223 | if (!skill) { | |
| 224 | continue; | |
| 225 | } | |
| 226 | const start = (match.index ?? 0) + prefix.length; | |
| 227 | const end = start + name.length + 1; | |
| 228 | if (start > cursor) { | |
| 229 | decorated.push({ ...run, text: run.text.slice(cursor, start) }); | |
| 230 | } | |
| 231 | decorated.push({ | |
| 232 | ...run, | |
| 233 | text: run.text.slice(start, end), | |
| 234 | skillName: name, | |
| 235 | skillLabel: formatSkillLabel(skill), | |
| 236 | }); | |
| 237 | cursor = end; | |
| 238 | matched = true; | |
| 239 | } | |
| 240 | if (!matched) { | |
| 241 | decorated.push(run); | |
| 242 | } else if (cursor < run.text.length) { | |
| 243 | decorated.push({ ...run, text: run.text.slice(cursor) }); | |
| 244 | } | |
| 245 | } | |
| 246 | ||
| 247 | return decorated; | |
| 248 | } | |
| 249 | ||
| 250 | function appendChildren( | |
| 251 | runs: NativeMarkdownTextRun[], | |
| 252 | node: MarkdownNode, | |
| 253 | context: RunContext, | |
| 254 | ): NativeMarkdownTextRun[] { | |
| 255 | for (const child of node.children ?? []) { | |
| 256 | appendNode(runs, child, context); | |
| 257 | } | |
| 258 | return runs; | |
| 259 | } | |
| 260 | ||
| 261 | function nodeTextContent(node: MarkdownNode): string { | |
| 262 | if (node.content !== undefined) { | |
| 263 | return node.content; | |
| 264 | } | |
| 265 | return (node.children ?? []).map(nodeTextContent).join(""); | |
| 266 | } | |
| 267 | ||
| 268 | function appendNode( | |
| 269 | runs: NativeMarkdownTextRun[], | |
| 270 | node: MarkdownNode, | |
| 271 | context: RunContext, | |
| 272 | ): NativeMarkdownTextRun[] { | |
| 273 | switch (node.type) { | |
| 274 | case "text": | |
| 275 | case "math_inline": | |
| 276 | return appendRun(runs, textNodeContent(nodeTextContent(node)), context); | |
| 277 | case "html_inline": | |
| 278 | return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); | |
| 279 | case "code_inline": | |
| 280 | return appendRun(runs, nodeTextContent(node), { ...context, code: true }); | |
| 281 | case "soft_break": | |
| 282 | return appendRun(runs, " ", context); | |
| 283 | case "line_break": | |
| 284 | return appendRun(runs, "\n", context); | |
| 285 | case "bold": | |
| 286 | return appendChildren(runs, node, { ...context, bold: true }); | |
| 287 | case "italic": | |
| 288 | return appendChildren(runs, node, { ...context, italic: true }); | |
| 289 | case "strikethrough": | |
| 290 | return appendChildren(runs, node, { ...context, strikethrough: true }); | |
| 291 | case "link": { | |
| 292 | const presentation = resolveMarkdownLinkPresentation(node.href ?? ""); | |
| 293 | if (presentation.kind === "file") { | |
| 294 | return appendRun(runs, presentation.label, { | |
| 295 | ...context, | |
| 296 | fileIcon: presentation.icon, | |
| 297 | }); | |
| 298 | } | |
| 299 | if (presentation.kind === "external") { | |
| 300 | return appendChildren(runs, node, { | |
| 301 | ...context, | |
| 302 | href: presentation.href, | |
| 303 | externalHost: presentation.host, | |
| 304 | }); | |
| 305 | } | |
| 306 | return appendChildren(runs, node, { | |
| 307 | ...context, | |
| 308 | ...(presentation.href ? { href: presentation.href } : {}), | |
| 309 | }); | |
| 310 | } | |
| 311 | case "image": | |
| 312 | return appendRun(runs, node.alt ?? node.title ?? "", context); | |
| 313 | default: | |
| 314 | return appendChildren(runs, node, context); | |
| 315 | } | |
| 316 | } | |
| 317 | ||
| 318 | export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray<NativeMarkdownTextRun> { | |
| 319 | return appendChildren([], node, EMPTY_CONTEXT); | |
| 320 | } | |
| 321 | ||
| 322 | function appendBlockTerminator( | |
| 323 | runs: NativeMarkdownTextRun[], | |
| 324 | context: RunContext, | |
| 325 | ): NativeMarkdownTextRun[] { | |
| 326 | return appendRun(runs, "\n", context); | |
| 327 | } | |
| 328 | ||
| 329 | function appendSpacer(runs: NativeMarkdownTextRun[], spacing: number): NativeMarkdownTextRun[] { | |
| 330 | return appendRun(runs, "\n", { ...EMPTY_CONTEXT, role: "spacer", spacing }); | |
| 331 | } | |
| 332 | ||
| 333 | function appendInlineChildren( | |
| 334 | runs: NativeMarkdownTextRun[], | |
| 335 | node: MarkdownNode, | |
| 336 | context: RunContext, | |
| 337 | ): NativeMarkdownTextRun[] { | |
| 338 | for (const child of node.children ?? []) { | |
| 339 | appendNode(runs, child, context); | |
| 340 | } | |
| 341 | return runs; | |
| 342 | } | |
| 343 | ||
| 344 | function isInlineNode(node: MarkdownNode): boolean { | |
| 345 | return ( | |
| 346 | node.type === "text" || | |
| 347 | node.type === "bold" || | |
| 348 | node.type === "italic" || | |
| 349 | node.type === "strikethrough" || | |
| 350 | node.type === "link" || | |
| 351 | node.type === "image" || | |
| 352 | node.type === "code_inline" || | |
| 353 | node.type === "math_inline" || | |
| 354 | node.type === "html_inline" || | |
| 355 | node.type === "soft_break" || | |
| 356 | node.type === "line_break" | |
| 357 | ); | |
| 358 | } | |
| 359 | ||
| 360 | export function nativeMarkdownListItemBlocks(node: MarkdownNode): ReadonlyArray<MarkdownNode> { | |
| 361 | const blocks: MarkdownNode[] = []; | |
| 362 | let inlineNodes: MarkdownNode[] = []; | |
| 363 | const flushInlineNodes = () => { | |
| 364 | if (inlineNodes.length === 0) { | |
| 365 | return; | |
| 366 | } | |
| 367 | blocks.push({ type: "paragraph", children: inlineNodes }); | |
| 368 | inlineNodes = []; | |
| 369 | }; | |
| 370 | ||
| 371 | for (const child of node.children ?? []) { | |
| 372 | if (isInlineNode(child)) { | |
| 373 | inlineNodes.push(child); | |
| 374 | continue; | |
| 375 | } | |
| 376 | ||
| 377 | flushInlineNodes(); | |
| 378 | blocks.push(child); | |
| 379 | } | |
| 380 | flushInlineNodes(); | |
| 381 | return blocks; | |
| 382 | } | |
| 383 | ||
| 384 | function appendListItem( | |
| 385 | runs: NativeMarkdownTextRun[], | |
| 386 | node: MarkdownNode, | |
| 387 | marker: string, | |
| 388 | depth: number, | |
| 389 | markerColumnWidth: number, | |
| 390 | ): NativeMarkdownTextRun[] { | |
| 391 | const firstLineHeadIndent = Math.max(0, depth - 1) * 20; | |
| 392 | appendRun(runs, `${marker}\t`, { | |
| 393 | ...EMPTY_CONTEXT, | |
| 394 | role: "list-marker", | |
| 395 | depth, | |
| 396 | firstLineHeadIndent, | |
| 397 | headIndent: firstLineHeadIndent + markerColumnWidth, | |
| 398 | paragraphSpacing: 2, | |
| 399 | }); | |
| 400 | ||
| 401 | const children = node.children ?? []; | |
| 402 | let wroteInlineContent = false; | |
| 403 | for (const child of children) { | |
| 404 | if (child.type === "paragraph") { | |
| 405 | appendInlineChildren(runs, child, { | |
| 406 | ...EMPTY_CONTEXT, | |
| 407 | role: "body", | |
| 408 | depth, | |
| 409 | }); | |
| 410 | wroteInlineContent = true; | |
| 411 | continue; | |
| 412 | } | |
| 413 | if (child.type === "list") { | |
| 414 | if (wroteInlineContent) { | |
| 415 | appendBlockTerminator(runs, { | |
| 416 | ...EMPTY_CONTEXT, | |
| 417 | role: "list-break", | |
| 418 | depth, | |
| 419 | spacing: 1, | |
| 420 | }); | |
| 421 | } | |
| 422 | appendList(runs, child, depth + 1); | |
| 423 | wroteInlineContent = false; | |
| 424 | continue; | |
| 425 | } | |
| 426 | if (isInlineNode(child)) { | |
| 427 | appendNode(runs, child, { | |
| 428 | ...EMPTY_CONTEXT, | |
| 429 | role: "body", | |
| 430 | depth, | |
| 431 | }); | |
| 432 | wroteInlineContent = true; | |
| 433 | continue; | |
| 434 | } | |
| 435 | appendDocumentBlock(runs, child, depth); | |
| 436 | wroteInlineContent = true; | |
| 437 | } | |
| 438 | ||
| 439 | if (wroteInlineContent) { | |
| 440 | appendBlockTerminator(runs, { | |
| 441 | ...EMPTY_CONTEXT, | |
| 442 | role: "list-break", | |
| 443 | depth, | |
| 444 | spacing: depth === 1 ? 4 : 2, | |
| 445 | }); | |
| 446 | } | |
| 447 | return runs; | |
| 448 | } | |
| 449 | ||
| 450 | function appendList( | |
| 451 | runs: NativeMarkdownTextRun[], | |
| 452 | node: MarkdownNode, | |
| 453 | depth: number, | |
| 454 | ): NativeMarkdownTextRun[] { | |
| 455 | const ordered = node.ordered ?? false; | |
| 456 | const start = node.start ?? 1; | |
| 457 | const children = node.children ?? []; | |
| 458 | const markers = children.map((child, index) => | |
| 459 | child.type === "task_list_item" | |
| 460 | ? child.checked | |
| 461 | ? "☑︎" | |
| 462 | : "☐︎" | |
| 463 | : ordered | |
| 464 | ? `${start + index}.` | |
| 465 | : depth % 3 === 2 | |
| 466 | ? "◦" | |
| 467 | : depth % 3 === 0 | |
| 468 | ? "▪︎" | |
| 469 | : "•", | |
| 470 | ); | |
| 471 | const markerWidth = ordered | |
| 472 | ? Math.max(0, ...markers.map((marker) => Array.from(marker).length)) | |
| 473 | : 0; | |
| 474 | ||
| 475 | for (const [index, child] of children.entries()) { | |
| 476 | const marker = markers[index] ?? "•"; | |
| 477 | const alignedMarker = | |
| 478 | child.type === "task_list_item" | |
| 479 | ? marker | |
| 480 | : ordered | |
| 481 | ? `${"\u2007".repeat(Math.max(0, markerWidth - Array.from(marker).length))}${marker}` | |
| 482 | : marker; | |
| 483 | const markerColumnWidth = | |
| 484 | child.type === "task_list_item" ? 28 : ordered ? 10 + markerWidth * 8 : 24; | |
| 485 | appendListItem(runs, child, alignedMarker, depth, markerColumnWidth); | |
| 486 | } | |
| 487 | return runs; | |
| 488 | } | |
| 489 | ||
| 490 | function appendQuoteBlock( | |
| 491 | runs: NativeMarkdownTextRun[], | |
| 492 | node: MarkdownNode, | |
| 493 | depth: number, | |
| 494 | ): NativeMarkdownTextRun[] { | |
| 495 | for (const [index, child] of (node.children ?? []).entries()) { | |
| 496 | if (index > 0) { | |
| 497 | appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); | |
| 498 | } | |
| 499 | appendRun(runs, "│\u00a0", { | |
| 500 | ...EMPTY_CONTEXT, | |
| 501 | role: "quote-marker", | |
| 502 | depth, | |
| 503 | }); | |
| 504 | if (child.type === "paragraph") { | |
| 505 | appendInlineChildren(runs, child, { | |
| 506 | ...EMPTY_CONTEXT, | |
| 507 | role: "body", | |
| 508 | depth, | |
| 509 | }); | |
| 510 | } else { | |
| 511 | appendDocumentBlock(runs, child, depth); | |
| 512 | } | |
| 513 | } | |
| 514 | appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); | |
| 515 | return runs; | |
| 516 | } | |
| 517 | ||
| 518 | function appendTableRow( | |
| 519 | runs: NativeMarkdownTextRun[], | |
| 520 | node: MarkdownNode, | |
| 521 | depth: number, | |
| 522 | ): NativeMarkdownTextRun[] { | |
| 523 | const cells = node.children ?? []; | |
| 524 | for (const [index, cell] of cells.entries()) { | |
| 525 | if (index > 0) { | |
| 526 | appendRun(runs, "\u00a0│\u00a0", { | |
| 527 | ...EMPTY_CONTEXT, | |
| 528 | role: "divider", | |
| 529 | depth, | |
| 530 | }); | |
| 531 | } | |
| 532 | appendInlineChildren(runs, cell, { | |
| 533 | ...EMPTY_CONTEXT, | |
| 534 | role: "body", | |
| 535 | bold: cell.isHeader ?? false, | |
| 536 | depth, | |
| 537 | }); | |
| 538 | } | |
| 539 | appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); | |
| 540 | return runs; | |
| 541 | } | |
| 542 | ||
| 543 | function appendTable( | |
| 544 | runs: NativeMarkdownTextRun[], | |
| 545 | node: MarkdownNode, | |
| 546 | depth: number, | |
| 547 | ): NativeMarkdownTextRun[] { | |
| 548 | const visit = (child: MarkdownNode) => { | |
| 549 | if (child.type === "table_row") { | |
| 550 | appendTableRow(runs, child, depth); | |
| 551 | return; | |
| 552 | } | |
| 553 | for (const nested of child.children ?? []) { | |
| 554 | visit(nested); | |
| 555 | } | |
| 556 | }; | |
| 557 | visit(node); | |
| 558 | return runs; | |
| 559 | } | |
| 560 | ||
| 561 | function appendDocumentBlock( | |
| 562 | runs: NativeMarkdownTextRun[], | |
| 563 | node: MarkdownNode, | |
| 564 | depth = 0, | |
| 565 | ): NativeMarkdownTextRun[] { | |
| 566 | switch (node.type) { | |
| 567 | case "document": { | |
| 568 | const children = node.children ?? []; | |
| 569 | for (const [index, child] of children.entries()) { | |
| 570 | if (index > 0) { | |
| 571 | const previous = children[index - 1]; | |
| 572 | appendSpacer( | |
| 573 | runs, | |
| 574 | child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12, | |
| 575 | ); | |
| 576 | } | |
| 577 | appendDocumentBlock(runs, child, depth); | |
| 578 | } | |
| 579 | return runs; | |
| 580 | } | |
| 581 | case "heading": { | |
| 582 | const context: RunContext = { | |
| 583 | ...EMPTY_CONTEXT, | |
| 584 | role: "heading", | |
| 585 | headingLevel: node.level ?? 1, | |
| 586 | depth, | |
| 587 | }; | |
| 588 | appendInlineChildren(runs, node, context); | |
| 589 | return appendBlockTerminator(runs, context); | |
| 590 | } | |
| 591 | case "paragraph": { | |
| 592 | const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth }; | |
| 593 | appendInlineChildren(runs, node, context); | |
| 594 | return appendBlockTerminator(runs, context); | |
| 595 | } | |
| 596 | case "list": | |
| 597 | return appendList(runs, node, depth + 1); | |
| 598 | case "blockquote": | |
| 599 | return appendQuoteBlock(runs, node, depth); | |
| 600 | case "code_block": { | |
| 601 | if (node.language) { | |
| 602 | appendRun(runs, `${node.language.toUpperCase()}\n`, { | |
| 603 | ...EMPTY_CONTEXT, | |
| 604 | role: "code-language", | |
| 605 | code: true, | |
| 606 | depth, | |
| 607 | }); | |
| 608 | } | |
| 609 | const content = nodeTextContent(node); | |
| 610 | appendRun(runs, content, { | |
| 611 | ...EMPTY_CONTEXT, | |
| 612 | role: "code-block", | |
| 613 | code: true, | |
| 614 | depth, | |
| 615 | }); | |
| 616 | if (!content.endsWith("\n")) { | |
| 617 | appendBlockTerminator(runs, { | |
| 618 | ...EMPTY_CONTEXT, | |
| 619 | role: "code-block", | |
| 620 | code: true, | |
| 621 | depth, | |
| 622 | }); | |
| 623 | } | |
| 624 | return runs; | |
| 625 | } | |
| 626 | case "horizontal_rule": | |
| 627 | appendRun(runs, "────────────────────────\n", { | |
| 628 | ...EMPTY_CONTEXT, | |
| 629 | role: "divider", | |
| 630 | depth, | |
| 631 | }); | |
| 632 | return runs; | |
| 633 | case "table": | |
| 634 | return appendTable(runs, node, depth); | |
| 635 | case "html_block": | |
| 636 | appendRun(runs, inlineHtmlText(nodeTextContent(node)), { | |
| 637 | ...EMPTY_CONTEXT, | |
| 638 | role: "body", | |
| 639 | depth, | |
| 640 | }); | |
| 641 | return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); | |
| 642 | case "math_block": | |
| 643 | appendRun(runs, nodeTextContent(node), { ...EMPTY_CONTEXT, role: "body", depth }); | |
| 644 | return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); | |
| 645 | default: | |
| 646 | appendInlineChildren(runs, node, { ...EMPTY_CONTEXT, role: "body", depth }); | |
| 647 | return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); | |
| 648 | } | |
| 649 | } | |
| 650 | ||
| 651 | function containsRichBlock(node: MarkdownNode): boolean { | |
| 652 | if ( | |
| 653 | node.type === "code_block" || | |
| 654 | node.type === "table" || | |
| 655 | node.type === "image" || | |
| 656 | node.type === "horizontal_rule" || | |
| 657 | node.type === "html_block" || | |
| 658 | node.type === "math_block" | |
| 659 | ) { | |
| 660 | return true; | |
| 661 | } | |
| 662 | return (node.children ?? []).some(containsRichBlock); | |
| 663 | } | |
| 664 | ||
| 665 | export function nativeMarkdownDocumentChunks( | |
| 666 | document: MarkdownNode, | |
| 667 | ): ReadonlyArray<NativeMarkdownDocumentChunk> { | |
| 668 | const chunks: NativeMarkdownDocumentChunk[] = []; | |
| 669 | let selectableNodes: MarkdownNode[] = []; | |
| 670 | ||
| 671 | const flushSelectable = () => { | |
| 672 | if (selectableNodes.length === 0) { | |
| 673 | return; | |
| 674 | } | |
| 675 | const first = selectableNodes[0]; | |
| 676 | const last = selectableNodes.at(-1); | |
| 677 | chunks.push({ | |
| 678 | kind: "selectable", | |
| 679 | key: `selectable:${first?.beg ?? "start"}:${last?.end ?? "end"}`, | |
| 680 | node: { | |
| 681 | type: "document", | |
| 682 | children: selectableNodes, | |
| 683 | }, | |
| 684 | }); | |
| 685 | selectableNodes = []; | |
| 686 | }; | |
| 687 | ||
| 688 | for (const [index, child] of (document.children ?? []).entries()) { | |
| 689 | if (!containsRichBlock(child)) { | |
| 690 | selectableNodes.push(child); | |
| 691 | continue; | |
| 692 | } | |
| 693 | ||
| 694 | flushSelectable(); | |
| 695 | chunks.push({ | |
| 696 | kind: "rich", | |
| 697 | key: `rich:${child.type}:${child.beg ?? index}:${child.end ?? index}`, | |
| 698 | node: child, | |
| 699 | }); | |
| 700 | } | |
| 701 | flushSelectable(); | |
| 702 | return chunks; | |
| 703 | } | |
| 704 | ||
| 705 | function topLevelNodes(node: MarkdownNode): ReadonlyArray<MarkdownNode> { | |
| 706 | return node.type === "document" ? (node.children ?? []) : [node]; | |
| 707 | } | |
| 708 | ||
| 709 | export function nativeMarkdownChunkSpacing( | |
| 710 | previous: NativeMarkdownDocumentChunk | undefined, | |
| 711 | current: NativeMarkdownDocumentChunk, | |
| 712 | ): number { | |
| 713 | if (!previous) { | |
| 714 | return 0; | |
| 715 | } | |
| 716 | ||
| 717 | const previousLast = topLevelNodes(previous.node).at(-1); | |
| 718 | const currentFirst = topLevelNodes(current.node)[0]; | |
| 719 | ||
| 720 | if (currentFirst?.type === "heading") { | |
| 721 | return 20; | |
| 722 | } | |
| 723 | if (previousLast?.type === "heading") { | |
| 724 | return 10; | |
| 725 | } | |
| 726 | if (previousLast?.type === "list" && currentFirst?.type === "list") { | |
| 727 | return 12; | |
| 728 | } | |
| 729 | return 14; | |
| 730 | } | |
| 731 | ||
| 732 | export function nativeMarkdownDocumentRuns( | |
| 733 | node: MarkdownNode, | |
| 734 | skills: ReadonlyArray<SelectableMarkdownSkill> = [], | |
| 735 | ): ReadonlyArray<NativeMarkdownTextRun> { | |
| 736 | const runs = appendDocumentBlock([], node); | |
| 737 | while (runs.length > 0) { | |
| 738 | const lastIndex = runs.length - 1; | |
| 739 | const last = runs[lastIndex]; | |
| 740 | if (!last?.text.endsWith("\n")) { | |
| 741 | break; | |
| 742 | } | |
| 743 | const text = last.text.slice(0, -1); | |
| 744 | if (text.length === 0) { | |
| 745 | runs.pop(); | |
| 746 | } else { | |
| 747 | runs[lastIndex] = { ...last, text }; | |
| 748 | } | |
| 749 | } | |
| 750 | return decorateSkillRuns(runs, skills); | |
| 751 | } |
apps/mobile/modules/t3-markdown-text/src/util.ts created+62| ... | ... | @@ -0,0 +1,62 @@ |
| 1 | import { type StyleProp, StyleSheet, type TextStyle } from "react-native"; | |
| 2 | import type { NativeFontWeight } from "./T3MarkdownTextRunNativeComponent"; | |
| 3 | ||
| 4 | export function flattenStyles(rootStyle: TextStyle, style: StyleProp<TextStyle>) { | |
| 5 | const flattenedStyle = StyleSheet.flatten([rootStyle, style]) as TextStyle; | |
| 6 | return { | |
| 7 | ...flattenedStyle, | |
| 8 | fontWeight: fontWeightToNativeProp(flattenedStyle.fontWeight ?? "normal"), | |
| 9 | backgroundColor: flattenedStyle.backgroundColor | |
| 10 | ? flattenedStyle.backgroundColor | |
| 11 | : "transparent", | |
| 12 | shadowOffset: flattenedStyle.shadowOffset | |
| 13 | ? flattenedStyle.shadowOffset | |
| 14 | : { width: 0, height: 0 }, | |
| 15 | }; | |
| 16 | } | |
| 17 | ||
| 18 | // Codegen doesn't like using integer values for enums (c++ L) so we'll conver them to the proper native prop | |
| 19 | // value before returning flattened styles. | |
| 20 | function fontWeightToNativeProp(fontWeight: TextStyle["fontWeight"]): NativeFontWeight { | |
| 21 | switch (fontWeight) { | |
| 22 | case "normal": | |
| 23 | return "normal"; | |
| 24 | case "bold": | |
| 25 | return "bold"; | |
| 26 | case 100: | |
| 27 | case "100": | |
| 28 | case "ultralight": | |
| 29 | return "ultraLight"; | |
| 30 | case 200: | |
| 31 | case "200": | |
| 32 | return "ultraLight"; | |
| 33 | case 300: | |
| 34 | case "300": | |
| 35 | case "light": | |
| 36 | return "light"; | |
| 37 | case 400: | |
| 38 | case "400": | |
| 39 | case "regular": | |
| 40 | return "normal"; | |
| 41 | case 500: | |
| 42 | case "500": | |
| 43 | case "medium": | |
| 44 | return "medium"; | |
| 45 | case 600: | |
| 46 | case "600": | |
| 47 | case "semibold": | |
| 48 | return "semibold"; | |
| 49 | case 700: | |
| 50 | case "700": | |
| 51 | return "semibold"; | |
| 52 | case 800: | |
| 53 | case "800": | |
| 54 | return "bold"; | |
| 55 | case 900: | |
| 56 | case "900": | |
| 57 | case "heavy": | |
| 58 | return "heavy"; | |
| 59 | default: | |
| 60 | return "normal"; | |
| 61 | } | |
| 62 | } |
apps/mobile/package.json+5-1| ... | ... | @@ -34,12 +34,13 @@ |
| 34 | 34 | "config:preview": "APP_VARIANT=preview expo config", |
| 35 | 35 | "config:prod": "APP_VARIANT=production expo config", |
| 36 | 36 | "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", |
| 37 | "sync:pierre-icons": "node modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs", | |
| 37 | 38 | "test": "vp test run", |
| 38 | 39 | "typecheck": "tsc --noEmit" |
| 39 | 40 | }, |
| 40 | 41 | "dependencies": { |
| 41 | 42 | "@callstack/liquid-glass": "^0.7.1", |
| 42 | "@clerk/expo": "^3.3.0", | |
| 43 | "@clerk/expo": "^3.4.1", | |
| 43 | 44 | "@effect/atom-react": "catalog:", |
| 44 | 45 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 45 | 46 | "@expo/ui": "~56.0.8", |
| ... | ... | @@ -54,6 +55,7 @@ |
| 54 | 55 | "@shikijs/themes": "3.23.0", |
| 55 | 56 | "@t3tools/client-runtime": "workspace:*", |
| 56 | 57 | "@t3tools/contracts": "workspace:*", |
| 58 | "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text", | |
| 57 | 59 | "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", |
| 58 | 60 | "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", |
| 59 | 61 | "@t3tools/shared": "workspace:*", |
| ... | ... | @@ -61,6 +63,7 @@ |
| 61 | 63 | "diff": "8.0.3", |
| 62 | 64 | "effect": "catalog:", |
| 63 | 65 | "expo": "^56.0.0", |
| 66 | "expo-asset": "~56.0.15", | |
| 64 | 67 | "expo-auth-session": "~56.0.12", |
| 65 | 68 | "expo-build-properties": "~56.0.15", |
| 66 | 69 | "expo-camera": "~56.0.7", |
| ... | ... | @@ -104,6 +107,7 @@ |
| 104 | 107 | }, |
| 105 | 108 | "devDependencies": { |
| 106 | 109 | "@effect/vitest": "catalog:", |
| 110 | "@pierre/trees": "1.0.0-beta.4", | |
| 107 | 111 | "@types/react": "~19.2.0", |
| 108 | 112 | "babel-preset-expo": "~56.0.0", |
| 109 | 113 | "tailwindcss": "^4.0.0", |
apps/mobile/src/app/_layout.tsx+36-4| ... | ... | @@ -5,7 +5,9 @@ import { |
| 5 | 5 | DMSans_700Bold, |
| 6 | 6 | useFonts, |
| 7 | 7 | } from "@expo-google-fonts/dm-sans"; |
| 8 | import { usePathname } from "expo-router"; | |
| 8 | 9 | import Stack from "expo-router/stack"; |
| 10 | import { useCallback } from "react"; | |
| 9 | 11 | import { StatusBar, useColorScheme } from "react-native"; |
| 10 | 12 | import { GestureHandlerRootView } from "react-native-gesture-handler"; |
| 11 | 13 | import { KeyboardProvider } from "react-native-keyboard-controller"; |
| ... | ... | @@ -21,15 +23,41 @@ import { |
| 21 | 23 | import { RegistryContext } from "@effect/atom-react"; |
| 22 | 24 | import { appAtomRegistry } from "../state/atom-registry"; |
| 23 | 25 | import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; |
| 26 | import { | |
| 27 | ClerkSettingsSheetDetentProvider, | |
| 28 | useClerkSettingsSheetDetent, | |
| 29 | } from "../features/cloud/ClerkSettingsSheetDetent"; | |
| 24 | 30 | import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation"; |
| 31 | import { useThemeColor } from "../lib/useThemeColor"; | |
| 25 | 32 | |
| 26 | 33 | function AppNavigator() { |
| 34 | const pathname = usePathname(); | |
| 35 | const clerkRouteIsActive = pathname === "/settings/auth"; | |
| 36 | ||
| 37 | return ( | |
| 38 | <ClerkSettingsSheetDetentProvider initiallyExpanded={clerkRouteIsActive}> | |
| 39 | <AppNavigatorContent /> | |
| 40 | </ClerkSettingsSheetDetentProvider> | |
| 41 | ); | |
| 42 | } | |
| 43 | ||
| 44 | function AppNavigatorContent() { | |
| 27 | 45 | const { isLoadingSavedConnection } = useRemoteEnvironmentState(); |
| 46 | const { collapse, isExpanded } = useClerkSettingsSheetDetent(); | |
| 28 | 47 | const colorScheme = useColorScheme(); |
| 29 | const statusBarBg = colorScheme === "dark" ? "#0a0a0a" : "#f2f2f7"; | |
| 48 | const statusBarBg = useThemeColor("--color-status-bar"); | |
| 30 | 49 | const sheetStyle = useResolveClassNames("bg-sheet"); |
| 31 | 50 | useAgentNotificationNavigation(); |
| 32 | 51 | |
| 52 | const handleSettingsTransitionEnd = useCallback( | |
| 53 | (event: { data: { closing: boolean } }) => { | |
| 54 | if (event.data.closing) { | |
| 55 | collapse(); | |
| 56 | } | |
| 57 | }, | |
| 58 | [collapse], | |
| 59 | ); | |
| 60 | ||
| 33 | 61 | const newTaskScreenOptions = { |
| 34 | 62 | contentStyle: sheetStyle, |
| 35 | 63 | gestureEnabled: true, |
| ... | ... | @@ -50,7 +78,7 @@ function AppNavigator() { |
| 50 | 78 | |
| 51 | 79 | const settingsSheetScreenOptions = { |
| 52 | 80 | ...connectionSheetScreenOptions, |
| 53 | sheetAllowedDetents: [0.7], | |
| 81 | sheetAllowedDetents: isExpanded ? [0.92] : [0.7], | |
| 54 | 82 | }; |
| 55 | 83 | |
| 56 | 84 | if (isLoadingSavedConnection) { |
| ... | ... | @@ -61,7 +89,7 @@ function AppNavigator() { |
| 61 | 89 | <> |
| 62 | 90 | <StatusBar |
| 63 | 91 | barStyle={colorScheme === "dark" ? "light-content" : "dark-content"} |
| 64 | backgroundColor={statusBarBg as string} | |
| 92 | backgroundColor={String(statusBarBg)} | |
| 65 | 93 | translucent |
| 66 | 94 | /> |
| 67 | 95 | <Stack screenOptions={{ headerShown: false }}> |
| ... | ... | @@ -74,7 +102,11 @@ function AppNavigator() { |
| 74 | 102 | headerShadowVisible: false, |
| 75 | 103 | }} |
| 76 | 104 | /> |
| 77 | <Stack.Screen name="settings" options={settingsSheetScreenOptions} /> | |
| 105 | <Stack.Screen | |
| 106 | name="settings" | |
| 107 | listeners={{ transitionEnd: handleSettingsTransitionEnd }} | |
| 108 | options={settingsSheetScreenOptions} | |
| 109 | /> | |
| 78 | 110 | <Stack.Screen name="connections" options={connectionSheetScreenOptions} /> |
| 79 | 111 | <Stack.Screen name="new" options={newTaskScreenOptions} /> |
| 80 | 112 | <Stack.Screen |
apps/mobile/src/app/connections/_layout.tsx+3-4| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import Stack from "expo-router/stack"; |
| 2 | import { useColorScheme } from "react-native"; | |
| 3 | 2 | import { useResolveClassNames } from "uniwind"; |
| 3 | import { useThemeColor } from "../../lib/useThemeColor"; | |
| 4 | 4 | |
| 5 | 5 | export const unstable_settings = { |
| 6 | 6 | anchor: "index", |
| ... | ... | @@ -8,9 +8,8 @@ export const unstable_settings = { |
| 8 | 8 | |
| 9 | 9 | export default function ConnectionsLayout() { |
| 10 | 10 | const contentStyle = useResolveClassNames("bg-sheet"); |
| 11 | const isDark = useColorScheme() === "dark"; | |
| 12 | const connSheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; | |
| 13 | const headerTint = isDark ? "#f5f5f5" : "#262626"; | |
| 11 | const connSheetBg = useThemeColor("--color-sheet"); | |
| 12 | const headerTint = useThemeColor("--color-foreground"); | |
| 14 | 13 | |
| 15 | 14 | return ( |
| 16 | 15 | <Stack |
apps/mobile/src/app/index.tsx+7-5| ... | ... | @@ -1,11 +1,12 @@ |
| 1 | 1 | import { Stack, useRouter } from "expo-router"; |
| 2 | 2 | import { useState } from "react"; |
| 3 | import { Text as RNText, View, useColorScheme } from "react-native"; | |
| 3 | import { Text as RNText, View } from "react-native"; | |
| 4 | 4 | |
| 5 | 5 | import { buildThreadRoutePath } from "../lib/routes"; |
| 6 | 6 | import { useRemoteCatalog } from "../state/use-remote-catalog"; |
| 7 | 7 | import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry"; |
| 8 | 8 | import { HomeScreen } from "../features/home/HomeScreen"; |
| 9 | import { useThemeColor } from "../lib/useThemeColor"; | |
| 9 | 10 | |
| 10 | 11 | /* ─── Route screen ───────────────────────────────────────────────────── */ |
| 11 | 12 | |
| ... | ... | @@ -15,8 +16,9 @@ export default function HomeRouteScreen() { |
| 15 | 16 | const router = useRouter(); |
| 16 | 17 | const [searchQuery, setSearchQuery] = useState(""); |
| 17 | 18 | |
| 18 | const isDark = useColorScheme() === "dark"; | |
| 19 | const iconColor = isDark ? "#f5f5f5" : "#262626"; | |
| 19 | const iconColor = useThemeColor("--color-icon"); | |
| 20 | const mutedColor = useThemeColor("--color-foreground-muted"); | |
| 21 | const subtleColor = useThemeColor("--color-subtle"); | |
| 20 | 22 | |
| 21 | 23 | return ( |
| 22 | 24 | <> |
| ... | ... | @@ -54,7 +56,7 @@ export default function HomeRouteScreen() { |
| 54 | 56 | </RNText> |
| 55 | 57 | <View |
| 56 | 58 | style={{ |
| 57 | backgroundColor: isDark ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)", | |
| 59 | backgroundColor: subtleColor, | |
| 58 | 60 | borderRadius: 99, |
| 59 | 61 | paddingHorizontal: 8, |
| 60 | 62 | paddingVertical: 3, |
| ... | ... | @@ -64,7 +66,7 @@ export default function HomeRouteScreen() { |
| 64 | 66 | style={{ |
| 65 | 67 | fontFamily: "DMSans_700Bold", |
| 66 | 68 | fontSize: 10, |
| 67 | color: "#737373", | |
| 69 | color: mutedColor, | |
| 68 | 70 | letterSpacing: 1.1, |
| 69 | 71 | textTransform: "uppercase", |
| 70 | 72 | }} |
apps/mobile/src/app/new/_layout.tsx+3-4| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | 1 | import Stack from "expo-router/stack"; |
| 2 | import { useColorScheme } from "react-native"; | |
| 3 | 2 | import { useResolveClassNames } from "uniwind"; |
| 4 | 3 | |
| 5 | 4 | import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider"; |
| 5 | import { useThemeColor } from "../../lib/useThemeColor"; | |
| 6 | 6 | |
| 7 | 7 | export const unstable_settings = { |
| 8 | 8 | anchor: "index", |
| ... | ... | @@ -10,9 +10,8 @@ export const unstable_settings = { |
| 10 | 10 | |
| 11 | 11 | export default function NewTaskLayout() { |
| 12 | 12 | const sheetStyle = useResolveClassNames("bg-sheet"); |
| 13 | const isDark = useColorScheme() === "dark"; | |
| 14 | const sheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; | |
| 15 | const headerTint = isDark ? "#f5f5f5" : "#262626"; | |
| 13 | const sheetBg = useThemeColor("--color-sheet"); | |
| 14 | const headerTint = useThemeColor("--color-foreground"); | |
| 16 | 15 | |
| 17 | 16 | return ( |
| 18 | 17 | <NewTaskFlowProvider> |
apps/mobile/src/app/settings/_layout.tsx+20-4| ... | ... | @@ -1,16 +1,27 @@ |
| 1 | 1 | import Stack from "expo-router/stack"; |
| 2 | import { useColorScheme } from "react-native"; | |
| 2 | import { useCallback } from "react"; | |
| 3 | 3 | import { useResolveClassNames } from "uniwind"; |
| 4 | 4 | |
| 5 | import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; | |
| 6 | import { useThemeColor } from "../../lib/useThemeColor"; | |
| 7 | ||
| 5 | 8 | export const unstable_settings = { |
| 6 | 9 | anchor: "index", |
| 7 | 10 | }; |
| 8 | 11 | |
| 9 | 12 | export default function SettingsLayout() { |
| 13 | const { collapse } = useClerkSettingsSheetDetent(); | |
| 10 | 14 | const contentStyle = useResolveClassNames("bg-sheet"); |
| 11 | const isDark = useColorScheme() === "dark"; | |
| 12 | const sheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; | |
| 13 | const headerTint = isDark ? "#f5f5f5" : "#262626"; | |
| 15 | const sheetBg = useThemeColor("--color-sheet"); | |
| 16 | const headerTint = useThemeColor("--color-foreground"); | |
| 17 | const handleClerkRouteTransitionEnd = useCallback( | |
| 18 | (event: { data: { closing: boolean } }) => { | |
| 19 | if (event.data.closing) { | |
| 20 | collapse(); | |
| 21 | } | |
| 22 | }, | |
| 23 | [collapse], | |
| 24 | ); | |
| 14 | 25 | |
| 15 | 26 | return ( |
| 16 | 27 | <Stack |
| ... | ... | @@ -36,6 +47,11 @@ export default function SettingsLayout() { |
| 36 | 47 | name="waitlist" |
| 37 | 48 | options={{ animation: "slide_from_right", title: "Join the waitlist" }} |
| 38 | 49 | /> |
| 50 | <Stack.Screen | |
| 51 | name="auth" | |
| 52 | listeners={{ transitionEnd: handleClerkRouteTransitionEnd }} | |
| 53 | options={{ animation: "slide_from_right", title: "Sign in" }} | |
| 54 | /> | |
| 39 | 55 | </Stack> |
| 40 | 56 | ); |
| 41 | 57 | } |
apps/mobile/src/app/settings/auth.tsx created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | import { useAuth } from "@clerk/expo"; | |
| 2 | import { AuthView, UserProfileView } from "@clerk/expo/native"; | |
| 3 | import { Redirect, Stack } from "expo-router"; | |
| 4 | import { View } from "react-native"; | |
| 5 | ||
| 6 | import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; | |
| 7 | ||
| 8 | export default function SettingsAuthRouteScreen() { | |
| 9 | return hasCloudPublicConfig() ? ( | |
| 10 | <ConfiguredSettingsAuthRouteScreen /> | |
| 11 | ) : ( | |
| 12 | <Redirect href="/settings" /> | |
| 13 | ); | |
| 14 | } | |
| 15 | ||
| 16 | function ConfiguredSettingsAuthRouteScreen() { | |
| 17 | const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | |
| 18 | ||
| 19 | return ( | |
| 20 | <> | |
| 21 | <Stack.Screen options={{ title: isSignedIn ? "Account" : "Sign in" }} /> | |
| 22 | <View collapsable={false} className="flex-1 overflow-hidden bg-sheet"> | |
| 23 | {isLoaded ? ( | |
| 24 | isSignedIn ? ( | |
| 25 | <UserProfileView isDismissible={false} /> | |
| 26 | ) : ( | |
| 27 | <AuthView isDismissible={false} /> | |
| 28 | ) | |
| 29 | ) : null} | |
| 30 | </View> | |
| 31 | </> | |
| 32 | ); | |
| 33 | } |
apps/mobile/src/app/settings/index.tsx+6-6| ... | ... | @@ -13,6 +13,7 @@ import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/li |
| 13 | 13 | import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; |
| 14 | 14 | import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration"; |
| 15 | 15 | import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState"; |
| 16 | import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; | |
| 16 | 17 | import { |
| 17 | 18 | hasCloudPublicConfig, |
| 18 | 19 | resolveRelayClerkTokenOptions, |
| ... | ... | @@ -66,6 +67,7 @@ function LocalSettingsRouteScreen() { |
| 66 | 67 | function ConfiguredSettingsRouteScreen() { |
| 67 | 68 | const insets = useSafeAreaInsets(); |
| 68 | 69 | const { push } = useRouter(); |
| 70 | const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); | |
| 69 | 71 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); |
| 70 | 72 | const { user } = useUser(); |
| 71 | 73 | const { savedConnectionsById } = useRemoteEnvironmentState(); |
| ... | ... | @@ -265,11 +267,9 @@ function ConfiguredSettingsRouteScreen() { |
| 265 | 267 | push("/settings/waitlist"); |
| 266 | 268 | return; |
| 267 | 269 | } |
| 268 | Alert.alert( | |
| 269 | "T3 Cloud unavailable", | |
| 270 | "Native T3 Cloud account management is not available in this build.", | |
| 271 | ); | |
| 272 | }, [isLoaded, isSignedIn, push]); | |
| 270 | expandClerkSheet(); | |
| 271 | push("/settings/auth"); | |
| 272 | }, [expandClerkSheet, isLoaded, isSignedIn, push]); | |
| 273 | 273 | |
| 274 | 274 | return ( |
| 275 | 275 | <View collapsable={false} className="flex-1 bg-sheet"> |
| ... | ... | @@ -335,7 +335,7 @@ type SymbolName = ComponentProps<typeof SymbolView>["name"]; |
| 335 | 335 | function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { |
| 336 | 336 | return ( |
| 337 | 337 | <View className="gap-2"> |
| 338 | <Text className="px-2 font-t3-bold text-[17px] text-foreground-tertiary">{props.title}</Text> | |
| 338 | <Text className="px-2 text-[13px] font-t3-medium text-foreground-muted">{props.title}</Text> | |
| 339 | 339 | <View |
| 340 | 340 | className="overflow-hidden rounded-[28px] bg-card" |
| 341 | 341 | style={{ borderCurve: "continuous" }} |
apps/mobile/src/app/settings/waitlist.tsx+21-4| ... | ... | @@ -1,9 +1,11 @@ |
| 1 | import { Redirect, Stack } from "expo-router"; | |
| 1 | import { useAuth } from "@clerk/expo"; | |
| 2 | import { Redirect, Stack, useFocusEffect, useRouter } from "expo-router"; | |
| 3 | import { useCallback } from "react"; | |
| 2 | 4 | import { ScrollView } from "react-native"; |
| 3 | 5 | |
| 4 | 6 | import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment"; |
| 7 | import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; | |
| 5 | 8 | import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; |
| 6 | import { useNativeClerkAuthModal } from "../../features/cloud/useNativeClerkAuthModal"; | |
| 7 | 9 | |
| 8 | 10 | export default function SettingsWaitlistRouteScreen() { |
| 9 | 11 | return hasCloudPublicConfig() ? ( |
| ... | ... | @@ -14,7 +16,17 @@ export default function SettingsWaitlistRouteScreen() { |
| 14 | 16 | } |
| 15 | 17 | |
| 16 | 18 | function ConfiguredSettingsWaitlistRouteScreen() { |
| 17 | const { presentAuth } = useNativeClerkAuthModal(); | |
| 19 | const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | |
| 20 | const { expand } = useClerkSettingsSheetDetent(); | |
| 21 | const router = useRouter(); | |
| 22 | ||
| 23 | useFocusEffect( | |
| 24 | useCallback(() => { | |
| 25 | if (isLoaded && isSignedIn) { | |
| 26 | router.replace("/settings"); | |
| 27 | } | |
| 28 | }, [isLoaded, isSignedIn, router]), | |
| 29 | ); | |
| 18 | 30 | |
| 19 | 31 | return ( |
| 20 | 32 | <> |
| ... | ... | @@ -31,7 +43,12 @@ function ConfiguredSettingsWaitlistRouteScreen() { |
| 31 | 43 | keyboardShouldPersistTaps="handled" |
| 32 | 44 | showsVerticalScrollIndicator={false} |
| 33 | 45 | > |
| 34 | <CloudWaitlistEnrollment onSignIn={() => void presentAuth()} /> | |
| 46 | <CloudWaitlistEnrollment | |
| 47 | onSignIn={() => { | |
| 48 | expand(); | |
| 49 | router.push("/settings/auth"); | |
| 50 | }} | |
| 51 | /> | |
| 35 | 52 | </ScrollView> |
| 36 | 53 | </> |
| 37 | 54 | ); |
apps/mobile/src/components/ComposerEditor.tsx created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | export { ComposerEditor } from "../native/T3ComposerEditor"; | |
| 2 | export type { | |
| 3 | ComposerEditorHandle, | |
| 4 | ComposerEditorProps, | |
| 5 | ComposerEditorSelection, | |
| 6 | } from "../native/T3ComposerEditor"; |
apps/mobile/src/components/CopyTextButton.tsx created+68| ... | ... | @@ -0,0 +1,68 @@ |
| 1 | import { SymbolView } from "expo-symbols"; | |
| 2 | import { memo, useEffect, useRef, useState } from "react"; | |
| 3 | import { Pressable, type ColorValue } from "react-native"; | |
| 4 | ||
| 5 | import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; | |
| 6 | ||
| 7 | const COPY_FEEDBACK_DURATION_MS = 1200; | |
| 8 | ||
| 9 | export const CopyTextButton = memo(function CopyTextButton(props: { | |
| 10 | readonly accessibilityLabel: string; | |
| 11 | readonly text: string; | |
| 12 | readonly tintColor: ColorValue; | |
| 13 | readonly copiedTintColor?: ColorValue; | |
| 14 | readonly backgroundColor?: ColorValue; | |
| 15 | readonly borderColor?: ColorValue; | |
| 16 | readonly iconSize?: number; | |
| 17 | readonly buttonSize?: number; | |
| 18 | }) { | |
| 19 | const [copied, setCopied] = useState(false); | |
| 20 | const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| 21 | ||
| 22 | useEffect( | |
| 23 | () => () => { | |
| 24 | if (resetTimeoutRef.current) { | |
| 25 | clearTimeout(resetTimeoutRef.current); | |
| 26 | } | |
| 27 | }, | |
| 28 | [], | |
| 29 | ); | |
| 30 | ||
| 31 | return ( | |
| 32 | <Pressable | |
| 33 | accessibilityRole="button" | |
| 34 | accessibilityLabel={copied ? "Copied" : props.accessibilityLabel} | |
| 35 | disabled={props.text.length === 0} | |
| 36 | hitSlop={8} | |
| 37 | onPress={() => { | |
| 38 | copyTextWithHaptic(props.text); | |
| 39 | setCopied(true); | |
| 40 | if (resetTimeoutRef.current) { | |
| 41 | clearTimeout(resetTimeoutRef.current); | |
| 42 | } | |
| 43 | resetTimeoutRef.current = setTimeout(() => { | |
| 44 | setCopied(false); | |
| 45 | resetTimeoutRef.current = null; | |
| 46 | }, COPY_FEEDBACK_DURATION_MS); | |
| 47 | }} | |
| 48 | style={({ pressed }) => ({ | |
| 49 | width: props.buttonSize ?? 30, | |
| 50 | height: props.buttonSize ?? 30, | |
| 51 | alignItems: "center", | |
| 52 | justifyContent: "center", | |
| 53 | borderRadius: 9, | |
| 54 | borderWidth: props.borderColor ? 1 : 0, | |
| 55 | borderColor: props.borderColor, | |
| 56 | backgroundColor: props.backgroundColor, | |
| 57 | opacity: pressed ? 0.52 : 1, | |
| 58 | })} | |
| 59 | > | |
| 60 | <SymbolView | |
| 61 | name={copied ? "checkmark" : "doc.on.doc"} | |
| 62 | size={props.iconSize ?? 13} | |
| 63 | tintColor={copied ? (props.copiedTintColor ?? props.tintColor) : props.tintColor} | |
| 64 | type="monochrome" | |
| 65 | /> | |
| 66 | </Pressable> | |
| 67 | ); | |
| 68 | }); |
apps/mobile/src/components/GlassSafeAreaView.tsx+8-5| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import type { ReactNode } from "react"; |
| 2 | import { useColorScheme, View, type StyleProp, type ViewStyle } from "react-native"; | |
| 2 | import { View, type StyleProp, type ViewStyle } from "react-native"; | |
| 3 | 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 4 | import { useThemeColor } from "../lib/useThemeColor"; | |
| 4 | 5 | |
| 5 | 6 | import { GlassSurface } from "./GlassSurface"; |
| 6 | 7 | |
| ... | ... | @@ -17,14 +18,16 @@ export function GlassSafeAreaView({ |
| 17 | 18 | rightSlot, |
| 18 | 19 | style, |
| 19 | 20 | }: GlassSafeAreaViewProps) { |
| 20 | const isDarkMode = useColorScheme() === "dark"; | |
| 21 | 21 | const insets = useSafeAreaInsets(); |
| 22 | const headerColor = useThemeColor("--color-header"); | |
| 23 | const headerBorderColor = useThemeColor("--color-header-border"); | |
| 24 | const glassTint = useThemeColor("--color-glass-tint"); | |
| 22 | 25 | const headerPaddingTop = insets.top + 16; |
| 23 | 26 | const surfaceStyle = { |
| 24 | 27 | borderRadius: 0, |
| 25 | backgroundColor: isDarkMode ? "rgba(10,10,10,0.97)" : "rgba(255,255,255,0.97)", | |
| 28 | backgroundColor: headerColor, | |
| 26 | 29 | borderBottomWidth: 1, |
| 27 | borderBottomColor: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)", | |
| 30 | borderBottomColor: headerBorderColor, | |
| 28 | 31 | } as const; |
| 29 | 32 | |
| 30 | 33 | return ( |
| ... | ... | @@ -32,7 +35,7 @@ export function GlassSafeAreaView({ |
| 32 | 35 | <GlassSurface |
| 33 | 36 | chrome="none" |
| 34 | 37 | glassEffectStyle="regular" |
| 35 | tintColor={isDarkMode ? "rgba(15,23,42,0.24)" : "rgba(255,255,255,0.18)"} | |
| 38 | tintColor={glassTint} | |
| 36 | 39 | style={{ borderRadius: 0, backgroundColor: "transparent" }} |
| 37 | 40 | > |
| 38 | 41 | <View |
apps/mobile/src/components/GlassSurface.tsx+17-16| ... | ... | @@ -1,11 +1,19 @@ |
| 1 | 1 | import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; |
| 2 | 2 | import type { ReactNode } from "react"; |
| 3 | import { Platform, useColorScheme, View, type ViewProps, type ViewStyle } from "react-native"; | |
| 3 | import { | |
| 4 | Platform, | |
| 5 | useColorScheme, | |
| 6 | View, | |
| 7 | type ColorValue, | |
| 8 | type ViewProps, | |
| 9 | type ViewStyle, | |
| 10 | } from "react-native"; | |
| 11 | import { useThemeColor } from "../lib/useThemeColor"; | |
| 4 | 12 | |
| 5 | 13 | export interface GlassSurfaceProps extends ViewProps { |
| 6 | 14 | readonly children: ReactNode; |
| 7 | 15 | readonly glassEffectStyle?: "clear" | "regular" | "none"; |
| 8 | readonly tintColor?: string; | |
| 16 | readonly tintColor?: ColorValue; | |
| 9 | 17 | readonly chrome?: "default" | "none"; |
| 10 | 18 | } |
| 11 | 19 | |
| ... | ... | @@ -18,24 +26,17 @@ export function GlassSurface({ |
| 18 | 26 | ...props |
| 19 | 27 | }: GlassSurfaceProps) { |
| 20 | 28 | const isDarkMode = useColorScheme() === "dark"; |
| 29 | const borderColor = useThemeColor("--color-border"); | |
| 30 | const glassSurface = useThemeColor("--color-glass-surface"); | |
| 31 | const glassTint = useThemeColor("--color-glass-tint"); | |
| 21 | 32 | const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); |
| 22 | 33 | const surfaceStyle: ViewStyle = { |
| 23 | 34 | borderRadius: 32, |
| 24 | 35 | overflow: "hidden", |
| 25 | 36 | borderWidth: chrome === "none" ? 0 : 1, |
| 26 | borderColor: | |
| 27 | chrome === "none" | |
| 28 | ? "transparent" | |
| 29 | : isDarkMode | |
| 30 | ? "rgba(255,255,255,0.08)" | |
| 31 | : "rgba(226,232,240,0.9)", | |
| 32 | backgroundColor: | |
| 33 | chrome === "none" | |
| 34 | ? "transparent" | |
| 35 | : isDarkMode | |
| 36 | ? "rgba(15,23,42,0.78)" | |
| 37 | : "rgba(255,255,255,0.72)", | |
| 38 | shadowColor: chrome === "none" ? "transparent" : "#020617", | |
| 37 | borderColor: chrome === "none" ? "transparent" : borderColor, | |
| 38 | backgroundColor: chrome === "none" ? "transparent" : glassSurface, | |
| 39 | shadowColor: chrome === "none" ? "transparent" : "#000000", | |
| 39 | 40 | shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08, |
| 40 | 41 | shadowRadius: chrome === "none" ? 0 : 28, |
| 41 | 42 | shadowOffset: |
| ... | ... | @@ -56,7 +57,7 @@ export function GlassSurface({ |
| 56 | 57 | <GlassView |
| 57 | 58 | {...props} |
| 58 | 59 | glassEffectStyle={glassEffectStyle} |
| 59 | tintColor={tintColor ?? (isDarkMode ? "rgba(15,23,42,0.18)" : "rgba(255,255,255,0.18)")} | |
| 60 | tintColor={String(tintColor ?? glassTint)} | |
| 60 | 61 | colorScheme={isDarkMode ? "dark" : "light"} |
| 61 | 62 | style={[surfaceStyle, style]} |
| 62 | 63 | > |
apps/mobile/src/components/PierreEntryIcon.tsx created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | import { SymbolView } from "expo-symbols"; | |
| 2 | import { Image, type ImageStyle, type StyleProp } from "react-native"; | |
| 3 | ||
| 4 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | |
| 5 | import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; | |
| 6 | ||
| 7 | export function PierreEntryIcon(props: { | |
| 8 | readonly path: string; | |
| 9 | readonly kind: "file" | "directory"; | |
| 10 | readonly size?: number; | |
| 11 | readonly style?: StyleProp<ImageStyle>; | |
| 12 | }) { | |
| 13 | const size = props.size ?? 16; | |
| 14 | if (props.kind === "directory") { | |
| 15 | return <SymbolView name="folder" size={size} tintColor="#a1a1aa" type="monochrome" />; | |
| 16 | } | |
| 17 | ||
| 18 | return ( | |
| 19 | <Image | |
| 20 | source={markdownFileIconSource(resolveMarkdownFileIcon(props.path))} | |
| 21 | resizeMode="contain" | |
| 22 | style={[{ width: size, height: size }, props.style]} | |
| 23 | /> | |
| 24 | ); | |
| 25 | } |
apps/mobile/src/components/ProviderIcon.tsx+1-1| ... | ... | @@ -24,7 +24,7 @@ export function ProviderIcon(props: ProviderIconProps) { |
| 24 | 24 | return ( |
| 25 | 25 | <Svg width={size} height={size} viewBox="0 0 256 260" fill="none"> |
| 26 | 26 | <Path |
| 27 | fill={isDarkMode ? "#e2e8f0" : "#171717"} | |
| 27 | fill={isDarkMode ? "#e5e5e5" : "#171717"} | |
| 28 | 28 | d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z" |
| 29 | 29 | /> |
| 30 | 30 | </Svg> |
apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import { | |
| 2 | createContext, | |
| 3 | type PropsWithChildren, | |
| 4 | useCallback, | |
| 5 | useContext, | |
| 6 | useMemo, | |
| 7 | useState, | |
| 8 | } from "react"; | |
| 9 | ||
| 10 | interface ClerkSettingsSheetDetentValue { | |
| 11 | collapse: () => void; | |
| 12 | expand: () => void; | |
| 13 | isExpanded: boolean; | |
| 14 | } | |
| 15 | ||
| 16 | const ClerkSettingsSheetDetentContext = createContext<ClerkSettingsSheetDetentValue | null>(null); | |
| 17 | ||
| 18 | interface ClerkSettingsSheetDetentProviderProps extends PropsWithChildren { | |
| 19 | initiallyExpanded: boolean; | |
| 20 | } | |
| 21 | ||
| 22 | export function ClerkSettingsSheetDetentProvider({ | |
| 23 | children, | |
| 24 | initiallyExpanded, | |
| 25 | }: ClerkSettingsSheetDetentProviderProps) { | |
| 26 | const [isExpanded, setIsExpanded] = useState(initiallyExpanded); | |
| 27 | const collapse = useCallback(() => setIsExpanded(false), []); | |
| 28 | const expand = useCallback(() => setIsExpanded(true), []); | |
| 29 | const value = useMemo(() => ({ collapse, expand, isExpanded }), [collapse, expand, isExpanded]); | |
| 30 | ||
| 31 | return ( | |
| 32 | <ClerkSettingsSheetDetentContext value={value}>{children}</ClerkSettingsSheetDetentContext> | |
| 33 | ); | |
| 34 | } | |
| 35 | ||
| 36 | export function useClerkSettingsSheetDetent(): ClerkSettingsSheetDetentValue { | |
| 37 | const value = useContext(ClerkSettingsSheetDetentContext); | |
| 38 | if (!value) { | |
| 39 | throw new Error( | |
| 40 | "useClerkSettingsSheetDetent must be used inside ClerkSettingsSheetDetentProvider", | |
| 41 | ); | |
| 42 | } | |
| 43 | return value; | |
| 44 | } |
apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts deleted-134| ... | ... | @@ -1,134 +0,0 @@ |
| 1 | import { getClerkInstance } from "@clerk/expo"; | |
| 2 | import { tokenCache } from "@clerk/expo/token-cache"; | |
| 3 | import * as Data from "effect/Data"; | |
| 4 | import { useCallback, useRef } from "react"; | |
| 5 | import type { TurboModule } from "react-native"; | |
| 6 | import { TurboModuleRegistry } from "react-native"; | |
| 7 | ||
| 8 | const CLERK_CLIENT_JWT_KEY = "__clerk_client_jwt"; | |
| 9 | ||
| 10 | interface NativeClerkModule extends TurboModule { | |
| 11 | readonly getClientToken?: () => Promise<string | null>; | |
| 12 | readonly presentAuth?: (options: { | |
| 13 | readonly dismissable: boolean; | |
| 14 | readonly mode: "signInOrUp"; | |
| 15 | }) => Promise<NativeAuthResult | null>; | |
| 16 | } | |
| 17 | ||
| 18 | interface NativeAuthResult { | |
| 19 | readonly cancelled?: boolean; | |
| 20 | readonly session?: { | |
| 21 | readonly id?: string; | |
| 22 | }; | |
| 23 | readonly sessionId?: string; | |
| 24 | } | |
| 25 | ||
| 26 | interface ClerkWithNativeSync { | |
| 27 | readonly __internal_reloadInitialResources?: () => Promise<void>; | |
| 28 | readonly setActive?: (params: { readonly session: string }) => Promise<void>; | |
| 29 | } | |
| 30 | ||
| 31 | const NativeClerk = TurboModuleRegistry.get<NativeClerkModule>("ClerkExpo"); | |
| 32 | ||
| 33 | class NativeClerkAuthError extends Data.TaggedError("NativeClerkAuthError")<{ | |
| 34 | readonly message: string; | |
| 35 | readonly cause?: unknown; | |
| 36 | }> {} | |
| 37 | ||
| 38 | async function syncNativeSession(sessionId: string): Promise<void> { | |
| 39 | const getClientToken = NativeClerk?.getClientToken; | |
| 40 | let nativeClientToken: string | null = null; | |
| 41 | if (getClientToken) { | |
| 42 | try { | |
| 43 | nativeClientToken = await getClientToken(); | |
| 44 | } catch (cause) { | |
| 45 | throw new NativeClerkAuthError({ | |
| 46 | message: "Could not read native Clerk client token.", | |
| 47 | cause, | |
| 48 | }); | |
| 49 | } | |
| 50 | } | |
| 51 | if (nativeClientToken) { | |
| 52 | const saveToken = tokenCache?.saveToken; | |
| 53 | if (saveToken) { | |
| 54 | try { | |
| 55 | await saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); | |
| 56 | } catch (cause) { | |
| 57 | throw new NativeClerkAuthError({ | |
| 58 | message: "Could not save native Clerk client token.", | |
| 59 | cause, | |
| 60 | }); | |
| 61 | } | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | const clerk = getClerkInstance(); | |
| 66 | const clerkWithNativeSync = clerk as ClerkWithNativeSync; | |
| 67 | const reloadInitialResources = clerkWithNativeSync.__internal_reloadInitialResources; | |
| 68 | if (reloadInitialResources) { | |
| 69 | try { | |
| 70 | await reloadInitialResources(); | |
| 71 | } catch (cause) { | |
| 72 | throw new NativeClerkAuthError({ | |
| 73 | message: "Could not reload Clerk resources after native auth.", | |
| 74 | cause, | |
| 75 | }); | |
| 76 | } | |
| 77 | } | |
| 78 | const setActive = clerkWithNativeSync.setActive; | |
| 79 | if (setActive) { | |
| 80 | try { | |
| 81 | await setActive({ session: sessionId }); | |
| 82 | } catch (cause) { | |
| 83 | throw new NativeClerkAuthError({ | |
| 84 | message: "Could not activate native Clerk session.", | |
| 85 | cause, | |
| 86 | }); | |
| 87 | } | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | export function useNativeClerkAuthModal() { | |
| 92 | const presentingRef = useRef(false); | |
| 93 | ||
| 94 | const presentAuth = useCallback(async (): Promise<void> => { | |
| 95 | if (presentingRef.current || !NativeClerk?.presentAuth) { | |
| 96 | return; | |
| 97 | } | |
| 98 | ||
| 99 | presentingRef.current = true; | |
| 100 | const presentNativeAuth = NativeClerk.presentAuth; | |
| 101 | try { | |
| 102 | // Clerk's iOS AuthView is not inline. It presents this same native modal | |
| 103 | // internally; call the presenter directly so Expo Router does not render | |
| 104 | // an empty formSheet behind it. | |
| 105 | let result: NativeAuthResult | null; | |
| 106 | try { | |
| 107 | result = await presentNativeAuth({ | |
| 108 | dismissable: true, | |
| 109 | mode: "signInOrUp", | |
| 110 | }); | |
| 111 | } catch (cause) { | |
| 112 | throw new NativeClerkAuthError({ | |
| 113 | message: "Native Clerk auth presentation failed.", | |
| 114 | cause, | |
| 115 | }); | |
| 116 | } | |
| 117 | const sessionId = result?.sessionId ?? result?.session?.id ?? null; | |
| 118 | if (sessionId && !result?.cancelled) { | |
| 119 | await syncNativeSession(sessionId); | |
| 120 | } | |
| 121 | } catch (error) { | |
| 122 | if (__DEV__) { | |
| 123 | console.error("[useNativeClerkAuthModal] presentAuth failed:", error); | |
| 124 | } | |
| 125 | } finally { | |
| 126 | presentingRef.current = false; | |
| 127 | } | |
| 128 | }, []); | |
| 129 | ||
| 130 | return { | |
| 131 | isAvailable: !!NativeClerk?.presentAuth, | |
| 132 | presentAuth, | |
| 133 | }; | |
| 134 | } |
apps/mobile/src/features/home/HomeScreen.tsx+6-5| ... | ... | @@ -146,8 +146,8 @@ function ProjectGroupLabel(props: { |
| 146 | 146 | bearerToken={props.bearerToken} |
| 147 | 147 | /> |
| 148 | 148 | <Text |
| 149 | className="flex-1 text-[13px] font-t3-bold uppercase text-foreground-muted" | |
| 150 | style={{ letterSpacing: 0.6 }} | |
| 149 | className="flex-1 text-[12px] font-t3-medium uppercase text-foreground-muted" | |
| 150 | style={{ letterSpacing: 0.5 }} | |
| 151 | 151 | numberOfLines={1} |
| 152 | 152 | > |
| 153 | 153 | {props.project.title} |
| ... | ... | @@ -156,8 +156,8 @@ function ProjectGroupLabel(props: { |
| 156 | 156 | {hiddenCount > 0 ? ( |
| 157 | 157 | <Pressable onPress={props.onToggleExpand} hitSlop={8}> |
| 158 | 158 | <Text |
| 159 | className="text-[13px] font-t3-bold text-foreground-muted" | |
| 160 | style={{ letterSpacing: 0.6 }} | |
| 159 | className="text-[12px] font-t3-medium text-foreground-muted" | |
| 160 | style={{ letterSpacing: 0.4 }} | |
| 161 | 161 | > |
| 162 | 162 | {props.isExpanded ? "Show less" : `${hiddenCount} more`} |
| 163 | 163 | </Text> |
| ... | ... | @@ -191,6 +191,7 @@ function ThreadRow(props: { |
| 191 | 191 | readonly isLast: boolean; |
| 192 | 192 | }) { |
| 193 | 193 | const separatorColor = useThemeColor("--color-separator"); |
| 194 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); | |
| 194 | 195 | const { bg, fg } = statusColors(props.thread); |
| 195 | 196 | const tone = threadStatusTone(props.thread); |
| 196 | 197 | const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt); |
| ... | ... | @@ -267,7 +268,7 @@ function ThreadRow(props: { |
| 267 | 268 | <SymbolView |
| 268 | 269 | name="arrow.triangle.branch" |
| 269 | 270 | size={10} |
| 270 | tintColor="#737373" | |
| 271 | tintColor={iconSubtleColor} | |
| 271 | 272 | type="monochrome" |
| 272 | 273 | /> |
| 273 | 274 | <Text |
apps/mobile/src/features/review/shikiReviewHighlighter.test.ts+20-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | 3 | import type { ReviewRenderableFile } from "./reviewModel"; |
| 4 | import { highlightReviewFile } from "./shikiReviewHighlighter"; | |
| 4 | import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter"; | |
| 5 | 5 | |
| 6 | 6 | function makeRenderableFile( |
| 7 | 7 | input: Partial<ReviewRenderableFile> & Pick<ReviewRenderableFile, "path">, |
| ... | ... | @@ -119,3 +119,22 @@ describe("highlightReviewFile", () => { |
| 119 | 119 | ]); |
| 120 | 120 | }); |
| 121 | 121 | }); |
| 122 | ||
| 123 | describe("highlightCodeSnippet", () => { | |
| 124 | it("resolves language aliases and returns syntax-colored tokens", async () => { | |
| 125 | const source = "const answer: number = 42;"; | |
| 126 | const highlighted = await highlightCodeSnippet({ | |
| 127 | code: source, | |
| 128 | language: "ts", | |
| 129 | theme: "dark", | |
| 130 | }); | |
| 131 | ||
| 132 | expect( | |
| 133 | highlighted | |
| 134 | .flat() | |
| 135 | .map((token) => token.content) | |
| 136 | .join(""), | |
| 137 | ).toBe(source); | |
| 138 | expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); | |
| 139 | }); | |
| 140 | }); |
apps/mobile/src/features/review/shikiReviewHighlighter.ts+10| ... | ... | @@ -685,6 +685,16 @@ async function highlightLines( |
| 685 | 685 | return highlightedLines; |
| 686 | 686 | } |
| 687 | 687 | |
| 688 | export async function highlightCodeSnippet(input: { | |
| 689 | readonly code: string; | |
| 690 | readonly language?: string | null; | |
| 691 | readonly theme: ReviewDiffTheme; | |
| 692 | }): Promise<ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>> { | |
| 693 | const languageHint = input.language?.trim() || "text"; | |
| 694 | const language = await resolveLanguageFromPath(`snippet.${languageHint}`, languageHint); | |
| 695 | return highlightLines(input.code, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); | |
| 696 | } | |
| 697 | ||
| 688 | 698 | async function highlightPatchLinesInChunks(input: { |
| 689 | 699 | readonly lines: ReadonlyArray<string>; |
| 690 | 700 | readonly language: string; |
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+8-3| ... | ... | @@ -6,6 +6,7 @@ import { memo } from "react"; |
| 6 | 6 | import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; |
| 7 | 7 | |
| 8 | 8 | import { AppText as Text } from "../../components/AppText"; |
| 9 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; | |
| 9 | 10 | |
| 10 | 11 | export type ComposerCommandItem = |
| 11 | 12 | | { |
| ... | ... | @@ -88,13 +89,13 @@ function PopoverSurface(props: { |
| 88 | 89 | |
| 89 | 90 | function itemIcon(item: ComposerCommandItem) { |
| 90 | 91 | switch (item.type) { |
| 91 | case "path": | |
| 92 | return item.kind === "directory" ? ("folder" as const) : ("doc" as const); | |
| 93 | 92 | case "slash-command": |
| 94 | 93 | case "provider-slash-command": |
| 95 | 94 | return "terminal" as const; |
| 96 | 95 | case "skill": |
| 97 | 96 | return "cube" as const; |
| 97 | case "path": | |
| 98 | return null; | |
| 98 | 99 | } |
| 99 | 100 | } |
| 100 | 101 | |
| ... | ... | @@ -149,7 +150,11 @@ const CommandRow = memo(function CommandRow(props: { |
| 149 | 150 | borderBottomColor: "rgba(255,255,255,0.1)", |
| 150 | 151 | })} |
| 151 | 152 | > |
| 152 | <SymbolView name={iconName} size={14} tintColor={iconColor} type="monochrome" /> | |
| 153 | {props.item.type === "path" ? ( | |
| 154 | <PierreEntryIcon path={props.item.path} kind={props.item.kind} size={16} /> | |
| 155 | ) : iconName ? ( | |
| 156 | <SymbolView name={iconName} size={14} tintColor={iconColor} type="monochrome" /> | |
| 157 | ) : null} | |
| 153 | 158 | <Text |
| 154 | 159 | className="text-[14px] font-t3-medium text-foreground" |
| 155 | 160 | numberOfLines={1} |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+15-30| ... | ... | @@ -1,19 +1,13 @@ |
| 1 | 1 | import { Stack, useRouter } from "expo-router"; |
| 2 | import { TextInputWrapper } from "expo-paste-input"; | |
| 3 | 2 | import { useCallback, useEffect, useMemo, useRef } from "react"; |
| 4 | import { | |
| 5 | InteractionManager, | |
| 6 | View, | |
| 7 | useColorScheme, | |
| 8 | type TextInput as RNTextInput, | |
| 9 | } from "react-native"; | |
| 3 | import { InteractionManager, View, useColorScheme } from "react-native"; | |
| 10 | 4 | import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; |
| 11 | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 12 | 6 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 13 | 7 | |
| 14 | 8 | import { EnvironmentId, type ModelSelection } from "@t3tools/contracts"; |
| 15 | 9 | |
| 16 | import { AppTextInput as TextInput } from "../../components/AppText"; | |
| 10 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; | |
| 17 | 11 | import { |
| 18 | 12 | ComposerToolbarButton, |
| 19 | 13 | ComposerToolbarRow, |
| ... | ... | @@ -27,7 +21,6 @@ import { ProviderIcon } from "../../components/ProviderIcon"; |
| 27 | 21 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; |
| 28 | 22 | import { buildThreadRoutePath } from "../../lib/routes"; |
| 29 | 23 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| 30 | import { useNativePaste } from "../../lib/useNativePaste"; | |
| 31 | 24 | import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions"; |
| 32 | 25 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| 33 | 26 | import { useProjectActions } from "./use-project-actions"; |
| ... | ... | @@ -75,7 +68,7 @@ export function NewTaskDraftScreen(props: { |
| 75 | 68 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); |
| 76 | 69 | const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); |
| 77 | 70 | const { logicalProjects, selectedProject, setProject } = flow; |
| 78 | const promptInputRef = useRef<RNTextInput>(null); | |
| 71 | const promptInputRef = useRef<ComposerEditorHandle>(null); | |
| 79 | 72 | |
| 80 | 73 | const borderColor = useThemeColor("--color-border"); |
| 81 | 74 | const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; |
| ... | ... | @@ -410,10 +403,6 @@ export function NewTaskDraftScreen(props: { |
| 410 | 403 | [flow], |
| 411 | 404 | ); |
| 412 | 405 | |
| 413 | const handleNativePaste = useNativePaste((uris) => { | |
| 414 | void handleNativePasteImages(uris); | |
| 415 | }); | |
| 416 | ||
| 417 | 406 | async function handleStart(): Promise<void> { |
| 418 | 407 | if ( |
| 419 | 408 | !flow.selectedProject || |
| ... | ... | @@ -478,23 +467,19 @@ export function NewTaskDraftScreen(props: { |
| 478 | 467 | |
| 479 | 468 | <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}> |
| 480 | 469 | <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}> |
| 481 | <TextInputWrapper | |
| 482 | onPaste={(payload) => void handleNativePaste(payload)} | |
| 470 | <ComposerEditor | |
| 471 | ref={promptInputRef} | |
| 472 | autoFocus | |
| 473 | multiline | |
| 474 | scrollEnabled | |
| 475 | value={flow.prompt} | |
| 476 | skills={flow.selectedProviderSkills} | |
| 477 | onChangeText={flow.setPrompt} | |
| 478 | onPasteImages={(uris) => void handleNativePasteImages(uris)} | |
| 479 | placeholder={`Describe a coding task in ${selectedProject.title}`} | |
| 483 | 480 | style={{ flex: 1, minHeight: 0 }} |
| 484 | > | |
| 485 | <TextInput | |
| 486 | ref={promptInputRef} | |
| 487 | autoFocus | |
| 488 | multiline | |
| 489 | scrollEnabled | |
| 490 | value={flow.prompt} | |
| 491 | onChangeText={flow.setPrompt} | |
| 492 | placeholder={`Describe a coding task in ${selectedProject.title}`} | |
| 493 | textAlignVertical="top" | |
| 494 | className="h-full flex-1 border-0 bg-transparent text-[18px] leading-[28px]" | |
| 495 | style={{ flex: 1, minHeight: 0 }} | |
| 496 | /> | |
| 497 | </TextInputWrapper> | |
| 481 | textStyle={{ fontSize: 18, lineHeight: 28 }} | |
| 482 | /> | |
| 498 | 483 | </View> |
| 499 | 484 | |
| 500 | 485 | <View |
apps/mobile/src/features/threads/ThreadComposer.tsx+82-85| ... | ... | @@ -13,24 +13,19 @@ import { |
| 13 | 13 | serializeComposerFileLink, |
| 14 | 14 | type ComposerTrigger, |
| 15 | 15 | } from "@t3tools/shared/composerTrigger"; |
| 16 | import { TextInputWrapper } from "expo-paste-input"; | |
| 17 | 16 | import type { ReactNode } from "react"; |
| 18 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 19 | import { | |
| 20 | Image, | |
| 21 | Pressable, | |
| 22 | TextInput as RNTextInput, | |
| 23 | useColorScheme, | |
| 24 | View, | |
| 25 | type NativeSyntheticEvent, | |
| 26 | type TextInputSelectionChangeEventData, | |
| 27 | type ViewStyle, | |
| 28 | } from "react-native"; | |
| 17 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; | |
| 18 | import { Image, Pressable, useColorScheme, View, type ViewStyle } from "react-native"; | |
| 29 | 19 | import ImageViewing from "react-native-image-viewing"; |
| 30 | 20 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 31 | 21 | |
| 32 | 22 | import { AppText as Text } from "../../components/AppText"; |
| 33 | 23 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 24 | import { | |
| 25 | ComposerEditor, | |
| 26 | type ComposerEditorHandle, | |
| 27 | type ComposerEditorSelection, | |
| 28 | } from "../../components/ComposerEditor"; | |
| 34 | 29 | import { |
| 35 | 30 | ComposerToolbarButton, |
| 36 | 31 | ComposerToolbarRow, |
| ... | ... | @@ -42,7 +37,6 @@ import { ProviderIcon } from "../../components/ProviderIcon"; |
| 42 | 37 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 43 | 38 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 44 | 39 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| 45 | import { useNativePaste } from "../../lib/useNativePaste"; | |
| 46 | 40 | import { |
| 47 | 41 | insertRankedSearchResult, |
| 48 | 42 | normalizeSearchQuery, |
| ... | ... | @@ -60,7 +54,7 @@ import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerComm |
| 60 | 54 | * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). |
| 61 | 55 | * Exported so the parent can compute feed overlap / content insets. |
| 62 | 56 | */ |
| 63 | export const COMPOSER_COLLAPSED_CHROME = 68; | |
| 57 | export const COMPOSER_COLLAPSED_CHROME = 60; | |
| 64 | 58 | |
| 65 | 59 | /** |
| 66 | 60 | * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). |
| ... | ... | @@ -68,13 +62,6 @@ export const COMPOSER_COLLAPSED_CHROME = 68; |
| 68 | 62 | */ |
| 69 | 63 | export const COMPOSER_EXPANDED_CHROME = 174; |
| 70 | 64 | |
| 71 | /** | |
| 72 | * Height of the expanded-only toolbar below the text surface. | |
| 73 | * Used by the feed inset because KeyboardAvoidingLegendList only accounts for | |
| 74 | * keyboard height; the floating toolbar remains an additional overlay. | |
| 75 | */ | |
| 76 | export const COMPOSER_EXPANDED_TOOLBAR_CHROME = 60; | |
| 77 | ||
| 78 | 65 | export interface ThreadComposerProps { |
| 79 | 66 | readonly draftMessage: string; |
| 80 | 67 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| ... | ... | @@ -87,6 +74,7 @@ export interface ThreadComposerProps { |
| 87 | 74 | readonly activeThreadBusy: boolean; |
| 88 | 75 | readonly environmentId: EnvironmentId; |
| 89 | 76 | readonly projectCwd: string | null; |
| 77 | readonly editorRef?: RefObject<ComposerEditorHandle | null>; | |
| 90 | 78 | readonly onChangeDraftMessage: (value: string) => void; |
| 91 | 79 | readonly onPickDraftImages: () => Promise<void>; |
| 92 | 80 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| ... | ... | @@ -156,10 +144,9 @@ function formatTitleCase(value: string): string { |
| 156 | 144 | |
| 157 | 145 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { |
| 158 | 146 | const isDarkMode = useColorScheme() === "dark"; |
| 159 | const themePlaceholderColor = useThemeColor("--color-placeholder"); | |
| 160 | const placeholderColor = isDarkMode ? "#a1a1aa" : themePlaceholderColor; | |
| 161 | 147 | const foregroundColor = useThemeColor("--color-foreground"); |
| 162 | const inputRef = useRef<RNTextInput>(null); | |
| 148 | const fallbackInputRef = useRef<ComposerEditorHandle>(null); | |
| 149 | const inputRef = props.editorRef ?? fallbackInputRef; | |
| 163 | 150 | const [isFocused, setIsFocused] = useState(false); |
| 164 | 151 | const wasExpandedBeforePreviewRef = useRef(false); |
| 165 | 152 | const { onExpandedChange } = props; |
| ... | ... | @@ -182,11 +169,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 182 | 169 | if (wasExpandedBeforePreviewRef.current) { |
| 183 | 170 | setTimeout(() => inputRef.current?.focus(), 100); |
| 184 | 171 | } |
| 185 | }, []); | |
| 172 | }, [inputRef]); | |
| 186 | 173 | |
| 187 | useEffect(() => { | |
| 188 | onExpandedChange?.(isExpanded); | |
| 189 | }, [isExpanded, onExpandedChange]); | |
| 174 | const handleFocus = useCallback(() => { | |
| 175 | setIsFocused(true); | |
| 176 | onExpandedChange?.(true); | |
| 177 | }, [onExpandedChange]); | |
| 178 | ||
| 179 | const handleBlur = useCallback(() => { | |
| 180 | setIsFocused(false); | |
| 181 | onExpandedChange?.(false); | |
| 182 | }, [onExpandedChange]); | |
| 190 | 183 | const showStopAction = |
| 191 | 184 | props.selectedThread.session?.status === "running" || |
| 192 | 185 | props.selectedThread.session?.status === "starting" || |
| ... | ... | @@ -219,26 +212,33 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 219 | 212 | selectedProviderDriver === "claudeAgent" |
| 220 | 213 | ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M") |
| 221 | 214 | : "1M"; |
| 222 | ||
| 223 | const handleNativePaste = useNativePaste((uris) => { | |
| 224 | void props.onNativePasteImages(uris); | |
| 225 | }); | |
| 226 | ||
| 227 | 215 | // ── Trigger detection ──────────────────────────────────── |
| 228 | const [cursorPosition, setCursorPosition] = useState(0); | |
| 216 | const [composerSelection, setComposerSelection] = useState(() => ({ | |
| 217 | start: props.draftMessage.length, | |
| 218 | end: props.draftMessage.length, | |
| 219 | })); | |
| 229 | 220 | |
| 230 | const handleSelectionChange = useCallback( | |
| 231 | (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { | |
| 232 | const { start } = event.nativeEvent.selection; | |
| 233 | setCursorPosition(start); | |
| 234 | }, | |
| 235 | [], | |
| 236 | ); | |
| 221 | const handleSelectionChange = useCallback((selection: ComposerEditorSelection) => { | |
| 222 | setComposerSelection(selection); | |
| 223 | }, []); | |
| 224 | useEffect(() => { | |
| 225 | const end = props.draftMessage.length; | |
| 226 | setComposerSelection((selection) => { | |
| 227 | const start = Math.min(selection.start, end); | |
| 228 | const selectionEnd = Math.min(selection.end, end); | |
| 229 | if (start === selection.start && selectionEnd === selection.end) { | |
| 230 | return selection; | |
| 231 | } | |
| 232 | return { start, end: selectionEnd }; | |
| 233 | }); | |
| 234 | }, [props.draftMessage.length]); | |
| 237 | 235 | |
| 238 | const composerTrigger = useMemo<ComposerTrigger | null>( | |
| 239 | () => detectComposerTrigger(props.draftMessage, cursorPosition), | |
| 240 | [cursorPosition, props.draftMessage], | |
| 241 | ); | |
| 236 | const composerTrigger = useMemo<ComposerTrigger | null>(() => { | |
| 237 | if (composerSelection.start !== composerSelection.end) { | |
| 238 | return null; | |
| 239 | } | |
| 240 | return detectComposerTrigger(props.draftMessage, composerSelection.end); | |
| 241 | }, [composerSelection, props.draftMessage]); | |
| 242 | 242 | const pathSearch = useComposerPathSearch({ |
| 243 | 243 | environmentId: props.environmentId, |
| 244 | 244 | cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, |
| ... | ... | @@ -411,7 +411,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 411 | 411 | composerTrigger.rangeEnd, |
| 412 | 412 | "", |
| 413 | 413 | ); |
| 414 | setCursorPosition(result.cursor); | |
| 414 | setComposerSelection({ start: result.cursor, end: result.cursor }); | |
| 415 | 415 | onChangeDraftMessage(result.text); |
| 416 | 416 | void onUpdateInteractionMode(item.command); |
| 417 | 417 | return; |
| ... | ... | @@ -434,7 +434,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 434 | 434 | composerTrigger.rangeEnd, |
| 435 | 435 | replacement, |
| 436 | 436 | ); |
| 437 | setCursorPosition(result.cursor); | |
| 437 | setComposerSelection({ start: result.cursor, end: result.cursor }); | |
| 438 | 438 | onChangeDraftMessage(result.text); |
| 439 | 439 | }, |
| 440 | 440 | [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], |
| ... | ... | @@ -624,8 +624,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 624 | 624 | <View |
| 625 | 625 | style={{ |
| 626 | 626 | paddingHorizontal: 16, |
| 627 | paddingTop: isExpanded ? 12 : 10, | |
| 628 | paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 4 : 10), | |
| 627 | paddingTop: isExpanded ? 8 : 6, | |
| 628 | paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6), | |
| 629 | 629 | experimental_backgroundImage: isDarkMode |
| 630 | 630 | ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 40%, rgba(0,0,0,0.95) 100%)" |
| 631 | 631 | : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)", |
| ... | ... | @@ -685,42 +685,39 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 685 | 685 | ) : null} |
| 686 | 686 | |
| 687 | 687 | <View style={isExpanded ? undefined : { flex: 1, minWidth: 0 }}> |
| 688 | <TextInputWrapper onPaste={handleNativePaste}> | |
| 689 | <RNTextInput | |
| 690 | ref={inputRef} | |
| 691 | multiline | |
| 692 | value={props.draftMessage} | |
| 693 | onChangeText={props.onChangeDraftMessage} | |
| 694 | onSelectionChange={handleSelectionChange} | |
| 695 | placeholder={props.placeholder} | |
| 696 | placeholderTextColor={placeholderColor} | |
| 697 | editable={props.connectionState === "ready"} | |
| 698 | onFocus={() => setIsFocused(true)} | |
| 699 | onBlur={() => setIsFocused(false)} | |
| 700 | textAlignVertical={isExpanded ? "top" : "center"} | |
| 701 | style={ | |
| 702 | isExpanded | |
| 703 | ? { | |
| 704 | minHeight: 80, | |
| 705 | maxHeight: 160, | |
| 706 | paddingHorizontal: 4, | |
| 707 | paddingVertical: 4, | |
| 708 | fontSize: 15, | |
| 709 | lineHeight: 22, | |
| 710 | color: foregroundColor, | |
| 711 | fontFamily: "DMSans_400Regular", | |
| 712 | } | |
| 713 | : { | |
| 714 | maxHeight: 36, | |
| 715 | paddingVertical: 6, | |
| 716 | fontSize: 15, | |
| 717 | lineHeight: 20, | |
| 718 | color: foregroundColor, | |
| 719 | fontFamily: "DMSans_400Regular", | |
| 720 | } | |
| 721 | } | |
| 722 | /> | |
| 723 | </TextInputWrapper> | |
| 688 | <ComposerEditor | |
| 689 | ref={inputRef} | |
| 690 | multiline | |
| 691 | value={props.draftMessage} | |
| 692 | skills={selectedProviderStatus?.skills ?? []} | |
| 693 | selection={composerSelection} | |
| 694 | onChangeText={props.onChangeDraftMessage} | |
| 695 | onSelectionChange={handleSelectionChange} | |
| 696 | onPasteImages={(uris) => void props.onNativePasteImages(uris)} | |
| 697 | placeholder={props.placeholder} | |
| 698 | onFocus={handleFocus} | |
| 699 | onBlur={handleBlur} | |
| 700 | scrollEnabled={isExpanded} | |
| 701 | contentInsetVertical={isExpanded ? 0 : 6} | |
| 702 | style={ | |
| 703 | isExpanded | |
| 704 | ? { | |
| 705 | minHeight: 80, | |
| 706 | maxHeight: 160, | |
| 707 | paddingHorizontal: 4, | |
| 708 | paddingVertical: 4, | |
| 709 | } | |
| 710 | : { | |
| 711 | height: 36, | |
| 712 | } | |
| 713 | } | |
| 714 | textStyle={{ | |
| 715 | fontSize: 15, | |
| 716 | lineHeight: isExpanded ? 22 : 20, | |
| 717 | color: foregroundColor, | |
| 718 | fontFamily: "DMSans_400Regular", | |
| 719 | }} | |
| 720 | /> | |
| 724 | 721 | </View> |
| 725 | 722 | {!isExpanded && props.draftAttachments.length > 0 ? ( |
| 726 | 723 | <View style={{ flexDirection: "row", gap: 4, paddingLeft: 4 }}> |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+76-15| ... | ... | @@ -11,17 +11,20 @@ import type { |
| 11 | 11 | } from "@t3tools/contracts"; |
| 12 | 12 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 13 | 13 | import * as Haptics from "expo-haptics"; |
| 14 | import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; | |
| 14 | 15 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 15 | import { View, type LayoutChangeEvent } from "react-native"; | |
| 16 | import { View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native"; | |
| 16 | 17 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 17 | 18 | import { KeyboardStickyView } from "react-native-keyboard-controller"; |
| 18 | 19 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 19 | 20 | import { runOnJS } from "react-native-reanimated"; |
| 20 | 21 | |
| 21 | 22 | import { AppText as Text } from "../../components/AppText"; |
| 23 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | |
| 22 | 24 | import type { StatusTone } from "../../components/StatusPill"; |
| 23 | 25 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 24 | 26 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; |
| 27 | import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout"; | |
| 25 | 28 | import type { |
| 26 | 29 | PendingApproval, |
| 27 | 30 | PendingUserInput, |
| ... | ... | @@ -33,7 +36,6 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; |
| 33 | 36 | import { |
| 34 | 37 | COMPOSER_COLLAPSED_CHROME, |
| 35 | 38 | COMPOSER_EXPANDED_CHROME, |
| 36 | COMPOSER_EXPANDED_TOOLBAR_CHROME, | |
| 37 | 39 | ThreadComposer, |
| 38 | 40 | } from "./ThreadComposer"; |
| 39 | 41 | import { ThreadFeed } from "./ThreadFeed"; |
| ... | ... | @@ -200,7 +202,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 200 | 202 | const { onOpenDrawer } = props; |
| 201 | 203 | |
| 202 | 204 | const insets = useSafeAreaInsets(); |
| 205 | const headerHeight = useHeaderHeight(); | |
| 203 | 206 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| 207 | const composerRef = useRef<ComposerEditorHandle>(null); | |
| 208 | const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); | |
| 204 | 209 | const [composerExpanded, setComposerExpanded] = useState(false); |
| 205 | 210 | const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); |
| 206 | 211 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| ... | ... | @@ -211,10 +216,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 211 | 216 | const showContent = props.showContent ?? true; |
| 212 | 217 | const layoutVariant = props.layoutVariant ?? "compact"; |
| 213 | 218 | const isSplitLayout = layoutVariant === "split"; |
| 219 | const selectedInstanceId = props.selectedThread.modelSelection.instanceId; | |
| 214 | 220 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); |
| 215 | const expandedToolbarInset = composerExpanded ? COMPOSER_EXPANDED_TOOLBAR_CHROME : 0; | |
| 216 | const feedBottomInset = | |
| 217 | Math.max(estimatedOverlayHeight, measuredOverlayHeight) + expandedToolbarInset + 8; | |
| 221 | const feedBottomInset = resolveThreadFeedBottomInset({ | |
| 222 | estimatedOverlayHeight, | |
| 223 | measuredOverlayHeight, | |
| 224 | gap: 8, | |
| 225 | }); | |
| 226 | const selectedProviderSkills = useMemo( | |
| 227 | () => | |
| 228 | props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) | |
| 229 | ?.skills ?? [], | |
| 230 | [props.serverConfig, selectedInstanceId], | |
| 231 | ); | |
| 218 | 232 | |
| 219 | 233 | const completeDrawerGesture = useCallback(() => { |
| 220 | 234 | void Haptics.selectionAsync(); |
| ... | ... | @@ -245,20 +259,66 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 245 | 259 | ); |
| 246 | 260 | }, []); |
| 247 | 261 | |
| 262 | const collapseComposer = useCallback(() => { | |
| 263 | composerRef.current?.blur(); | |
| 264 | }, []); | |
| 265 | ||
| 266 | const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { | |
| 267 | feedTouchStartRef.current = { | |
| 268 | pageX: event.nativeEvent.pageX, | |
| 269 | pageY: event.nativeEvent.pageY, | |
| 270 | }; | |
| 271 | }, []); | |
| 272 | ||
| 273 | const handleFeedTouchMove = useCallback((event: GestureResponderEvent) => { | |
| 274 | const start = feedTouchStartRef.current; | |
| 275 | if (!start) { | |
| 276 | return; | |
| 277 | } | |
| 278 | const deltaX = event.nativeEvent.pageX - start.pageX; | |
| 279 | const deltaY = event.nativeEvent.pageY - start.pageY; | |
| 280 | if (Math.hypot(deltaX, deltaY) > 8) { | |
| 281 | feedTouchStartRef.current = null; | |
| 282 | } | |
| 283 | }, []); | |
| 284 | ||
| 285 | const handleFeedTouchEnd = useCallback(() => { | |
| 286 | if (feedTouchStartRef.current) { | |
| 287 | collapseComposer(); | |
| 288 | } | |
| 289 | feedTouchStartRef.current = null; | |
| 290 | }, [collapseComposer]); | |
| 291 | ||
| 292 | const handleFeedTouchCancel = useCallback(() => { | |
| 293 | feedTouchStartRef.current = null; | |
| 294 | }, []); | |
| 295 | ||
| 248 | 296 | return ( |
| 249 | 297 | <GestureDetector gesture={headerDrawerGesture}> |
| 250 | 298 | <View className="flex-1"> |
| 251 | 299 | {showContent ? ( |
| 252 | <ThreadFeed | |
| 253 | threadId={props.selectedThread.id} | |
| 254 | feed={props.selectedThreadFeed} | |
| 255 | httpBaseUrl={props.httpBaseUrl} | |
| 256 | bearerToken={props.bearerToken} | |
| 257 | agentLabel={agentLabel} | |
| 258 | contentBottomInset={feedBottomInset} | |
| 259 | layoutVariant={layoutVariant} | |
| 260 | composerExpanded={composerExpanded} | |
| 261 | /> | |
| 300 | <View | |
| 301 | style={{ flex: 1 }} | |
| 302 | onTouchStart={handleFeedTouchStart} | |
| 303 | onTouchMove={handleFeedTouchMove} | |
| 304 | onTouchEnd={handleFeedTouchEnd} | |
| 305 | onTouchCancel={handleFeedTouchCancel} | |
| 306 | > | |
| 307 | <ThreadFeed | |
| 308 | key={props.selectedThread.id} | |
| 309 | threadId={props.selectedThread.id} | |
| 310 | feed={props.selectedThreadFeed} | |
| 311 | httpBaseUrl={props.httpBaseUrl} | |
| 312 | bearerToken={props.bearerToken} | |
| 313 | agentLabel={agentLabel} | |
| 314 | latestTurn={props.selectedThread.latestTurn} | |
| 315 | contentTopInset={headerHeight} | |
| 316 | contentBottomInset={feedBottomInset} | |
| 317 | layoutVariant={layoutVariant} | |
| 318 | composerExpanded={composerExpanded} | |
| 319 | skills={selectedProviderSkills} | |
| 320 | /> | |
| 321 | </View> | |
| 262 | 322 | ) : ( |
| 263 | 323 | <View style={{ flex: 1 }} /> |
| 264 | 324 | )} |
| ... | ... | @@ -298,6 +358,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 298 | 358 | ) : null} |
| 299 | 359 | |
| 300 | 360 | <ThreadComposer |
| 361 | editorRef={composerRef} | |
| 301 | 362 | draftMessage={props.draftMessage} |
| 302 | 363 | draftAttachments={props.draftAttachments} |
| 303 | 364 | placeholder="Ask the repo agent, or run a command…" |
apps/mobile/src/features/threads/ThreadFeed.tsx+790-205| ... | ... | @@ -2,9 +2,9 @@ import * as Clipboard from "expo-clipboard"; |
| 2 | 2 | import * as Haptics from "expo-haptics"; |
| 3 | 3 | import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; |
| 4 | 4 | import { type LegendListRef } from "@legendapp/list/react-native"; |
| 5 | import type { ThreadId } from "@t3tools/contracts"; | |
| 5 | import type { ThreadId, TurnId } from "@t3tools/contracts"; | |
| 6 | 6 | import { SymbolView } from "expo-symbols"; |
| 7 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 7 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | |
| 8 | 8 | import { |
| 9 | 9 | Markdown, |
| 10 | 10 | type CustomRenderers, |
| ... | ... | @@ -13,6 +13,9 @@ import { |
| 13 | 13 | } from "react-native-nitro-markdown"; |
| 14 | 14 | import { |
| 15 | 15 | Image, |
| 16 | Linking, | |
| 17 | type NativeScrollEvent, | |
| 18 | type NativeSyntheticEvent, | |
| 16 | 19 | Pressable, |
| 17 | 20 | ScrollView, |
| 18 | 21 | StyleSheet, |
| ... | ... | @@ -26,9 +29,16 @@ import { TouchableOpacity } from "react-native-gesture-handler"; |
| 26 | 29 | import ImageViewing from "react-native-image-viewing"; |
| 27 | 30 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 28 | 31 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 32 | import { | |
| 33 | hasNativeSelectableMarkdownText, | |
| 34 | SelectableMarkdownText, | |
| 35 | type NativeMarkdownTextStyle, | |
| 36 | type SelectableMarkdownSkill, | |
| 37 | } from "../../native/SelectableMarkdownText"; | |
| 29 | 38 | |
| 30 | 39 | import { AppText as Text } from "../../components/AppText"; |
| 31 | 40 | import { EmptyState } from "../../components/EmptyState"; |
| 41 | import { CopyTextButton } from "../../components/CopyTextButton"; | |
| 32 | 42 | import { |
| 33 | 43 | parseReviewCommentMessageSegments, |
| 34 | 44 | type ReviewInlineComment, |
| ... | ... | @@ -44,19 +54,43 @@ import { |
| 44 | 54 | import { buildReviewParsedDiff } from "../review/reviewModel"; |
| 45 | 55 | import { cn } from "../../lib/cn"; |
| 46 | 56 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; |
| 47 | import type { ThreadFeedEntry } from "../../lib/threadActivity"; | |
| 57 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | |
| 58 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; | |
| 59 | import { | |
| 60 | deriveThreadFeedPresentation, | |
| 61 | type ThreadFeedEntry, | |
| 62 | type ThreadFeedLatestTurn, | |
| 63 | } from "../../lib/threadActivity"; | |
| 64 | import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout"; | |
| 48 | 65 | import { relativeTime } from "../../lib/time"; |
| 49 | 66 | import { messageImageUrl } from "./threadPresentation"; |
| 50 | 67 | |
| 68 | const THREAD_FEED_END_THRESHOLD = 80; | |
| 69 | const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { | |
| 70 | hour: "numeric", | |
| 71 | minute: "2-digit", | |
| 72 | }); | |
| 73 | ||
| 74 | function formatMessageTime(input: string): string { | |
| 75 | const timestamp = Date.parse(input); | |
| 76 | if (Number.isNaN(timestamp)) { | |
| 77 | return ""; | |
| 78 | } | |
| 79 | return MESSAGE_TIME_FORMATTER.format(timestamp); | |
| 80 | } | |
| 81 | ||
| 51 | 82 | export interface ThreadFeedProps { |
| 52 | 83 | readonly threadId: ThreadId; |
| 53 | 84 | readonly feed: ReadonlyArray<ThreadFeedEntry>; |
| 54 | 85 | readonly httpBaseUrl: string | null; |
| 55 | 86 | readonly bearerToken: string | null; |
| 56 | 87 | readonly agentLabel: string; |
| 88 | readonly latestTurn: ThreadFeedLatestTurn | null; | |
| 89 | readonly contentTopInset?: number; | |
| 57 | 90 | readonly contentBottomInset?: number; |
| 58 | 91 | readonly layoutVariant?: MobileLayoutVariant; |
| 59 | 92 | readonly composerExpanded?: boolean; |
| 93 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; | |
| 60 | 94 | } |
| 61 | 95 | |
| 62 | 96 | function stripShellWrapper(value: string): string { |
| ... | ... | @@ -75,34 +109,48 @@ function compactActivityDetail(detail: string | null): string | null { |
| 75 | 109 | } |
| 76 | 110 | |
| 77 | 111 | function buildActivityRows( |
| 78 | activities: ReadonlyArray<{ | |
| 79 | readonly id: string; | |
| 80 | readonly createdAt: string; | |
| 81 | readonly summary: string; | |
| 82 | readonly detail: string | null; | |
| 83 | readonly status: string | null; | |
| 84 | }>, | |
| 112 | activities: Extract<ThreadFeedEntry, { type: "activity-group" }>["activities"], | |
| 85 | 113 | ) { |
| 86 | return activities.map<{ | |
| 87 | id: string; | |
| 88 | createdAt: string; | |
| 89 | summary: string; | |
| 90 | detail: string | null; | |
| 91 | status: string | null; | |
| 92 | }>((activity) => ({ | |
| 93 | id: activity.id, | |
| 94 | createdAt: activity.createdAt, | |
| 95 | summary: activity.summary, | |
| 114 | return activities.map((activity) => ({ | |
| 115 | ...activity, | |
| 96 | 116 | detail: compactActivityDetail(activity.detail), |
| 97 | status: activity.status, | |
| 98 | 117 | })); |
| 99 | 118 | } |
| 100 | 119 | |
| 101 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | |
| 120 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; | |
| 102 | 121 | |
| 103 | function toMarkdownThemeColor(value: ColorValue): string { | |
| 104 | return value as string; | |
| 105 | } | |
| 122 | const MARKDOWN_COLORS = { | |
| 123 | light: { | |
| 124 | body: "#111111", | |
| 125 | strong: "#000000", | |
| 126 | link: "#2563eb", | |
| 127 | blockquoteBorder: "rgba(0, 0, 0, 0.08)", | |
| 128 | blockquoteBackground: "rgba(0, 0, 0, 0.02)", | |
| 129 | codeBackground: "rgba(0, 0, 0, 0.04)", | |
| 130 | codeText: "#262626", | |
| 131 | horizontalRule: "rgba(0, 0, 0, 0.08)", | |
| 132 | userBody: "#ffffff", | |
| 133 | userCodeBackground: "rgba(255, 255, 255, 0.22)", | |
| 134 | userCodeText: "#ffffff", | |
| 135 | userFenceBackground: "rgba(0, 0, 0, 0.16)", | |
| 136 | userFenceText: "#ffffff", | |
| 137 | }, | |
| 138 | dark: { | |
| 139 | body: "#e5e5e5", | |
| 140 | strong: "#f5f5f5", | |
| 141 | link: "#60a5fa", | |
| 142 | blockquoteBorder: "rgba(255, 255, 255, 0.1)", | |
| 143 | blockquoteBackground: "rgba(255, 255, 255, 0.03)", | |
| 144 | codeBackground: "rgba(255, 255, 255, 0.06)", | |
| 145 | codeText: "#e5e5e5", | |
| 146 | horizontalRule: "rgba(255, 255, 255, 0.08)", | |
| 147 | userBody: "#ffffff", | |
| 148 | userCodeBackground: "rgba(255, 255, 255, 0.18)", | |
| 149 | userCodeText: "#ffffff", | |
| 150 | userFenceBackground: "rgba(0, 0, 0, 0.28)", | |
| 151 | userFenceText: "#ffffff", | |
| 152 | }, | |
| 153 | } as const; | |
| 106 | 154 | |
| 107 | 155 | interface MarkdownStyleSets { |
| 108 | 156 | readonly user: MarkdownStyleSet; |
| ... | ... | @@ -113,6 +161,7 @@ interface MarkdownStyleSet { |
| 113 | 161 | readonly theme: PartialMarkdownTheme; |
| 114 | 162 | readonly styles: NodeStyleOverrides; |
| 115 | 163 | readonly renderers: CustomRenderers; |
| 164 | readonly nativeTextStyle: NativeMarkdownTextStyle; | |
| 116 | 165 | } |
| 117 | 166 | |
| 118 | 167 | interface ReviewCommentColors { |
| ... | ... | @@ -124,6 +173,70 @@ interface ReviewCommentColors { |
| 124 | 173 | readonly codeBackground: ColorValue; |
| 125 | 174 | } |
| 126 | 175 | |
| 176 | const failedMarkdownFaviconHosts = new Set<string>(); | |
| 177 | const markdownLinkStyles = StyleSheet.create({ | |
| 178 | favicon: { | |
| 179 | width: 14, | |
| 180 | height: 14, | |
| 181 | borderRadius: 3, | |
| 182 | marginHorizontal: 3, | |
| 183 | transform: [{ translateY: 2 }], | |
| 184 | }, | |
| 185 | file: { | |
| 186 | borderRadius: 5, | |
| 187 | borderWidth: StyleSheet.hairlineWidth, | |
| 188 | fontFamily: "DMSans_500Medium", | |
| 189 | fontSize: 13, | |
| 190 | lineHeight: 20, | |
| 191 | paddingHorizontal: 6, | |
| 192 | paddingVertical: 2, | |
| 193 | }, | |
| 194 | fileIcon: { | |
| 195 | width: 15, | |
| 196 | height: 15, | |
| 197 | marginRight: 4, | |
| 198 | transform: [{ translateY: 2 }], | |
| 199 | }, | |
| 200 | }); | |
| 201 | ||
| 202 | const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { | |
| 203 | readonly children: ReactNode; | |
| 204 | readonly color: string; | |
| 205 | readonly host: string; | |
| 206 | readonly href: string; | |
| 207 | }) { | |
| 208 | const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); | |
| 209 | ||
| 210 | return ( | |
| 211 | <NativeText | |
| 212 | onPress={() => { | |
| 213 | void Linking.openURL(props.href); | |
| 214 | }} | |
| 215 | style={{ | |
| 216 | color: props.color, | |
| 217 | fontFamily: "DMSans_400Regular", | |
| 218 | textDecorationLine: "none", | |
| 219 | }} | |
| 220 | > | |
| 221 | {!failed ? ( | |
| 222 | <Image | |
| 223 | source={{ | |
| 224 | uri: `https://www.google.com/s2/favicons?domain=${encodeURIComponent(props.host)}&sz=32`, | |
| 225 | }} | |
| 226 | style={markdownLinkStyles.favicon} | |
| 227 | onError={() => { | |
| 228 | failedMarkdownFaviconHosts.add(props.host); | |
| 229 | setFailed(true); | |
| 230 | }} | |
| 231 | /> | |
| 232 | ) : ( | |
| 233 | <NativeText style={{ color: props.color }}>{" ◉ "}</NativeText> | |
| 234 | )} | |
| 235 | {props.children} | |
| 236 | </NativeText> | |
| 237 | ); | |
| 238 | }); | |
| 239 | ||
| 127 | 240 | function useReviewCommentColors(): ReviewCommentColors { |
| 128 | 241 | const colorScheme = useColorScheme(); |
| 129 | 242 | const isDark = colorScheme === "dark"; |
| ... | ... | @@ -148,34 +261,26 @@ function useReviewCommentColors(): ReviewCommentColors { |
| 148 | 261 | } |
| 149 | 262 | |
| 150 | 263 | function useMarkdownStyles(): MarkdownStyleSets { |
| 151 | const bodyColor = useThemeColor("--color-md-body"); | |
| 152 | const strongColor = useThemeColor("--color-md-strong"); | |
| 153 | const linkColor = useThemeColor("--color-md-link"); | |
| 154 | const blockquoteBg = useThemeColor("--color-md-blockquote-bg"); | |
| 155 | const blockquoteBorder = useThemeColor("--color-md-blockquote-border"); | |
| 156 | const codeBg = useThemeColor("--color-md-code-bg"); | |
| 157 | const codeText = useThemeColor("--color-md-code-text"); | |
| 158 | const hrColor = useThemeColor("--color-md-hr"); | |
| 159 | const userBodyColor = useThemeColor("--color-user-bubble-foreground"); | |
| 160 | const userCodeBg = useThemeColor("--color-md-user-code-bg"); | |
| 161 | const userCodeText = useThemeColor("--color-md-user-code-text"); | |
| 162 | const userFenceBg = useThemeColor("--color-md-user-fence-bg"); | |
| 163 | const userFenceText = useThemeColor("--color-md-user-fence-text"); | |
| 264 | const colorScheme = useColorScheme(); | |
| 265 | const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; | |
| 266 | const inlineChipBackground = String(useThemeColor("--color-subtle")); | |
| 267 | const inlineSkillBackground = String(useThemeColor("--color-inline-skill-background")); | |
| 268 | const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); | |
| 164 | 269 | |
| 165 | 270 | return useMemo(() => { |
| 166 | const markdownBodyColor = toMarkdownThemeColor(bodyColor); | |
| 167 | const markdownStrongColor = toMarkdownThemeColor(strongColor); | |
| 168 | const markdownLinkColor = toMarkdownThemeColor(linkColor); | |
| 169 | const markdownBlockquoteBg = toMarkdownThemeColor(blockquoteBg); | |
| 170 | const markdownBlockquoteBorder = toMarkdownThemeColor(blockquoteBorder); | |
| 171 | const markdownCodeBg = toMarkdownThemeColor(codeBg); | |
| 172 | const markdownCodeText = toMarkdownThemeColor(codeText); | |
| 173 | const markdownHrColor = toMarkdownThemeColor(hrColor); | |
| 174 | const markdownUserBodyColor = toMarkdownThemeColor(userBodyColor); | |
| 175 | const markdownUserCodeBg = toMarkdownThemeColor(userCodeBg); | |
| 176 | const markdownUserCodeText = toMarkdownThemeColor(userCodeText); | |
| 177 | const markdownUserFenceBg = toMarkdownThemeColor(userFenceBg); | |
| 178 | const markdownUserFenceText = toMarkdownThemeColor(userFenceText); | |
| 271 | const markdownBodyColor = colors.body; | |
| 272 | const markdownStrongColor = colors.strong; | |
| 273 | const markdownLinkColor = colors.link; | |
| 274 | const markdownBlockquoteBg = colors.blockquoteBackground; | |
| 275 | const markdownBlockquoteBorder = colors.blockquoteBorder; | |
| 276 | const markdownCodeBg = colors.codeBackground; | |
| 277 | const markdownCodeText = colors.codeText; | |
| 278 | const markdownHrColor = colors.horizontalRule; | |
| 279 | const markdownUserBodyColor = colors.userBody; | |
| 280 | const markdownUserCodeBg = colors.userCodeBackground; | |
| 281 | const markdownUserCodeText = colors.userCodeText; | |
| 282 | const markdownUserFenceBg = colors.userFenceBackground; | |
| 283 | const markdownUserFenceText = colors.userFenceText; | |
| 179 | 284 | |
| 180 | 285 | const baseTheme: PartialMarkdownTheme = { |
| 181 | 286 | colors: { |
| ... | ... | @@ -202,12 +307,12 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 202 | 307 | fontSizes: { |
| 203 | 308 | s: 13, |
| 204 | 309 | m: 15, |
| 205 | h1: 22, | |
| 206 | h2: 19, | |
| 207 | h3: 17, | |
| 208 | h4: 15, | |
| 209 | h5: 15, | |
| 210 | h6: 15, | |
| 310 | h1: 20, | |
| 311 | h2: 18, | |
| 312 | h3: 16, | |
| 313 | h4: 14, | |
| 314 | h5: 14, | |
| 315 | h6: 14, | |
| 211 | 316 | }, |
| 212 | 317 | fontFamilies: { |
| 213 | 318 | regular: "DMSans_400Regular", |
| ... | ... | @@ -225,8 +330,8 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 225 | 330 | |
| 226 | 331 | const baseStyles: NodeStyleOverrides = { |
| 227 | 332 | document: { flexShrink: 1 }, |
| 228 | paragraph: { marginTop: 0, marginBottom: 8 }, | |
| 229 | list: { marginTop: 4, marginBottom: 4 }, | |
| 333 | paragraph: { marginTop: 0, marginBottom: 10 }, | |
| 334 | list: { marginTop: 4, marginBottom: 8 }, | |
| 230 | 335 | list_item: { marginTop: 0, marginBottom: 4 }, |
| 231 | 336 | task_list_item: { marginTop: 0, marginBottom: 4 }, |
| 232 | 337 | text: { lineHeight: 22 }, |
| ... | ... | @@ -241,20 +346,18 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 241 | 346 | textDecorationLine: "underline" as const, |
| 242 | 347 | }, |
| 243 | 348 | blockquote: { |
| 244 | borderLeftWidth: 3, | |
| 349 | borderLeftWidth: 2, | |
| 245 | 350 | borderLeftColor: markdownBlockquoteBorder, |
| 246 | backgroundColor: markdownBlockquoteBg, | |
| 247 | paddingLeft: 12, | |
| 248 | paddingVertical: 6, | |
| 351 | paddingLeft: 11, | |
| 352 | paddingVertical: 2, | |
| 249 | 353 | marginLeft: 0, |
| 250 | marginVertical: 4, | |
| 251 | borderRadius: 4, | |
| 354 | marginVertical: 10, | |
| 252 | 355 | }, |
| 253 | 356 | heading: { |
| 254 | 357 | fontFamily: "DMSans_700Bold", |
| 255 | 358 | color: markdownStrongColor, |
| 256 | marginTop: 12, | |
| 257 | marginBottom: 6, | |
| 359 | marginTop: 18, | |
| 360 | marginBottom: 8, | |
| 258 | 361 | }, |
| 259 | 362 | horizontal_rule: { |
| 260 | 363 | backgroundColor: markdownHrColor, |
| ... | ... | @@ -263,44 +366,173 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 263 | 366 | }, |
| 264 | 367 | }; |
| 265 | 368 | |
| 266 | const createCodeRenderers = ( | |
| 369 | const createMarkdownRenderers = ( | |
| 267 | 370 | inlineBackgroundColor: string, |
| 268 | 371 | inlineTextColor: string, |
| 269 | 372 | blockBackgroundColor: string, |
| 270 | 373 | blockTextColor: string, |
| 271 | 374 | ): CustomRenderers => ({ |
| 272 | code_inline: ({ content }) => ( | |
| 273 | <NativeText | |
| 274 | style={{ | |
| 275 | backgroundColor: inlineBackgroundColor, | |
| 276 | color: inlineTextColor, | |
| 277 | borderRadius: 5, | |
| 278 | paddingHorizontal: 5, | |
| 279 | paddingVertical: 1, | |
| 280 | fontFamily: "ui-monospace", | |
| 281 | fontSize: 13, | |
| 282 | }} | |
| 283 | > | |
| 284 | {content} | |
| 285 | </NativeText> | |
| 375 | link: ({ children, href = "" }) => { | |
| 376 | const presentation = resolveMarkdownLinkPresentation(href); | |
| 377 | if (presentation.kind === "file") { | |
| 378 | return ( | |
| 379 | <NativeText | |
| 380 | style={[ | |
| 381 | markdownLinkStyles.file, | |
| 382 | { | |
| 383 | backgroundColor: inlineBackgroundColor, | |
| 384 | borderColor: markdownHrColor, | |
| 385 | color: inlineTextColor, | |
| 386 | }, | |
| 387 | ]} | |
| 388 | > | |
| 389 | <Image | |
| 390 | source={markdownFileIconSource(presentation.icon)} | |
| 391 | style={markdownLinkStyles.fileIcon} | |
| 392 | /> | |
| 393 | {presentation.label} | |
| 394 | </NativeText> | |
| 395 | ); | |
| 396 | } | |
| 397 | if (presentation.kind === "external") { | |
| 398 | return ( | |
| 399 | <MarkdownExternalLink | |
| 400 | href={presentation.href} | |
| 401 | host={presentation.host} | |
| 402 | color={markdownLinkColor} | |
| 403 | > | |
| 404 | {children} | |
| 405 | </MarkdownExternalLink> | |
| 406 | ); | |
| 407 | } | |
| 408 | const linkHref = presentation.href; | |
| 409 | return ( | |
| 410 | <NativeText | |
| 411 | onPress={ | |
| 412 | linkHref | |
| 413 | ? () => { | |
| 414 | void Linking.openURL(linkHref); | |
| 415 | } | |
| 416 | : undefined | |
| 417 | } | |
| 418 | style={{ | |
| 419 | color: markdownLinkColor, | |
| 420 | textDecorationLine: "underline", | |
| 421 | }} | |
| 422 | > | |
| 423 | {children} | |
| 424 | </NativeText> | |
| 425 | ); | |
| 426 | }, | |
| 427 | list: ({ node, Renderer, ordered = false, start = 1 }) => ( | |
| 428 | <View style={{ marginTop: 2, marginBottom: 8 }}> | |
| 429 | {node.children?.map((child, index) => { | |
| 430 | const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`; | |
| 431 | if (child.type === "task_list_item") { | |
| 432 | return ( | |
| 433 | <Renderer key={childKey} node={child} depth={1} inListItem parentIsText={false} /> | |
| 434 | ); | |
| 435 | } | |
| 436 | return ( | |
| 437 | <View | |
| 438 | key={childKey} | |
| 439 | style={{ | |
| 440 | flexDirection: "row", | |
| 441 | alignItems: "flex-start", | |
| 442 | marginBottom: 3, | |
| 443 | }} | |
| 444 | > | |
| 445 | <NativeText | |
| 446 | style={{ | |
| 447 | width: ordered ? 22 : 12, | |
| 448 | marginRight: 5, | |
| 449 | color: inlineTextColor, | |
| 450 | fontFamily: "DMSans_400Regular", | |
| 451 | fontSize: 15, | |
| 452 | lineHeight: 22, | |
| 453 | textAlign: ordered ? "right" : "center", | |
| 454 | }} | |
| 455 | > | |
| 456 | {ordered ? `${start + index}.` : "•"} | |
| 457 | </NativeText> | |
| 458 | <View style={{ flex: 1, minWidth: 0 }}> | |
| 459 | <Renderer node={child} depth={1} inListItem parentIsText={false} /> | |
| 460 | </View> | |
| 461 | </View> | |
| 462 | ); | |
| 463 | })} | |
| 464 | </View> | |
| 286 | 465 | ), |
| 287 | code_block: ({ content }) => ( | |
| 466 | code_inline: ({ content }) => { | |
| 467 | const value = content ?? ""; | |
| 468 | const wrapsPoorly = | |
| 469 | value.length > 24 || value.includes("/") || value.includes("\\") || value.includes(":"); | |
| 470 | return ( | |
| 471 | <NativeText | |
| 472 | style={{ | |
| 473 | color: inlineTextColor, | |
| 474 | fontFamily: "ui-monospace", | |
| 475 | fontSize: 12, | |
| 476 | lineHeight: 18, | |
| 477 | ...(wrapsPoorly | |
| 478 | ? { opacity: 0.82 } | |
| 479 | : { | |
| 480 | backgroundColor: inlineBackgroundColor, | |
| 481 | borderRadius: 4, | |
| 482 | paddingHorizontal: 3, | |
| 483 | }), | |
| 484 | }} | |
| 485 | > | |
| 486 | {value} | |
| 487 | </NativeText> | |
| 488 | ); | |
| 489 | }, | |
| 490 | code_block: ({ content, language }) => ( | |
| 288 | 491 | <View |
| 289 | 492 | style={{ |
| 290 | 493 | backgroundColor: blockBackgroundColor, |
| 291 | borderRadius: 12, | |
| 292 | padding: 12, | |
| 293 | marginVertical: 8, | |
| 494 | borderRadius: 10, | |
| 495 | borderWidth: 1, | |
| 496 | borderColor: markdownHrColor, | |
| 497 | marginVertical: 12, | |
| 498 | overflow: "hidden", | |
| 294 | 499 | }} |
| 295 | 500 | > |
| 296 | <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}> | |
| 501 | {language ? ( | |
| 502 | <View | |
| 503 | style={{ | |
| 504 | borderBottomWidth: 1, | |
| 505 | borderBottomColor: markdownHrColor, | |
| 506 | paddingHorizontal: 14, | |
| 507 | paddingVertical: 8, | |
| 508 | }} | |
| 509 | > | |
| 510 | <NativeText | |
| 511 | style={{ | |
| 512 | color: markdownBodyColor, | |
| 513 | fontFamily: "ui-monospace", | |
| 514 | fontSize: 12, | |
| 515 | opacity: 0.7, | |
| 516 | textTransform: "uppercase", | |
| 517 | }} | |
| 518 | > | |
| 519 | {language} | |
| 520 | </NativeText> | |
| 521 | </View> | |
| 522 | ) : null} | |
| 523 | <ScrollView | |
| 524 | horizontal | |
| 525 | showsHorizontalScrollIndicator={false} | |
| 526 | bounces={false} | |
| 527 | contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }} | |
| 528 | > | |
| 297 | 529 | <NativeText |
| 298 | 530 | selectable |
| 299 | 531 | style={{ |
| 300 | 532 | color: blockTextColor, |
| 301 | 533 | fontFamily: "ui-monospace", |
| 302 | fontSize: 13, | |
| 303 | lineHeight: 19, | |
| 534 | fontSize: 12, | |
| 535 | lineHeight: 18, | |
| 304 | 536 | }} |
| 305 | 537 | > |
| 306 | 538 | {content} |
| ... | ... | @@ -333,6 +565,8 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 333 | 565 | heading: { |
| 334 | 566 | ...baseStyles.heading, |
| 335 | 567 | color: markdownUserBodyColor, |
| 568 | marginTop: 8, | |
| 569 | marginBottom: 4, | |
| 336 | 570 | }, |
| 337 | 571 | link: { |
| 338 | 572 | color: markdownUserBodyColor, |
| ... | ... | @@ -357,48 +591,79 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 357 | 591 | user: { |
| 358 | 592 | theme: userTheme, |
| 359 | 593 | styles: userStyles, |
| 360 | renderers: createCodeRenderers( | |
| 594 | renderers: createMarkdownRenderers( | |
| 361 | 595 | markdownUserCodeBg, |
| 362 | 596 | markdownUserCodeText, |
| 363 | 597 | markdownUserFenceBg, |
| 364 | 598 | markdownUserFenceText, |
| 365 | 599 | ), |
| 600 | nativeTextStyle: { | |
| 601 | color: markdownUserBodyColor, | |
| 602 | strongColor: markdownUserBodyColor, | |
| 603 | mutedColor: markdownUserBodyColor, | |
| 604 | linkColor: markdownUserBodyColor, | |
| 605 | codeColor: markdownUserCodeText, | |
| 606 | codeBackgroundColor: markdownUserCodeBg, | |
| 607 | codeBlockBackgroundColor: markdownUserFenceBg, | |
| 608 | fileBackgroundColor: "rgba(255, 255, 255, 0.12)", | |
| 609 | fileTextColor: "#ffffff", | |
| 610 | skillBackgroundColor: "rgba(217, 70, 239, 0.24)", | |
| 611 | skillTextColor: "#ffffff", | |
| 612 | quoteMarkerColor: markdownUserBodyColor, | |
| 613 | dividerColor: markdownUserBodyColor, | |
| 614 | fontSize: 15, | |
| 615 | lineHeight: 22, | |
| 616 | fontFamily: "DMSans_400Regular", | |
| 617 | headingFontFamily: "DMSans_700Bold", | |
| 618 | boldFontFamily: "DMSans_700Bold", | |
| 619 | }, | |
| 366 | 620 | }, |
| 367 | 621 | assistant: { |
| 368 | 622 | theme: assistantTheme, |
| 369 | 623 | styles: assistantStyles, |
| 370 | renderers: createCodeRenderers( | |
| 624 | renderers: createMarkdownRenderers( | |
| 371 | 625 | markdownCodeBg, |
| 372 | 626 | markdownCodeText, |
| 373 | 627 | markdownCodeBg, |
| 374 | 628 | markdownCodeText, |
| 375 | 629 | ), |
| 630 | nativeTextStyle: { | |
| 631 | color: markdownBodyColor, | |
| 632 | strongColor: markdownStrongColor, | |
| 633 | mutedColor: markdownBodyColor, | |
| 634 | linkColor: markdownLinkColor, | |
| 635 | codeColor: markdownCodeText, | |
| 636 | codeBackgroundColor: markdownCodeBg, | |
| 637 | codeBlockBackgroundColor: markdownCodeBg, | |
| 638 | fileBackgroundColor: inlineChipBackground, | |
| 639 | fileTextColor: markdownCodeText, | |
| 640 | skillBackgroundColor: inlineSkillBackground, | |
| 641 | skillTextColor: inlineSkillForeground, | |
| 642 | quoteMarkerColor: markdownBlockquoteBorder, | |
| 643 | dividerColor: markdownHrColor, | |
| 644 | fontSize: 15, | |
| 645 | lineHeight: 22, | |
| 646 | fontFamily: "DMSans_400Regular", | |
| 647 | headingFontFamily: "DMSans_700Bold", | |
| 648 | boldFontFamily: "DMSans_700Bold", | |
| 649 | }, | |
| 376 | 650 | }, |
| 377 | 651 | }; |
| 378 | }, [ | |
| 379 | blockquoteBg, | |
| 380 | blockquoteBorder, | |
| 381 | bodyColor, | |
| 382 | codeBg, | |
| 383 | codeText, | |
| 384 | hrColor, | |
| 385 | linkColor, | |
| 386 | strongColor, | |
| 387 | userBodyColor, | |
| 388 | userCodeBg, | |
| 389 | userCodeText, | |
| 390 | userFenceBg, | |
| 391 | userFenceText, | |
| 392 | ]); | |
| 652 | }, [colors, inlineChipBackground, inlineSkillBackground, inlineSkillForeground]); | |
| 393 | 653 | } |
| 394 | 654 | |
| 395 | 655 | function renderFeedEntry( |
| 396 | 656 | info: { item: ThreadFeedEntry; index: number }, |
| 397 | props: Pick<ThreadFeedProps, "bearerToken" | "httpBaseUrl"> & { | |
| 657 | props: Pick<ThreadFeedProps, "bearerToken" | "httpBaseUrl" | "skills"> & { | |
| 398 | 658 | readonly copiedRowId: string | null; |
| 399 | 659 | readonly expandedWorkGroups: Record<string, boolean>; |
| 660 | readonly expandedWorkRows: Record<string, boolean>; | |
| 661 | readonly terminalAssistantMessageIds: ReadonlySet<string>; | |
| 662 | readonly unsettledTurnId: TurnId | null; | |
| 400 | 663 | readonly onCopyWorkRow: (rowId: string, value: string) => void; |
| 401 | 664 | readonly onToggleWorkGroup: (groupId: string) => void; |
| 665 | readonly onToggleWorkRow: (rowId: string) => void; | |
| 666 | readonly onToggleTurnFold: (turnId: TurnId) => void; | |
| 402 | 667 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; |
| 403 | 668 | readonly iconSubtleColor: string | import("react-native").ColorValue; |
| 404 | 669 | readonly userBubbleColor: string | import("react-native").ColorValue; |
| ... | ... | @@ -410,19 +675,50 @@ function renderFeedEntry( |
| 410 | 675 | const entry = info.item; |
| 411 | 676 | const { markdownStyles, iconSubtleColor, userBubbleColor } = props; |
| 412 | 677 | |
| 678 | if (entry.type === "turn-fold") { | |
| 679 | return ( | |
| 680 | <Pressable | |
| 681 | accessibilityRole="button" | |
| 682 | accessibilityState={{ expanded: entry.expanded }} | |
| 683 | onPress={() => props.onToggleTurnFold(entry.turnId)} | |
| 684 | hitSlop={4} | |
| 685 | className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-neutral-200/80 px-2 dark:border-white/[0.08]" | |
| 686 | > | |
| 687 | <Text className="font-t3-medium text-sm tabular-nums text-foreground-muted"> | |
| 688 | {entry.label} | |
| 689 | </Text> | |
| 690 | <SymbolView | |
| 691 | name={entry.expanded ? "chevron.down" : "chevron.right"} | |
| 692 | size={15} | |
| 693 | tintColor={iconSubtleColor} | |
| 694 | type="monochrome" | |
| 695 | /> | |
| 696 | </Pressable> | |
| 697 | ); | |
| 698 | } | |
| 699 | ||
| 413 | 700 | if (entry.type === "message") { |
| 414 | 701 | const { message } = entry; |
| 415 | 702 | const isUser = message.role === "user"; |
| 416 | 703 | const styles = isUser ? markdownStyles.user : markdownStyles.assistant; |
| 417 | const timestampLabel = `${relativeTime(message.createdAt)}${message.streaming ? " • live" : ""}`; | |
| 704 | const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); | |
| 418 | 705 | const attachments = message.attachments ?? []; |
| 419 | 706 | const hasReviewCommentContext = message.text.includes("<review_comment"); |
| 707 | const assistantTurnStillInProgress = | |
| 708 | message.role === "assistant" && | |
| 709 | props.unsettledTurnId !== null && | |
| 710 | message.turnId === props.unsettledTurnId; | |
| 711 | const showAssistantMeta = | |
| 712 | message.role === "assistant" && | |
| 713 | props.terminalAssistantMessageIds.has(message.id) && | |
| 714 | !assistantTurnStillInProgress && | |
| 715 | !message.streaming; | |
| 420 | 716 | |
| 421 | 717 | if (isUser) { |
| 422 | 718 | return ( |
| 423 | 719 | <View className="mb-5 items-end"> |
| 424 | 720 | <View |
| 425 | className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[6px] px-3.5 py-2.5" | |
| 721 | className="max-w-[85%] gap-2 rounded-[20px] px-3.5 py-2.5" | |
| 426 | 722 | style={{ |
| 427 | 723 | backgroundColor: userBubbleColor, |
| 428 | 724 | ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null), |
| ... | ... | @@ -433,6 +729,7 @@ function renderFeedEntry( |
| 433 | 729 | text={message.text} |
| 434 | 730 | markdownStyles={styles} |
| 435 | 731 | reviewCommentColors={props.reviewCommentColors} |
| 732 | skills={props.skills} | |
| 436 | 733 | /> |
| 437 | 734 | ) : null} |
| 438 | 735 | {attachments.map((attachment) => { |
| ... | ... | @@ -459,9 +756,20 @@ function renderFeedEntry( |
| 459 | 756 | ); |
| 460 | 757 | })} |
| 461 | 758 | </View> |
| 462 | <Text className="mt-1.5 px-1 text-right font-t3-medium text-xs text-neutral-600 dark:text-neutral-400"> | |
| 463 | {timestampLabel} | |
| 464 | </Text> | |
| 759 | <View className="mt-1 flex-row items-center justify-end gap-1 pr-0.5"> | |
| 760 | <Text className="font-t3-medium text-xs tabular-nums text-neutral-600 dark:text-neutral-400"> | |
| 761 | {timestampLabel} | |
| 762 | </Text> | |
| 763 | {message.text.trim().length > 0 ? ( | |
| 764 | <CopyTextButton | |
| 765 | accessibilityLabel="Copy message" | |
| 766 | text={message.text} | |
| 767 | tintColor={iconSubtleColor} | |
| 768 | buttonSize={28} | |
| 769 | iconSize={13} | |
| 770 | /> | |
| 771 | ) : null} | |
| 772 | </View> | |
| 465 | 773 | </View> |
| 466 | 774 | ); |
| 467 | 775 | } |
| ... | ... | @@ -473,16 +781,24 @@ function renderFeedEntry( |
| 473 | 781 | } |
| 474 | 782 | |
| 475 | 783 | return ( |
| 476 | <View className="mb-5 px-1"> | |
| 784 | <View className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}> | |
| 477 | 785 | {message.text.trim().length > 0 ? ( |
| 478 | <Markdown | |
| 479 | options={{ gfm: true }} | |
| 480 | renderers={styles.renderers} | |
| 481 | styles={styles.styles} | |
| 482 | theme={styles.theme} | |
| 483 | > | |
| 484 | {message.text} | |
| 485 | </Markdown> | |
| 786 | hasNativeSelectableMarkdownText() ? ( | |
| 787 | <SelectableMarkdownText | |
| 788 | markdown={message.text} | |
| 789 | skills={props.skills} | |
| 790 | textStyle={styles.nativeTextStyle} | |
| 791 | /> | |
| 792 | ) : ( | |
| 793 | <Markdown | |
| 794 | options={{ gfm: true }} | |
| 795 | renderers={styles.renderers} | |
| 796 | styles={styles.styles} | |
| 797 | theme={styles.theme} | |
| 798 | > | |
| 799 | {message.text} | |
| 800 | </Markdown> | |
| 801 | ) | |
| 486 | 802 | ) : null} |
| 487 | 803 | {attachments.map((attachment) => { |
| 488 | 804 | const uri = messageImageUrl(props.httpBaseUrl, attachment.id); |
| ... | ... | @@ -508,9 +824,20 @@ function renderFeedEntry( |
| 508 | 824 | </TouchableOpacity> |
| 509 | 825 | ); |
| 510 | 826 | })} |
| 511 | <Text className="mt-1.5 font-t3-medium text-xs text-neutral-600 dark:text-neutral-400"> | |
| 512 | {timestampLabel} | |
| 513 | </Text> | |
| 827 | {showAssistantMeta ? ( | |
| 828 | <View className="mt-1 flex-row items-center gap-1"> | |
| 829 | <CopyTextButton | |
| 830 | accessibilityLabel="Copy message" | |
| 831 | text={message.text} | |
| 832 | tintColor={iconSubtleColor} | |
| 833 | buttonSize={28} | |
| 834 | iconSize={13} | |
| 835 | /> | |
| 836 | <Text className="font-t3-medium text-xs tabular-nums text-neutral-600 dark:text-neutral-400"> | |
| 837 | {timestampLabel} | |
| 838 | </Text> | |
| 839 | </View> | |
| 840 | ) : null} | |
| 514 | 841 | </View> |
| 515 | 842 | ); |
| 516 | 843 | } |
| ... | ... | @@ -539,67 +866,121 @@ function renderFeedEntry( |
| 539 | 866 | ); |
| 540 | 867 | } |
| 541 | 868 | |
| 542 | const rows = buildActivityRows(entry.activities); | |
| 869 | const rows = buildActivityRows(entry.activities).filter( | |
| 870 | (activity) => !(activity.toolLike && activity.status === "neutral"), | |
| 871 | ); | |
| 872 | if (rows.length === 0) { | |
| 873 | return null; | |
| 874 | } | |
| 543 | 875 | const isExpanded = props.expandedWorkGroups[entry.id] ?? false; |
| 544 | 876 | const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES; |
| 545 | 877 | const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows; |
| 546 | 878 | const hiddenCount = rows.length - visibleRows.length; |
| 547 | const showHeader = hasOverflow; | |
| 879 | const onlyToolRows = rows.every((row) => row.toolLike); | |
| 880 | const headerTitle = onlyToolRows | |
| 881 | ? rows.length === 1 | |
| 882 | ? "1 tool call" | |
| 883 | : `${rows.length} tool calls` | |
| 884 | : "Work log"; | |
| 548 | 885 | |
| 549 | 886 | return ( |
| 550 | <View className="mb-3 rounded-[20px] border border-neutral-200/80 bg-neutral-50/85 px-3 py-2 dark:border-white/[0.06] dark:bg-white/[0.025]"> | |
| 551 | {showHeader ? ( | |
| 552 | <View className="mb-1.5 flex-row items-center justify-between gap-3 px-0.5"> | |
| 553 | <Text className="font-t3-bold text-[10px] uppercase tracking-[0.8px] text-neutral-500 dark:text-neutral-500"> | |
| 554 | Tool calls ({rows.length}) | |
| 555 | </Text> | |
| 556 | <Pressable onPress={() => props.onToggleWorkGroup(entry.id)}> | |
| 557 | <Text className="font-t3-medium text-[10px] uppercase tracking-[0.8px] text-neutral-500 dark:text-neutral-500"> | |
| 887 | <View className="mb-3 rounded-[16px] border border-neutral-300/70 bg-background px-2 py-2.5 dark:border-white/[0.1] dark:bg-white/[0.035]"> | |
| 888 | <View className="mb-1.5 flex-row items-center justify-between gap-3 px-2"> | |
| 889 | <Text className="font-t3-medium text-xs text-foreground">{headerTitle}</Text> | |
| 890 | {hasOverflow ? ( | |
| 891 | <Pressable | |
| 892 | accessibilityRole="button" | |
| 893 | accessibilityState={{ expanded: isExpanded }} | |
| 894 | onPress={() => props.onToggleWorkGroup(entry.id)} | |
| 895 | className="flex-row items-center gap-1" | |
| 896 | > | |
| 897 | <Text className="font-t3-medium text-xs text-foreground-muted"> | |
| 558 | 898 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} |
| 559 | 899 | </Text> |
| 900 | <SymbolView | |
| 901 | name={isExpanded ? "chevron.up" : "chevron.down"} | |
| 902 | size={12} | |
| 903 | tintColor={iconSubtleColor} | |
| 904 | type="monochrome" | |
| 905 | /> | |
| 560 | 906 | </Pressable> |
| 561 | </View> | |
| 562 | ) : null} | |
| 907 | ) : null} | |
| 908 | </View> | |
| 563 | 909 | {visibleRows.map((row, index) => ( |
| 564 | <View | |
| 910 | <Pressable | |
| 565 | 911 | key={row.id} |
| 912 | accessibilityRole={row.fullDetail ? "button" : undefined} | |
| 913 | accessibilityState={ | |
| 914 | row.fullDetail ? { expanded: props.expandedWorkRows[row.id] ?? false } : undefined | |
| 915 | } | |
| 916 | onPress={() => { | |
| 917 | if (row.fullDetail) { | |
| 918 | props.onToggleWorkRow(row.id); | |
| 919 | } | |
| 920 | }} | |
| 921 | onLongPress={() => props.onCopyWorkRow(row.id, row.copyText)} | |
| 566 | 922 | className={cn( |
| 567 | "flex-row items-center gap-2 rounded-lg px-1 py-1", | |
| 923 | "rounded-lg px-2 py-1.5", | |
| 568 | 924 | index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", |
| 569 | 925 | )} |
| 570 | 926 | > |
| 571 | <View className="items-center justify-center pt-0.5"> | |
| 572 | <SymbolView name="terminal" size={13} tintColor={iconSubtleColor} type="monochrome" /> | |
| 573 | </View> | |
| 574 | <ScrollView | |
| 575 | horizontal | |
| 576 | nestedScrollEnabled | |
| 577 | directionalLockEnabled | |
| 578 | showsHorizontalScrollIndicator={false} | |
| 579 | bounces={false} | |
| 580 | className="flex-1" | |
| 581 | contentContainerStyle={{ paddingRight: 12 }} | |
| 582 | style={{ flex: 1 }} | |
| 583 | > | |
| 927 | <View className="flex-row items-center gap-2"> | |
| 928 | <View className="w-4 items-center justify-center"> | |
| 929 | <SymbolView | |
| 930 | name={ | |
| 931 | row.status === "failure" | |
| 932 | ? "xmark" | |
| 933 | : row.status === "success" | |
| 934 | ? "checkmark" | |
| 935 | : row.status === "neutral" | |
| 936 | ? "minus" | |
| 937 | : "terminal" | |
| 938 | } | |
| 939 | size={row.status ? 11 : 13} | |
| 940 | tintColor={row.status === "failure" ? "#e11d48" : iconSubtleColor} | |
| 941 | type="monochrome" | |
| 942 | /> | |
| 943 | </View> | |
| 584 | 944 | <Text |
| 585 | className="text-[12px] leading-[18px] text-neutral-600 dark:text-neutral-400" | |
| 586 | onLongPress={() => { | |
| 587 | const copyValue = row.detail ?? row.summary; | |
| 588 | props.onCopyWorkRow(row.id, copyValue); | |
| 589 | }} | |
| 590 | style={{ | |
| 591 | fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace", | |
| 592 | }} | |
| 945 | className="min-w-0 flex-1 text-[12px] leading-[18px] text-neutral-700 dark:text-neutral-300" | |
| 946 | numberOfLines={props.expandedWorkRows[row.id] ? undefined : 1} | |
| 593 | 947 | > |
| 594 | 948 | {row.detail ? `${row.summary} - ${row.detail}` : row.summary} |
| 595 | 949 | </Text> |
| 596 | </ScrollView> | |
| 597 | {props.copiedRowId === row.id ? ( | |
| 598 | <Text className="shrink-0 font-t3-medium text-[10px] uppercase tracking-[0.8px] text-emerald-600 dark:text-emerald-400"> | |
| 599 | Copied | |
| 600 | </Text> | |
| 950 | {row.fullDetail ? ( | |
| 951 | <SymbolView | |
| 952 | name={props.expandedWorkRows[row.id] ? "chevron.up" : "chevron.down"} | |
| 953 | size={11} | |
| 954 | tintColor={iconSubtleColor} | |
| 955 | type="monochrome" | |
| 956 | /> | |
| 957 | ) : null} | |
| 958 | {props.copiedRowId === row.id ? ( | |
| 959 | <Text className="shrink-0 font-t3-medium text-[10px] text-emerald-600 dark:text-emerald-400"> | |
| 960 | Copied | |
| 961 | </Text> | |
| 962 | ) : null} | |
| 963 | </View> | |
| 964 | {row.fullDetail && props.expandedWorkRows[row.id] ? ( | |
| 965 | <ScrollView | |
| 966 | horizontal | |
| 967 | nestedScrollEnabled | |
| 968 | directionalLockEnabled | |
| 969 | showsHorizontalScrollIndicator | |
| 970 | bounces={false} | |
| 971 | className="mt-2 rounded-lg bg-neutral-100 dark:bg-black/20" | |
| 972 | contentContainerStyle={{ paddingHorizontal: 10, paddingVertical: 8 }} | |
| 973 | > | |
| 974 | <Text | |
| 975 | selectable | |
| 976 | className="text-[11px] leading-[17px] text-neutral-600 dark:text-neutral-400" | |
| 977 | style={{ fontFamily: "ui-monospace" }} | |
| 978 | > | |
| 979 | {row.fullDetail} | |
| 980 | </Text> | |
| 981 | </ScrollView> | |
| 601 | 982 | ) : null} |
| 602 | </View> | |
| 983 | </Pressable> | |
| 603 | 984 | ))} |
| 604 | 985 | </View> |
| 605 | 986 | ); |
| ... | ... | @@ -609,10 +990,20 @@ function UserMessageContent(props: { |
| 609 | 990 | readonly text: string; |
| 610 | 991 | readonly markdownStyles: MarkdownStyleSet; |
| 611 | 992 | readonly reviewCommentColors: ReviewCommentColors; |
| 993 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; | |
| 612 | 994 | }) { |
| 613 | 995 | const segments = parseReviewCommentMessageSegments(props.text); |
| 614 | 996 | const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); |
| 615 | 997 | if (!hasReviewComment) { |
| 998 | if (hasNativeSelectableMarkdownText()) { | |
| 999 | return ( | |
| 1000 | <SelectableMarkdownText | |
| 1001 | markdown={props.text} | |
| 1002 | skills={props.skills} | |
| 1003 | textStyle={props.markdownStyles.nativeTextStyle} | |
| 1004 | /> | |
| 1005 | ); | |
| 1006 | } | |
| 616 | 1007 | return ( |
| 617 | 1008 | <Markdown |
| 618 | 1009 | options={{ gfm: true }} |
| ... | ... | @@ -643,7 +1034,14 @@ function UserMessageContent(props: { |
| 643 | 1034 | return null; |
| 644 | 1035 | } |
| 645 | 1036 | |
| 646 | return ( | |
| 1037 | return hasNativeSelectableMarkdownText() ? ( | |
| 1038 | <SelectableMarkdownText | |
| 1039 | key={segment.id} | |
| 1040 | markdown={text} | |
| 1041 | skills={props.skills} | |
| 1042 | textStyle={props.markdownStyles.nativeTextStyle} | |
| 1043 | /> | |
| 1044 | ) : ( | |
| 647 | 1045 | <Markdown |
| 648 | 1046 | key={segment.id} |
| 649 | 1047 | options={{ gfm: true }} |
| ... | ... | @@ -819,14 +1217,30 @@ function compactFileName(filePath: string): string { |
| 819 | 1217 | return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized; |
| 820 | 1218 | } |
| 821 | 1219 | |
| 822 | const IOS_NAV_BAR_HEIGHT = 44; | |
| 823 | ||
| 824 | 1220 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 825 | 1221 | const listRef = useRef<LegendListRef>(null); |
| 826 | 1222 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 1223 | const scrollFrameRef = useRef<number | null>(null); | |
| 1224 | const foldSettleFrameRef = useRef<number | null>(null); | |
| 1225 | const foldSettleSecondFrameRef = useRef<number | null>(null); | |
| 1226 | const suppressAutoFollowRef = useRef(false); | |
| 1227 | const previousLatestTurnRef = useRef(props.latestTurn); | |
| 1228 | const isNearEndRef = useRef(true); | |
| 1229 | const initialScrollReadyRef = useRef(false); | |
| 1230 | const lastContentHeightRef = useRef(0); | |
| 827 | 1231 | const { width: viewportWidth } = useWindowDimensions(); |
| 828 | const [copiedRowId, setCopiedRowId] = useState<string | null>(null); | |
| 829 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); | |
| 1232 | const [interactionState, setInteractionState] = useState<{ | |
| 1233 | readonly copiedRowId: string | null; | |
| 1234 | readonly expandedWorkGroups: Record<string, boolean>; | |
| 1235 | readonly expandedWorkRows: Record<string, boolean>; | |
| 1236 | readonly expandedTurnIds: ReadonlySet<TurnId>; | |
| 1237 | }>({ | |
| 1238 | copiedRowId: null, | |
| 1239 | expandedWorkGroups: {}, | |
| 1240 | expandedWorkRows: {}, | |
| 1241 | expandedTurnIds: new Set(), | |
| 1242 | }); | |
| 1243 | const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; | |
| 830 | 1244 | const [expandedImage, setExpandedImage] = useState<{ |
| 831 | 1245 | uri: string; |
| 832 | 1246 | headers?: Record<string, string>; |
| ... | ... | @@ -835,47 +1249,193 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 835 | 1249 | const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); |
| 836 | 1250 | const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); |
| 837 | 1251 | const insets = useSafeAreaInsets(); |
| 838 | const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; | |
| 1252 | const topContentInset = props.contentTopInset ?? insets.top + 44; | |
| 839 | 1253 | const bottomContentInset = props.contentBottomInset ?? 18; |
| 840 | 1254 | |
| 841 | 1255 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); |
| 842 | 1256 | const userBubbleColor = useThemeColor("--color-user-bubble"); |
| 843 | 1257 | const markdownStyles = useMarkdownStyles(); |
| 844 | 1258 | const reviewCommentColors = useReviewCommentColors(); |
| 1259 | const listAppearanceData = useMemo( | |
| 1260 | () => ({ | |
| 1261 | iconSubtleColor, | |
| 1262 | markdownStyles, | |
| 1263 | reviewCommentColors, | |
| 1264 | userBubbleColor, | |
| 1265 | }), | |
| 1266 | [iconSubtleColor, markdownStyles, reviewCommentColors, userBubbleColor], | |
| 1267 | ); | |
| 1268 | const presentedFeed = useMemo( | |
| 1269 | () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds), | |
| 1270 | [expandedTurnIds, props.feed, props.latestTurn], | |
| 1271 | ); | |
| 1272 | const terminalAssistantMessageIds = useMemo(() => { | |
| 1273 | const terminalIdsByTurn = new Map<TurnId, string>(); | |
| 1274 | for (const entry of props.feed) { | |
| 1275 | if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { | |
| 1276 | terminalIdsByTurn.set(entry.message.turnId, entry.message.id); | |
| 1277 | } | |
| 1278 | } | |
| 1279 | return new Set(terminalIdsByTurn.values()); | |
| 1280 | }, [props.feed]); | |
| 1281 | const unsettledTurnId = | |
| 1282 | props.latestTurn && | |
| 1283 | (props.latestTurn.completedAt === null || props.latestTurn.state === "running") | |
| 1284 | ? props.latestTurn.turnId | |
| 1285 | : null; | |
| 1286 | ||
| 1287 | const scrollToEnd = useCallback(() => { | |
| 1288 | if (scrollFrameRef.current !== null) { | |
| 1289 | return; | |
| 1290 | } | |
| 1291 | scrollFrameRef.current = requestAnimationFrame(() => { | |
| 1292 | scrollFrameRef.current = null; | |
| 1293 | listRef.current?.scrollToEnd({ animated: false }); | |
| 1294 | }); | |
| 1295 | }, []); | |
| 1296 | ||
| 1297 | const onListScroll = useCallback( | |
| 1298 | (event: NativeSyntheticEvent<NativeScrollEvent> | NativeScrollEvent) => { | |
| 1299 | const scrollEvent = "nativeEvent" in event ? event.nativeEvent : event; | |
| 1300 | const { contentInset, contentOffset, contentSize, layoutMeasurement } = scrollEvent; | |
| 1301 | isNearEndRef.current = isThreadFeedNearEnd( | |
| 1302 | { | |
| 1303 | contentHeight: contentSize.height, | |
| 1304 | viewportHeight: layoutMeasurement.height, | |
| 1305 | offsetY: contentOffset.y, | |
| 1306 | bottomInset: contentInset.bottom, | |
| 1307 | }, | |
| 1308 | THREAD_FEED_END_THRESHOLD, | |
| 1309 | ); | |
| 1310 | }, | |
| 1311 | [], | |
| 1312 | ); | |
| 1313 | ||
| 1314 | const onListContentSizeChange = useCallback( | |
| 1315 | (_width: number, height: number) => { | |
| 1316 | const contentGrew = height > lastContentHeightRef.current + 0.5; | |
| 1317 | lastContentHeightRef.current = height; | |
| 1318 | ||
| 1319 | if ( | |
| 1320 | initialScrollReadyRef.current && | |
| 1321 | contentGrew && | |
| 1322 | isNearEndRef.current && | |
| 1323 | !suppressAutoFollowRef.current | |
| 1324 | ) { | |
| 1325 | scrollToEnd(); | |
| 1326 | } | |
| 1327 | }, | |
| 1328 | [scrollToEnd], | |
| 1329 | ); | |
| 1330 | ||
| 1331 | const onListLoad = useCallback(() => { | |
| 1332 | initialScrollReadyRef.current = true; | |
| 1333 | }, []); | |
| 845 | 1334 | |
| 846 | 1335 | useEffect(() => { |
| 847 | setCopiedRowId(null); | |
| 848 | setExpandedWorkGroups({}); | |
| 849 | }, [props.threadId]); | |
| 1336 | const previous = previousLatestTurnRef.current; | |
| 1337 | previousLatestTurnRef.current = props.latestTurn; | |
| 1338 | if (!props.latestTurn || !previous) { | |
| 1339 | return; | |
| 1340 | } | |
| 1341 | if (props.latestTurn.turnId === previous.turnId) { | |
| 1342 | if (previous.state === "running" && props.latestTurn.state === "interrupted") { | |
| 1343 | const interruptedTurnId = props.latestTurn.turnId; | |
| 1344 | setInteractionState((current) => ({ | |
| 1345 | ...current, | |
| 1346 | expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId), | |
| 1347 | })); | |
| 1348 | } | |
| 1349 | return; | |
| 1350 | } | |
| 1351 | setInteractionState((current) => { | |
| 1352 | if (!current.expandedTurnIds.has(previous.turnId)) { | |
| 1353 | return current; | |
| 1354 | } | |
| 1355 | const next = new Set(current.expandedTurnIds); | |
| 1356 | next.delete(previous.turnId); | |
| 1357 | return { ...current, expandedTurnIds: next }; | |
| 1358 | }); | |
| 1359 | }, [props.latestTurn]); | |
| 850 | 1360 | |
| 851 | 1361 | useEffect(() => { |
| 852 | 1362 | return () => { |
| 853 | 1363 | if (copyFeedbackTimeoutRef.current) { |
| 854 | 1364 | clearTimeout(copyFeedbackTimeoutRef.current); |
| 855 | 1365 | } |
| 1366 | if (scrollFrameRef.current !== null) { | |
| 1367 | cancelAnimationFrame(scrollFrameRef.current); | |
| 1368 | } | |
| 1369 | if (foldSettleFrameRef.current !== null) { | |
| 1370 | cancelAnimationFrame(foldSettleFrameRef.current); | |
| 1371 | } | |
| 1372 | if (foldSettleSecondFrameRef.current !== null) { | |
| 1373 | cancelAnimationFrame(foldSettleSecondFrameRef.current); | |
| 1374 | } | |
| 856 | 1375 | }; |
| 857 | 1376 | }, []); |
| 858 | 1377 | |
| 859 | 1378 | const onCopyWorkRow = useCallback((rowId: string, value: string) => { |
| 860 | 1379 | void Clipboard.setStringAsync(value); |
| 861 | 1380 | void Haptics.selectionAsync(); |
| 862 | setCopiedRowId(rowId); | |
| 1381 | setInteractionState((current) => ({ ...current, copiedRowId: rowId })); | |
| 863 | 1382 | if (copyFeedbackTimeoutRef.current) { |
| 864 | 1383 | clearTimeout(copyFeedbackTimeoutRef.current); |
| 865 | 1384 | } |
| 866 | 1385 | copyFeedbackTimeoutRef.current = setTimeout(() => { |
| 867 | setCopiedRowId((current) => (current === rowId ? null : current)); | |
| 1386 | setInteractionState((current) => | |
| 1387 | current.copiedRowId === rowId ? { ...current, copiedRowId: null } : current, | |
| 1388 | ); | |
| 868 | 1389 | copyFeedbackTimeoutRef.current = null; |
| 869 | 1390 | }, 1200); |
| 870 | 1391 | }, []); |
| 871 | 1392 | |
| 872 | 1393 | const onToggleWorkGroup = useCallback((groupId: string) => { |
| 873 | setExpandedWorkGroups((current) => ({ | |
| 1394 | setInteractionState((current) => ({ | |
| 874 | 1395 | ...current, |
| 875 | [groupId]: !(current[groupId] ?? false), | |
| 1396 | expandedWorkGroups: { | |
| 1397 | ...current.expandedWorkGroups, | |
| 1398 | [groupId]: !(current.expandedWorkGroups[groupId] ?? false), | |
| 1399 | }, | |
| 876 | 1400 | })); |
| 877 | 1401 | }, []); |
| 878 | 1402 | |
| 1403 | const onToggleWorkRow = useCallback((rowId: string) => { | |
| 1404 | setInteractionState((current) => ({ | |
| 1405 | ...current, | |
| 1406 | expandedWorkRows: { | |
| 1407 | ...current.expandedWorkRows, | |
| 1408 | [rowId]: !(current.expandedWorkRows[rowId] ?? false), | |
| 1409 | }, | |
| 1410 | })); | |
| 1411 | }, []); | |
| 1412 | ||
| 1413 | const onToggleTurnFold = useCallback((turnId: TurnId) => { | |
| 1414 | suppressAutoFollowRef.current = true; | |
| 1415 | if (foldSettleFrameRef.current !== null) { | |
| 1416 | cancelAnimationFrame(foldSettleFrameRef.current); | |
| 1417 | } | |
| 1418 | if (foldSettleSecondFrameRef.current !== null) { | |
| 1419 | cancelAnimationFrame(foldSettleSecondFrameRef.current); | |
| 1420 | } | |
| 1421 | setInteractionState((current) => { | |
| 1422 | const next = new Set(current.expandedTurnIds); | |
| 1423 | if (next.has(turnId)) { | |
| 1424 | next.delete(turnId); | |
| 1425 | } else { | |
| 1426 | next.add(turnId); | |
| 1427 | } | |
| 1428 | return { ...current, expandedTurnIds: next }; | |
| 1429 | }); | |
| 1430 | foldSettleFrameRef.current = requestAnimationFrame(() => { | |
| 1431 | foldSettleSecondFrameRef.current = requestAnimationFrame(() => { | |
| 1432 | suppressAutoFollowRef.current = false; | |
| 1433 | foldSettleFrameRef.current = null; | |
| 1434 | foldSettleSecondFrameRef.current = null; | |
| 1435 | }); | |
| 1436 | }); | |
| 1437 | }, []); | |
| 1438 | ||
| 879 | 1439 | const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => { |
| 880 | 1440 | setExpandedImage({ uri, headers }); |
| 881 | 1441 | }, []); |
| ... | ... | @@ -887,18 +1447,27 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 887 | 1447 | copiedRowId, |
| 888 | 1448 | httpBaseUrl: props.httpBaseUrl, |
| 889 | 1449 | expandedWorkGroups, |
| 1450 | expandedWorkRows, | |
| 1451 | terminalAssistantMessageIds, | |
| 1452 | unsettledTurnId, | |
| 890 | 1453 | onCopyWorkRow, |
| 891 | 1454 | onToggleWorkGroup, |
| 1455 | onToggleWorkRow, | |
| 1456 | onToggleTurnFold, | |
| 892 | 1457 | onPressImage, |
| 893 | 1458 | iconSubtleColor, |
| 894 | 1459 | userBubbleColor, |
| 895 | 1460 | markdownStyles, |
| 896 | 1461 | reviewCommentColors, |
| 897 | 1462 | reviewCommentBubbleWidth, |
| 1463 | skills: props.skills, | |
| 898 | 1464 | }), |
| 899 | 1465 | [ |
| 900 | 1466 | copiedRowId, |
| 901 | 1467 | expandedWorkGroups, |
| 1468 | expandedWorkRows, | |
| 1469 | terminalAssistantMessageIds, | |
| 1470 | unsettledTurnId, | |
| 902 | 1471 | iconSubtleColor, |
| 903 | 1472 | userBubbleColor, |
| 904 | 1473 | markdownStyles, |
| ... | ... | @@ -906,9 +1475,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 906 | 1475 | reviewCommentBubbleWidth, |
| 907 | 1476 | onCopyWorkRow, |
| 908 | 1477 | onPressImage, |
| 1478 | onToggleTurnFold, | |
| 909 | 1479 | onToggleWorkGroup, |
| 1480 | onToggleWorkRow, | |
| 910 | 1481 | props.bearerToken, |
| 911 | 1482 | props.httpBaseUrl, |
| 1483 | props.skills, | |
| 912 | 1484 | ], |
| 913 | 1485 | ); |
| 914 | 1486 | |
| ... | ... | @@ -935,33 +1507,46 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 935 | 1507 | |
| 936 | 1508 | return ( |
| 937 | 1509 | <> |
| 938 | <KeyboardAvoidingLegendList | |
| 939 | ref={listRef} | |
| 940 | key={props.threadId} | |
| 941 | style={{ flex: 1 }} | |
| 942 | alignItemsAtEnd | |
| 943 | contentInsetAdjustmentBehavior="never" | |
| 944 | contentInset={{ top: topContentInset, bottom: bottomContentInset }} | |
| 945 | scrollIndicatorInsets={{ top: topContentInset, bottom: bottomContentInset }} | |
| 946 | data={props.feed as ThreadFeedEntry[]} | |
| 947 | renderItem={renderItem} | |
| 948 | keyExtractor={(entry) => `${entry.type}:${entry.id}`} | |
| 949 | getItemType={(entry) => | |
| 950 | entry.type === "message" ? `message:${entry.message.role}` : entry.type | |
| 951 | } | |
| 952 | keyboardShouldPersistTaps="handled" | |
| 953 | estimatedItemSize={180} | |
| 954 | initialScrollAtEnd | |
| 955 | maintainScrollAtEnd={{ | |
| 956 | on: { layout: true, itemLayout: true, dataChange: true }, | |
| 957 | }} | |
| 958 | maintainScrollAtEndThreshold={0.1} | |
| 959 | safeAreaInsetBottom={insets.bottom} | |
| 960 | contentContainerStyle={{ | |
| 961 | paddingTop: 12, | |
| 962 | paddingHorizontal: horizontalPadding, | |
| 963 | }} | |
| 964 | /> | |
| 1510 | <View style={{ flex: 1 }}> | |
| 1511 | <KeyboardAvoidingLegendList | |
| 1512 | ref={listRef} | |
| 1513 | key={props.threadId} | |
| 1514 | style={{ flex: 1 }} | |
| 1515 | automaticallyAdjustsScrollIndicatorInsets={false} | |
| 1516 | contentInset={{ top: 0, bottom: 0 }} | |
| 1517 | scrollIndicatorInsets={{ top: topContentInset, bottom: 0 }} | |
| 1518 | alignItemsAtEnd | |
| 1519 | maintainScrollAtEnd={{ | |
| 1520 | animated: false, | |
| 1521 | on: { | |
| 1522 | dataChange: true, | |
| 1523 | itemLayout: true, | |
| 1524 | layout: true, | |
| 1525 | }, | |
| 1526 | }} | |
| 1527 | data={presentedFeed} | |
| 1528 | extraData={listAppearanceData} | |
| 1529 | renderItem={renderItem} | |
| 1530 | keyExtractor={(entry) => `${entry.type}:${entry.id}`} | |
| 1531 | getItemType={(entry) => | |
| 1532 | entry.type === "message" ? `message:${entry.message.role}` : entry.type | |
| 1533 | } | |
| 1534 | keyboardShouldPersistTaps="always" | |
| 1535 | keyboardDismissMode="none" | |
| 1536 | estimatedItemSize={180} | |
| 1537 | initialScrollAtEnd | |
| 1538 | onContentSizeChange={onListContentSizeChange} | |
| 1539 | onLoad={onListLoad} | |
| 1540 | onScroll={onListScroll} | |
| 1541 | scrollEventThrottle={16} | |
| 1542 | ListHeaderComponent={<View style={{ height: topContentInset }} />} | |
| 1543 | contentContainerStyle={{ | |
| 1544 | paddingTop: 12, | |
| 1545 | paddingBottom: bottomContentInset, | |
| 1546 | paddingHorizontal: horizontalPadding, | |
| 1547 | }} | |
| 1548 | /> | |
| 1549 | </View> | |
| 965 | 1550 | |
| 966 | 1551 | <ImageViewing |
| 967 | 1552 | images={ |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+2-3| ... | ... | @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; |
| 5 | 5 | import { pipe } from "effect/Function"; |
| 6 | 6 | import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; |
| 7 | 7 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 8 | import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native"; | |
| 8 | import { Pressable, ScrollView, Text as RNText, View } from "react-native"; | |
| 9 | 9 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 10 | 10 | import { useVcsStatus } from "../../state/use-vcs-status"; |
| 11 | 11 | import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; |
| ... | ... | @@ -91,10 +91,9 @@ export function ThreadRouteScreen() { |
| 91 | 91 | pendingConnectionError ?? routeEnvironmentRuntime?.connectionError ?? aggregateConnectionError; |
| 92 | 92 | |
| 93 | 93 | /* ─── Native header theming ──────────────────────────────────────── */ |
| 94 | const isDark = useColorScheme() === "dark"; | |
| 95 | 94 | const iconColor = String(useThemeColor("--color-icon")); |
| 96 | 95 | const foregroundColor = String(useThemeColor("--color-foreground")); |
| 97 | const secondaryFg = isDark ? "#a3a3a3" : "#525252"; | |
| 96 | const secondaryFg = String(useThemeColor("--color-foreground-secondary")); | |
| 98 | 97 | |
| 99 | 98 | /* ─── Git status for native header trigger ───────────────────────── */ |
| 100 | 99 | const gitStatus = useVcsStatus({ |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+10| ... | ... | @@ -5,6 +5,7 @@ import type { |
| 5 | 5 | ModelSelection, |
| 6 | 6 | ProviderInteractionMode, |
| 7 | 7 | RuntimeMode, |
| 8 | ServerProviderSkill, | |
| 8 | 9 | } from "@t3tools/contracts"; |
| 9 | 10 | import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts"; |
| 10 | 11 | import * as Arr from "effect/Array"; |
| ... | ... | @@ -94,6 +95,7 @@ type NewTaskFlowContextValue = { |
| 94 | 95 | readonly modelOptions: ReadonlyArray<ModelOption>; |
| 95 | 96 | readonly selectedModel: ModelSelection | null; |
| 96 | 97 | readonly selectedModelOption: ModelOption | null; |
| 98 | readonly selectedProviderSkills: ReadonlyArray<ServerProviderSkill>; | |
| 97 | 99 | readonly providerGroups: ReadonlyArray<ProviderGroup>; |
| 98 | 100 | readonly filteredBranches: ReadonlyArray<VcsRef>; |
| 99 | 101 | readonly reset: () => void; |
| ... | ... | @@ -283,6 +285,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 283 | 285 | option.selection.instanceId === selectedModel.instanceId && |
| 284 | 286 | option.selection.model === selectedModel.model, |
| 285 | 287 | ) ?? null; |
| 288 | const selectedProviderSkills = | |
| 289 | (selectedProject | |
| 290 | ? serverConfigByEnvironmentId[selectedProject.environmentId] | |
| 291 | : null | |
| 292 | )?.providers.find((provider) => provider.instanceId === selectedModel?.instanceId)?.skills ?? | |
| 293 | []; | |
| 286 | 294 | |
| 287 | 295 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); |
| 288 | 296 | const setPrompt = useCallback( |
| ... | ... | @@ -450,6 +458,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 450 | 458 | modelOptions, |
| 451 | 459 | selectedModel, |
| 452 | 460 | selectedModelOption, |
| 461 | selectedProviderSkills, | |
| 453 | 462 | providerGroups, |
| 454 | 463 | filteredBranches, |
| 455 | 464 | reset, |
| ... | ... | @@ -498,6 +507,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 498 | 507 | selectedModel, |
| 499 | 508 | selectedModelKey, |
| 500 | 509 | selectedModelOption, |
| 510 | selectedProviderSkills, | |
| 501 | 511 | selectedProject, |
| 502 | 512 | selectedProjectKey, |
| 503 | 513 | selectedWorktreePath, |
apps/mobile/src/lib/composerImages.test.ts created+94| ... | ... | @@ -0,0 +1,94 @@ |
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | const files = new Map<string, { base64: string; deleted: boolean }>(); | |
| 5 | ||
| 6 | vi.mock("expo-file-system", () => ({ | |
| 7 | File: class { | |
| 8 | readonly uri: string; | |
| 9 | ||
| 10 | constructor(uri: string) { | |
| 11 | this.uri = uri; | |
| 12 | } | |
| 13 | ||
| 14 | get exists(): boolean { | |
| 15 | return files.has(this.uri) && files.get(this.uri)?.deleted === false; | |
| 16 | } | |
| 17 | ||
| 18 | async base64(): Promise<string> { | |
| 19 | const entry = files.get(this.uri); | |
| 20 | if (!entry || entry.deleted) { | |
| 21 | throw new Error("missing file"); | |
| 22 | } | |
| 23 | return entry.base64; | |
| 24 | } | |
| 25 | ||
| 26 | delete(): void { | |
| 27 | const entry = files.get(this.uri); | |
| 28 | if (entry) { | |
| 29 | entry.deleted = true; | |
| 30 | } | |
| 31 | } | |
| 32 | }, | |
| 33 | })); | |
| 34 | ||
| 35 | vi.mock("./uuid", () => ({ | |
| 36 | uuidv4: () => "attachment-id", | |
| 37 | })); | |
| 38 | ||
| 39 | import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; | |
| 40 | ||
| 41 | describe("native pasted image cleanup", () => { | |
| 42 | beforeEach(() => { | |
| 43 | files.clear(); | |
| 44 | }); | |
| 45 | ||
| 46 | it("recognizes only files created in the native composer paste directory", () => { | |
| 47 | expect( | |
| 48 | isOwnedPastedImageUri( | |
| 49 | "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png", | |
| 50 | ), | |
| 51 | ).toBe(true); | |
| 52 | expect(isOwnedPastedImageUri("file:///private/var/mobile/photos/id.png")).toBe(false); | |
| 53 | expect(isOwnedPastedImageUri("https://example.com/t3-composer-paste/id.png")).toBe(false); | |
| 54 | }); | |
| 55 | ||
| 56 | it("converts owned files to data-backed previews and deletes the source", async () => { | |
| 57 | const uri = | |
| 58 | "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png"; | |
| 59 | files.set(uri, { base64: "aGVsbG8=", deleted: false }); | |
| 60 | ||
| 61 | const attachments = await convertPastedImagesToAttachments({ | |
| 62 | uris: [uri], | |
| 63 | existingCount: 0, | |
| 64 | }); | |
| 65 | ||
| 66 | expect(attachments).toEqual([ | |
| 67 | expect.objectContaining({ | |
| 68 | dataUrl: "data:image/png;base64,aGVsbG8=", | |
| 69 | previewUri: "data:image/png;base64,aGVsbG8=", | |
| 70 | }), | |
| 71 | ]); | |
| 72 | expect(files.get(uri)?.deleted).toBe(true); | |
| 73 | }); | |
| 74 | ||
| 75 | it("deletes rejected and overflow owned files without deleting user-owned files", async () => { | |
| 76 | const rejected = | |
| 77 | "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/bad.png"; | |
| 78 | const overflow = | |
| 79 | "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/overflow.png"; | |
| 80 | const userOwned = "file:///private/var/mobile/photos/library.png"; | |
| 81 | files.set(rejected, { base64: "", deleted: false }); | |
| 82 | files.set(overflow, { base64: "aGVsbG8=", deleted: false }); | |
| 83 | files.set(userOwned, { base64: "aGVsbG8=", deleted: false }); | |
| 84 | ||
| 85 | await convertPastedImagesToAttachments({ | |
| 86 | uris: [rejected, overflow, userOwned], | |
| 87 | existingCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1, | |
| 88 | }); | |
| 89 | ||
| 90 | expect(files.get(rejected)?.deleted).toBe(true); | |
| 91 | expect(files.get(overflow)?.deleted).toBe(true); | |
| 92 | expect(files.get(userOwned)?.deleted).toBe(false); | |
| 93 | }); | |
| 94 | }); |
apps/mobile/src/lib/composerImages.ts+34-3| ... | ... | @@ -10,6 +10,8 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment |
| 10 | 10 | readonly previewUri: string; |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; | |
| 14 | ||
| 13 | 15 | function estimateBase64ByteSize(base64: string): number { |
| 14 | 16 | const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; |
| 15 | 17 | return Math.floor((base64.length * 3) / 4) - padding; |
| ... | ... | @@ -213,17 +215,35 @@ function mimeTypeFromUri(uri: string): string { |
| 213 | 215 | } |
| 214 | 216 | } |
| 215 | 217 | |
| 218 | export function isOwnedPastedImageUri(uri: string): boolean { | |
| 219 | try { | |
| 220 | const url = new URL(uri); | |
| 221 | if (url.protocol !== "file:") { | |
| 222 | return false; | |
| 223 | } | |
| 224 | const segments = url.pathname.split("/").filter(Boolean); | |
| 225 | return ( | |
| 226 | segments.at(-2) === OWNED_PASTED_IMAGE_DIRECTORY && segments.at(-1)?.endsWith(".png") === true | |
| 227 | ); | |
| 228 | } catch { | |
| 229 | return false; | |
| 230 | } | |
| 231 | } | |
| 232 | ||
| 216 | 233 | export async function convertPastedImagesToAttachments(input: { |
| 217 | 234 | readonly uris: ReadonlyArray<string>; |
| 218 | 235 | readonly existingCount: number; |
| 219 | 236 | }): Promise<ReadonlyArray<DraftComposerImageAttachment>> { |
| 220 | 237 | const { File } = await import("expo-file-system"); |
| 221 | 238 | const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; |
| 222 | const uris = input.uris.slice(0, Math.max(0, remainingSlots)); | |
| 223 | 239 | const results: DraftComposerImageAttachment[] = []; |
| 224 | 240 | |
| 225 | for (const uri of uris) { | |
| 241 | for (const [index, uri] of input.uris.entries()) { | |
| 242 | const ownedTemporaryFile = isOwnedPastedImageUri(uri); | |
| 226 | 243 | try { |
| 244 | if (index >= Math.max(0, remainingSlots)) { | |
| 245 | continue; | |
| 246 | } | |
| 227 | 247 | const file = new File(uri); |
| 228 | 248 | const base64 = await file.base64(); |
| 229 | 249 | const sizeBytes = estimateBase64ByteSize(base64); |
| ... | ... | @@ -238,10 +258,21 @@ export async function convertPastedImagesToAttachments(input: { |
| 238 | 258 | mimeType, |
| 239 | 259 | sizeBytes, |
| 240 | 260 | dataUrl: `data:${mimeType};base64,${base64}`, |
| 241 | previewUri: uri, | |
| 261 | previewUri: ownedTemporaryFile ? `data:${mimeType};base64,${base64}` : uri, | |
| 242 | 262 | }); |
| 243 | 263 | } catch (error) { |
| 244 | 264 | console.warn("Failed to read pasted image", uri, error); |
| 265 | } finally { | |
| 266 | if (ownedTemporaryFile) { | |
| 267 | try { | |
| 268 | const file = new File(uri); | |
| 269 | if (file.exists) { | |
| 270 | file.delete(); | |
| 271 | } | |
| 272 | } catch (error) { | |
| 273 | console.warn("Failed to remove temporary pasted image", uri, error); | |
| 274 | } | |
| 275 | } | |
| 245 | 276 | } |
| 246 | 277 | } |
| 247 | 278 |
apps/mobile/src/lib/copyTextWithHaptic.test.ts created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | const mocks = vi.hoisted(() => ({ | |
| 4 | impactAsync: vi.fn(), | |
| 5 | setStringAsync: vi.fn(), | |
| 6 | })); | |
| 7 | ||
| 8 | vi.mock("expo-clipboard", () => ({ | |
| 9 | setStringAsync: mocks.setStringAsync, | |
| 10 | })); | |
| 11 | ||
| 12 | vi.mock("expo-haptics", () => ({ | |
| 13 | ImpactFeedbackStyle: { | |
| 14 | Light: "light", | |
| 15 | }, | |
| 16 | impactAsync: mocks.impactAsync, | |
| 17 | })); | |
| 18 | ||
| 19 | import { copyTextWithHaptic } from "./copyTextWithHaptic"; | |
| 20 | ||
| 21 | describe("copyTextWithHaptic", () => { | |
| 22 | beforeEach(() => { | |
| 23 | vi.clearAllMocks(); | |
| 24 | mocks.setStringAsync.mockReturnValue(new Promise<void>(() => undefined)); | |
| 25 | mocks.impactAsync.mockResolvedValue(undefined); | |
| 26 | }); | |
| 27 | ||
| 28 | it("triggers haptic feedback without waiting for the clipboard promise", () => { | |
| 29 | copyTextWithHaptic("trace-123"); | |
| 30 | ||
| 31 | expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123"); | |
| 32 | expect(mocks.impactAsync).toHaveBeenCalledWith("light"); | |
| 33 | }); | |
| 34 | }); |
apps/mobile/src/lib/copyTextWithHaptic.ts created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | import * as Clipboard from "expo-clipboard"; | |
| 2 | import * as Haptics from "expo-haptics"; | |
| 3 | ||
| 4 | export function copyTextWithHaptic(value: string): void { | |
| 5 | void Clipboard.setStringAsync(value); | |
| 6 | void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); | |
| 7 | } |
apps/mobile/src/lib/markdownLinks.test.ts created+67| ... | ... | @@ -0,0 +1,67 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; | |
| 4 | ||
| 5 | describe("resolveMarkdownLinkPresentation", () => { | |
| 6 | it("extracts external link hosts", () => { | |
| 7 | expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({ | |
| 8 | kind: "external", | |
| 9 | href: "https://example.com/docs?q=1", | |
| 10 | host: "example.com", | |
| 11 | }); | |
| 12 | }); | |
| 13 | ||
| 14 | it("renders file URLs as basename pills with positions", () => { | |
| 15 | expect( | |
| 16 | resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"), | |
| 17 | ).toEqual({ | |
| 18 | kind: "file", | |
| 19 | icon: "typescript", | |
| 20 | label: "main.ts:42:7", | |
| 21 | }); | |
| 22 | }); | |
| 23 | ||
| 24 | it("recognizes relative source paths and bare filenames", () => { | |
| 25 | expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:10")).toEqual({ | |
| 26 | kind: "file", | |
| 27 | icon: "typescript", | |
| 28 | label: "index.ts:10", | |
| 29 | }); | |
| 30 | expect(resolveMarkdownLinkPresentation("AGENTS.md")).toEqual({ | |
| 31 | kind: "file", | |
| 32 | icon: "agents", | |
| 33 | label: "AGENTS.md", | |
| 34 | }); | |
| 35 | expect(resolveMarkdownLinkPresentation("package.json")).toEqual({ | |
| 36 | kind: "file", | |
| 37 | icon: "package", | |
| 38 | label: "package.json", | |
| 39 | }); | |
| 40 | }); | |
| 41 | ||
| 42 | it("uses the Pierre complete icon mappings", () => { | |
| 43 | expect(resolveMarkdownLinkPresentation("src/Button.tsx")).toMatchObject({ | |
| 44 | kind: "file", | |
| 45 | icon: "react", | |
| 46 | }); | |
| 47 | expect(resolveMarkdownLinkPresentation("vite.config.ts")).toMatchObject({ | |
| 48 | kind: "file", | |
| 49 | icon: "vite", | |
| 50 | }); | |
| 51 | expect(resolveMarkdownLinkPresentation("Dockerfile")).toMatchObject({ | |
| 52 | kind: "file", | |
| 53 | icon: "docker", | |
| 54 | }); | |
| 55 | expect(resolveMarkdownLinkPresentation("pnpm-lock.yaml")).toMatchObject({ | |
| 56 | kind: "file", | |
| 57 | icon: "pnpm", | |
| 58 | }); | |
| 59 | }); | |
| 60 | ||
| 61 | it("does not style app routes as file links", () => { | |
| 62 | expect(resolveMarkdownLinkPresentation("/chat/settings")).toEqual({ | |
| 63 | kind: "link", | |
| 64 | href: null, | |
| 65 | }); | |
| 66 | }); | |
| 67 | }); |
apps/mobile/src/lib/nativeMarkdownText.test.ts created+734| ... | ... | @@ -0,0 +1,734 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | |
| 3 | ||
| 4 | import { | |
| 5 | nativeMarkdownChunkSpacing, | |
| 6 | nativeMarkdownDocumentChunks, | |
| 7 | nativeMarkdownDocumentRuns, | |
| 8 | nativeMarkdownListItemBlocks, | |
| 9 | nativeMarkdownTextRuns, | |
| 10 | } from "@t3tools/mobile-markdown-text/markdown"; | |
| 11 | ||
| 12 | describe("nativeMarkdownTextRuns", () => { | |
| 13 | it("preserves inline emphasis and code styles", () => { | |
| 14 | const node: MarkdownNode = { | |
| 15 | type: "paragraph", | |
| 16 | children: [ | |
| 17 | { type: "text", content: "plain " }, | |
| 18 | { type: "bold", children: [{ type: "text", content: "bold" }] }, | |
| 19 | { type: "text", content: " " }, | |
| 20 | { type: "code_inline", content: "const value = 1" }, | |
| 21 | ], | |
| 22 | }; | |
| 23 | ||
| 24 | expect(nativeMarkdownTextRuns(node)).toEqual([ | |
| 25 | { text: "plain " }, | |
| 26 | { text: "bold", bold: true }, | |
| 27 | { text: " " }, | |
| 28 | { text: "const value = 1", code: true }, | |
| 29 | ]); | |
| 30 | }); | |
| 31 | ||
| 32 | it("normalizes external and file links for native presentation", () => { | |
| 33 | const node: MarkdownNode = { | |
| 34 | type: "paragraph", | |
| 35 | children: [ | |
| 36 | { | |
| 37 | type: "link", | |
| 38 | href: "https://example.com/docs", | |
| 39 | children: [{ type: "text", content: "Docs" }], | |
| 40 | }, | |
| 41 | { type: "text", content: " " }, | |
| 42 | { | |
| 43 | type: "link", | |
| 44 | href: "file:///repo/README.md#L12", | |
| 45 | children: [{ type: "text", content: "ignored label" }], | |
| 46 | }, | |
| 47 | ], | |
| 48 | }; | |
| 49 | ||
| 50 | expect(nativeMarkdownTextRuns(node)).toEqual([ | |
| 51 | { | |
| 52 | text: "Docs", | |
| 53 | href: "https://example.com/docs", | |
| 54 | externalHost: "example.com", | |
| 55 | }, | |
| 56 | { text: " " }, | |
| 57 | { text: "README.md:12", fileIcon: "readme" }, | |
| 58 | ]); | |
| 59 | }); | |
| 60 | ||
| 61 | it("keeps hard breaks and collapses soft breaks", () => { | |
| 62 | const node: MarkdownNode = { | |
| 63 | type: "paragraph", | |
| 64 | children: [ | |
| 65 | { type: "text", content: "first" }, | |
| 66 | { type: "soft_break" }, | |
| 67 | { type: "text", content: "second" }, | |
| 68 | { type: "line_break" }, | |
| 69 | { type: "text", content: "third" }, | |
| 70 | ], | |
| 71 | }; | |
| 72 | ||
| 73 | expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "first second\nthird" }]); | |
| 74 | }); | |
| 75 | ||
| 76 | it("normalizes common inline HTML and entities", () => { | |
| 77 | const node: MarkdownNode = { | |
| 78 | type: "paragraph", | |
| 79 | children: [ | |
| 80 | { type: "text", content: "Less than: &lt; " }, | |
| 81 | { type: "html_inline", content: "<kbd>" }, | |
| 82 | { type: "text", content: "⌘" }, | |
| 83 | { type: "html_inline", content: "</kbd>" }, | |
| 84 | { type: "html_inline", content: "<br />" }, | |
| 85 | { type: "html_inline", content: "<mark>highlighted</mark>" }, | |
| 86 | ], | |
| 87 | }; | |
| 88 | ||
| 89 | expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "Less than: < ⌘\nhighlighted" }]); | |
| 90 | }); | |
| 91 | ||
| 92 | it("normalizes double-encoded entities and inline tags emitted as text", () => { | |
| 93 | const node: MarkdownNode = { | |
| 94 | type: "paragraph", | |
| 95 | children: [ | |
| 96 | { | |
| 97 | type: "text", | |
| 98 | content: | |
| 99 | "Keyboard: <kbd>⌘</kbd> + <kbd>K</kbd>; Less than: &amp;lt;; Greater than: &amp;gt;", | |
| 100 | }, | |
| 101 | ], | |
| 102 | }; | |
| 103 | ||
| 104 | expect(nativeMarkdownTextRuns(node)).toEqual([ | |
| 105 | { text: "Keyboard: ⌘ + K; Less than: <; Greater than: >" }, | |
| 106 | ]); | |
| 107 | }); | |
| 108 | ||
| 109 | it("reads inline content from nested text nodes", () => { | |
| 110 | const node: MarkdownNode = { | |
| 111 | type: "paragraph", | |
| 112 | children: [ | |
| 113 | { | |
| 114 | type: "text", | |
| 115 | children: [{ type: "text", content: "Plain text" }], | |
| 116 | }, | |
| 117 | { type: "text", content: " and " }, | |
| 118 | { | |
| 119 | type: "code_inline", | |
| 120 | children: [{ type: "text", content: "inline code" }], | |
| 121 | }, | |
| 122 | ], | |
| 123 | }; | |
| 124 | ||
| 125 | expect(nativeMarkdownTextRuns(node)).toEqual([ | |
| 126 | { text: "Plain text and " }, | |
| 127 | { text: "inline code", code: true }, | |
| 128 | ]); | |
| 129 | }); | |
| 130 | }); | |
| 131 | ||
| 132 | describe("nativeMarkdownDocumentRuns", () => { | |
| 133 | it("decorates known skill references as selectable skill chips", () => { | |
| 134 | const node: MarkdownNode = { | |
| 135 | type: "document", | |
| 136 | children: [ | |
| 137 | { | |
| 138 | type: "paragraph", | |
| 139 | children: [{ type: "text", content: "Use $ui for this." }], | |
| 140 | }, | |
| 141 | ], | |
| 142 | }; | |
| 143 | ||
| 144 | expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([ | |
| 145 | { text: "Use ", role: "body" }, | |
| 146 | { | |
| 147 | text: "$ui", | |
| 148 | role: "body", | |
| 149 | skillName: "ui", | |
| 150 | skillLabel: "UI", | |
| 151 | }, | |
| 152 | { text: " for this.", role: "body" }, | |
| 153 | ]); | |
| 154 | }); | |
| 155 | ||
| 156 | it("leaves unknown skill-like text unchanged", () => { | |
| 157 | const node: MarkdownNode = { | |
| 158 | type: "document", | |
| 159 | children: [ | |
| 160 | { | |
| 161 | type: "paragraph", | |
| 162 | children: [{ type: "text", content: "Use $unknown for this." }], | |
| 163 | }, | |
| 164 | ], | |
| 165 | }; | |
| 166 | ||
| 167 | expect(nativeMarkdownDocumentRuns(node, [])).toEqual([ | |
| 168 | { text: "Use $unknown for this.", role: "body" }, | |
| 169 | ]); | |
| 170 | }); | |
| 171 | ||
| 172 | it("keeps headings, paragraphs, and lists in one continuous document", () => { | |
| 173 | const node: MarkdownNode = { | |
| 174 | type: "document", | |
| 175 | children: [ | |
| 176 | { | |
| 177 | type: "heading", | |
| 178 | level: 1, | |
| 179 | children: [{ type: "text", content: "Header One" }], | |
| 180 | }, | |
| 181 | { | |
| 182 | type: "paragraph", | |
| 183 | children: [ | |
| 184 | { type: "text", content: "A paragraph with " }, | |
| 185 | { type: "bold", children: [{ type: "text", content: "bold text" }] }, | |
| 186 | { type: "text", content: "." }, | |
| 187 | ], | |
| 188 | }, | |
| 189 | { | |
| 190 | type: "list", | |
| 191 | ordered: false, | |
| 192 | children: [ | |
| 193 | { | |
| 194 | type: "list_item", | |
| 195 | children: [ | |
| 196 | { | |
| 197 | type: "paragraph", | |
| 198 | children: [{ type: "text", content: "First item" }], | |
| 199 | }, | |
| 200 | ], | |
| 201 | }, | |
| 202 | { | |
| 203 | type: "list_item", | |
| 204 | children: [ | |
| 205 | { | |
| 206 | type: "paragraph", | |
| 207 | children: [{ type: "text", content: "Second item" }], | |
| 208 | }, | |
| 209 | ], | |
| 210 | }, | |
| 211 | ], | |
| 212 | }, | |
| 213 | ], | |
| 214 | }; | |
| 215 | ||
| 216 | const runs = nativeMarkdownDocumentRuns(node); | |
| 217 | expect(runs.map((run) => run.text).join("")).toBe( | |
| 218 | "Header One\n\nA paragraph with bold text.\n\n•\tFirst item\n•\tSecond item", | |
| 219 | ); | |
| 220 | expect(runs).toContainEqual({ | |
| 221 | text: "Header One\n", | |
| 222 | role: "heading", | |
| 223 | headingLevel: 1, | |
| 224 | }); | |
| 225 | expect(runs).toContainEqual({ | |
| 226 | text: "bold text", | |
| 227 | bold: true, | |
| 228 | role: "body", | |
| 229 | }); | |
| 230 | expect(runs).toContainEqual({ | |
| 231 | text: "•\t", | |
| 232 | role: "list-marker", | |
| 233 | depth: 1, | |
| 234 | firstLineHeadIndent: 0, | |
| 235 | headIndent: 24, | |
| 236 | paragraphSpacing: 2, | |
| 237 | }); | |
| 238 | }); | |
| 239 | ||
| 240 | it("uses distinct section, heading-content, and body spacing", () => { | |
| 241 | const node: MarkdownNode = { | |
| 242 | type: "document", | |
| 243 | children: [ | |
| 244 | { | |
| 245 | type: "paragraph", | |
| 246 | children: [{ type: "text", content: "Intro" }], | |
| 247 | }, | |
| 248 | { | |
| 249 | type: "heading", | |
| 250 | level: 2, | |
| 251 | children: [{ type: "text", content: "Section" }], | |
| 252 | }, | |
| 253 | { | |
| 254 | type: "paragraph", | |
| 255 | children: [{ type: "text", content: "First paragraph" }], | |
| 256 | }, | |
| 257 | { | |
| 258 | type: "paragraph", | |
| 259 | children: [{ type: "text", content: "Second paragraph" }], | |
| 260 | }, | |
| 261 | ], | |
| 262 | }; | |
| 263 | ||
| 264 | expect( | |
| 265 | nativeMarkdownDocumentRuns(node) | |
| 266 | .filter((run) => run.role === "spacer") | |
| 267 | .map((run) => run.spacing), | |
| 268 | ).toEqual([20, 10, 12]); | |
| 269 | }); | |
| 270 | ||
| 271 | it("renders tight list items whose inline nodes are direct children", () => { | |
| 272 | const node: MarkdownNode = { | |
| 273 | type: "document", | |
| 274 | children: [ | |
| 275 | { | |
| 276 | type: "list", | |
| 277 | children: [ | |
| 278 | { | |
| 279 | type: "list_item", | |
| 280 | children: [ | |
| 281 | { | |
| 282 | type: "bold", | |
| 283 | children: [{ type: "text", content: "Finding:" }], | |
| 284 | }, | |
| 285 | { type: "text", content: " details with " }, | |
| 286 | { type: "code_inline", content: "inline code" }, | |
| 287 | { type: "text", content: "." }, | |
| 288 | ], | |
| 289 | }, | |
| 290 | ], | |
| 291 | }, | |
| 292 | ], | |
| 293 | }; | |
| 294 | ||
| 295 | expect(nativeMarkdownDocumentRuns(node)).toEqual([ | |
| 296 | { | |
| 297 | text: "•\t", | |
| 298 | role: "list-marker", | |
| 299 | depth: 1, | |
| 300 | firstLineHeadIndent: 0, | |
| 301 | headIndent: 24, | |
| 302 | paragraphSpacing: 2, | |
| 303 | }, | |
| 304 | { text: "Finding:", bold: true, role: "body", depth: 1 }, | |
| 305 | { text: " details with ", role: "body", depth: 1 }, | |
| 306 | { text: "inline code", code: true, role: "body", depth: 1 }, | |
| 307 | { text: ".", role: "body", depth: 1 }, | |
| 308 | ]); | |
| 309 | }); | |
| 310 | ||
| 311 | it("includes quotes and fenced code in the same selectable string", () => { | |
| 312 | const node: MarkdownNode = { | |
| 313 | type: "document", | |
| 314 | children: [ | |
| 315 | { | |
| 316 | type: "blockquote", | |
| 317 | children: [ | |
| 318 | { | |
| 319 | type: "paragraph", | |
| 320 | children: [{ type: "text", content: "Read this" }], | |
| 321 | }, | |
| 322 | ], | |
| 323 | }, | |
| 324 | { | |
| 325 | type: "code_block", | |
| 326 | language: "ts", | |
| 327 | content: "const answer = 42;", | |
| 328 | }, | |
| 329 | ], | |
| 330 | }; | |
| 331 | ||
| 332 | const runs = nativeMarkdownDocumentRuns(node); | |
| 333 | expect(runs.map((run) => run.text).join("")).toBe("│\u00a0Read this\n\nTS\nconst answer = 42;"); | |
| 334 | expect(runs).toContainEqual({ | |
| 335 | text: "const answer = 42;", | |
| 336 | code: true, | |
| 337 | role: "code-block", | |
| 338 | }); | |
| 339 | }); | |
| 340 | ||
| 341 | it("reads fenced code content from child text nodes", () => { | |
| 342 | const node: MarkdownNode = { | |
| 343 | type: "document", | |
| 344 | children: [ | |
| 345 | { | |
| 346 | type: "code_block", | |
| 347 | language: "bash", | |
| 348 | children: [{ type: "text", content: "pnpm install\n" }], | |
| 349 | }, | |
| 350 | ], | |
| 351 | }; | |
| 352 | ||
| 353 | expect( | |
| 354 | nativeMarkdownDocumentRuns(node) | |
| 355 | .map((run) => run.text) | |
| 356 | .join(""), | |
| 357 | ).toBe("BASH\npnpm install"); | |
| 358 | }); | |
| 359 | }); | |
| 360 | ||
| 361 | describe("nativeMarkdownListItemBlocks", () => { | |
| 362 | it("groups consecutive inline nodes into one paragraph block", () => { | |
| 363 | const item: MarkdownNode = { | |
| 364 | type: "list_item", | |
| 365 | children: [ | |
| 366 | { type: "text", content: "Finding: " }, | |
| 367 | { type: "bold", children: [{ type: "text", content: "important" }] }, | |
| 368 | { type: "text", content: " details." }, | |
| 369 | { | |
| 370 | type: "list", | |
| 371 | children: [ | |
| 372 | { | |
| 373 | type: "list_item", | |
| 374 | children: [{ type: "text", content: "Nested" }], | |
| 375 | }, | |
| 376 | ], | |
| 377 | }, | |
| 378 | { type: "text", content: "Trailing prose." }, | |
| 379 | ], | |
| 380 | }; | |
| 381 | ||
| 382 | expect(nativeMarkdownListItemBlocks(item)).toEqual([ | |
| 383 | { | |
| 384 | type: "paragraph", | |
| 385 | children: item.children?.slice(0, 3), | |
| 386 | }, | |
| 387 | item.children?.[3], | |
| 388 | { | |
| 389 | type: "paragraph", | |
| 390 | children: [item.children?.[4]], | |
| 391 | }, | |
| 392 | ]); | |
| 393 | }); | |
| 394 | }); | |
| 395 | ||
| 396 | describe("nativeMarkdownDocumentChunks", () => { | |
| 397 | it("keeps headings and plain lists in one selectable document", () => { | |
| 398 | const document: MarkdownNode = { | |
| 399 | type: "document", | |
| 400 | children: [ | |
| 401 | { | |
| 402 | type: "heading", | |
| 403 | level: 2, | |
| 404 | children: [{ type: "text", content: "Tasks" }], | |
| 405 | }, | |
| 406 | { | |
| 407 | type: "list", | |
| 408 | children: [ | |
| 409 | { | |
| 410 | type: "task_list_item", | |
| 411 | checked: true, | |
| 412 | children: [ | |
| 413 | { | |
| 414 | type: "paragraph", | |
| 415 | children: [{ type: "text", content: "Completed" }], | |
| 416 | }, | |
| 417 | ], | |
| 418 | }, | |
| 419 | { | |
| 420 | type: "list_item", | |
| 421 | children: [ | |
| 422 | { | |
| 423 | type: "paragraph", | |
| 424 | children: [{ type: "text", content: "Parent" }], | |
| 425 | }, | |
| 426 | { | |
| 427 | type: "list", | |
| 428 | children: [ | |
| 429 | { | |
| 430 | type: "list_item", | |
| 431 | children: [ | |
| 432 | { | |
| 433 | type: "paragraph", | |
| 434 | children: [{ type: "text", content: "Nested" }], | |
| 435 | }, | |
| 436 | ], | |
| 437 | }, | |
| 438 | ], | |
| 439 | }, | |
| 440 | ], | |
| 441 | }, | |
| 442 | ], | |
| 443 | }, | |
| 444 | ], | |
| 445 | }; | |
| 446 | ||
| 447 | const chunks = nativeMarkdownDocumentChunks(document); | |
| 448 | expect(chunks).toHaveLength(1); | |
| 449 | expect(chunks[0]).toMatchObject({ kind: "selectable" }); | |
| 450 | expect( | |
| 451 | nativeMarkdownDocumentRuns(chunks[0]?.node ?? document) | |
| 452 | .map((run) => run.text) | |
| 453 | .join(""), | |
| 454 | ).toBe("Tasks\n\n☑︎\tCompleted\n•\tParent\n◦\tNested"); | |
| 455 | }); | |
| 456 | ||
| 457 | it("aligns ordered markers while keeping the list in one selectable string", () => { | |
| 458 | const document: MarkdownNode = { | |
| 459 | type: "document", | |
| 460 | children: [ | |
| 461 | { | |
| 462 | type: "list", | |
| 463 | ordered: true, | |
| 464 | start: 9, | |
| 465 | children: [ | |
| 466 | { | |
| 467 | type: "list_item", | |
| 468 | children: [{ type: "text", content: "Ninth" }], | |
| 469 | }, | |
| 470 | { | |
| 471 | type: "list_item", | |
| 472 | children: [{ type: "text", content: "Tenth" }], | |
| 473 | }, | |
| 474 | ], | |
| 475 | }, | |
| 476 | ], | |
| 477 | }; | |
| 478 | ||
| 479 | expect( | |
| 480 | nativeMarkdownDocumentRuns(document) | |
| 481 | .map((run) => run.text) | |
| 482 | .join(""), | |
| 483 | ).toBe("\u20079.\tNinth\n10.\tTenth"); | |
| 484 | }); | |
| 485 | ||
| 486 | it("keeps prose selectable while exposing rich AST blocks", () => { | |
| 487 | const document: MarkdownNode = { | |
| 488 | type: "document", | |
| 489 | children: [ | |
| 490 | { | |
| 491 | type: "heading", | |
| 492 | level: 1, | |
| 493 | beg: 0, | |
| 494 | end: 9, | |
| 495 | children: [{ type: "text", content: "Install" }], | |
| 496 | }, | |
| 497 | { | |
| 498 | type: "code_block", | |
| 499 | language: "bash", | |
| 500 | beg: 11, | |
| 501 | end: 35, | |
| 502 | children: [{ type: "text", content: "pnpm install\n" }], | |
| 503 | }, | |
| 504 | { | |
| 505 | type: "paragraph", | |
| 506 | beg: 37, | |
| 507 | end: 42, | |
| 508 | children: [{ type: "text", content: "Done." }], | |
| 509 | }, | |
| 510 | ], | |
| 511 | }; | |
| 512 | ||
| 513 | const chunks = nativeMarkdownDocumentChunks(document); | |
| 514 | expect(chunks).toHaveLength(3); | |
| 515 | expect(chunks[0]).toMatchObject({ kind: "selectable" }); | |
| 516 | expect(chunks[1]).toEqual({ | |
| 517 | kind: "rich", | |
| 518 | key: "rich:code_block:11:35", | |
| 519 | node: document.children?.[1], | |
| 520 | }); | |
| 521 | expect(chunks[2]).toMatchObject({ kind: "selectable" }); | |
| 522 | }); | |
| 523 | ||
| 524 | it("keeps a list containing fenced code as one rich AST container", () => { | |
| 525 | const document: MarkdownNode = { | |
| 526 | type: "document", | |
| 527 | children: [ | |
| 528 | { | |
| 529 | type: "list", | |
| 530 | beg: 0, | |
| 531 | end: 45, | |
| 532 | children: [ | |
| 533 | { | |
| 534 | type: "list_item", | |
| 535 | children: [ | |
| 536 | { | |
| 537 | type: "paragraph", | |
| 538 | children: [{ type: "text", content: "Install" }], | |
| 539 | }, | |
| 540 | { | |
| 541 | type: "code_block", | |
| 542 | language: "bash", | |
| 543 | children: [{ type: "text", content: "pnpm install\n" }], | |
| 544 | }, | |
| 545 | ], | |
| 546 | }, | |
| 547 | ], | |
| 548 | }, | |
| 549 | ], | |
| 550 | }; | |
| 551 | ||
| 552 | expect(nativeMarkdownDocumentChunks(document)).toEqual([ | |
| 553 | { | |
| 554 | kind: "rich", | |
| 555 | key: "rich:list:0:45", | |
| 556 | node: document.children?.[0], | |
| 557 | }, | |
| 558 | ]); | |
| 559 | }); | |
| 560 | ||
| 561 | it("keeps surrounding prose selectable when rich nodes have no source offsets", () => { | |
| 562 | const document: MarkdownNode = { | |
| 563 | type: "document", | |
| 564 | children: [ | |
| 565 | { | |
| 566 | type: "heading", | |
| 567 | level: 1, | |
| 568 | children: [{ type: "text", content: "Before" }], | |
| 569 | }, | |
| 570 | { type: "horizontal_rule" }, | |
| 571 | { | |
| 572 | type: "paragraph", | |
| 573 | children: [{ type: "text", content: "After." }], | |
| 574 | }, | |
| 575 | ], | |
| 576 | }; | |
| 577 | ||
| 578 | const chunks = nativeMarkdownDocumentChunks(document); | |
| 579 | expect(chunks).toHaveLength(3); | |
| 580 | expect(chunks[0]).toMatchObject({ kind: "selectable" }); | |
| 581 | expect(chunks[1]).toEqual({ | |
| 582 | kind: "rich", | |
| 583 | key: "rich:horizontal_rule:1:1", | |
| 584 | node: document.children?.[1], | |
| 585 | }); | |
| 586 | expect(chunks[2]).toMatchObject({ kind: "selectable" }); | |
| 587 | }); | |
| 588 | ||
| 589 | it("keeps offset-free structural lists isolated without promoting the whole document", () => { | |
| 590 | const document: MarkdownNode = { | |
| 591 | type: "document", | |
| 592 | children: [ | |
| 593 | { | |
| 594 | type: "paragraph", | |
| 595 | children: [{ type: "text", content: "Before." }], | |
| 596 | }, | |
| 597 | { | |
| 598 | type: "list", | |
| 599 | ordered: true, | |
| 600 | children: [ | |
| 601 | { | |
| 602 | type: "list_item", | |
| 603 | children: [ | |
| 604 | { | |
| 605 | type: "paragraph", | |
| 606 | children: [{ type: "text", content: "Install" }], | |
| 607 | }, | |
| 608 | { | |
| 609 | type: "code_block", | |
| 610 | language: "bash", | |
| 611 | children: [{ type: "text", content: "pnpm install\n" }], | |
| 612 | }, | |
| 613 | ], | |
| 614 | }, | |
| 615 | ], | |
| 616 | }, | |
| 617 | { | |
| 618 | type: "paragraph", | |
| 619 | children: [{ type: "text", content: "After." }], | |
| 620 | }, | |
| 621 | ], | |
| 622 | }; | |
| 623 | ||
| 624 | const chunks = nativeMarkdownDocumentChunks(document); | |
| 625 | expect(chunks).toHaveLength(3); | |
| 626 | expect(chunks[0]).toMatchObject({ kind: "selectable" }); | |
| 627 | expect(chunks[1]).toEqual({ | |
| 628 | kind: "rich", | |
| 629 | key: "rich:list:1:1", | |
| 630 | node: document.children?.[1], | |
| 631 | }); | |
| 632 | expect(chunks[2]).toMatchObject({ kind: "selectable" }); | |
| 633 | }); | |
| 634 | ||
| 635 | it("never collapses a rich subtree into a second markdown parsing pass", () => { | |
| 636 | const document: MarkdownNode = { | |
| 637 | type: "document", | |
| 638 | children: [ | |
| 639 | { | |
| 640 | type: "paragraph", | |
| 641 | children: [{ type: "text", content: "Before." }], | |
| 642 | }, | |
| 643 | { | |
| 644 | type: "blockquote", | |
| 645 | children: [ | |
| 646 | { | |
| 647 | type: "list", | |
| 648 | children: [ | |
| 649 | { | |
| 650 | type: "list_item", | |
| 651 | children: [ | |
| 652 | { type: "text", content: "Run this" }, | |
| 653 | { | |
| 654 | type: "code_block", | |
| 655 | language: "sh", | |
| 656 | children: [{ type: "text", content: "vp check\n" }], | |
| 657 | }, | |
| 658 | ], | |
| 659 | }, | |
| 660 | ], | |
| 661 | }, | |
| 662 | ], | |
| 663 | }, | |
| 664 | { | |
| 665 | type: "paragraph", | |
| 666 | children: [{ type: "text", content: "After." }], | |
| 667 | }, | |
| 668 | ], | |
| 669 | }; | |
| 670 | ||
| 671 | const chunks = nativeMarkdownDocumentChunks(document); | |
| 672 | expect(chunks.map((chunk) => chunk.kind)).toEqual(["selectable", "rich", "selectable"]); | |
| 673 | expect(chunks[1]).toMatchObject({ | |
| 674 | kind: "rich", | |
| 675 | node: { type: "blockquote" }, | |
| 676 | }); | |
| 677 | }); | |
| 678 | ||
| 679 | it("keeps a plain list in one selectable native text container", () => { | |
| 680 | const list: MarkdownNode = { | |
| 681 | type: "list", | |
| 682 | ordered: false, | |
| 683 | children: [ | |
| 684 | { | |
| 685 | type: "list_item", | |
| 686 | children: [{ type: "text", content: "First" }], | |
| 687 | }, | |
| 688 | ], | |
| 689 | }; | |
| 690 | ||
| 691 | const chunks = nativeMarkdownDocumentChunks({ | |
| 692 | type: "document", | |
| 693 | children: [list], | |
| 694 | }); | |
| 695 | ||
| 696 | expect(chunks).toHaveLength(1); | |
| 697 | expect(chunks[0]).toMatchObject({ | |
| 698 | kind: "selectable", | |
| 699 | node: { type: "document", children: [list] }, | |
| 700 | }); | |
| 701 | }); | |
| 702 | ||
| 703 | it("separates sections more than related rich blocks", () => { | |
| 704 | const headingChunk = { | |
| 705 | kind: "selectable" as const, | |
| 706 | key: "heading", | |
| 707 | node: { | |
| 708 | type: "document", | |
| 709 | children: [ | |
| 710 | { | |
| 711 | type: "heading", | |
| 712 | level: 2, | |
| 713 | children: [{ type: "text", content: "Section" }], | |
| 714 | }, | |
| 715 | ], | |
| 716 | } satisfies MarkdownNode, | |
| 717 | }; | |
| 718 | const firstList = { | |
| 719 | kind: "rich" as const, | |
| 720 | key: "list-1", | |
| 721 | node: { type: "list", children: [] } satisfies MarkdownNode, | |
| 722 | }; | |
| 723 | const secondList = { | |
| 724 | kind: "rich" as const, | |
| 725 | key: "list-2", | |
| 726 | node: { type: "list", children: [] } satisfies MarkdownNode, | |
| 727 | }; | |
| 728 | ||
| 729 | expect(nativeMarkdownChunkSpacing(undefined, headingChunk)).toBe(0); | |
| 730 | expect(nativeMarkdownChunkSpacing(headingChunk, firstList)).toBe(10); | |
| 731 | expect(nativeMarkdownChunkSpacing(firstList, secondList)).toBe(12); | |
| 732 | expect(nativeMarkdownChunkSpacing(firstList, headingChunk)).toBe(20); | |
| 733 | }); | |
| 734 | }); |
apps/mobile/src/lib/threadActivity.test.ts+204-18| ... | ... | @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | EventId, |
| 5 | MessageId, | |
| 5 | 6 | ProjectId, |
| 6 | 7 | ProviderInstanceId, |
| 7 | 8 | ThreadId, |
| ... | ... | @@ -10,7 +11,7 @@ import { |
| 10 | 11 | type OrchestrationThreadActivity, |
| 11 | 12 | } from "@t3tools/contracts"; |
| 12 | 13 | |
| 13 | import { buildThreadFeed } from "./threadActivity"; | |
| 14 | import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity"; | |
| 14 | 15 | |
| 15 | 16 | function makeActivity( |
| 16 | 17 | input: Partial<OrchestrationThreadActivity> & |
| ... | ... | @@ -48,7 +49,7 @@ function makeThread( |
| 48 | 49 | } |
| 49 | 50 | |
| 50 | 51 | describe("buildThreadFeed", () => { |
| 51 | it("includes runtime warnings from the latest turn", () => { | |
| 52 | it("keeps historic work entries attributed to their turns", () => { | |
| 52 | 53 | const thread = makeThread({ |
| 53 | 54 | id: ThreadId.make("thread-1"), |
| 54 | 55 | projectId: ProjectId.make("project-1"), |
| ... | ... | @@ -86,22 +87,16 @@ describe("buildThreadFeed", () => { |
| 86 | 87 | }); |
| 87 | 88 | |
| 88 | 89 | const feed = buildThreadFeed(thread, [], null); |
| 89 | const group = feed[0]; | |
| 90 | ||
| 91 | expect(group).toMatchObject({ | |
| 92 | type: "activity-group", | |
| 93 | }); | |
| 94 | if (!group || group.type !== "activity-group") { | |
| 95 | return; | |
| 96 | } | |
| 97 | ||
| 98 | expect(group.activities).toEqual([ | |
| 90 | expect(feed).toMatchObject([ | |
| 91 | { | |
| 92 | type: "activity-group", | |
| 93 | turnId: "turn-old", | |
| 94 | activities: [{ id: "activity-old", turnId: "turn-old" }], | |
| 95 | }, | |
| 99 | 96 | { |
| 100 | id: "activity-latest", | |
| 101 | createdAt: "2026-04-01T00:00:03.000Z", | |
| 102 | summary: "Runtime warning", | |
| 103 | detail: null, | |
| 104 | status: null, | |
| 97 | type: "activity-group", | |
| 98 | turnId: "turn-latest", | |
| 99 | activities: [{ id: "activity-latest", turnId: "turn-latest" }], | |
| 105 | 100 | }, |
| 106 | 101 | ]); |
| 107 | 102 | }); |
| ... | ... | @@ -163,10 +158,201 @@ describe("buildThreadFeed", () => { |
| 163 | 158 | { |
| 164 | 159 | id: "tool-completed", |
| 165 | 160 | createdAt: "2026-04-01T00:00:02.000Z", |
| 161 | turnId: "turn-1", | |
| 166 | 162 | summary: "Run tests", |
| 167 | 163 | detail: "bun run test", |
| 168 | status: null, | |
| 164 | fullDetail: null, | |
| 165 | copyText: "Run tests\nbun run test", | |
| 166 | toolLike: true, | |
| 167 | status: "success", | |
| 169 | 168 | }, |
| 170 | 169 | ]); |
| 171 | 170 | }); |
| 171 | ||
| 172 | it("folds settled turn work while leaving the terminal answer visible", () => { | |
| 173 | const turnId = TurnId.make("turn-1"); | |
| 174 | const thread = makeThread({ | |
| 175 | id: ThreadId.make("thread-3"), | |
| 176 | projectId: ProjectId.make("project-1"), | |
| 177 | title: "Folded work", | |
| 178 | latestTurn: { | |
| 179 | turnId, | |
| 180 | state: "completed", | |
| 181 | requestedAt: "2026-04-01T00:00:00.000Z", | |
| 182 | startedAt: "2026-04-01T00:00:01.000Z", | |
| 183 | completedAt: "2026-04-01T00:00:18.000Z", | |
| 184 | assistantMessageId: MessageId.make("assistant-final"), | |
| 185 | }, | |
| 186 | messages: [ | |
| 187 | { | |
| 188 | id: MessageId.make("assistant-commentary"), | |
| 189 | role: "assistant", | |
| 190 | text: "I am checking.", | |
| 191 | turnId, | |
| 192 | streaming: false, | |
| 193 | createdAt: "2026-04-01T00:00:02.000Z", | |
| 194 | updatedAt: "2026-04-01T00:00:03.000Z", | |
| 195 | }, | |
| 196 | { | |
| 197 | id: MessageId.make("assistant-final"), | |
| 198 | role: "assistant", | |
| 199 | text: "Done.", | |
| 200 | turnId, | |
| 201 | streaming: false, | |
| 202 | createdAt: "2026-04-01T00:00:17.000Z", | |
| 203 | updatedAt: "2026-04-01T00:00:18.000Z", | |
| 204 | }, | |
| 205 | ], | |
| 206 | activities: [ | |
| 207 | makeActivity({ | |
| 208 | id: EventId.make("tool-completed"), | |
| 209 | kind: "tool.completed", | |
| 210 | tone: "tool", | |
| 211 | summary: "Read files", | |
| 212 | createdAt: "2026-04-01T00:00:05.000Z", | |
| 213 | turnId, | |
| 214 | payload: { | |
| 215 | title: "Read files", | |
| 216 | itemType: "file_read", | |
| 217 | status: "completed", | |
| 218 | }, | |
| 219 | }), | |
| 220 | ], | |
| 221 | }); | |
| 222 | ||
| 223 | const feed = buildThreadFeed(thread, [], null); | |
| 224 | const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); | |
| 225 | expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); | |
| 226 | expect(collapsed[0]).toMatchObject({ | |
| 227 | type: "turn-fold", | |
| 228 | label: "Worked for 17s", | |
| 229 | expanded: false, | |
| 230 | }); | |
| 231 | ||
| 232 | const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); | |
| 233 | expect(expanded.map((entry) => entry.id)).toEqual([ | |
| 234 | "turn-fold:turn-1", | |
| 235 | "assistant-commentary", | |
| 236 | "tool-completed", | |
| 237 | "assistant-final", | |
| 238 | ]); | |
| 239 | }); | |
| 240 | ||
| 241 | it("measures a steer-superseded turn from its user boundary through trailing work", () => { | |
| 242 | const firstTurnId = TurnId.make("turn-1"); | |
| 243 | const secondTurnId = TurnId.make("turn-2"); | |
| 244 | const thread = makeThread({ | |
| 245 | id: ThreadId.make("thread-steered"), | |
| 246 | projectId: ProjectId.make("project-1"), | |
| 247 | title: "Steered work", | |
| 248 | latestTurn: { | |
| 249 | turnId: secondTurnId, | |
| 250 | state: "running", | |
| 251 | requestedAt: "2026-04-01T00:00:14.000Z", | |
| 252 | startedAt: "2026-04-01T00:00:14.000Z", | |
| 253 | completedAt: null, | |
| 254 | assistantMessageId: MessageId.make("assistant-next"), | |
| 255 | }, | |
| 256 | messages: [ | |
| 257 | { | |
| 258 | id: MessageId.make("user-1"), | |
| 259 | role: "user", | |
| 260 | text: "Do it once more.", | |
| 261 | turnId: null, | |
| 262 | streaming: false, | |
| 263 | createdAt: "2026-04-01T00:00:00.000Z", | |
| 264 | updatedAt: "2026-04-01T00:00:00.000Z", | |
| 265 | }, | |
| 266 | { | |
| 267 | id: MessageId.make("assistant-commentary"), | |
| 268 | role: "assistant", | |
| 269 | text: "Kicking off call 1.", | |
| 270 | turnId: firstTurnId, | |
| 271 | streaming: false, | |
| 272 | createdAt: "2026-04-01T00:00:09.000Z", | |
| 273 | updatedAt: "2026-04-01T00:00:09.000Z", | |
| 274 | }, | |
| 275 | { | |
| 276 | id: MessageId.make("user-2"), | |
| 277 | role: "user", | |
| 278 | text: "Actually do 15.", | |
| 279 | turnId: null, | |
| 280 | streaming: false, | |
| 281 | createdAt: "2026-04-01T00:00:14.000Z", | |
| 282 | updatedAt: "2026-04-01T00:00:14.000Z", | |
| 283 | }, | |
| 284 | { | |
| 285 | id: MessageId.make("assistant-next"), | |
| 286 | role: "assistant", | |
| 287 | text: "One down - adjusting.", | |
| 288 | turnId: secondTurnId, | |
| 289 | streaming: true, | |
| 290 | createdAt: "2026-04-01T00:00:17.000Z", | |
| 291 | updatedAt: "2026-04-01T00:00:17.000Z", | |
| 292 | }, | |
| 293 | ], | |
| 294 | activities: [ | |
| 295 | makeActivity({ | |
| 296 | id: EventId.make("work-1"), | |
| 297 | kind: "tool.completed", | |
| 298 | tone: "tool", | |
| 299 | summary: "Ran command", | |
| 300 | createdAt: "2026-04-01T00:00:12.000Z", | |
| 301 | turnId: firstTurnId, | |
| 302 | payload: { | |
| 303 | title: "Ran command", | |
| 304 | itemType: "command_execution", | |
| 305 | status: "completed", | |
| 306 | }, | |
| 307 | }), | |
| 308 | ], | |
| 309 | }); | |
| 310 | ||
| 311 | const feed = buildThreadFeed(thread, [], null); | |
| 312 | const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); | |
| 313 | expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({ | |
| 314 | turnId: firstTurnId, | |
| 315 | label: "Worked for 12s", | |
| 316 | }); | |
| 317 | }); | |
| 318 | ||
| 319 | it("keeps an active turn expanded and classifies error-shaped tool output", () => { | |
| 320 | const turnId = TurnId.make("turn-running"); | |
| 321 | const thread = makeThread({ | |
| 322 | id: ThreadId.make("thread-4"), | |
| 323 | projectId: ProjectId.make("project-1"), | |
| 324 | title: "Running work", | |
| 325 | latestTurn: { | |
| 326 | turnId, | |
| 327 | state: "running", | |
| 328 | requestedAt: "2026-04-01T00:00:00.000Z", | |
| 329 | startedAt: "2026-04-01T00:00:01.000Z", | |
| 330 | completedAt: null, | |
| 331 | assistantMessageId: null, | |
| 332 | }, | |
| 333 | activities: [ | |
| 334 | makeActivity({ | |
| 335 | id: EventId.make("tool-failed"), | |
| 336 | kind: "tool.completed", | |
| 337 | tone: "tool", | |
| 338 | summary: "Run command", | |
| 339 | createdAt: "2026-04-01T00:00:05.000Z", | |
| 340 | turnId, | |
| 341 | payload: { | |
| 342 | title: "Run command", | |
| 343 | itemType: "command_execution", | |
| 344 | detail: "zsh: command not found: nope", | |
| 345 | status: "completed", | |
| 346 | }, | |
| 347 | }), | |
| 348 | ], | |
| 349 | }); | |
| 350 | ||
| 351 | const feed = buildThreadFeed(thread, [], null); | |
| 352 | expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); | |
| 353 | expect(feed[0]).toMatchObject({ | |
| 354 | type: "activity-group", | |
| 355 | activities: [{ status: "failure" }], | |
| 356 | }); | |
| 357 | }); | |
| 172 | 358 | }); |
apps/mobile/src/lib/threadActivity.ts+356-23| ... | ... | @@ -3,13 +3,15 @@ import type { |
| 3 | 3 | CommandId, |
| 4 | 4 | EnvironmentId, |
| 5 | 5 | MessageId, |
| 6 | OrchestrationLatestTurn, | |
| 6 | 7 | OrchestrationThread, |
| 7 | 8 | OrchestrationThreadActivity, |
| 8 | TurnId, | |
| 9 | 9 | ToolLifecycleItemType, |
| 10 | 10 | ThreadId, |
| 11 | TurnId, | |
| 11 | 12 | UserInputQuestion, |
| 12 | 13 | } from "@t3tools/contracts"; |
| 14 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | |
| 13 | 15 | |
| 14 | 16 | import type { DraftComposerImageAttachment } from "./composerImages"; |
| 15 | 17 | import * as Arr from "effect/Array"; |
| ... | ... | @@ -46,14 +48,21 @@ export interface QueuedThreadMessage { |
| 46 | 48 | export interface ThreadFeedActivity { |
| 47 | 49 | readonly id: string; |
| 48 | 50 | readonly createdAt: string; |
| 51 | readonly turnId: TurnId | null; | |
| 49 | 52 | readonly summary: string; |
| 50 | 53 | readonly detail: string | null; |
| 51 | readonly status: string | null; | |
| 54 | readonly fullDetail: string | null; | |
| 55 | readonly copyText: string; | |
| 56 | readonly toolLike: boolean; | |
| 57 | readonly status: "success" | "failure" | "neutral" | null; | |
| 52 | 58 | } |
| 53 | 59 | |
| 60 | type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped"; | |
| 61 | ||
| 54 | 62 | interface WorkLogEntry { |
| 55 | 63 | id: string; |
| 56 | 64 | createdAt: string; |
| 65 | turnId: TurnId | null; | |
| 57 | 66 | label: string; |
| 58 | 67 | detail?: string; |
| 59 | 68 | command?: string; |
| ... | ... | @@ -63,6 +72,7 @@ interface WorkLogEntry { |
| 63 | 72 | toolTitle?: string; |
| 64 | 73 | itemType?: ToolLifecycleItemType; |
| 65 | 74 | requestKind?: PendingApproval["requestKind"]; |
| 75 | toolLifecycleStatus?: WorkLogToolLifecycleStatus; | |
| 66 | 76 | } |
| 67 | 77 | |
| 68 | 78 | interface DerivedWorkLogEntry extends WorkLogEntry { |
| ... | ... | @@ -88,6 +98,7 @@ type RawThreadFeedEntry = |
| 88 | 98 | readonly type: "activity"; |
| 89 | 99 | readonly id: string; |
| 90 | 100 | readonly createdAt: string; |
| 101 | readonly turnId: TurnId | null; | |
| 91 | 102 | readonly activity: ThreadFeedActivity; |
| 92 | 103 | }; |
| 93 | 104 | |
| ... | ... | @@ -97,9 +108,23 @@ export type ThreadFeedEntry = |
| 97 | 108 | readonly type: "activity-group"; |
| 98 | 109 | readonly id: string; |
| 99 | 110 | readonly createdAt: string; |
| 111 | readonly turnId: TurnId | null; | |
| 100 | 112 | readonly activities: ReadonlyArray<ThreadFeedActivity>; |
| 113 | } | |
| 114 | | { | |
| 115 | readonly type: "turn-fold"; | |
| 116 | readonly id: string; | |
| 117 | readonly createdAt: string; | |
| 118 | readonly turnId: TurnId; | |
| 119 | readonly label: string; | |
| 120 | readonly expanded: boolean; | |
| 101 | 121 | }; |
| 102 | 122 | |
| 123 | export type ThreadFeedLatestTurn = Pick< | |
| 124 | OrchestrationLatestTurn, | |
| 125 | "turnId" | "state" | "startedAt" | "completedAt" | |
| 126 | >; | |
| 127 | ||
| 103 | 128 | function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { |
| 104 | 129 | switch (requestType) { |
| 105 | 130 | case "command_execution_approval": |
| ... | ... | @@ -202,14 +227,12 @@ function resolvePendingUserInputAnswer( |
| 202 | 227 | |
| 203 | 228 | function deriveWorkLogEntries( |
| 204 | 229 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 205 | latestTurnId: TurnId | undefined, | |
| 206 | 230 | ): WorkLogEntry[] { |
| 207 | 231 | const ordered = Arr.sort(activities, activityOrder); |
| 208 | 232 | const entries: DerivedWorkLogEntry[] = []; |
| 209 | 233 | for (const activity of ordered) { |
| 210 | if (latestTurnId && activity.turnId !== latestTurnId) continue; | |
| 211 | 234 | if (activity.kind === "tool.started") continue; |
| 212 | if (activity.kind === "task.started" || activity.kind === "task.completed") continue; | |
| 235 | if (activity.kind === "task.started") continue; | |
| 213 | 236 | if (activity.kind === "context-window.updated") continue; |
| 214 | 237 | if (activity.summary === "Checkpoint captured") continue; |
| 215 | 238 | if (isPlanBoundaryToolActivity(activity)) continue; |
| ... | ... | @@ -240,16 +263,40 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 240 | 263 | const commandPreview = extractToolCommand(payload); |
| 241 | 264 | const changedFiles = extractChangedFiles(payload); |
| 242 | 265 | const title = extractToolTitle(payload); |
| 266 | const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; | |
| 267 | const taskSummary = | |
| 268 | isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 | |
| 269 | ? payload.summary | |
| 270 | : null; | |
| 271 | const taskDetailAsLabel = | |
| 272 | isTaskActivity && | |
| 273 | !taskSummary && | |
| 274 | typeof payload?.detail === "string" && | |
| 275 | payload.detail.length > 0 | |
| 276 | ? payload.detail | |
| 277 | : null; | |
| 278 | const taskLabel = taskSummary || taskDetailAsLabel; | |
| 243 | 279 | const entry: DerivedWorkLogEntry = { |
| 244 | 280 | id: activity.id, |
| 245 | 281 | createdAt: activity.createdAt, |
| 246 | label: activity.summary, | |
| 247 | tone: activity.tone === "approval" ? "info" : activity.tone, | |
| 282 | turnId: activity.turnId, | |
| 283 | label: taskLabel || activity.summary, | |
| 284 | tone: | |
| 285 | activity.kind === "task.progress" | |
| 286 | ? "thinking" | |
| 287 | : activity.tone === "approval" | |
| 288 | ? "info" | |
| 289 | : activity.tone, | |
| 248 | 290 | activityKind: activity.kind, |
| 249 | 291 | }; |
| 250 | 292 | const itemType = extractWorkLogItemType(payload); |
| 251 | 293 | const requestKind = extractWorkLogRequestKind(payload); |
| 252 | if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { | |
| 294 | if ( | |
| 295 | !taskDetailAsLabel && | |
| 296 | payload && | |
| 297 | typeof payload.detail === "string" && | |
| 298 | payload.detail.length > 0 | |
| 299 | ) { | |
| 253 | 300 | const detail = stripTrailingExitCode(payload.detail).output; |
| 254 | 301 | if (detail) { |
| 255 | 302 | entry.detail = detail; |
| ... | ... | @@ -273,6 +320,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 273 | 320 | if (requestKind) { |
| 274 | 321 | entry.requestKind = requestKind; |
| 275 | 322 | } |
| 323 | let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); | |
| 324 | if (!toolLifecycleStatus && activity.kind === "tool.completed") { | |
| 325 | toolLifecycleStatus = "completed"; | |
| 326 | } | |
| 327 | if (toolLifecycleStatus) { | |
| 328 | entry.toolLifecycleStatus = toolLifecycleStatus; | |
| 329 | } | |
| 276 | 330 | const collapseKey = deriveToolLifecycleCollapseKey(entry); |
| 277 | 331 | if (collapseKey) { |
| 278 | 332 | entry.collapseKey = collapseKey; |
| ... | ... | @@ -323,6 +377,7 @@ function mergeDerivedWorkLogEntries( |
| 323 | 377 | const itemType = next.itemType ?? previous.itemType; |
| 324 | 378 | const requestKind = next.requestKind ?? previous.requestKind; |
| 325 | 379 | const collapseKey = next.collapseKey ?? previous.collapseKey; |
| 380 | const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; | |
| 326 | 381 | return { |
| 327 | 382 | ...previous, |
| 328 | 383 | ...next, |
| ... | ... | @@ -334,6 +389,7 @@ function mergeDerivedWorkLogEntries( |
| 334 | 389 | ...(itemType ? { itemType } : {}), |
| 335 | 390 | ...(requestKind ? { requestKind } : {}), |
| 336 | 391 | ...(collapseKey ? { collapseKey } : {}), |
| 392 | ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), | |
| 337 | 393 | }; |
| 338 | 394 | } |
| 339 | 395 | |
| ... | ... | @@ -365,6 +421,78 @@ function normalizeCompactToolLabel(value: string): string { |
| 365 | 421 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); |
| 366 | 422 | } |
| 367 | 423 | |
| 424 | function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { | |
| 425 | if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { | |
| 426 | return true; | |
| 427 | } | |
| 428 | if (entry.command !== undefined && entry.command.trim().length > 0) { | |
| 429 | return true; | |
| 430 | } | |
| 431 | if (entry.requestKind !== undefined) { | |
| 432 | return true; | |
| 433 | } | |
| 434 | return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); | |
| 435 | } | |
| 436 | ||
| 437 | function toolDetailTextLooksLikeFailure(text: string): boolean { | |
| 438 | const normalized = text.toLowerCase(); | |
| 439 | return ( | |
| 440 | normalized.includes("file not found") || | |
| 441 | normalized.includes("no files found") || | |
| 442 | normalized.includes("enoent") || | |
| 443 | normalized.includes("no such file or directory") || | |
| 444 | normalized.includes("no such file") || | |
| 445 | normalized.includes("commandnotfoundexception") || | |
| 446 | normalized.includes("command not found") || | |
| 447 | (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) || | |
| 448 | (normalized.includes("is not recognized") && normalized.includes("the term '")) || | |
| 449 | /<exited with exit code\s+[1-9]\d*\s*>/i.test(text) || | |
| 450 | /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || | |
| 451 | /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) | |
| 452 | ); | |
| 453 | } | |
| 454 | ||
| 455 | function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { | |
| 456 | if (entry.tone === "error") { | |
| 457 | return true; | |
| 458 | } | |
| 459 | if (entry.toolLifecycleStatus === "failed" || entry.toolLifecycleStatus === "declined") { | |
| 460 | return true; | |
| 461 | } | |
| 462 | if (!workLogEntryIsToolLike(entry)) { | |
| 463 | return false; | |
| 464 | } | |
| 465 | return toolDetailTextLooksLikeFailure([entry.detail, entry.command].filter(Boolean).join("\n")); | |
| 466 | } | |
| 467 | ||
| 468 | function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { | |
| 469 | if (!workLogEntryIsToolLike(entry) || workEntryIndicatesToolFailure(entry)) { | |
| 470 | return false; | |
| 471 | } | |
| 472 | if (entry.tone === "thinking") { | |
| 473 | return false; | |
| 474 | } | |
| 475 | return ( | |
| 476 | entry.toolLifecycleStatus !== "inProgress" && | |
| 477 | entry.toolLifecycleStatus !== "stopped" && | |
| 478 | entry.toolLifecycleStatus !== "failed" && | |
| 479 | entry.toolLifecycleStatus !== "declined" | |
| 480 | ); | |
| 481 | } | |
| 482 | ||
| 483 | function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { | |
| 484 | if (!workLogEntryIsToolLike(entry)) { | |
| 485 | return null; | |
| 486 | } | |
| 487 | if (workEntryIndicatesToolFailure(entry)) { | |
| 488 | return "failure"; | |
| 489 | } | |
| 490 | if (workEntryIndicatesToolSuccess(entry)) { | |
| 491 | return "success"; | |
| 492 | } | |
| 493 | return "neutral"; | |
| 494 | } | |
| 495 | ||
| 368 | 496 | function workEntryPreview( |
| 369 | 497 | workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">, |
| 370 | 498 | ): string | null { |
| ... | ... | @@ -592,6 +720,22 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul |
| 592 | 720 | return asTrimmedString(payload?.title); |
| 593 | 721 | } |
| 594 | 722 | |
| 723 | function extractWorkLogToolLifecycleStatus( | |
| 724 | payload: Record<string, unknown> | null, | |
| 725 | ): WorkLogToolLifecycleStatus | undefined { | |
| 726 | const status = payload?.status; | |
| 727 | if ( | |
| 728 | status === "inProgress" || | |
| 729 | status === "completed" || | |
| 730 | status === "failed" || | |
| 731 | status === "declined" || | |
| 732 | status === "stopped" | |
| 733 | ) { | |
| 734 | return status; | |
| 735 | } | |
| 736 | return undefined; | |
| 737 | } | |
| 738 | ||
| 595 | 739 | function stripTrailingExitCode(value: string): { |
| 596 | 740 | output: string | null; |
| 597 | 741 | exitCode?: number | undefined; |
| ... | ... | @@ -743,7 +887,7 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th |
| 743 | 887 | } |
| 744 | 888 | |
| 745 | 889 | const previous = grouped.at(-1); |
| 746 | if (previous?.type === "activity-group") { | |
| 890 | if (previous?.type === "activity-group" && previous.turnId === entry.turnId) { | |
| 747 | 891 | grouped[grouped.length - 1] = { |
| 748 | 892 | ...previous, |
| 749 | 893 | activities: [...previous.activities, entry.activity], |
| ... | ... | @@ -755,6 +899,7 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th |
| 755 | 899 | type: "activity-group", |
| 756 | 900 | id: entry.id, |
| 757 | 901 | createdAt: entry.createdAt, |
| 902 | turnId: entry.turnId, | |
| 758 | 903 | activities: [entry.activity], |
| 759 | 904 | }); |
| 760 | 905 | } |
| ... | ... | @@ -762,6 +907,179 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th |
| 762 | 907 | return grouped; |
| 763 | 908 | } |
| 764 | 909 | |
| 910 | function computeElapsedMs(startIso: string, endIso: string): number | null { | |
| 911 | const start = Date.parse(startIso); | |
| 912 | const end = Date.parse(endIso); | |
| 913 | if (!Number.isFinite(start) || !Number.isFinite(end)) { | |
| 914 | return null; | |
| 915 | } | |
| 916 | return Math.max(0, end - start); | |
| 917 | } | |
| 918 | ||
| 919 | function maxIsoTimestamp(a: string | null, b: string | null): string | null { | |
| 920 | if (a === null) return b; | |
| 921 | if (b === null) return a; | |
| 922 | const aMs = Date.parse(a); | |
| 923 | const bMs = Date.parse(b); | |
| 924 | if (!Number.isFinite(aMs)) return b; | |
| 925 | if (!Number.isFinite(bMs)) return a; | |
| 926 | return bMs > aMs ? b : a; | |
| 927 | } | |
| 928 | ||
| 929 | function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null { | |
| 930 | if (!latestTurn) { | |
| 931 | return null; | |
| 932 | } | |
| 933 | const settled = latestTurn.completedAt !== null && latestTurn.state !== "running"; | |
| 934 | return settled ? null : latestTurn.turnId; | |
| 935 | } | |
| 936 | ||
| 937 | interface ThreadFeedTurnFold { | |
| 938 | readonly turnId: TurnId; | |
| 939 | readonly createdAt: string; | |
| 940 | readonly hiddenEntryIds: ReadonlySet<string>; | |
| 941 | readonly label: string; | |
| 942 | } | |
| 943 | ||
| 944 | function deriveThreadFeedTurnFolds( | |
| 945 | feed: ReadonlyArray<ThreadFeedEntry>, | |
| 946 | latestTurn: ThreadFeedLatestTurn | null, | |
| 947 | ): ReadonlyMap<string, ThreadFeedTurnFold> { | |
| 948 | const terminalAssistantMessageIdByTurn = new Map<TurnId, string>(); | |
| 949 | for (const entry of feed) { | |
| 950 | if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { | |
| 951 | terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); | |
| 952 | } | |
| 953 | } | |
| 954 | ||
| 955 | interface TurnGroup { | |
| 956 | readonly entries: ThreadFeedEntry[]; | |
| 957 | readonly startBoundary: string | null; | |
| 958 | } | |
| 959 | const groupsByTurnId = new Map<TurnId, TurnGroup>(); | |
| 960 | let pendingUserBoundary: string | null = null; | |
| 961 | for (const entry of feed) { | |
| 962 | if (entry.type === "message" && entry.message.role === "user") { | |
| 963 | pendingUserBoundary = entry.message.createdAt; | |
| 964 | continue; | |
| 965 | } | |
| 966 | const turnId = | |
| 967 | entry.type === "message" && entry.message.role === "assistant" | |
| 968 | ? entry.message.turnId | |
| 969 | : entry.type === "activity-group" | |
| 970 | ? entry.turnId | |
| 971 | : null; | |
| 972 | if (!turnId) { | |
| 973 | continue; | |
| 974 | } | |
| 975 | let group = groupsByTurnId.get(turnId); | |
| 976 | if (!group) { | |
| 977 | group = { | |
| 978 | entries: [], | |
| 979 | startBoundary: pendingUserBoundary, | |
| 980 | }; | |
| 981 | pendingUserBoundary = null; | |
| 982 | groupsByTurnId.set(turnId, group); | |
| 983 | } | |
| 984 | group.entries.push(entry); | |
| 985 | } | |
| 986 | ||
| 987 | const unsettledTurnId = deriveUnsettledTurnId(latestTurn); | |
| 988 | const foldsByAnchorId = new Map<string, ThreadFeedTurnFold>(); | |
| 989 | for (const [turnId, group] of groupsByTurnId) { | |
| 990 | const { entries } = group; | |
| 991 | if (turnId === unsettledTurnId) { | |
| 992 | continue; | |
| 993 | } | |
| 994 | if (entries.some((entry) => entry.type === "message" && entry.message.streaming)) { | |
| 995 | continue; | |
| 996 | } | |
| 997 | ||
| 998 | const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId); | |
| 999 | const hiddenEntryIds = new Set( | |
| 1000 | entries.filter((entry) => entry.id !== terminalAssistantMessageId).map((entry) => entry.id), | |
| 1001 | ); | |
| 1002 | if (hiddenEntryIds.size === 0) { | |
| 1003 | continue; | |
| 1004 | } | |
| 1005 | ||
| 1006 | const firstEntry = entries[0]; | |
| 1007 | const lastEntry = entries.at(-1); | |
| 1008 | if (!firstEntry || !lastEntry) { | |
| 1009 | continue; | |
| 1010 | } | |
| 1011 | const terminalEntry = terminalAssistantMessageId | |
| 1012 | ? entries.find((entry) => entry.id === terminalAssistantMessageId) | |
| 1013 | : null; | |
| 1014 | const latestTurnMatches = latestTurn?.turnId === turnId; | |
| 1015 | const lastEntryEnd = | |
| 1016 | lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; | |
| 1017 | const elapsedMs = | |
| 1018 | latestTurnMatches && latestTurn.startedAt && latestTurn.completedAt | |
| 1019 | ? computeElapsedMs(latestTurn.startedAt, latestTurn.completedAt) | |
| 1020 | : computeElapsedMs( | |
| 1021 | group.startBoundary ?? firstEntry.createdAt, | |
| 1022 | maxIsoTimestamp( | |
| 1023 | terminalEntry?.type === "message" ? terminalEntry.message.updatedAt : null, | |
| 1024 | lastEntryEnd, | |
| 1025 | ) ?? lastEntryEnd, | |
| 1026 | ); | |
| 1027 | const duration = elapsedMs === null ? null : formatDuration(elapsedMs); | |
| 1028 | const interrupted = latestTurnMatches && latestTurn.state === "interrupted"; | |
| 1029 | const label = interrupted | |
| 1030 | ? duration | |
| 1031 | ? `You stopped after ${duration}` | |
| 1032 | : "You stopped this response" | |
| 1033 | : duration | |
| 1034 | ? `Worked for ${duration}` | |
| 1035 | : "Worked"; | |
| 1036 | ||
| 1037 | foldsByAnchorId.set(firstEntry.id, { | |
| 1038 | turnId, | |
| 1039 | createdAt: firstEntry.createdAt, | |
| 1040 | hiddenEntryIds, | |
| 1041 | label, | |
| 1042 | }); | |
| 1043 | } | |
| 1044 | return foldsByAnchorId; | |
| 1045 | } | |
| 1046 | ||
| 1047 | export function deriveThreadFeedPresentation( | |
| 1048 | feed: ReadonlyArray<ThreadFeedEntry>, | |
| 1049 | latestTurn: ThreadFeedLatestTurn | null, | |
| 1050 | expandedTurnIds: ReadonlySet<TurnId>, | |
| 1051 | ): ThreadFeedEntry[] { | |
| 1052 | const sourceFeed = feed.filter((entry) => entry.type !== "turn-fold"); | |
| 1053 | const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); | |
| 1054 | const collapsedEntryIds = new Set<string>(); | |
| 1055 | for (const fold of foldsByAnchorId.values()) { | |
| 1056 | if (!expandedTurnIds.has(fold.turnId)) { | |
| 1057 | for (const entryId of fold.hiddenEntryIds) { | |
| 1058 | collapsedEntryIds.add(entryId); | |
| 1059 | } | |
| 1060 | } | |
| 1061 | } | |
| 1062 | ||
| 1063 | const result: ThreadFeedEntry[] = []; | |
| 1064 | for (const entry of sourceFeed) { | |
| 1065 | const fold = foldsByAnchorId.get(entry.id); | |
| 1066 | if (fold) { | |
| 1067 | result.push({ | |
| 1068 | type: "turn-fold", | |
| 1069 | id: `turn-fold:${fold.turnId}`, | |
| 1070 | createdAt: fold.createdAt, | |
| 1071 | turnId: fold.turnId, | |
| 1072 | label: fold.label, | |
| 1073 | expanded: expandedTurnIds.has(fold.turnId), | |
| 1074 | }); | |
| 1075 | } | |
| 1076 | if (!collapsedEntryIds.has(entry.id)) { | |
| 1077 | result.push(entry); | |
| 1078 | } | |
| 1079 | } | |
| 1080 | return result; | |
| 1081 | } | |
| 1082 | ||
| 765 | 1083 | export function derivePendingApprovals( |
| 766 | 1084 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 767 | 1085 | ): PendingApproval[] { |
| ... | ... | @@ -893,10 +1211,7 @@ export function buildThreadFeed( |
| 893 | 1211 | const loadedMessages = options?.loadedMessages ?? thread.messages; |
| 894 | 1212 | const oldestLoadedMessageCreatedAt = |
| 895 | 1213 | options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; |
| 896 | const workLogEntries = deriveWorkLogEntries( | |
| 897 | thread.activities, | |
| 898 | thread.latestTurn?.turnId ?? undefined, | |
| 899 | ); | |
| 1214 | const workLogEntries = deriveWorkLogEntries(thread.activities); | |
| 900 | 1215 | const entries = Arr.sortWith( |
| 901 | 1216 | [ |
| 902 | 1217 | ...loadedMessages.map<RawThreadFeedEntry>((message) => ({ |
| ... | ... | @@ -921,18 +1236,36 @@ export function buildThreadFeed( |
| 921 | 1236 | oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt |
| 922 | 1237 | ); |
| 923 | 1238 | }) |
| 924 | .map<RawThreadFeedEntry>((entry) => ({ | |
| 925 | type: "activity", | |
| 926 | id: entry.id, | |
| 927 | createdAt: entry.createdAt, | |
| 928 | activity: { | |
| 1239 | .map<RawThreadFeedEntry>((entry) => { | |
| 1240 | const summary = workEntryHeading(entry); | |
| 1241 | const detail = workEntryPreview(entry); | |
| 1242 | const normalizedFullDetail = entry.detail | |
| 1243 | ? unwrapKnownShellCommandWrapper(entry.detail) | |
| 1244 | : null; | |
| 1245 | const fullDetail = | |
| 1246 | normalizedFullDetail && normalizedFullDetail !== detail ? normalizedFullDetail : null; | |
| 1247 | return { | |
| 1248 | type: "activity", | |
| 929 | 1249 | id: entry.id, |
| 930 | 1250 | createdAt: entry.createdAt, |
| 931 | summary: workEntryHeading(entry), | |
| 932 | detail: workEntryPreview(entry), | |
| 933 | status: null, | |
| 934 | }, | |
| 935 | })), | |
| 1251 | turnId: entry.turnId, | |
| 1252 | activity: { | |
| 1253 | id: entry.id, | |
| 1254 | createdAt: entry.createdAt, | |
| 1255 | turnId: entry.turnId, | |
| 1256 | summary, | |
| 1257 | detail, | |
| 1258 | fullDetail, | |
| 1259 | copyText: [summary, detail, fullDetail] | |
| 1260 | .filter((value, index, values): value is string => { | |
| 1261 | return Boolean(value) && values.indexOf(value) === index; | |
| 1262 | }) | |
| 1263 | .join("\n"), | |
| 1264 | toolLike: workLogEntryIsToolLike(entry), | |
| 1265 | status: workEntryStatus(entry), | |
| 1266 | }, | |
| 1267 | }; | |
| 1268 | }), | |
| 936 | 1269 | ], |
| 937 | 1270 | (s) => new Date(s.createdAt), |
| 938 | 1271 | Order.Date, |
apps/mobile/src/lib/threadFeedLayout.test.ts created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | isThreadFeedNearEnd, | |
| 5 | resolveThreadFeedBottomInset, | |
| 6 | threadFeedDistanceFromEnd, | |
| 7 | } from "./threadFeedLayout"; | |
| 8 | ||
| 9 | describe("thread feed layout", () => { | |
| 10 | it("accounts for the bottom inset when measuring distance from the end", () => { | |
| 11 | const metrics = { | |
| 12 | contentHeight: 900, | |
| 13 | viewportHeight: 600, | |
| 14 | offsetY: 380, | |
| 15 | bottomInset: 100, | |
| 16 | }; | |
| 17 | ||
| 18 | expect(threadFeedDistanceFromEnd(metrics)).toBe(20); | |
| 19 | expect(isThreadFeedNearEnd(metrics, 50)).toBe(true); | |
| 20 | expect(isThreadFeedNearEnd(metrics, 10)).toBe(false); | |
| 21 | }); | |
| 22 | ||
| 23 | it("does not double count chrome already included in the measured composer overlay", () => { | |
| 24 | expect( | |
| 25 | resolveThreadFeedBottomInset({ | |
| 26 | estimatedOverlayHeight: 162, | |
| 27 | measuredOverlayHeight: 182, | |
| 28 | gap: 8, | |
| 29 | }), | |
| 30 | ).toBe(190); | |
| 31 | }); | |
| 32 | }); |
apps/mobile/src/lib/threadFeedLayout.ts created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | export interface ThreadFeedScrollMetrics { | |
| 2 | readonly contentHeight: number; | |
| 3 | readonly viewportHeight: number; | |
| 4 | readonly offsetY: number; | |
| 5 | readonly bottomInset: number; | |
| 6 | } | |
| 7 | ||
| 8 | export function threadFeedDistanceFromEnd(metrics: ThreadFeedScrollMetrics): number { | |
| 9 | return metrics.contentHeight + metrics.bottomInset - metrics.viewportHeight - metrics.offsetY; | |
| 10 | } | |
| 11 | ||
| 12 | export function isThreadFeedNearEnd(metrics: ThreadFeedScrollMetrics, threshold: number): boolean { | |
| 13 | return threadFeedDistanceFromEnd(metrics) <= threshold; | |
| 14 | } | |
| 15 | ||
| 16 | export function resolveThreadFeedBottomInset(input: { | |
| 17 | readonly estimatedOverlayHeight: number; | |
| 18 | readonly measuredOverlayHeight: number; | |
| 19 | readonly gap: number; | |
| 20 | }): number { | |
| 21 | return Math.max(input.estimatedOverlayHeight, input.measuredOverlayHeight) + input.gap; | |
| 22 | } |
apps/mobile/src/native/SelectableMarkdownText.ios.tsx created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | import { | |
| 2 | SelectableMarkdownText as T3SelectableMarkdownText, | |
| 3 | type SelectableMarkdownTextProps, | |
| 4 | } from "@t3tools/mobile-markdown-text/renderer"; | |
| 5 | ||
| 6 | import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; | |
| 7 | ||
| 8 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; | |
| 9 | ||
| 10 | export type { | |
| 11 | NativeMarkdownTextStyle, | |
| 12 | SelectableMarkdownSkill, | |
| 13 | } from "@t3tools/mobile-markdown-text/types"; | |
| 14 | ||
| 15 | export function hasNativeSelectableMarkdownText(): boolean { | |
| 16 | return true; | |
| 17 | } | |
| 18 | ||
| 19 | export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { | |
| 20 | return <T3SelectableMarkdownText {...props} highlightCode={highlightCodeSnippet} />; | |
| 21 | } |
apps/mobile/src/native/SelectableMarkdownText.tsx created+16| ... | ... | @@ -0,0 +1,16 @@ |
| 1 | import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/renderer"; | |
| 2 | ||
| 3 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; | |
| 4 | ||
| 5 | export type { | |
| 6 | NativeMarkdownTextStyle, | |
| 7 | SelectableMarkdownSkill, | |
| 8 | } from "@t3tools/mobile-markdown-text/types"; | |
| 9 | ||
| 10 | export function hasNativeSelectableMarkdownText(): boolean { | |
| 11 | return false; | |
| 12 | } | |
| 13 | ||
| 14 | export function SelectableMarkdownText(_props: MobileSelectableMarkdownTextProps) { | |
| 15 | return null; | |
| 16 | } |
apps/mobile/src/native/T3ComposerEditor.ios.tsx created+180| ... | ... | @@ -0,0 +1,180 @@ |
| 1 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; | |
| 2 | import { requireNativeView } from "expo"; | |
| 3 | import { useImperativeHandle, useMemo, useRef, type Ref } from "react"; | |
| 4 | import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; | |
| 5 | import { Image, StyleSheet } from "react-native"; | |
| 6 | ||
| 7 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | |
| 8 | import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; | |
| 9 | import { useThemeColor } from "../lib/useThemeColor"; | |
| 10 | import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; | |
| 11 | ||
| 12 | const NATIVE_MODULE_NAME = "T3ComposerEditor"; | |
| 13 | const EMPTY_SKILLS: NonNullable<ComposerEditorProps["skills"]> = []; | |
| 14 | ||
| 15 | type NativeEditorEvent = NativeSyntheticEvent<{ | |
| 16 | readonly value: string; | |
| 17 | readonly selection: ComposerEditorSelection; | |
| 18 | }>; | |
| 19 | ||
| 20 | type NativeSelectionEvent = NativeSyntheticEvent<{ | |
| 21 | readonly selection: ComposerEditorSelection; | |
| 22 | }>; | |
| 23 | ||
| 24 | type NativePasteImagesEvent = NativeSyntheticEvent<{ | |
| 25 | readonly uris: ReadonlyArray<string>; | |
| 26 | }>; | |
| 27 | ||
| 28 | interface NativeComposerEditorRef { | |
| 29 | focus: () => Promise<void>; | |
| 30 | blur: () => Promise<void>; | |
| 31 | setSelection: (start: number, end: number) => Promise<void>; | |
| 32 | } | |
| 33 | ||
| 34 | interface NativeComposerEditorProps extends ViewProps { | |
| 35 | readonly ref?: Ref<NativeComposerEditorRef>; | |
| 36 | readonly value: string; | |
| 37 | readonly tokensJson: string; | |
| 38 | readonly selectionJson: string; | |
| 39 | readonly themeJson: string; | |
| 40 | readonly placeholder: string; | |
| 41 | readonly fontFamily: string; | |
| 42 | readonly fontSize: number; | |
| 43 | readonly lineHeight: number; | |
| 44 | readonly contentInsetVertical: number; | |
| 45 | readonly editable: boolean; | |
| 46 | readonly scrollEnabled: boolean; | |
| 47 | readonly autoFocus: boolean; | |
| 48 | readonly autoCorrect: boolean; | |
| 49 | readonly spellCheck: boolean; | |
| 50 | readonly onComposerChange: (event: NativeEditorEvent) => void; | |
| 51 | readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void; | |
| 52 | readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; | |
| 53 | readonly onComposerFocus?: () => void; | |
| 54 | readonly onComposerBlur?: () => void; | |
| 55 | } | |
| 56 | ||
| 57 | const NativeView = requireNativeView<NativeComposerEditorProps>(NATIVE_MODULE_NAME); | |
| 58 | ||
| 59 | function basename(path: string): string { | |
| 60 | const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); | |
| 61 | return separator >= 0 ? path.slice(separator + 1) : path; | |
| 62 | } | |
| 63 | ||
| 64 | function fileIconUri(path: string): string { | |
| 65 | return Image.resolveAssetSource(markdownFileIconSource(resolveMarkdownFileIcon(path))).uri; | |
| 66 | } | |
| 67 | ||
| 68 | export function ComposerEditor({ | |
| 69 | ref, | |
| 70 | skills = EMPTY_SKILLS, | |
| 71 | selection, | |
| 72 | style, | |
| 73 | textStyle, | |
| 74 | onChangeText, | |
| 75 | onSelectionChange, | |
| 76 | onPasteImages, | |
| 77 | onFocus, | |
| 78 | onBlur, | |
| 79 | contentInsetVertical = 0, | |
| 80 | ...props | |
| 81 | }: ComposerEditorProps) { | |
| 82 | const nativeRef = useRef<NativeComposerEditorRef>(null); | |
| 83 | const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); | |
| 84 | const textColor = useThemeColor("--color-foreground"); | |
| 85 | const placeholderColor = useThemeColor("--color-placeholder"); | |
| 86 | const chipBackground = useThemeColor("--color-subtle"); | |
| 87 | const chipBorder = useThemeColor("--color-border"); | |
| 88 | const chipText = useThemeColor("--color-foreground"); | |
| 89 | const skillBackground = useThemeColor("--color-inline-skill-background"); | |
| 90 | const skillBorder = useThemeColor("--color-inline-skill-border"); | |
| 91 | const skillText = useThemeColor("--color-inline-skill-foreground"); | |
| 92 | const fileTint = useThemeColor("--color-icon-muted"); | |
| 93 | ||
| 94 | useImperativeHandle( | |
| 95 | ref, | |
| 96 | () => ({ | |
| 97 | focus: () => void nativeRef.current?.focus(), | |
| 98 | blur: () => void nativeRef.current?.blur(), | |
| 99 | setSelection: (nextSelection) => | |
| 100 | void nativeRef.current?.setSelection(nextSelection.start, nextSelection.end), | |
| 101 | }), | |
| 102 | [], | |
| 103 | ); | |
| 104 | ||
| 105 | const skillLabels = useMemo( | |
| 106 | () => new Map(skills.map((skill) => [skill.name, skill.displayName?.trim() || skill.name])), | |
| 107 | [skills], | |
| 108 | ); | |
| 109 | const tokensJson = useMemo(() => { | |
| 110 | const tokens = collectComposerInlineTokens(props.value, { | |
| 111 | preserveTrailingFrom: confirmedTokensRef.current, | |
| 112 | }); | |
| 113 | confirmedTokensRef.current = tokens; | |
| 114 | return JSON.stringify( | |
| 115 | tokens.map((token) => ({ | |
| 116 | type: token.type, | |
| 117 | source: token.source, | |
| 118 | start: token.start, | |
| 119 | end: token.end, | |
| 120 | label: | |
| 121 | token.type === "skill" | |
| 122 | ? (skillLabels.get(token.value) ?? token.value) | |
| 123 | : basename(token.value), | |
| 124 | iconUri: token.type === "mention" ? fileIconUri(token.value) : null, | |
| 125 | })), | |
| 126 | ); | |
| 127 | }, [props.value, skillLabels]); | |
| 128 | const themeJson = JSON.stringify({ | |
| 129 | text: String(textColor), | |
| 130 | placeholder: String(placeholderColor), | |
| 131 | chipBackground: String(chipBackground), | |
| 132 | chipBorder: String(chipBorder), | |
| 133 | chipText: String(chipText), | |
| 134 | skillBackground: String(skillBackground), | |
| 135 | skillBorder: String(skillBorder), | |
| 136 | skillText: String(skillText), | |
| 137 | fileTint: String(fileTint), | |
| 138 | }); | |
| 139 | const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; | |
| 140 | return ( | |
| 141 | <NativeView | |
| 142 | ref={nativeRef} | |
| 143 | value={props.value} | |
| 144 | tokensJson={tokensJson} | |
| 145 | selectionJson={selection ? JSON.stringify(selection) : ""} | |
| 146 | themeJson={themeJson} | |
| 147 | placeholder={props.placeholder ?? ""} | |
| 148 | fontFamily={ | |
| 149 | typeof resolvedTextStyle.fontFamily === "string" | |
| 150 | ? resolvedTextStyle.fontFamily | |
| 151 | : "DMSans_400Regular" | |
| 152 | } | |
| 153 | fontSize={typeof resolvedTextStyle.fontSize === "number" ? resolvedTextStyle.fontSize : 15} | |
| 154 | lineHeight={ | |
| 155 | typeof resolvedTextStyle.lineHeight === "number" ? resolvedTextStyle.lineHeight : 22 | |
| 156 | } | |
| 157 | contentInsetVertical={contentInsetVertical} | |
| 158 | editable={props.editable ?? true} | |
| 159 | scrollEnabled={props.scrollEnabled ?? true} | |
| 160 | autoFocus={props.autoFocus ?? false} | |
| 161 | autoCorrect={props.autoCorrect ?? true} | |
| 162 | spellCheck={props.spellCheck ?? true} | |
| 163 | style={style as StyleProp<ViewStyle>} | |
| 164 | onComposerChange={(event) => { | |
| 165 | onChangeText(event.nativeEvent.value); | |
| 166 | onSelectionChange?.(event.nativeEvent.selection); | |
| 167 | }} | |
| 168 | onComposerSelectionChange={(event) => onSelectionChange?.(event.nativeEvent.selection)} | |
| 169 | onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} | |
| 170 | onComposerFocus={onFocus} | |
| 171 | onComposerBlur={onBlur} | |
| 172 | /> | |
| 173 | ); | |
| 174 | } | |
| 175 | ||
| 176 | export type { | |
| 177 | ComposerEditorHandle, | |
| 178 | ComposerEditorProps, | |
| 179 | ComposerEditorSelection, | |
| 180 | } from "./T3ComposerEditor.types"; |
apps/mobile/src/native/T3ComposerEditor.tsx created+65| ... | ... | @@ -0,0 +1,65 @@ |
| 1 | import { TextInputWrapper } from "expo-paste-input"; | |
| 2 | import { useImperativeHandle, useRef } from "react"; | |
| 3 | import { TextInput, type TextInput as RNTextInput } from "react-native"; | |
| 4 | ||
| 5 | import { useThemeColor } from "../lib/useThemeColor"; | |
| 6 | import { useNativePaste } from "../lib/useNativePaste"; | |
| 7 | import type { ComposerEditorProps } from "./T3ComposerEditor.types"; | |
| 8 | ||
| 9 | export function ComposerEditor({ | |
| 10 | ref, | |
| 11 | skills: _skills, | |
| 12 | selection, | |
| 13 | onPasteImages, | |
| 14 | style, | |
| 15 | textStyle, | |
| 16 | contentInsetVertical = 0, | |
| 17 | ...props | |
| 18 | }: ComposerEditorProps) { | |
| 19 | const inputRef = useRef<RNTextInput>(null); | |
| 20 | const foregroundColor = useThemeColor("--color-foreground"); | |
| 21 | const placeholderColor = useThemeColor("--color-placeholder"); | |
| 22 | const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); | |
| 23 | ||
| 24 | useImperativeHandle( | |
| 25 | ref, | |
| 26 | () => ({ | |
| 27 | focus: () => inputRef.current?.focus(), | |
| 28 | blur: () => inputRef.current?.blur(), | |
| 29 | setSelection: (nextSelection) => | |
| 30 | inputRef.current?.setSelection(nextSelection.start, nextSelection.end), | |
| 31 | }), | |
| 32 | [], | |
| 33 | ); | |
| 34 | ||
| 35 | return ( | |
| 36 | <TextInputWrapper onPaste={handlePaste} style={[{ minHeight: 0 }, style]}> | |
| 37 | <TextInput | |
| 38 | ref={inputRef} | |
| 39 | {...props} | |
| 40 | selection={selection} | |
| 41 | onSelectionChange={(event) => props.onSelectionChange?.(event.nativeEvent.selection)} | |
| 42 | multiline={props.multiline ?? true} | |
| 43 | placeholderTextColor={placeholderColor} | |
| 44 | style={[ | |
| 45 | { | |
| 46 | flex: 1, | |
| 47 | minHeight: 0, | |
| 48 | color: foregroundColor, | |
| 49 | fontFamily: "DMSans_400Regular", | |
| 50 | fontSize: 15, | |
| 51 | lineHeight: 22, | |
| 52 | paddingVertical: contentInsetVertical, | |
| 53 | }, | |
| 54 | textStyle, | |
| 55 | ]} | |
| 56 | /> | |
| 57 | </TextInputWrapper> | |
| 58 | ); | |
| 59 | } | |
| 60 | ||
| 61 | export type { | |
| 62 | ComposerEditorHandle, | |
| 63 | ComposerEditorProps, | |
| 64 | ComposerEditorSelection, | |
| 65 | } from "./T3ComposerEditor.types"; |
apps/mobile/src/native/T3ComposerEditor.types.ts created+38| ... | ... | @@ -0,0 +1,38 @@ |
| 1 | import type { ServerProviderSkill } from "@t3tools/contracts"; | |
| 2 | import type { Ref } from "react"; | |
| 3 | import type { StyleProp, TextStyle, ViewStyle } from "react-native"; | |
| 4 | ||
| 5 | export type ComposerEditorSelection = { | |
| 6 | readonly start: number; | |
| 7 | readonly end: number; | |
| 8 | }; | |
| 9 | ||
| 10 | export interface ComposerEditorHandle { | |
| 11 | focus: () => void; | |
| 12 | blur: () => void; | |
| 13 | setSelection: (selection: ComposerEditorSelection) => void; | |
| 14 | } | |
| 15 | ||
| 16 | export interface ComposerEditorProps { | |
| 17 | readonly ref?: Ref<ComposerEditorHandle>; | |
| 18 | readonly value: string; | |
| 19 | readonly skills?: ReadonlyArray< | |
| 20 | Pick<ServerProviderSkill, "name" | "displayName" | "shortDescription" | "description"> | |
| 21 | >; | |
| 22 | readonly selection?: ComposerEditorSelection; | |
| 23 | readonly placeholder?: string; | |
| 24 | readonly autoFocus?: boolean; | |
| 25 | readonly editable?: boolean; | |
| 26 | readonly scrollEnabled?: boolean; | |
| 27 | readonly autoCorrect?: boolean; | |
| 28 | readonly spellCheck?: boolean; | |
| 29 | readonly multiline?: boolean; | |
| 30 | readonly contentInsetVertical?: number; | |
| 31 | readonly style?: StyleProp<ViewStyle>; | |
| 32 | readonly textStyle?: StyleProp<TextStyle>; | |
| 33 | readonly onChangeText: (value: string) => void; | |
| 34 | readonly onSelectionChange?: (selection: ComposerEditorSelection) => void; | |
| 35 | readonly onPasteImages?: (uris: ReadonlyArray<string>) => void; | |
| 36 | readonly onFocus?: () => void; | |
| 37 | readonly onBlur?: () => void; | |
| 38 | } |
apps/mobile/src/widgets/AgentActivity.tsx+3-3| ... | ... | @@ -58,9 +58,9 @@ export function AgentActivity( |
| 58 | 58 | : "now"; |
| 59 | 59 | const activeLabel = `${props.activeCount} active`; |
| 60 | 60 | const isLight = environment.colorScheme === "light"; |
| 61 | const primaryForeground = isLight ? "#0f172a" : "#ffffff"; | |
| 62 | const secondaryForeground = isLight ? "#475569" : "#cbd5e1"; | |
| 63 | const mutedForeground = isLight ? "#64748b" : "#94a3b8"; | |
| 61 | const primaryForeground = isLight ? "#262626" : "#f5f5f5"; | |
| 62 | const secondaryForeground = isLight ? "#525252" : "#a3a3a3"; | |
| 63 | const mutedForeground = isLight ? "#737373" : "#8e8e93"; | |
| 64 | 64 | const tint = environment.isLuminanceReduced |
| 65 | 65 | ? secondaryForeground |
| 66 | 66 | : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input" |
apps/web/package.json+2-2| ... | ... | @@ -14,8 +14,8 @@ |
| 14 | 14 | }, |
| 15 | 15 | "dependencies": { |
| 16 | 16 | "@base-ui/react": "^1.4.1", |
| 17 | "@clerk/clerk-js": "^6.13.0", | |
| 18 | "@clerk/react": "^6.7.2", | |
| 17 | "@clerk/clerk-js": "^6.16.0", | |
| 18 | "@clerk/react": "^6.9.0", | |
| 19 | 19 | "@dnd-kit/core": "^6.3.1", |
| 20 | 20 | "@dnd-kit/modifiers": "^9.0.0", |
| 21 | 21 | "@dnd-kit/sortable": "^10.0.0", |
apps/web/src/composer-editor-mentions.ts+14-88| ... | ... | @@ -2,6 +2,10 @@ import { |
| 2 | 2 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, |
| 3 | 3 | type TerminalContextDraft, |
| 4 | 4 | } from "./lib/terminalContext"; |
| 5 | import { | |
| 6 | collectComposerInlineTokens, | |
| 7 | type ComposerInlineToken, | |
| 8 | } from "@t3tools/shared/composerInlineTokens"; | |
| 5 | 9 | |
| 6 | 10 | export type ComposerPromptSegment = |
| 7 | 11 | | { |
| ... | ... | @@ -22,12 +26,6 @@ export type ComposerPromptSegment = |
| 22 | 26 | context: TerminalContextDraft | null; |
| 23 | 27 | }; |
| 24 | 28 | |
| 25 | const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; | |
| 26 | const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; | |
| 27 | const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; | |
| 28 | const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; | |
| 29 | const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; | |
| 30 | ||
| 31 | 29 | function rangeIncludesIndex(start: number, end: number, index: number): boolean { |
| 32 | 30 | return start <= index && index < end; |
| 33 | 31 | } |
| ... | ... | @@ -42,84 +40,6 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void |
| 42 | 40 | segments.push({ type: "text", text }); |
| 43 | 41 | } |
| 44 | 42 | |
| 45 | type InlineTokenMatch = | |
| 46 | | { | |
| 47 | type: "mention"; | |
| 48 | value: string; | |
| 49 | start: number; | |
| 50 | end: number; | |
| 51 | } | |
| 52 | | { | |
| 53 | type: "skill"; | |
| 54 | value: string; | |
| 55 | start: number; | |
| 56 | end: number; | |
| 57 | }; | |
| 58 | ||
| 59 | type MentionTokenMatch = Extract<InlineTokenMatch, { type: "mention" }>; | |
| 60 | ||
| 61 | function collectMentionTokenMatches(text: string): MentionTokenMatch[] { | |
| 62 | const matches: MentionTokenMatch[] = []; | |
| 63 | ||
| 64 | for (const match of text.matchAll(FILE_LINK_TOKEN_REGEX)) { | |
| 65 | const fullMatch = match[0]; | |
| 66 | const prefix = match[1] ?? ""; | |
| 67 | const label = (match[2] ?? "").replace(/\\(.)/g, "$1"); | |
| 68 | const encodedPath = match[3] ?? ""; | |
| 69 | let path = encodedPath; | |
| 70 | try { | |
| 71 | path = decodeURIComponent(encodedPath); | |
| 72 | } catch { | |
| 73 | // Keep the source value when malformed percent escapes are present. | |
| 74 | } | |
| 75 | const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); | |
| 76 | const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; | |
| 77 | const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path); | |
| 78 | if (!path || hasExternalScheme || label !== basename) { | |
| 79 | continue; | |
| 80 | } | |
| 81 | const matchIndex = match.index ?? 0; | |
| 82 | const start = matchIndex + prefix.length; | |
| 83 | const end = start + fullMatch.length - prefix.length; | |
| 84 | matches.push({ type: "mention", value: path, start, end }); | |
| 85 | } | |
| 86 | ||
| 87 | for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { | |
| 88 | const fullMatch = match[0]; | |
| 89 | const prefix = match[1] ?? ""; | |
| 90 | const quotedPath = match[2]; | |
| 91 | const unquotedPath = match[3]; | |
| 92 | const path = | |
| 93 | quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (unquotedPath ?? ""); | |
| 94 | const matchIndex = match.index ?? 0; | |
| 95 | const start = matchIndex + prefix.length; | |
| 96 | const end = start + fullMatch.length - prefix.length; | |
| 97 | if (path.length > 0) { | |
| 98 | matches.push({ type: "mention", value: path, start, end }); | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | return matches; | |
| 103 | } | |
| 104 | ||
| 105 | function collectInlineTokenMatches(text: string): InlineTokenMatch[] { | |
| 106 | const matches: InlineTokenMatch[] = collectMentionTokenMatches(text); | |
| 107 | ||
| 108 | for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { | |
| 109 | const fullMatch = match[0]; | |
| 110 | const prefix = match[1] ?? ""; | |
| 111 | const skillName = match[2] ?? ""; | |
| 112 | const matchIndex = match.index ?? 0; | |
| 113 | const start = matchIndex + prefix.length; | |
| 114 | const end = start + fullMatch.length - prefix.length; | |
| 115 | if (skillName.length > 0) { | |
| 116 | matches.push({ type: "skill", value: skillName, start, end }); | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | return matches.toSorted((left, right) => left.start - right.start); | |
| 121 | } | |
| 122 | ||
| 123 | 43 | function forEachPromptSegmentSlice( |
| 124 | 44 | prompt: string, |
| 125 | 45 | visitor: ( |
| ... | ... | @@ -186,10 +106,16 @@ function forEachPromptTextSlice( |
| 186 | 106 | |
| 187 | 107 | function forEachMentionMatch( |
| 188 | 108 | prompt: string, |
| 189 | visitor: (match: MentionTokenMatch, promptOffset: number) => boolean | void, | |
| 109 | visitor: ( | |
| 110 | match: Extract<ComposerInlineToken, { type: "mention" }>, | |
| 111 | promptOffset: number, | |
| 112 | ) => boolean | void, | |
| 190 | 113 | ): boolean { |
| 191 | 114 | return forEachPromptTextSlice(prompt, (text, promptOffset) => { |
| 192 | for (const match of collectMentionTokenMatches(text)) { | |
| 115 | for (const match of collectComposerInlineTokens(text)) { | |
| 116 | if (match.type !== "mention") { | |
| 117 | continue; | |
| 118 | } | |
| 193 | 119 | if (visitor(match, promptOffset) === true) { |
| 194 | 120 | return true; |
| 195 | 121 | } |
| ... | ... | @@ -204,7 +130,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen |
| 204 | 130 | return segments; |
| 205 | 131 | } |
| 206 | 132 | |
| 207 | const tokenMatches = collectInlineTokenMatches(text); | |
| 133 | const tokenMatches = collectComposerInlineTokens(text); | |
| 208 | 134 | let cursor = 0; |
| 209 | 135 | for (const match of tokenMatches) { |
| 210 | 136 | if (match.start < cursor) { |
| ... | ... | @@ -219,7 +145,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen |
| 219 | 145 | segments.push({ |
| 220 | 146 | type: "mention", |
| 221 | 147 | path: match.value, |
| 222 | source: text.slice(match.start, match.end), | |
| 148 | source: match.source, | |
| 223 | 149 | }); |
| 224 | 150 | } else { |
| 225 | 151 | segments.push({ type: "skill", name: match.value }); |
infra/relay/package.json+1-1| ... | ... | @@ -9,7 +9,7 @@ |
| 9 | 9 | "typecheck": "tsgo --noEmit" |
| 10 | 10 | }, |
| 11 | 11 | "dependencies": { |
| 12 | "@clerk/backend": "3.4.14", | |
| 12 | "@clerk/backend": "3.6.1", | |
| 13 | 13 | "@effect/sql-pg": "catalog:", |
| 14 | 14 | "@t3tools/client-runtime": "workspace:*", |
| 15 | 15 | "@t3tools/contracts": "workspace:*", |
packages/shared/package.json+4| ... | ... | @@ -143,6 +143,10 @@ |
| 143 | 143 | "types": "./src/composerTrigger.ts", |
| 144 | 144 | "import": "./src/composerTrigger.ts" |
| 145 | 145 | }, |
| 146 | "./composerInlineTokens": { | |
| 147 | "types": "./src/composerInlineTokens.ts", | |
| 148 | "import": "./src/composerInlineTokens.ts" | |
| 149 | }, | |
| 146 | 150 | "./terminalLabels": { |
| 147 | 151 | "types": "./src/terminalLabels.ts", |
| 148 | 152 | "import": "./src/terminalLabels.ts" |
packages/shared/src/composerInlineTokens.test.ts created+84| ... | ... | @@ -0,0 +1,84 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { collectComposerInlineTokens } from "./composerInlineTokens.ts"; | |
| 4 | ||
| 5 | describe("collectComposerInlineTokens", () => { | |
| 6 | it("collects file links, mentions, and skills with source ranges", () => { | |
| 7 | const text = "Use $ui and inspect [Chat.tsx](src/Chat.tsx) with @AGENTS.md please"; | |
| 8 | ||
| 9 | expect(collectComposerInlineTokens(text)).toEqual([ | |
| 10 | { | |
| 11 | type: "skill", | |
| 12 | value: "ui", | |
| 13 | source: "$ui", | |
| 14 | start: 4, | |
| 15 | end: 7, | |
| 16 | }, | |
| 17 | { | |
| 18 | type: "mention", | |
| 19 | value: "src/Chat.tsx", | |
| 20 | source: "[Chat.tsx](src/Chat.tsx)", | |
| 21 | start: 20, | |
| 22 | end: 44, | |
| 23 | }, | |
| 24 | { | |
| 25 | type: "mention", | |
| 26 | value: "AGENTS.md", | |
| 27 | source: "@AGENTS.md", | |
| 28 | start: 50, | |
| 29 | end: 60, | |
| 30 | }, | |
| 31 | ]); | |
| 32 | }); | |
| 33 | ||
| 34 | it("does not convert incomplete trailing tokens", () => { | |
| 35 | expect(collectComposerInlineTokens("Use $ui")).toEqual([]); | |
| 36 | expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]); | |
| 37 | }); | |
| 38 | ||
| 39 | it("keeps the delimiter after a token outside its source range", () => { | |
| 40 | const text = "Inspect [package.json](package.json) next"; | |
| 41 | ||
| 42 | expect(collectComposerInlineTokens(text)).toEqual([ | |
| 43 | { | |
| 44 | type: "mention", | |
| 45 | value: "package.json", | |
| 46 | source: "[package.json](package.json)", | |
| 47 | start: 8, | |
| 48 | end: 36, | |
| 49 | }, | |
| 50 | ]); | |
| 51 | expect(text.slice(36)).toBe(" next"); | |
| 52 | }); | |
| 53 | ||
| 54 | it("preserves a confirmed pill when only its trailing delimiter is removed", () => { | |
| 55 | const withDelimiter = "[package.json](package.json) "; | |
| 56 | const confirmed = collectComposerInlineTokens(withDelimiter); | |
| 57 | ||
| 58 | expect( | |
| 59 | collectComposerInlineTokens(withDelimiter.trimEnd(), { preserveTrailingFrom: confirmed }), | |
| 60 | ).toEqual([ | |
| 61 | { | |
| 62 | type: "mention", | |
| 63 | value: "package.json", | |
| 64 | source: "[package.json](package.json)", | |
| 65 | start: 0, | |
| 66 | end: 28, | |
| 67 | }, | |
| 68 | ]); | |
| 69 | }); | |
| 70 | ||
| 71 | it("does not preserve a pill after its source is edited", () => { | |
| 72 | const confirmed = collectComposerInlineTokens("[package.json](package.json) "); | |
| 73 | ||
| 74 | expect( | |
| 75 | collectComposerInlineTokens("[package.json](package-json)", { | |
| 76 | preserveTrailingFrom: confirmed, | |
| 77 | }), | |
| 78 | ).toEqual([]); | |
| 79 | }); | |
| 80 | ||
| 81 | it("ignores normal web links", () => { | |
| 82 | expect(collectComposerInlineTokens("Read [docs](https://example.com) first")).toEqual([]); | |
| 83 | }); | |
| 84 | }); |
packages/shared/src/composerInlineTokens.ts created+118| ... | ... | @@ -0,0 +1,118 @@ |
| 1 | export type ComposerInlineToken = | |
| 2 | | { | |
| 3 | readonly type: "mention"; | |
| 4 | readonly value: string; | |
| 5 | readonly source: string; | |
| 6 | readonly start: number; | |
| 7 | readonly end: number; | |
| 8 | } | |
| 9 | | { | |
| 10 | readonly type: "skill"; | |
| 11 | readonly value: string; | |
| 12 | readonly source: string; | |
| 13 | readonly start: number; | |
| 14 | readonly end: number; | |
| 15 | }; | |
| 16 | ||
| 17 | export interface CollectComposerInlineTokensOptions { | |
| 18 | readonly preserveTrailingFrom?: ReadonlyArray<ComposerInlineToken>; | |
| 19 | } | |
| 20 | ||
| 21 | const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; | |
| 22 | const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; | |
| 23 | const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; | |
| 24 | const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; | |
| 25 | const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; | |
| 26 | ||
| 27 | function collectMentionTokens(text: string): ComposerInlineToken[] { | |
| 28 | const matches: ComposerInlineToken[] = []; | |
| 29 | ||
| 30 | for (const match of text.matchAll(FILE_LINK_TOKEN_REGEX)) { | |
| 31 | const fullMatch = match[0]; | |
| 32 | const prefix = match[1] ?? ""; | |
| 33 | const label = (match[2] ?? "").replace(/\\(.)/g, "$1"); | |
| 34 | const encodedPath = match[3] ?? ""; | |
| 35 | let path = encodedPath; | |
| 36 | try { | |
| 37 | path = decodeURIComponent(encodedPath); | |
| 38 | } catch { | |
| 39 | // Preserve malformed source rather than dropping a user-authored token. | |
| 40 | } | |
| 41 | const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); | |
| 42 | const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; | |
| 43 | const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path); | |
| 44 | if (!path || hasExternalScheme || label !== basename) { | |
| 45 | continue; | |
| 46 | } | |
| 47 | const start = (match.index ?? 0) + prefix.length; | |
| 48 | const end = start + fullMatch.length - prefix.length; | |
| 49 | matches.push({ | |
| 50 | type: "mention", | |
| 51 | value: path, | |
| 52 | source: text.slice(start, end), | |
| 53 | start, | |
| 54 | end, | |
| 55 | }); | |
| 56 | } | |
| 57 | ||
| 58 | for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { | |
| 59 | const fullMatch = match[0]; | |
| 60 | const prefix = match[1] ?? ""; | |
| 61 | const quotedPath = match[2]; | |
| 62 | const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? ""); | |
| 63 | if (!path) { | |
| 64 | continue; | |
| 65 | } | |
| 66 | const start = (match.index ?? 0) + prefix.length; | |
| 67 | const end = start + fullMatch.length - prefix.length; | |
| 68 | matches.push({ | |
| 69 | type: "mention", | |
| 70 | value: path, | |
| 71 | source: text.slice(start, end), | |
| 72 | start, | |
| 73 | end, | |
| 74 | }); | |
| 75 | } | |
| 76 | ||
| 77 | return matches; | |
| 78 | } | |
| 79 | ||
| 80 | export function collectComposerInlineTokens( | |
| 81 | text: string, | |
| 82 | options: CollectComposerInlineTokensOptions = {}, | |
| 83 | ): ReadonlyArray<ComposerInlineToken> { | |
| 84 | const matches = collectMentionTokens(text); | |
| 85 | ||
| 86 | for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { | |
| 87 | const fullMatch = match[0]; | |
| 88 | const prefix = match[1] ?? ""; | |
| 89 | const value = match[2] ?? ""; | |
| 90 | if (!value) { | |
| 91 | continue; | |
| 92 | } | |
| 93 | const start = (match.index ?? 0) + prefix.length; | |
| 94 | const end = start + fullMatch.length - prefix.length; | |
| 95 | matches.push({ | |
| 96 | type: "skill", | |
| 97 | value, | |
| 98 | source: text.slice(start, end), | |
| 99 | start, | |
| 100 | end, | |
| 101 | }); | |
| 102 | } | |
| 103 | ||
| 104 | for (const token of options.preserveTrailingFrom ?? []) { | |
| 105 | if ( | |
| 106 | token.end === text.length && | |
| 107 | text.slice(token.start, token.end) === token.source && | |
| 108 | !matches.some( | |
| 109 | (match) => | |
| 110 | match.type === token.type && match.start === token.start && match.end === token.end, | |
| 111 | ) | |
| 112 | ) { | |
| 113 | matches.push(token); | |
| 114 | } | |
| 115 | } | |
| 116 | ||
| 117 | return [...matches].sort((left, right) => left.start - right.start); | |
| 118 | } |
pnpm-lock.yaml+74-35| ... | ... | @@ -179,8 +179,8 @@ importers: |
| 179 | 179 | specifier: ^0.7.1 |
| 180 | 180 | version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 181 | 181 | '@clerk/expo': |
| 182 | specifier: ^3.3.0 | |
| 183 | version: 3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 182 | specifier: ^3.4.1 | |
| 183 | version: 3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 184 | 184 | '@effect/atom-react': |
| 185 | 185 | specifier: 4.0.0-beta.78 |
| 186 | 186 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.3)(scheduler@0.27.0) |
| ... | ... | @@ -223,6 +223,9 @@ importers: |
| 223 | 223 | '@t3tools/contracts': |
| 224 | 224 | specifier: workspace:* |
| 225 | 225 | version: link:../../packages/contracts |
| 226 | '@t3tools/mobile-markdown-text': | |
| 227 | specifier: file:./modules/t3-markdown-text | |
| 228 | version: file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984) | |
| 226 | 229 | '@t3tools/mobile-review-diff-native': |
| 227 | 230 | specifier: file:./modules/t3-review-diff |
| 228 | 231 | version: file:apps/mobile/modules/t3-review-diff |
| ... | ... | @@ -244,6 +247,9 @@ importers: |
| 244 | 247 | expo: |
| 245 | 248 | specifier: ^56.0.0 |
| 246 | 249 | version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 250 | expo-asset: | |
| 251 | specifier: ~56.0.15 | |
| 252 | version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) | |
| 247 | 253 | expo-auth-session: |
| 248 | 254 | specifier: ~56.0.12 |
| 249 | 255 | version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| ... | ... | @@ -368,6 +374,9 @@ importers: |
| 368 | 374 | '@effect/vitest': |
| 369 | 375 | specifier: 4.0.0-beta.78 |
| 370 | 376 | version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 377 | '@pierre/trees': | |
| 378 | specifier: 1.0.0-beta.4 | |
| 379 | version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | |
| 371 | 380 | '@types/react': |
| 372 | 381 | specifier: ~19.2.0 |
| 373 | 382 | version: 19.2.16 |
| ... | ... | @@ -451,11 +460,11 @@ importers: |
| 451 | 460 | specifier: ^1.4.1 |
| 452 | 461 | version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 453 | 462 | '@clerk/clerk-js': |
| 454 | specifier: ^6.13.0 | |
| 455 | version: 6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 463 | specifier: ^6.16.0 | |
| 464 | version: 6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 456 | 465 | '@clerk/react': |
| 457 | specifier: ^6.7.2 | |
| 458 | version: 6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 466 | specifier: ^6.9.0 | |
| 467 | version: 6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 459 | 468 | '@dnd-kit/core': |
| 460 | 469 | specifier: ^6.3.1 |
| 461 | 470 | version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| ... | ... | @@ -614,8 +623,8 @@ importers: |
| 614 | 623 | infra/relay: |
| 615 | 624 | dependencies: |
| 616 | 625 | '@clerk/backend': |
| 617 | specifier: 3.4.14 | |
| 618 | version: 3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 626 | specifier: 3.6.1 | |
| 627 | version: 3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 619 | 628 | '@effect/sql-pg': |
| 620 | 629 | specifier: 4.0.0-beta.78 |
| 621 | 630 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| ... | ... | @@ -1509,16 +1518,16 @@ packages: |
| 1509 | 1518 | resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} |
| 1510 | 1519 | engines: {node: '>= 20.12.0'} |
| 1511 | 1520 | |
| 1512 | '@clerk/backend@3.4.14': | |
| 1513 | resolution: {integrity: sha512-0iaMT7k4wDk31QVC3HMaoeVFttblwsCECTHKNQpbRzIyD8j2gHdKEw/FNjffoyqyBqPw869IQlk1YokUlwVAqQ==} | |
| 1521 | '@clerk/backend@3.6.1': | |
| 1522 | resolution: {integrity: sha512-LkfekzF/0UMXacX+17xy3ExRraO0mm+thXejC8Q32gWHd1wLdxK3YXDsLDF00E1r1InWBKIt2ZOxs6hTwZPJjA==} | |
| 1514 | 1523 | engines: {node: '>=20.9.0'} |
| 1515 | 1524 | |
| 1516 | '@clerk/clerk-js@6.14.0': | |
| 1517 | resolution: {integrity: sha512-xreDPw31OIk/VQj36qdgjzc4Rk2HwMar25nOu/ts2gf7PrbhU4XQdrtnt74g4fTmSMp8xeyjzHqa9adDXVjISw==} | |
| 1525 | '@clerk/clerk-js@6.16.0': | |
| 1526 | resolution: {integrity: sha512-8xv/XDsxhOZd1n4DNIRJ2EehIRUg6UiqKAnfd0L88R2t1g6sVnLi1FInJ5i8Qyx5oY/creXx6X1AZ1V5PobRkA==} | |
| 1518 | 1527 | engines: {node: '>=20.9.0'} |
| 1519 | 1528 | |
| 1520 | '@clerk/expo@3.3.1': | |
| 1521 | resolution: {integrity: sha512-c4g64z5sgJoGYjK0NeasNwOMy9Di7cEjICq56BHSowdOuB+6UGtWBNw+yHzgS1gxi2kJgl7WQCmmXRsoZNWxAg==} | |
| 1529 | '@clerk/expo@3.4.1': | |
| 1530 | resolution: {integrity: sha512-gpAXsuUnsUdUD0/2XjyxaC9quF5rT+2umkmV74nBLVAFurGMMMMHvnHqrQEtZ7tH5GHNXYw5+pgmnzd1HiMQbQ==} | |
| 1522 | 1531 | engines: {node: '>=20.9.0'} |
| 1523 | 1532 | peerDependencies: |
| 1524 | 1533 | '@clerk/expo-passkeys': '>=0.0.6' |
| ... | ... | @@ -1550,16 +1559,18 @@ packages: |
| 1550 | 1559 | optional: true |
| 1551 | 1560 | expo-web-browser: |
| 1552 | 1561 | optional: true |
| 1562 | react-dom: | |
| 1563 | optional: true | |
| 1553 | 1564 | |
| 1554 | '@clerk/react@6.7.3': | |
| 1555 | resolution: {integrity: sha512-xdml8bFXbOQ/Egyp7iI1f0ksLjw5nYu2Db+mttHpJzet7PRXQ3jBEEc2c0AYhOJvIYxJifHGBsf75NM8SwlOag==} | |
| 1565 | '@clerk/react@6.9.0': | |
| 1566 | resolution: {integrity: sha512-M0QGyGS732tYBXeG+28UgElXM2TfoSZ+4mWGisC8yxJX8NjH4hEPJTAQuZmYRLNaCyGQCuzjYVQiQRC+GbDtmA==} | |
| 1556 | 1567 | engines: {node: '>=20.9.0'} |
| 1557 | 1568 | peerDependencies: |
| 1558 | 1569 | react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 |
| 1559 | 1570 | react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 |
| 1560 | 1571 | |
| 1561 | '@clerk/shared@4.15.0': | |
| 1562 | resolution: {integrity: sha512-uX8nfLb69m8mA6KWKWfuPSwoVNDRyUdufeCeTEZsdZxbRUsEYT/c0KWFN28IOQCtK09tpVtzrUHvW44v5Dc5OA==} | |
| 1572 | '@clerk/shared@4.17.0': | |
| 1573 | resolution: {integrity: sha512-YeQ+6zDmqyor1mPHjZx18j+LssL6Pobvid8hb7HQMioSo8sGDBEVi/Z12bs+gUhe9KbdP+ygHsKOqqeGAPuPZA==} | |
| 1563 | 1574 | engines: {node: '>=20.9.0'} |
| 1564 | 1575 | peerDependencies: |
| 1565 | 1576 | react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 |
| ... | ... | @@ -4168,6 +4179,17 @@ packages: |
| 4168 | 4179 | resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} |
| 4169 | 4180 | engines: {node: '>=10'} |
| 4170 | 4181 | |
| 4182 | '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text': | |
| 4183 | resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory} | |
| 4184 | peerDependencies: | |
| 4185 | expo-asset: '*' | |
| 4186 | expo-clipboard: '*' | |
| 4187 | expo-haptics: '*' | |
| 4188 | expo-symbols: '*' | |
| 4189 | react: '*' | |
| 4190 | react-native: '*' | |
| 4191 | react-native-nitro-markdown: '*' | |
| 4192 | ||
| 4171 | 4193 | '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': |
| 4172 | 4194 | resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory} |
| 4173 | 4195 | |
| ... | ... | @@ -10829,18 +10851,18 @@ snapshots: |
| 10829 | 10851 | fast-wrap-ansi: 0.2.2 |
| 10830 | 10852 | sisteransi: 1.0.5 |
| 10831 | 10853 | |
| 10832 | '@clerk/backend@3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | |
| 10854 | '@clerk/backend@3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | |
| 10833 | 10855 | dependencies: |
| 10834 | '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 10856 | '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 10835 | 10857 | standardwebhooks: 1.0.0 |
| 10836 | 10858 | tslib: 2.8.1 |
| 10837 | 10859 | transitivePeerDependencies: |
| 10838 | 10860 | - react |
| 10839 | 10861 | - react-dom |
| 10840 | 10862 | |
| 10841 | '@clerk/clerk-js@6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | |
| 10863 | '@clerk/clerk-js@6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | |
| 10842 | 10864 | dependencies: |
| 10843 | '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | |
| 10865 | '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | |
| 10844 | 10866 | '@stripe/stripe-js': 5.6.0 |
| 10845 | 10867 | '@swc/helpers': 0.5.21 |
| 10846 | 10868 | '@tanstack/query-core': 5.100.14 |
| ... | ... | @@ -10855,9 +10877,9 @@ snapshots: |
| 10855 | 10877 | - react |
| 10856 | 10878 | - react-dom |
| 10857 | 10879 | |
| 10858 | '@clerk/clerk-js@6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | |
| 10880 | '@clerk/clerk-js@6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | |
| 10859 | 10881 | dependencies: |
| 10860 | '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 10882 | '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 10861 | 10883 | '@stripe/stripe-js': 5.6.0 |
| 10862 | 10884 | '@swc/helpers': 0.5.21 |
| 10863 | 10885 | '@tanstack/query-core': 5.100.14 |
| ... | ... | @@ -10872,15 +10894,14 @@ snapshots: |
| 10872 | 10894 | - react |
| 10873 | 10895 | - react-dom |
| 10874 | 10896 | |
| 10875 | '@clerk/expo@3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | |
| 10897 | '@clerk/expo@3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | |
| 10876 | 10898 | dependencies: |
| 10877 | '@clerk/clerk-js': 6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | |
| 10878 | '@clerk/react': 6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | |
| 10879 | '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | |
| 10899 | '@clerk/clerk-js': 6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | |
| 10900 | '@clerk/react': 6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | |
| 10901 | '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | |
| 10880 | 10902 | base-64: 1.0.0 |
| 10881 | 10903 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 10882 | 10904 | react: 19.2.3 |
| 10883 | react-dom: 19.2.3(react@19.2.3) | |
| 10884 | 10905 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 10885 | 10906 | react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 10886 | 10907 | tslib: 2.8.1 |
| ... | ... | @@ -10890,22 +10911,23 @@ snapshots: |
| 10890 | 10911 | expo-crypto: 56.0.4(expo@56.0.8) |
| 10891 | 10912 | expo-secure-store: 56.0.4(expo@56.0.8) |
| 10892 | 10913 | expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 10914 | react-dom: 19.2.3(react@19.2.3) | |
| 10893 | 10915 | |
| 10894 | '@clerk/react@6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | |
| 10916 | '@clerk/react@6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | |
| 10895 | 10917 | dependencies: |
| 10896 | '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | |
| 10918 | '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | |
| 10897 | 10919 | react: 19.2.3 |
| 10898 | 10920 | react-dom: 19.2.3(react@19.2.3) |
| 10899 | 10921 | tslib: 2.8.1 |
| 10900 | 10922 | |
| 10901 | '@clerk/react@6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | |
| 10923 | '@clerk/react@6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | |
| 10902 | 10924 | dependencies: |
| 10903 | '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 10925 | '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 10904 | 10926 | react: 19.2.6 |
| 10905 | 10927 | react-dom: 19.2.6(react@19.2.6) |
| 10906 | 10928 | tslib: 2.8.1 |
| 10907 | 10929 | |
| 10908 | '@clerk/shared@4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | |
| 10930 | '@clerk/shared@4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | |
| 10909 | 10931 | dependencies: |
| 10910 | 10932 | '@tanstack/query-core': 5.100.14 |
| 10911 | 10933 | dequal: 2.0.3 |
| ... | ... | @@ -10915,7 +10937,7 @@ snapshots: |
| 10915 | 10937 | react: 19.2.3 |
| 10916 | 10938 | react-dom: 19.2.3(react@19.2.3) |
| 10917 | 10939 | |
| 10918 | '@clerk/shared@4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | |
| 10940 | '@clerk/shared@4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | |
| 10919 | 10941 | dependencies: |
| 10920 | 10942 | '@tanstack/query-core': 5.100.14 |
| 10921 | 10943 | dequal: 2.0.3 |
| ... | ... | @@ -12680,6 +12702,13 @@ snapshots: |
| 12680 | 12702 | react-dom: 19.2.6(react@19.2.6) |
| 12681 | 12703 | shiki: 3.23.0 |
| 12682 | 12704 | |
| 12705 | '@pierre/trees@1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | |
| 12706 | dependencies: | |
| 12707 | preact: 11.0.0-beta.0 | |
| 12708 | preact-render-to-string: 6.6.5(preact@11.0.0-beta.0) | |
| 12709 | react: 19.2.3 | |
| 12710 | react-dom: 19.2.3(react@19.2.3) | |
| 12711 | ||
| 12683 | 12712 | '@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': |
| 12684 | 12713 | dependencies: |
| 12685 | 12714 | preact: 11.0.0-beta.0 |
| ... | ... | @@ -13452,6 +13481,16 @@ snapshots: |
| 13452 | 13481 | dependencies: |
| 13453 | 13482 | defer-to-connect: 2.0.1 |
| 13454 | 13483 | |
| 13484 | '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984)': | |
| 13485 | dependencies: | |
| 13486 | expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) | |
| 13487 | expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 13488 | expo-haptics: 56.0.3(expo@56.0.8) | |
| 13489 | expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 13490 | react: 19.2.3 | |
| 13491 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | |
| 13492 | react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 13493 | ||
| 13455 | 13494 | '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} |
| 13456 | 13495 | |
| 13457 | 13496 | '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {} |
scripts/release-smoke.ts+1| ... | ... | @@ -26,6 +26,7 @@ const workspaceFiles = [ |
| 26 | 26 | "apps/web/package.json", |
| 27 | 27 | "apps/mobile/package.json", |
| 28 | 28 | "apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz", |
| 29 | "apps/mobile/modules/t3-markdown-text/package.json", | |
| 29 | 30 | "apps/mobile/modules/t3-review-diff/package.json", |
| 30 | 31 | "apps/mobile/modules/t3-terminal/package.json", |
| 31 | 32 | "apps/marketing/package.json", |