| 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,7 +13,7 @@ |
| 13 | "neutral": "#F5F5F5", | 13 | "neutral": "#F5F5F5", |
| 14 | "border": "#E5E5EA", | 14 | "border": "#E5E5EA", |
| 15 | "ring": "#A3A3A3", | 15 | "ring": "#A3A3A3", |
| 16 | "muted": "#F5F5F5", | 16 | "muted": "#F2F2F7", |
| 17 | "shadow": "#000000" | 17 | "shadow": "#000000" |
| 18 | }, | 18 | }, |
| 19 | "darkColors": { | 19 | "darkColors": { |
| ... | @@ -30,7 +30,7 @@ | ... | @@ -30,7 +30,7 @@ |
| 30 | "neutral": "#1C1C1C", | 30 | "neutral": "#1C1C1C", |
| 31 | "border": "#2A2A2A", | 31 | "border": "#2A2A2A", |
| 32 | "ring": "#525252", | 32 | "ring": "#525252", |
| 33 | "muted": "#1C1C1C", | 33 | "muted": "#0E0E0E", |
| 34 | "shadow": "#000000" | 34 | "shadow": "#000000" |
| 35 | }, | 35 | }, |
| 36 | "design": { | 36 | "design": { |
apps/mobile/global.css+15-5| ... | @@ -18,7 +18,7 @@ | ... | @@ -18,7 +18,7 @@ |
| 18 | --color-foreground: #262626; | 18 | --color-foreground: #262626; |
| 19 | --color-foreground-secondary: #525252; | 19 | --color-foreground-secondary: #525252; |
| 20 | --color-foreground-muted: #737373; | 20 | --color-foreground-muted: #737373; |
| 21 | --color-foreground-tertiary: #a3a3a3; | 21 | --color-foreground-tertiary: #8e8e93; |
| 22 | 22 | ||
| 23 | /* Borders & separators */ | 23 | /* Borders & separators */ |
| 24 | --color-border: rgba(0, 0, 0, 0.08); | 24 | --color-border: rgba(0, 0, 0, 0.08); |
| ... | @@ -28,6 +28,9 @@ | ... | @@ -28,6 +28,9 @@ |
| 28 | /* Subtle backgrounds (badges, pills, overlays) */ | 28 | /* Subtle backgrounds (badges, pills, overlays) */ |
| 29 | --color-subtle: rgba(0, 0, 0, 0.04); | 29 | --color-subtle: rgba(0, 0, 0, 0.04); |
| 30 | --color-subtle-strong: rgba(0, 0, 0, 0.08); | 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 | /* Primary action */ | 35 | /* Primary action */ |
| 33 | --color-primary: #262626; | 36 | --color-primary: #262626; |
| ... | @@ -58,6 +61,8 @@ | ... | @@ -58,6 +61,8 @@ |
| 58 | /* Header / glass chrome */ | 61 | /* Header / glass chrome */ |
| 59 | --color-header: rgba(255, 255, 255, 0.97); | 62 | --color-header: rgba(255, 255, 255, 0.97); |
| 60 | --color-header-border: rgba(0, 0, 0, 0.06); | 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 | /* StatusBar */ | 67 | /* StatusBar */ |
| 63 | --color-status-bar: #f2f2f7; | 68 | --color-status-bar: #f2f2f7; |
| ... | @@ -105,8 +110,8 @@ | ... | @@ -105,8 +110,8 @@ |
| 105 | /* Text */ | 110 | /* Text */ |
| 106 | --color-foreground: #f5f5f5; | 111 | --color-foreground: #f5f5f5; |
| 107 | --color-foreground-secondary: #a3a3a3; | 112 | --color-foreground-secondary: #a3a3a3; |
| 108 | --color-foreground-muted: #737373; | 113 | --color-foreground-muted: #8e8e93; |
| 109 | --color-foreground-tertiary: #525252; | 114 | --color-foreground-tertiary: #636366; |
| 110 | 115 | ||
| 111 | /* Borders & separators */ | 116 | /* Borders & separators */ |
| 112 | --color-border: rgba(255, 255, 255, 0.06); | 117 | --color-border: rgba(255, 255, 255, 0.06); |
| ... | @@ -116,6 +121,9 @@ | ... | @@ -116,6 +121,9 @@ |
| 116 | /* Subtle backgrounds (badges, pills, overlays) */ | 121 | /* Subtle backgrounds (badges, pills, overlays) */ |
| 117 | --color-subtle: rgba(255, 255, 255, 0.04); | 122 | --color-subtle: rgba(255, 255, 255, 0.04); |
| 118 | --color-subtle-strong: rgba(255, 255, 255, 0.08); | 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 | /* Primary action */ | 128 | /* Primary action */ |
| 121 | --color-primary: #f5f5f5; | 129 | --color-primary: #f5f5f5; |
| ... | @@ -136,16 +144,18 @@ | ... | @@ -136,16 +144,18 @@ |
| 136 | /* Inputs */ | 144 | /* Inputs */ |
| 137 | --color-input: #141414; | 145 | --color-input: #141414; |
| 138 | --color-input-border: rgba(255, 255, 255, 0.08); | 146 | --color-input-border: rgba(255, 255, 255, 0.08); |
| 139 | --color-placeholder: #737373; | 147 | --color-placeholder: #8e8e93; |
| 140 | 148 | ||
| 141 | /* Icons */ | 149 | /* Icons */ |
| 142 | --color-icon: #f5f5f5; | 150 | --color-icon: #f5f5f5; |
| 143 | --color-icon-muted: #a3a3a3; | 151 | --color-icon-muted: #a3a3a3; |
| 144 | --color-icon-subtle: #737373; | 152 | --color-icon-subtle: #8e8e93; |
| 145 | 153 | ||
| 146 | /* Header / glass chrome */ | 154 | /* Header / glass chrome */ |
| 147 | --color-header: rgba(10, 10, 10, 0.97); | 155 | --color-header: rgba(10, 10, 10, 0.97); |
| 148 | --color-header-border: rgba(255, 255, 255, 0.06); | 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 | /* StatusBar */ | 160 | /* StatusBar */ |
| 151 | --color-status-bar: #0a0a0a; | 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,12 +34,13 @@ |
| 34 | "config:preview": "APP_VARIANT=preview expo config", | 34 | "config:preview": "APP_VARIANT=preview expo config", |
| 35 | "config:prod": "APP_VARIANT=production expo config", | 35 | "config:prod": "APP_VARIANT=production expo config", |
| 36 | "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", | 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 | "test": "vp test run", | 38 | "test": "vp test run", |
| 38 | "typecheck": "tsc --noEmit" | 39 | "typecheck": "tsc --noEmit" |
| 39 | }, | 40 | }, |
| 40 | "dependencies": { | 41 | "dependencies": { |
| 41 | "@callstack/liquid-glass": "^0.7.1", | 42 | "@callstack/liquid-glass": "^0.7.1", |
| 42 | "@clerk/expo": "^3.3.0", | 43 | "@clerk/expo": "^3.4.1", |
| 43 | "@effect/atom-react": "catalog:", | 44 | "@effect/atom-react": "catalog:", |
| 44 | "@expo-google-fonts/dm-sans": "^0.4.2", | 45 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 45 | "@expo/ui": "~56.0.8", | 46 | "@expo/ui": "~56.0.8", |
| ... | @@ -54,6 +55,7 @@ | ... | @@ -54,6 +55,7 @@ |
| 54 | "@shikijs/themes": "3.23.0", | 55 | "@shikijs/themes": "3.23.0", |
| 55 | "@t3tools/client-runtime": "workspace:*", | 56 | "@t3tools/client-runtime": "workspace:*", |
| 56 | "@t3tools/contracts": "workspace:*", | 57 | "@t3tools/contracts": "workspace:*", |
| 58 | "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text", | ||
| 57 | "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", | 59 | "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", |
| 58 | "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", | 60 | "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", |
| 59 | "@t3tools/shared": "workspace:*", | 61 | "@t3tools/shared": "workspace:*", |
| ... | @@ -61,6 +63,7 @@ | ... | @@ -61,6 +63,7 @@ |
| 61 | "diff": "8.0.3", | 63 | "diff": "8.0.3", |
| 62 | "effect": "catalog:", | 64 | "effect": "catalog:", |
| 63 | "expo": "^56.0.0", | 65 | "expo": "^56.0.0", |
| 66 | "expo-asset": "~56.0.15", | ||
| 64 | "expo-auth-session": "~56.0.12", | 67 | "expo-auth-session": "~56.0.12", |
| 65 | "expo-build-properties": "~56.0.15", | 68 | "expo-build-properties": "~56.0.15", |
| 66 | "expo-camera": "~56.0.7", | 69 | "expo-camera": "~56.0.7", |
| ... | @@ -104,6 +107,7 @@ | ... | @@ -104,6 +107,7 @@ |
| 104 | }, | 107 | }, |
| 105 | "devDependencies": { | 108 | "devDependencies": { |
| 106 | "@effect/vitest": "catalog:", | 109 | "@effect/vitest": "catalog:", |
| 110 | "@pierre/trees": "1.0.0-beta.4", | ||
| 107 | "@types/react": "~19.2.0", | 111 | "@types/react": "~19.2.0", |
| 108 | "babel-preset-expo": "~56.0.0", | 112 | "babel-preset-expo": "~56.0.0", |
| 109 | "tailwindcss": "^4.0.0", | 113 | "tailwindcss": "^4.0.0", |
apps/mobile/src/app/_layout.tsx+36-4| ... | @@ -5,7 +5,9 @@ import { | ... | @@ -5,7 +5,9 @@ import { |
| 5 | DMSans_700Bold, | 5 | DMSans_700Bold, |
| 6 | useFonts, | 6 | useFonts, |
| 7 | } from "@expo-google-fonts/dm-sans"; | 7 | } from "@expo-google-fonts/dm-sans"; |
| 8 | import { usePathname } from "expo-router"; | ||
| 8 | import Stack from "expo-router/stack"; | 9 | import Stack from "expo-router/stack"; |
| 10 | import { useCallback } from "react"; | ||
| 9 | import { StatusBar, useColorScheme } from "react-native"; | 11 | import { StatusBar, useColorScheme } from "react-native"; |
| 10 | import { GestureHandlerRootView } from "react-native-gesture-handler"; | 12 | import { GestureHandlerRootView } from "react-native-gesture-handler"; |
| 11 | import { KeyboardProvider } from "react-native-keyboard-controller"; | 13 | import { KeyboardProvider } from "react-native-keyboard-controller"; |
| ... | @@ -21,15 +23,41 @@ import { | ... | @@ -21,15 +23,41 @@ import { |
| 21 | import { RegistryContext } from "@effect/atom-react"; | 23 | import { RegistryContext } from "@effect/atom-react"; |
| 22 | import { appAtomRegistry } from "../state/atom-registry"; | 24 | import { appAtomRegistry } from "../state/atom-registry"; |
| 23 | import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; | 25 | import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; |
| 26 | import { | ||
| 27 | ClerkSettingsSheetDetentProvider, | ||
| 28 | useClerkSettingsSheetDetent, | ||
| 29 | } from "../features/cloud/ClerkSettingsSheetDetent"; | ||
| 24 | import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation"; | 30 | import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation"; |
| 31 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 25 | 32 | ||
| 26 | function AppNavigator() { | 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 | const { isLoadingSavedConnection } = useRemoteEnvironmentState(); | 45 | const { isLoadingSavedConnection } = useRemoteEnvironmentState(); |
| 46 | const { collapse, isExpanded } = useClerkSettingsSheetDetent(); | ||
| 28 | const colorScheme = useColorScheme(); | 47 | const colorScheme = useColorScheme(); |
| 29 | const statusBarBg = colorScheme === "dark" ? "#0a0a0a" : "#f2f2f7"; | 48 | const statusBarBg = useThemeColor("--color-status-bar"); |
| 30 | const sheetStyle = useResolveClassNames("bg-sheet"); | 49 | const sheetStyle = useResolveClassNames("bg-sheet"); |
| 31 | useAgentNotificationNavigation(); | 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 | const newTaskScreenOptions = { | 61 | const newTaskScreenOptions = { |
| 34 | contentStyle: sheetStyle, | 62 | contentStyle: sheetStyle, |
| 35 | gestureEnabled: true, | 63 | gestureEnabled: true, |
| ... | @@ -50,7 +78,7 @@ function AppNavigator() { | ... | @@ -50,7 +78,7 @@ function AppNavigator() { |
| 50 | 78 | ||
| 51 | const settingsSheetScreenOptions = { | 79 | const settingsSheetScreenOptions = { |
| 52 | ...connectionSheetScreenOptions, | 80 | ...connectionSheetScreenOptions, |
| 53 | sheetAllowedDetents: [0.7], | 81 | sheetAllowedDetents: isExpanded ? [0.92] : [0.7], |
| 54 | }; | 82 | }; |
| 55 | 83 | ||
| 56 | if (isLoadingSavedConnection) { | 84 | if (isLoadingSavedConnection) { |
| ... | @@ -61,7 +89,7 @@ function AppNavigator() { | ... | @@ -61,7 +89,7 @@ function AppNavigator() { |
| 61 | <> | 89 | <> |
| 62 | <StatusBar | 90 | <StatusBar |
| 63 | barStyle={colorScheme === "dark" ? "light-content" : "dark-content"} | 91 | barStyle={colorScheme === "dark" ? "light-content" : "dark-content"} |
| 64 | backgroundColor={statusBarBg as string} | 92 | backgroundColor={String(statusBarBg)} |
| 65 | translucent | 93 | translucent |
| 66 | /> | 94 | /> |
| 67 | <Stack screenOptions={{ headerShown: false }}> | 95 | <Stack screenOptions={{ headerShown: false }}> |
| ... | @@ -74,7 +102,11 @@ function AppNavigator() { | ... | @@ -74,7 +102,11 @@ function AppNavigator() { |
| 74 | headerShadowVisible: false, | 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 | <Stack.Screen name="connections" options={connectionSheetScreenOptions} /> | 110 | <Stack.Screen name="connections" options={connectionSheetScreenOptions} /> |
| 79 | <Stack.Screen name="new" options={newTaskScreenOptions} /> | 111 | <Stack.Screen name="new" options={newTaskScreenOptions} /> |
| 80 | <Stack.Screen | 112 | <Stack.Screen |
apps/mobile/src/app/connections/_layout.tsx+3-4| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import Stack from "expo-router/stack"; | 1 | import Stack from "expo-router/stack"; |
| 2 | import { useColorScheme } from "react-native"; | ||
| 3 | import { useResolveClassNames } from "uniwind"; | 2 | import { useResolveClassNames } from "uniwind"; |
| 3 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 4 | 4 | ||
| 5 | export const unstable_settings = { | 5 | export const unstable_settings = { |
| 6 | anchor: "index", | 6 | anchor: "index", |
| ... | @@ -8,9 +8,8 @@ export const unstable_settings = { | ... | @@ -8,9 +8,8 @@ export const unstable_settings = { |
| 8 | 8 | ||
| 9 | export default function ConnectionsLayout() { | 9 | export default function ConnectionsLayout() { |
| 10 | const contentStyle = useResolveClassNames("bg-sheet"); | 10 | const contentStyle = useResolveClassNames("bg-sheet"); |
| 11 | const isDark = useColorScheme() === "dark"; | 11 | const connSheetBg = useThemeColor("--color-sheet"); |
| 12 | const connSheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; | 12 | const headerTint = useThemeColor("--color-foreground"); |
| 13 | const headerTint = isDark ? "#f5f5f5" : "#262626"; | ||
| 14 | 13 | ||
| 15 | return ( | 14 | return ( |
| 16 | <Stack | 15 | <Stack |
apps/mobile/src/app/index.tsx+7-5| ... | @@ -1,11 +1,12 @@ | ... | @@ -1,11 +1,12 @@ |
| 1 | import { Stack, useRouter } from "expo-router"; | 1 | import { Stack, useRouter } from "expo-router"; |
| 2 | import { useState } from "react"; | 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 | import { buildThreadRoutePath } from "../lib/routes"; | 5 | import { buildThreadRoutePath } from "../lib/routes"; |
| 6 | import { useRemoteCatalog } from "../state/use-remote-catalog"; | 6 | import { useRemoteCatalog } from "../state/use-remote-catalog"; |
| 7 | import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry"; | 7 | import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry"; |
| 8 | import { HomeScreen } from "../features/home/HomeScreen"; | 8 | import { HomeScreen } from "../features/home/HomeScreen"; |
| 9 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 9 | 10 | ||
| 10 | /* ─── Route screen ───────────────────────────────────────────────────── */ | 11 | /* ─── Route screen ───────────────────────────────────────────────────── */ |
| 11 | 12 | ||
| ... | @@ -15,8 +16,9 @@ export default function HomeRouteScreen() { | ... | @@ -15,8 +16,9 @@ export default function HomeRouteScreen() { |
| 15 | const router = useRouter(); | 16 | const router = useRouter(); |
| 16 | const [searchQuery, setSearchQuery] = useState(""); | 17 | const [searchQuery, setSearchQuery] = useState(""); |
| 17 | 18 | ||
| 18 | const isDark = useColorScheme() === "dark"; | 19 | const iconColor = useThemeColor("--color-icon"); |
| 19 | const iconColor = isDark ? "#f5f5f5" : "#262626"; | 20 | const mutedColor = useThemeColor("--color-foreground-muted"); |
| 21 | const subtleColor = useThemeColor("--color-subtle"); | ||
| 20 | 22 | ||
| 21 | return ( | 23 | return ( |
| 22 | <> | 24 | <> |
| ... | @@ -54,7 +56,7 @@ export default function HomeRouteScreen() { | ... | @@ -54,7 +56,7 @@ export default function HomeRouteScreen() { |
| 54 | </RNText> | 56 | </RNText> |
| 55 | <View | 57 | <View |
| 56 | style={{ | 58 | style={{ |
| 57 | backgroundColor: isDark ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)", | 59 | backgroundColor: subtleColor, |
| 58 | borderRadius: 99, | 60 | borderRadius: 99, |
| 59 | paddingHorizontal: 8, | 61 | paddingHorizontal: 8, |
| 60 | paddingVertical: 3, | 62 | paddingVertical: 3, |
| ... | @@ -64,7 +66,7 @@ export default function HomeRouteScreen() { | ... | @@ -64,7 +66,7 @@ export default function HomeRouteScreen() { |
| 64 | style={{ | 66 | style={{ |
| 65 | fontFamily: "DMSans_700Bold", | 67 | fontFamily: "DMSans_700Bold", |
| 66 | fontSize: 10, | 68 | fontSize: 10, |
| 67 | color: "#737373", | 69 | color: mutedColor, |
| 68 | letterSpacing: 1.1, | 70 | letterSpacing: 1.1, |
| 69 | textTransform: "uppercase", | 71 | textTransform: "uppercase", |
| 70 | }} | 72 | }} |
apps/mobile/src/app/new/_layout.tsx+3-4| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | import Stack from "expo-router/stack"; | 1 | import Stack from "expo-router/stack"; |
| 2 | import { useColorScheme } from "react-native"; | ||
| 3 | import { useResolveClassNames } from "uniwind"; | 2 | import { useResolveClassNames } from "uniwind"; |
| 4 | 3 | ||
| 5 | import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider"; | 4 | import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider"; |
| 5 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 6 | 6 | ||
| 7 | export const unstable_settings = { | 7 | export const unstable_settings = { |
| 8 | anchor: "index", | 8 | anchor: "index", |
| ... | @@ -10,9 +10,8 @@ export const unstable_settings = { | ... | @@ -10,9 +10,8 @@ export const unstable_settings = { |
| 10 | 10 | ||
| 11 | export default function NewTaskLayout() { | 11 | export default function NewTaskLayout() { |
| 12 | const sheetStyle = useResolveClassNames("bg-sheet"); | 12 | const sheetStyle = useResolveClassNames("bg-sheet"); |
| 13 | const isDark = useColorScheme() === "dark"; | 13 | const sheetBg = useThemeColor("--color-sheet"); |
| 14 | const sheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; | 14 | const headerTint = useThemeColor("--color-foreground"); |
| 15 | const headerTint = isDark ? "#f5f5f5" : "#262626"; | ||
| 16 | 15 | ||
| 17 | return ( | 16 | return ( |
| 18 | <NewTaskFlowProvider> | 17 | <NewTaskFlowProvider> |
apps/mobile/src/app/settings/_layout.tsx+20-4| ... | @@ -1,16 +1,27 @@ | ... | @@ -1,16 +1,27 @@ |
| 1 | import Stack from "expo-router/stack"; | 1 | import Stack from "expo-router/stack"; |
| 2 | import { useColorScheme } from "react-native"; | 2 | import { useCallback } from "react"; |
| 3 | import { useResolveClassNames } from "uniwind"; | 3 | import { useResolveClassNames } from "uniwind"; |
| 4 | 4 | ||
| 5 | import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; | ||
| 6 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 7 | |||
| 5 | export const unstable_settings = { | 8 | export const unstable_settings = { |
| 6 | anchor: "index", | 9 | anchor: "index", |
| 7 | }; | 10 | }; |
| 8 | 11 | ||
| 9 | export default function SettingsLayout() { | 12 | export default function SettingsLayout() { |
| 13 | const { collapse } = useClerkSettingsSheetDetent(); | ||
| 10 | const contentStyle = useResolveClassNames("bg-sheet"); | 14 | const contentStyle = useResolveClassNames("bg-sheet"); |
| 11 | const isDark = useColorScheme() === "dark"; | 15 | const sheetBg = useThemeColor("--color-sheet"); |
| 12 | const sheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; | 16 | const headerTint = useThemeColor("--color-foreground"); |
| 13 | const headerTint = isDark ? "#f5f5f5" : "#262626"; | 17 | const handleClerkRouteTransitionEnd = useCallback( |
| 18 | (event: { data: { closing: boolean } }) => { | ||
| 19 | if (event.data.closing) { | ||
| 20 | collapse(); | ||
| 21 | } | ||
| 22 | }, | ||
| 23 | [collapse], | ||
| 24 | ); | ||
| 14 | 25 | ||
| 15 | return ( | 26 | return ( |
| 16 | <Stack | 27 | <Stack |
| ... | @@ -36,6 +47,11 @@ export default function SettingsLayout() { | ... | @@ -36,6 +47,11 @@ export default function SettingsLayout() { |
| 36 | name="waitlist" | 47 | name="waitlist" |
| 37 | options={{ animation: "slide_from_right", title: "Join the waitlist" }} | 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 | </Stack> | 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,6 +13,7 @@ import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/li |
| 13 | import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; | 13 | import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; |
| 14 | import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration"; | 14 | import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration"; |
| 15 | import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState"; | 15 | import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState"; |
| 16 | import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; | ||
| 16 | import { | 17 | import { |
| 17 | hasCloudPublicConfig, | 18 | hasCloudPublicConfig, |
| 18 | resolveRelayClerkTokenOptions, | 19 | resolveRelayClerkTokenOptions, |
| ... | @@ -66,6 +67,7 @@ function LocalSettingsRouteScreen() { | ... | @@ -66,6 +67,7 @@ function LocalSettingsRouteScreen() { |
| 66 | function ConfiguredSettingsRouteScreen() { | 67 | function ConfiguredSettingsRouteScreen() { |
| 67 | const insets = useSafeAreaInsets(); | 68 | const insets = useSafeAreaInsets(); |
| 68 | const { push } = useRouter(); | 69 | const { push } = useRouter(); |
| 70 | const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); | ||
| 69 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | 71 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); |
| 70 | const { user } = useUser(); | 72 | const { user } = useUser(); |
| 71 | const { savedConnectionsById } = useRemoteEnvironmentState(); | 73 | const { savedConnectionsById } = useRemoteEnvironmentState(); |
| ... | @@ -265,11 +267,9 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -265,11 +267,9 @@ function ConfiguredSettingsRouteScreen() { |
| 265 | push("/settings/waitlist"); | 267 | push("/settings/waitlist"); |
| 266 | return; | 268 | return; |
| 267 | } | 269 | } |
| 268 | Alert.alert( | 270 | expandClerkSheet(); |
| 269 | "T3 Cloud unavailable", | 271 | push("/settings/auth"); |
| 270 | "Native T3 Cloud account management is not available in this build.", | 272 | }, [expandClerkSheet, isLoaded, isSignedIn, push]); |
| 271 | ); | ||
| 272 | }, [isLoaded, isSignedIn, push]); | ||
| 273 | 273 | ||
| 274 | return ( | 274 | return ( |
| 275 | <View collapsable={false} className="flex-1 bg-sheet"> | 275 | <View collapsable={false} className="flex-1 bg-sheet"> |
| ... | @@ -335,7 +335,7 @@ type SymbolName = ComponentProps<typeof SymbolView>["name"]; | ... | @@ -335,7 +335,7 @@ type SymbolName = ComponentProps<typeof SymbolView>["name"]; |
| 335 | function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { | 335 | function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { |
| 336 | return ( | 336 | return ( |
| 337 | <View className="gap-2"> | 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 | <View | 339 | <View |
| 340 | className="overflow-hidden rounded-[28px] bg-card" | 340 | className="overflow-hidden rounded-[28px] bg-card" |
| 341 | style={{ borderCurve: "continuous" }} | 341 | style={{ borderCurve: "continuous" }} |
apps/mobile/src/app/settings/waitlist.tsx+21-4| ... | @@ -1,9 +1,11 @@ | ... | @@ -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 | import { ScrollView } from "react-native"; | 4 | import { ScrollView } from "react-native"; |
| 3 | 5 | ||
| 4 | import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment"; | 6 | import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment"; |
| 7 | import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; | ||
| 5 | import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; | 8 | import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; |
| 6 | import { useNativeClerkAuthModal } from "../../features/cloud/useNativeClerkAuthModal"; | ||
| 7 | 9 | ||
| 8 | export default function SettingsWaitlistRouteScreen() { | 10 | export default function SettingsWaitlistRouteScreen() { |
| 9 | return hasCloudPublicConfig() ? ( | 11 | return hasCloudPublicConfig() ? ( |
| ... | @@ -14,7 +16,17 @@ export default function SettingsWaitlistRouteScreen() { | ... | @@ -14,7 +16,17 @@ export default function SettingsWaitlistRouteScreen() { |
| 14 | } | 16 | } |
| 15 | 17 | ||
| 16 | function ConfiguredSettingsWaitlistRouteScreen() { | 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 | return ( | 31 | return ( |
| 20 | <> | 32 | <> |
| ... | @@ -31,7 +43,12 @@ function ConfiguredSettingsWaitlistRouteScreen() { | ... | @@ -31,7 +43,12 @@ function ConfiguredSettingsWaitlistRouteScreen() { |
| 31 | keyboardShouldPersistTaps="handled" | 43 | keyboardShouldPersistTaps="handled" |
| 32 | showsVerticalScrollIndicator={false} | 44 | showsVerticalScrollIndicator={false} |
| 33 | > | 45 | > |
| 34 | <CloudWaitlistEnrollment onSignIn={() => void presentAuth()} /> | 46 | <CloudWaitlistEnrollment |
| 47 | onSignIn={() => { | ||
| 48 | expand(); | ||
| 49 | router.push("/settings/auth"); | ||
| 50 | }} | ||
| 51 | /> | ||
| 35 | </ScrollView> | 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,6 +1,7 @@ |
| 1 | import type { ReactNode } from "react"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 4 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 4 | 5 | ||
| 5 | import { GlassSurface } from "./GlassSurface"; | 6 | import { GlassSurface } from "./GlassSurface"; |
| 6 | 7 | ||
| ... | @@ -17,14 +18,16 @@ export function GlassSafeAreaView({ | ... | @@ -17,14 +18,16 @@ export function GlassSafeAreaView({ |
| 17 | rightSlot, | 18 | rightSlot, |
| 18 | style, | 19 | style, |
| 19 | }: GlassSafeAreaViewProps) { | 20 | }: GlassSafeAreaViewProps) { |
| 20 | const isDarkMode = useColorScheme() === "dark"; | ||
| 21 | const insets = useSafeAreaInsets(); | 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 | const headerPaddingTop = insets.top + 16; | 25 | const headerPaddingTop = insets.top + 16; |
| 23 | const surfaceStyle = { | 26 | const surfaceStyle = { |
| 24 | borderRadius: 0, | 27 | borderRadius: 0, |
| 25 | backgroundColor: isDarkMode ? "rgba(10,10,10,0.97)" : "rgba(255,255,255,0.97)", | 28 | backgroundColor: headerColor, |
| 26 | borderBottomWidth: 1, | 29 | borderBottomWidth: 1, |
| 27 | borderBottomColor: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)", | 30 | borderBottomColor: headerBorderColor, |
| 28 | } as const; | 31 | } as const; |
| 29 | 32 | ||
| 30 | return ( | 33 | return ( |
| ... | @@ -32,7 +35,7 @@ export function GlassSafeAreaView({ | ... | @@ -32,7 +35,7 @@ export function GlassSafeAreaView({ |
| 32 | <GlassSurface | 35 | <GlassSurface |
| 33 | chrome="none" | 36 | chrome="none" |
| 34 | glassEffectStyle="regular" | 37 | glassEffectStyle="regular" |
| 35 | tintColor={isDarkMode ? "rgba(15,23,42,0.24)" : "rgba(255,255,255,0.18)"} | 38 | tintColor={glassTint} |
| 36 | style={{ borderRadius: 0, backgroundColor: "transparent" }} | 39 | style={{ borderRadius: 0, backgroundColor: "transparent" }} |
| 37 | > | 40 | > |
| 38 | <View | 41 | <View |
apps/mobile/src/components/GlassSurface.tsx+17-16| ... | @@ -1,11 +1,19 @@ | ... | @@ -1,11 +1,19 @@ |
| 1 | import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; | 1 | import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; |
| 2 | import type { ReactNode } from "react"; | 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 | export interface GlassSurfaceProps extends ViewProps { | 13 | export interface GlassSurfaceProps extends ViewProps { |
| 6 | readonly children: ReactNode; | 14 | readonly children: ReactNode; |
| 7 | readonly glassEffectStyle?: "clear" | "regular" | "none"; | 15 | readonly glassEffectStyle?: "clear" | "regular" | "none"; |
| 8 | readonly tintColor?: string; | 16 | readonly tintColor?: ColorValue; |
| 9 | readonly chrome?: "default" | "none"; | 17 | readonly chrome?: "default" | "none"; |
| 10 | } | 18 | } |
| 11 | 19 | ||
| ... | @@ -18,24 +26,17 @@ export function GlassSurface({ | ... | @@ -18,24 +26,17 @@ export function GlassSurface({ |
| 18 | ...props | 26 | ...props |
| 19 | }: GlassSurfaceProps) { | 27 | }: GlassSurfaceProps) { |
| 20 | const isDarkMode = useColorScheme() === "dark"; | 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 | const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); | 32 | const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); |
| 22 | const surfaceStyle: ViewStyle = { | 33 | const surfaceStyle: ViewStyle = { |
| 23 | borderRadius: 32, | 34 | borderRadius: 32, |
| 24 | overflow: "hidden", | 35 | overflow: "hidden", |
| 25 | borderWidth: chrome === "none" ? 0 : 1, | 36 | borderWidth: chrome === "none" ? 0 : 1, |
| 26 | borderColor: | 37 | borderColor: chrome === "none" ? "transparent" : borderColor, |
| 27 | chrome === "none" | 38 | backgroundColor: chrome === "none" ? "transparent" : glassSurface, |
| 28 | ? "transparent" | 39 | shadowColor: chrome === "none" ? "transparent" : "#000000", |
| 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", | ||
| 39 | shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08, | 40 | shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08, |
| 40 | shadowRadius: chrome === "none" ? 0 : 28, | 41 | shadowRadius: chrome === "none" ? 0 : 28, |
| 41 | shadowOffset: | 42 | shadowOffset: |
| ... | @@ -56,7 +57,7 @@ export function GlassSurface({ | ... | @@ -56,7 +57,7 @@ export function GlassSurface({ |
| 56 | <GlassView | 57 | <GlassView |
| 57 | {...props} | 58 | {...props} |
| 58 | glassEffectStyle={glassEffectStyle} | 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 | colorScheme={isDarkMode ? "dark" : "light"} | 61 | colorScheme={isDarkMode ? "dark" : "light"} |
| 61 | style={[surfaceStyle, style]} | 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,7 +24,7 @@ export function ProviderIcon(props: ProviderIconProps) { |
| 24 | return ( | 24 | return ( |
| 25 | <Svg width={size} height={size} viewBox="0 0 256 260" fill="none"> | 25 | <Svg width={size} height={size} viewBox="0 0 256 260" fill="none"> |
| 26 | <Path | 26 | <Path |
| 27 | fill={isDarkMode ? "#e2e8f0" : "#171717"} | 27 | fill={isDarkMode ? "#e5e5e5" : "#171717"} |
| 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" | 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 | </Svg> | 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,8 +146,8 @@ function ProjectGroupLabel(props: { |
| 146 | bearerToken={props.bearerToken} | 146 | bearerToken={props.bearerToken} |
| 147 | /> | 147 | /> |
| 148 | <Text | 148 | <Text |
| 149 | className="flex-1 text-[13px] font-t3-bold uppercase text-foreground-muted" | 149 | className="flex-1 text-[12px] font-t3-medium uppercase text-foreground-muted" |
| 150 | style={{ letterSpacing: 0.6 }} | 150 | style={{ letterSpacing: 0.5 }} |
| 151 | numberOfLines={1} | 151 | numberOfLines={1} |
| 152 | > | 152 | > |
| 153 | {props.project.title} | 153 | {props.project.title} |
| ... | @@ -156,8 +156,8 @@ function ProjectGroupLabel(props: { | ... | @@ -156,8 +156,8 @@ function ProjectGroupLabel(props: { |
| 156 | {hiddenCount > 0 ? ( | 156 | {hiddenCount > 0 ? ( |
| 157 | <Pressable onPress={props.onToggleExpand} hitSlop={8}> | 157 | <Pressable onPress={props.onToggleExpand} hitSlop={8}> |
| 158 | <Text | 158 | <Text |
| 159 | className="text-[13px] font-t3-bold text-foreground-muted" | 159 | className="text-[12px] font-t3-medium text-foreground-muted" |
| 160 | style={{ letterSpacing: 0.6 }} | 160 | style={{ letterSpacing: 0.4 }} |
| 161 | > | 161 | > |
| 162 | {props.isExpanded ? "Show less" : `${hiddenCount} more`} | 162 | {props.isExpanded ? "Show less" : `${hiddenCount} more`} |
| 163 | </Text> | 163 | </Text> |
| ... | @@ -191,6 +191,7 @@ function ThreadRow(props: { | ... | @@ -191,6 +191,7 @@ function ThreadRow(props: { |
| 191 | readonly isLast: boolean; | 191 | readonly isLast: boolean; |
| 192 | }) { | 192 | }) { |
| 193 | const separatorColor = useThemeColor("--color-separator"); | 193 | const separatorColor = useThemeColor("--color-separator"); |
| 194 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); | ||
| 194 | const { bg, fg } = statusColors(props.thread); | 195 | const { bg, fg } = statusColors(props.thread); |
| 195 | const tone = threadStatusTone(props.thread); | 196 | const tone = threadStatusTone(props.thread); |
| 196 | const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt); | 197 | const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt); |
| ... | @@ -267,7 +268,7 @@ function ThreadRow(props: { | ... | @@ -267,7 +268,7 @@ function ThreadRow(props: { |
| 267 | <SymbolView | 268 | <SymbolView |
| 268 | name="arrow.triangle.branch" | 269 | name="arrow.triangle.branch" |
| 269 | size={10} | 270 | size={10} |
| 270 | tintColor="#737373" | 271 | tintColor={iconSubtleColor} |
| 271 | type="monochrome" | 272 | type="monochrome" |
| 272 | /> | 273 | /> |
| 273 | <Text | 274 | <Text |
apps/mobile/src/features/review/shikiReviewHighlighter.test.ts+20-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import type { ReviewRenderableFile } from "./reviewModel"; | 3 | import type { ReviewRenderableFile } from "./reviewModel"; |
| 4 | import { highlightReviewFile } from "./shikiReviewHighlighter"; | 4 | import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter"; |
| 5 | 5 | ||
| 6 | function makeRenderableFile( | 6 | function makeRenderableFile( |
| 7 | input: Partial<ReviewRenderableFile> & Pick<ReviewRenderableFile, "path">, | 7 | input: Partial<ReviewRenderableFile> & Pick<ReviewRenderableFile, "path">, |
| ... | @@ -119,3 +119,22 @@ describe("highlightReviewFile", () => { | ... | @@ -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,6 +685,16 @@ async function highlightLines( |
| 685 | return highlightedLines; | 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 | async function highlightPatchLinesInChunks(input: { | 698 | async function highlightPatchLinesInChunks(input: { |
| 689 | readonly lines: ReadonlyArray<string>; | 699 | readonly lines: ReadonlyArray<string>; |
| 690 | readonly language: string; | 700 | readonly language: string; |
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+8-3| ... | @@ -6,6 +6,7 @@ import { memo } from "react"; | ... | @@ -6,6 +6,7 @@ import { memo } from "react"; |
| 6 | import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; | 6 | import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; |
| 7 | 7 | ||
| 8 | import { AppText as Text } from "../../components/AppText"; | 8 | import { AppText as Text } from "../../components/AppText"; |
| 9 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; | ||
| 9 | 10 | ||
| 10 | export type ComposerCommandItem = | 11 | export type ComposerCommandItem = |
| 11 | | { | 12 | | { |
| ... | @@ -88,13 +89,13 @@ function PopoverSurface(props: { | ... | @@ -88,13 +89,13 @@ function PopoverSurface(props: { |
| 88 | 89 | ||
| 89 | function itemIcon(item: ComposerCommandItem) { | 90 | function itemIcon(item: ComposerCommandItem) { |
| 90 | switch (item.type) { | 91 | switch (item.type) { |
| 91 | case "path": | ||
| 92 | return item.kind === "directory" ? ("folder" as const) : ("doc" as const); | ||
| 93 | case "slash-command": | 92 | case "slash-command": |
| 94 | case "provider-slash-command": | 93 | case "provider-slash-command": |
| 95 | return "terminal" as const; | 94 | return "terminal" as const; |
| 96 | case "skill": | 95 | case "skill": |
| 97 | return "cube" as const; | 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,7 +150,11 @@ const CommandRow = memo(function CommandRow(props: { |
| 149 | borderBottomColor: "rgba(255,255,255,0.1)", | 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 | <Text | 158 | <Text |
| 154 | className="text-[14px] font-t3-medium text-foreground" | 159 | className="text-[14px] font-t3-medium text-foreground" |
| 155 | numberOfLines={1} | 160 | numberOfLines={1} |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+15-30| ... | @@ -1,19 +1,13 @@ | ... | @@ -1,19 +1,13 @@ |
| 1 | import { Stack, useRouter } from "expo-router"; | 1 | import { Stack, useRouter } from "expo-router"; |
| 2 | import { TextInputWrapper } from "expo-paste-input"; | ||
| 3 | import { useCallback, useEffect, useMemo, useRef } from "react"; | 2 | import { useCallback, useEffect, useMemo, useRef } from "react"; |
| 4 | import { | 3 | import { InteractionManager, View, useColorScheme } from "react-native"; |
| 5 | InteractionManager, | ||
| 6 | View, | ||
| 7 | useColorScheme, | ||
| 8 | type TextInput as RNTextInput, | ||
| 9 | } from "react-native"; | ||
| 10 | import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; | 4 | import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; |
| 11 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 12 | import { useThemeColor } from "../../lib/useThemeColor"; | 6 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 13 | 7 | ||
| 14 | import { EnvironmentId, type ModelSelection } from "@t3tools/contracts"; | 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 | import { | 11 | import { |
| 18 | ComposerToolbarButton, | 12 | ComposerToolbarButton, |
| 19 | ComposerToolbarRow, | 13 | ComposerToolbarRow, |
| ... | @@ -27,7 +21,6 @@ import { ProviderIcon } from "../../components/ProviderIcon"; | ... | @@ -27,7 +21,6 @@ import { ProviderIcon } from "../../components/ProviderIcon"; |
| 27 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; | 21 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; |
| 28 | import { buildThreadRoutePath } from "../../lib/routes"; | 22 | import { buildThreadRoutePath } from "../../lib/routes"; |
| 29 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | 23 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| 30 | import { useNativePaste } from "../../lib/useNativePaste"; | ||
| 31 | import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions"; | 24 | import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions"; |
| 32 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; | 25 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| 33 | import { useProjectActions } from "./use-project-actions"; | 26 | import { useProjectActions } from "./use-project-actions"; |
| ... | @@ -75,7 +68,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -75,7 +68,7 @@ export function NewTaskDraftScreen(props: { |
| 75 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); | 68 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); |
| 76 | const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); | 69 | const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); |
| 77 | const { logicalProjects, selectedProject, setProject } = flow; | 70 | const { logicalProjects, selectedProject, setProject } = flow; |
| 78 | const promptInputRef = useRef<RNTextInput>(null); | 71 | const promptInputRef = useRef<ComposerEditorHandle>(null); |
| 79 | 72 | ||
| 80 | const borderColor = useThemeColor("--color-border"); | 73 | const borderColor = useThemeColor("--color-border"); |
| 81 | const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; | 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,10 +403,6 @@ export function NewTaskDraftScreen(props: { |
| 410 | [flow], | 403 | [flow], |
| 411 | ); | 404 | ); |
| 412 | 405 | ||
| 413 | const handleNativePaste = useNativePaste((uris) => { | ||
| 414 | void handleNativePasteImages(uris); | ||
| 415 | }); | ||
| 416 | |||
| 417 | async function handleStart(): Promise<void> { | 406 | async function handleStart(): Promise<void> { |
| 418 | if ( | 407 | if ( |
| 419 | !flow.selectedProject || | 408 | !flow.selectedProject || |
| ... | @@ -478,23 +467,19 @@ export function NewTaskDraftScreen(props: { | ... | @@ -478,23 +467,19 @@ export function NewTaskDraftScreen(props: { |
| 478 | 467 | ||
| 479 | <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}> | 468 | <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}> |
| 480 | <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}> | 469 | <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}> |
| 481 | <TextInputWrapper | 470 | <ComposerEditor |
| 482 | onPaste={(payload) => void handleNativePaste(payload)} | 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 | style={{ flex: 1, minHeight: 0 }} | 480 | style={{ flex: 1, minHeight: 0 }} |
| 484 | > | 481 | textStyle={{ fontSize: 18, lineHeight: 28 }} |
| 485 | <TextInput | 482 | /> |
| 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> | ||
| 498 | </View> | 483 | </View> |
| 499 | 484 | ||
| 500 | <View | 485 | <View |
apps/mobile/src/features/threads/ThreadComposer.tsx+82-85| ... | @@ -13,24 +13,19 @@ import { | ... | @@ -13,24 +13,19 @@ import { |
| 13 | serializeComposerFileLink, | 13 | serializeComposerFileLink, |
| 14 | type ComposerTrigger, | 14 | type ComposerTrigger, |
| 15 | } from "@t3tools/shared/composerTrigger"; | 15 | } from "@t3tools/shared/composerTrigger"; |
| 16 | import { TextInputWrapper } from "expo-paste-input"; | ||
| 17 | import type { ReactNode } from "react"; | 16 | import type { ReactNode } from "react"; |
| 18 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 17 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; |
| 19 | import { | 18 | import { Image, Pressable, useColorScheme, View, type ViewStyle } from "react-native"; |
| 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"; | ||
| 29 | import ImageViewing from "react-native-image-viewing"; | 19 | import ImageViewing from "react-native-image-viewing"; |
| 30 | import { useThemeColor } from "../../lib/useThemeColor"; | 20 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 31 | 21 | ||
| 32 | import { AppText as Text } from "../../components/AppText"; | 22 | import { AppText as Text } from "../../components/AppText"; |
| 33 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | 23 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 24 | import { | ||
| 25 | ComposerEditor, | ||
| 26 | type ComposerEditorHandle, | ||
| 27 | type ComposerEditorSelection, | ||
| 28 | } from "../../components/ComposerEditor"; | ||
| 34 | import { | 29 | import { |
| 35 | ComposerToolbarButton, | 30 | ComposerToolbarButton, |
| 36 | ComposerToolbarRow, | 31 | ComposerToolbarRow, |
| ... | @@ -42,7 +37,6 @@ import { ProviderIcon } from "../../components/ProviderIcon"; | ... | @@ -42,7 +37,6 @@ import { ProviderIcon } from "../../components/ProviderIcon"; |
| 42 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 37 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 43 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | 38 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 44 | import type { RemoteClientConnectionState } from "../../lib/connection"; | 39 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| 45 | import { useNativePaste } from "../../lib/useNativePaste"; | ||
| 46 | import { | 40 | import { |
| 47 | insertRankedSearchResult, | 41 | insertRankedSearchResult, |
| 48 | normalizeSearchQuery, | 42 | normalizeSearchQuery, |
| ... | @@ -60,7 +54,7 @@ import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerComm | ... | @@ -60,7 +54,7 @@ import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerComm |
| 60 | * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). | 54 | * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). |
| 61 | * Exported so the parent can compute feed overlap / content insets. | 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 | * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). | 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,13 +62,6 @@ export const COMPOSER_COLLAPSED_CHROME = 68; |
| 68 | */ | 62 | */ |
| 69 | export const COMPOSER_EXPANDED_CHROME = 174; | 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 | export interface ThreadComposerProps { | 65 | export interface ThreadComposerProps { |
| 79 | readonly draftMessage: string; | 66 | readonly draftMessage: string; |
| 80 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 67 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| ... | @@ -87,6 +74,7 @@ export interface ThreadComposerProps { | ... | @@ -87,6 +74,7 @@ export interface ThreadComposerProps { |
| 87 | readonly activeThreadBusy: boolean; | 74 | readonly activeThreadBusy: boolean; |
| 88 | readonly environmentId: EnvironmentId; | 75 | readonly environmentId: EnvironmentId; |
| 89 | readonly projectCwd: string | null; | 76 | readonly projectCwd: string | null; |
| 77 | readonly editorRef?: RefObject<ComposerEditorHandle | null>; | ||
| 90 | readonly onChangeDraftMessage: (value: string) => void; | 78 | readonly onChangeDraftMessage: (value: string) => void; |
| 91 | readonly onPickDraftImages: () => Promise<void>; | 79 | readonly onPickDraftImages: () => Promise<void>; |
| 92 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | 80 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| ... | @@ -156,10 +144,9 @@ function formatTitleCase(value: string): string { | ... | @@ -156,10 +144,9 @@ function formatTitleCase(value: string): string { |
| 156 | 144 | ||
| 157 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { | 145 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { |
| 158 | const isDarkMode = useColorScheme() === "dark"; | 146 | const isDarkMode = useColorScheme() === "dark"; |
| 159 | const themePlaceholderColor = useThemeColor("--color-placeholder"); | ||
| 160 | const placeholderColor = isDarkMode ? "#a1a1aa" : themePlaceholderColor; | ||
| 161 | const foregroundColor = useThemeColor("--color-foreground"); | 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 | const [isFocused, setIsFocused] = useState(false); | 150 | const [isFocused, setIsFocused] = useState(false); |
| 164 | const wasExpandedBeforePreviewRef = useRef(false); | 151 | const wasExpandedBeforePreviewRef = useRef(false); |
| 165 | const { onExpandedChange } = props; | 152 | const { onExpandedChange } = props; |
| ... | @@ -182,11 +169,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -182,11 +169,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 182 | if (wasExpandedBeforePreviewRef.current) { | 169 | if (wasExpandedBeforePreviewRef.current) { |
| 183 | setTimeout(() => inputRef.current?.focus(), 100); | 170 | setTimeout(() => inputRef.current?.focus(), 100); |
| 184 | } | 171 | } |
| 185 | }, []); | 172 | }, [inputRef]); |
| 186 | 173 | ||
| 187 | useEffect(() => { | 174 | const handleFocus = useCallback(() => { |
| 188 | onExpandedChange?.(isExpanded); | 175 | setIsFocused(true); |
| 189 | }, [isExpanded, onExpandedChange]); | 176 | onExpandedChange?.(true); |
| 177 | }, [onExpandedChange]); | ||
| 178 | |||
| 179 | const handleBlur = useCallback(() => { | ||
| 180 | setIsFocused(false); | ||
| 181 | onExpandedChange?.(false); | ||
| 182 | }, [onExpandedChange]); | ||
| 190 | const showStopAction = | 183 | const showStopAction = |
| 191 | props.selectedThread.session?.status === "running" || | 184 | props.selectedThread.session?.status === "running" || |
| 192 | props.selectedThread.session?.status === "starting" || | 185 | props.selectedThread.session?.status === "starting" || |
| ... | @@ -219,26 +212,33 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -219,26 +212,33 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 219 | selectedProviderDriver === "claudeAgent" | 212 | selectedProviderDriver === "claudeAgent" |
| 220 | ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M") | 213 | ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M") |
| 221 | : "1M"; | 214 | : "1M"; |
| 222 | |||
| 223 | const handleNativePaste = useNativePaste((uris) => { | ||
| 224 | void props.onNativePasteImages(uris); | ||
| 225 | }); | ||
| 226 | |||
| 227 | // ── Trigger detection ──────────────────────────────────── | 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( | 221 | const handleSelectionChange = useCallback((selection: ComposerEditorSelection) => { |
| 231 | (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { | 222 | setComposerSelection(selection); |
| 232 | const { start } = event.nativeEvent.selection; | 223 | }, []); |
| 233 | setCursorPosition(start); | 224 | useEffect(() => { |
| 234 | }, | 225 | const end = props.draftMessage.length; |
| 235 | [], | 226 | setComposerSelection((selection) => { |
| 236 | ); | 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>( | 236 | const composerTrigger = useMemo<ComposerTrigger | null>(() => { |
| 239 | () => detectComposerTrigger(props.draftMessage, cursorPosition), | 237 | if (composerSelection.start !== composerSelection.end) { |
| 240 | [cursorPosition, props.draftMessage], | 238 | return null; |
| 241 | ); | 239 | } |
| 240 | return detectComposerTrigger(props.draftMessage, composerSelection.end); | ||
| 241 | }, [composerSelection, props.draftMessage]); | ||
| 242 | const pathSearch = useComposerPathSearch({ | 242 | const pathSearch = useComposerPathSearch({ |
| 243 | environmentId: props.environmentId, | 243 | environmentId: props.environmentId, |
| 244 | cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, | 244 | cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, |
| ... | @@ -411,7 +411,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -411,7 +411,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 411 | composerTrigger.rangeEnd, | 411 | composerTrigger.rangeEnd, |
| 412 | "", | 412 | "", |
| 413 | ); | 413 | ); |
| 414 | setCursorPosition(result.cursor); | 414 | setComposerSelection({ start: result.cursor, end: result.cursor }); |
| 415 | onChangeDraftMessage(result.text); | 415 | onChangeDraftMessage(result.text); |
| 416 | void onUpdateInteractionMode(item.command); | 416 | void onUpdateInteractionMode(item.command); |
| 417 | return; | 417 | return; |
| ... | @@ -434,7 +434,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -434,7 +434,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 434 | composerTrigger.rangeEnd, | 434 | composerTrigger.rangeEnd, |
| 435 | replacement, | 435 | replacement, |
| 436 | ); | 436 | ); |
| 437 | setCursorPosition(result.cursor); | 437 | setComposerSelection({ start: result.cursor, end: result.cursor }); |
| 438 | onChangeDraftMessage(result.text); | 438 | onChangeDraftMessage(result.text); |
| 439 | }, | 439 | }, |
| 440 | [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], | 440 | [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], |
| ... | @@ -624,8 +624,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -624,8 +624,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 624 | <View | 624 | <View |
| 625 | style={{ | 625 | style={{ |
| 626 | paddingHorizontal: 16, | 626 | paddingHorizontal: 16, |
| 627 | paddingTop: isExpanded ? 12 : 10, | 627 | paddingTop: isExpanded ? 8 : 6, |
| 628 | paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 4 : 10), | 628 | paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6), |
| 629 | experimental_backgroundImage: isDarkMode | 629 | experimental_backgroundImage: isDarkMode |
| 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%)" | 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 | : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)", | 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,42 +685,39 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 685 | ) : null} | 685 | ) : null} |
| 686 | 686 | ||
| 687 | <View style={isExpanded ? undefined : { flex: 1, minWidth: 0 }}> | 687 | <View style={isExpanded ? undefined : { flex: 1, minWidth: 0 }}> |
| 688 | <TextInputWrapper onPaste={handleNativePaste}> | 688 | <ComposerEditor |
| 689 | <RNTextInput | 689 | ref={inputRef} |
| 690 | ref={inputRef} | 690 | multiline |
| 691 | multiline | 691 | value={props.draftMessage} |
| 692 | value={props.draftMessage} | 692 | skills={selectedProviderStatus?.skills ?? []} |
| 693 | onChangeText={props.onChangeDraftMessage} | 693 | selection={composerSelection} |
| 694 | onSelectionChange={handleSelectionChange} | 694 | onChangeText={props.onChangeDraftMessage} |
| 695 | placeholder={props.placeholder} | 695 | onSelectionChange={handleSelectionChange} |
| 696 | placeholderTextColor={placeholderColor} | 696 | onPasteImages={(uris) => void props.onNativePasteImages(uris)} |
| 697 | editable={props.connectionState === "ready"} | 697 | placeholder={props.placeholder} |
| 698 | onFocus={() => setIsFocused(true)} | 698 | onFocus={handleFocus} |
| 699 | onBlur={() => setIsFocused(false)} | 699 | onBlur={handleBlur} |
| 700 | textAlignVertical={isExpanded ? "top" : "center"} | 700 | scrollEnabled={isExpanded} |
| 701 | style={ | 701 | contentInsetVertical={isExpanded ? 0 : 6} |
| 702 | isExpanded | 702 | style={ |
| 703 | ? { | 703 | isExpanded |
| 704 | minHeight: 80, | 704 | ? { |
| 705 | maxHeight: 160, | 705 | minHeight: 80, |
| 706 | paddingHorizontal: 4, | 706 | maxHeight: 160, |
| 707 | paddingVertical: 4, | 707 | paddingHorizontal: 4, |
| 708 | fontSize: 15, | 708 | paddingVertical: 4, |
| 709 | lineHeight: 22, | 709 | } |
| 710 | color: foregroundColor, | 710 | : { |
| 711 | fontFamily: "DMSans_400Regular", | 711 | height: 36, |
| 712 | } | 712 | } |
| 713 | : { | 713 | } |
| 714 | maxHeight: 36, | 714 | textStyle={{ |
| 715 | paddingVertical: 6, | 715 | fontSize: 15, |
| 716 | fontSize: 15, | 716 | lineHeight: isExpanded ? 22 : 20, |
| 717 | lineHeight: 20, | 717 | color: foregroundColor, |
| 718 | color: foregroundColor, | 718 | fontFamily: "DMSans_400Regular", |
| 719 | fontFamily: "DMSans_400Regular", | 719 | }} |
| 720 | } | 720 | /> |
| 721 | } | ||
| 722 | /> | ||
| 723 | </TextInputWrapper> | ||
| 724 | </View> | 721 | </View> |
| 725 | {!isExpanded && props.draftAttachments.length > 0 ? ( | 722 | {!isExpanded && props.draftAttachments.length > 0 ? ( |
| 726 | <View style={{ flexDirection: "row", gap: 4, paddingLeft: 4 }}> | 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,17 +11,20 @@ import type { |
| 11 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; | 12 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 13 | import * as Haptics from "expo-haptics"; | 13 | import * as Haptics from "expo-haptics"; |
| 14 | import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; | ||
| 14 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 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 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | 17 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 17 | import { KeyboardStickyView } from "react-native-keyboard-controller"; | 18 | import { KeyboardStickyView } from "react-native-keyboard-controller"; |
| 18 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 19 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 19 | import { runOnJS } from "react-native-reanimated"; | 20 | import { runOnJS } from "react-native-reanimated"; |
| 20 | 21 | ||
| 21 | import { AppText as Text } from "../../components/AppText"; | 22 | import { AppText as Text } from "../../components/AppText"; |
| 23 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | ||
| 22 | import type { StatusTone } from "../../components/StatusPill"; | 24 | import type { StatusTone } from "../../components/StatusPill"; |
| 23 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 25 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 24 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; | 26 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; |
| 27 | import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout"; | ||
| 25 | import type { | 28 | import type { |
| 26 | PendingApproval, | 29 | PendingApproval, |
| 27 | PendingUserInput, | 30 | PendingUserInput, |
| ... | @@ -33,7 +36,6 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; | ... | @@ -33,7 +36,6 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; |
| 33 | import { | 36 | import { |
| 34 | COMPOSER_COLLAPSED_CHROME, | 37 | COMPOSER_COLLAPSED_CHROME, |
| 35 | COMPOSER_EXPANDED_CHROME, | 38 | COMPOSER_EXPANDED_CHROME, |
| 36 | COMPOSER_EXPANDED_TOOLBAR_CHROME, | ||
| 37 | ThreadComposer, | 39 | ThreadComposer, |
| 38 | } from "./ThreadComposer"; | 40 | } from "./ThreadComposer"; |
| 39 | import { ThreadFeed } from "./ThreadFeed"; | 41 | import { ThreadFeed } from "./ThreadFeed"; |
| ... | @@ -200,7 +202,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -200,7 +202,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 200 | const { onOpenDrawer } = props; | 202 | const { onOpenDrawer } = props; |
| 201 | 203 | ||
| 202 | const insets = useSafeAreaInsets(); | 204 | const insets = useSafeAreaInsets(); |
| 205 | const headerHeight = useHeaderHeight(); | ||
| 203 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; | 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 | const [composerExpanded, setComposerExpanded] = useState(false); | 209 | const [composerExpanded, setComposerExpanded] = useState(false); |
| 205 | const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); | 210 | const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); |
| 206 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; | 211 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| ... | @@ -211,10 +216,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -211,10 +216,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 211 | const showContent = props.showContent ?? true; | 216 | const showContent = props.showContent ?? true; |
| 212 | const layoutVariant = props.layoutVariant ?? "compact"; | 217 | const layoutVariant = props.layoutVariant ?? "compact"; |
| 213 | const isSplitLayout = layoutVariant === "split"; | 218 | const isSplitLayout = layoutVariant === "split"; |
| 219 | const selectedInstanceId = props.selectedThread.modelSelection.instanceId; | ||
| 214 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); | 220 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); |
| 215 | const expandedToolbarInset = composerExpanded ? COMPOSER_EXPANDED_TOOLBAR_CHROME : 0; | 221 | const feedBottomInset = resolveThreadFeedBottomInset({ |
| 216 | const feedBottomInset = | 222 | estimatedOverlayHeight, |
| 217 | Math.max(estimatedOverlayHeight, measuredOverlayHeight) + expandedToolbarInset + 8; | 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 | const completeDrawerGesture = useCallback(() => { | 233 | const completeDrawerGesture = useCallback(() => { |
| 220 | void Haptics.selectionAsync(); | 234 | void Haptics.selectionAsync(); |
| ... | @@ -245,20 +259,66 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -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 | return ( | 296 | return ( |
| 249 | <GestureDetector gesture={headerDrawerGesture}> | 297 | <GestureDetector gesture={headerDrawerGesture}> |
| 250 | <View className="flex-1"> | 298 | <View className="flex-1"> |
| 251 | {showContent ? ( | 299 | {showContent ? ( |
| 252 | <ThreadFeed | 300 | <View |
| 253 | threadId={props.selectedThread.id} | 301 | style={{ flex: 1 }} |
| 254 | feed={props.selectedThreadFeed} | 302 | onTouchStart={handleFeedTouchStart} |
| 255 | httpBaseUrl={props.httpBaseUrl} | 303 | onTouchMove={handleFeedTouchMove} |
| 256 | bearerToken={props.bearerToken} | 304 | onTouchEnd={handleFeedTouchEnd} |
| 257 | agentLabel={agentLabel} | 305 | onTouchCancel={handleFeedTouchCancel} |
| 258 | contentBottomInset={feedBottomInset} | 306 | > |
| 259 | layoutVariant={layoutVariant} | 307 | <ThreadFeed |
| 260 | composerExpanded={composerExpanded} | 308 | key={props.selectedThread.id} |
| 261 | /> | 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 | <View style={{ flex: 1 }} /> | 323 | <View style={{ flex: 1 }} /> |
| 264 | )} | 324 | )} |
| ... | @@ -298,6 +358,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -298,6 +358,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 298 | ) : null} | 358 | ) : null} |
| 299 | 359 | ||
| 300 | <ThreadComposer | 360 | <ThreadComposer |
| 361 | editorRef={composerRef} | ||
| 301 | draftMessage={props.draftMessage} | 362 | draftMessage={props.draftMessage} |
| 302 | draftAttachments={props.draftAttachments} | 363 | draftAttachments={props.draftAttachments} |
| 303 | placeholder="Ask the repo agent, or run a command…" | 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,9 +2,9 @@ import * as Clipboard from "expo-clipboard"; |
| 2 | import * as Haptics from "expo-haptics"; | 2 | import * as Haptics from "expo-haptics"; |
| 3 | import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; | 3 | import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; |
| 4 | import { type LegendListRef } from "@legendapp/list/react-native"; | 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 | import { SymbolView } from "expo-symbols"; | 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 | import { | 8 | import { |
| 9 | Markdown, | 9 | Markdown, |
| 10 | type CustomRenderers, | 10 | type CustomRenderers, |
| ... | @@ -13,6 +13,9 @@ import { | ... | @@ -13,6 +13,9 @@ import { |
| 13 | } from "react-native-nitro-markdown"; | 13 | } from "react-native-nitro-markdown"; |
| 14 | import { | 14 | import { |
| 15 | Image, | 15 | Image, |
| 16 | Linking, | ||
| 17 | type NativeScrollEvent, | ||
| 18 | type NativeSyntheticEvent, | ||
| 16 | Pressable, | 19 | Pressable, |
| 17 | ScrollView, | 20 | ScrollView, |
| 18 | StyleSheet, | 21 | StyleSheet, |
| ... | @@ -26,9 +29,16 @@ import { TouchableOpacity } from "react-native-gesture-handler"; | ... | @@ -26,9 +29,16 @@ import { TouchableOpacity } from "react-native-gesture-handler"; |
| 26 | import ImageViewing from "react-native-image-viewing"; | 29 | import ImageViewing from "react-native-image-viewing"; |
| 27 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 30 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 28 | import { useThemeColor } from "../../lib/useThemeColor"; | 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 | import { AppText as Text } from "../../components/AppText"; | 39 | import { AppText as Text } from "../../components/AppText"; |
| 31 | import { EmptyState } from "../../components/EmptyState"; | 40 | import { EmptyState } from "../../components/EmptyState"; |
| 41 | import { CopyTextButton } from "../../components/CopyTextButton"; | ||
| 32 | import { | 42 | import { |
| 33 | parseReviewCommentMessageSegments, | 43 | parseReviewCommentMessageSegments, |
| 34 | type ReviewInlineComment, | 44 | type ReviewInlineComment, |
| ... | @@ -44,19 +54,43 @@ import { | ... | @@ -44,19 +54,43 @@ import { |
| 44 | import { buildReviewParsedDiff } from "../review/reviewModel"; | 54 | import { buildReviewParsedDiff } from "../review/reviewModel"; |
| 45 | import { cn } from "../../lib/cn"; | 55 | import { cn } from "../../lib/cn"; |
| 46 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; | 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 | import { relativeTime } from "../../lib/time"; | 65 | import { relativeTime } from "../../lib/time"; |
| 49 | import { messageImageUrl } from "./threadPresentation"; | 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 | export interface ThreadFeedProps { | 82 | export interface ThreadFeedProps { |
| 52 | readonly threadId: ThreadId; | 83 | readonly threadId: ThreadId; |
| 53 | readonly feed: ReadonlyArray<ThreadFeedEntry>; | 84 | readonly feed: ReadonlyArray<ThreadFeedEntry>; |
| 54 | readonly httpBaseUrl: string | null; | 85 | readonly httpBaseUrl: string | null; |
| 55 | readonly bearerToken: string | null; | 86 | readonly bearerToken: string | null; |
| 56 | readonly agentLabel: string; | 87 | readonly agentLabel: string; |
| 88 | readonly latestTurn: ThreadFeedLatestTurn | null; | ||
| 89 | readonly contentTopInset?: number; | ||
| 57 | readonly contentBottomInset?: number; | 90 | readonly contentBottomInset?: number; |
| 58 | readonly layoutVariant?: MobileLayoutVariant; | 91 | readonly layoutVariant?: MobileLayoutVariant; |
| 59 | readonly composerExpanded?: boolean; | 92 | readonly composerExpanded?: boolean; |
| 93 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; | ||
| 60 | } | 94 | } |
| 61 | 95 | ||
| 62 | function stripShellWrapper(value: string): string { | 96 | function stripShellWrapper(value: string): string { |
| ... | @@ -75,34 +109,48 @@ function compactActivityDetail(detail: string | null): string | null { | ... | @@ -75,34 +109,48 @@ function compactActivityDetail(detail: string | null): string | null { |
| 75 | } | 109 | } |
| 76 | 110 | ||
| 77 | function buildActivityRows( | 111 | function buildActivityRows( |
| 78 | activities: ReadonlyArray<{ | 112 | activities: Extract<ThreadFeedEntry, { type: "activity-group" }>["activities"], |
| 79 | readonly id: string; | ||
| 80 | readonly createdAt: string; | ||
| 81 | readonly summary: string; | ||
| 82 | readonly detail: string | null; | ||
| 83 | readonly status: string | null; | ||
| 84 | }>, | ||
| 85 | ) { | 113 | ) { |
| 86 | return activities.map<{ | 114 | return activities.map((activity) => ({ |
| 87 | id: string; | 115 | ...activity, |
| 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, | ||
| 96 | detail: compactActivityDetail(activity.detail), | 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 { | 122 | const MARKDOWN_COLORS = { |
| 104 | return value as string; | 123 | light: { |
| 105 | } | 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 | interface MarkdownStyleSets { | 155 | interface MarkdownStyleSets { |
| 108 | readonly user: MarkdownStyleSet; | 156 | readonly user: MarkdownStyleSet; |
| ... | @@ -113,6 +161,7 @@ interface MarkdownStyleSet { | ... | @@ -113,6 +161,7 @@ interface MarkdownStyleSet { |
| 113 | readonly theme: PartialMarkdownTheme; | 161 | readonly theme: PartialMarkdownTheme; |
| 114 | readonly styles: NodeStyleOverrides; | 162 | readonly styles: NodeStyleOverrides; |
| 115 | readonly renderers: CustomRenderers; | 163 | readonly renderers: CustomRenderers; |
| 164 | readonly nativeTextStyle: NativeMarkdownTextStyle; | ||
| 116 | } | 165 | } |
| 117 | 166 | ||
| 118 | interface ReviewCommentColors { | 167 | interface ReviewCommentColors { |
| ... | @@ -124,6 +173,70 @@ interface ReviewCommentColors { | ... | @@ -124,6 +173,70 @@ interface ReviewCommentColors { |
| 124 | readonly codeBackground: ColorValue; | 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 | function useReviewCommentColors(): ReviewCommentColors { | 240 | function useReviewCommentColors(): ReviewCommentColors { |
| 128 | const colorScheme = useColorScheme(); | 241 | const colorScheme = useColorScheme(); |
| 129 | const isDark = colorScheme === "dark"; | 242 | const isDark = colorScheme === "dark"; |
| ... | @@ -148,34 +261,26 @@ function useReviewCommentColors(): ReviewCommentColors { | ... | @@ -148,34 +261,26 @@ function useReviewCommentColors(): ReviewCommentColors { |
| 148 | } | 261 | } |
| 149 | 262 | ||
| 150 | function useMarkdownStyles(): MarkdownStyleSets { | 263 | function useMarkdownStyles(): MarkdownStyleSets { |
| 151 | const bodyColor = useThemeColor("--color-md-body"); | 264 | const colorScheme = useColorScheme(); |
| 152 | const strongColor = useThemeColor("--color-md-strong"); | 265 | const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; |
| 153 | const linkColor = useThemeColor("--color-md-link"); | 266 | const inlineChipBackground = String(useThemeColor("--color-subtle")); |
| 154 | const blockquoteBg = useThemeColor("--color-md-blockquote-bg"); | 267 | const inlineSkillBackground = String(useThemeColor("--color-inline-skill-background")); |
| 155 | const blockquoteBorder = useThemeColor("--color-md-blockquote-border"); | 268 | const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); |
| 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"); | ||
| 164 | 269 | ||
| 165 | return useMemo(() => { | 270 | return useMemo(() => { |
| 166 | const markdownBodyColor = toMarkdownThemeColor(bodyColor); | 271 | const markdownBodyColor = colors.body; |
| 167 | const markdownStrongColor = toMarkdownThemeColor(strongColor); | 272 | const markdownStrongColor = colors.strong; |
| 168 | const markdownLinkColor = toMarkdownThemeColor(linkColor); | 273 | const markdownLinkColor = colors.link; |
| 169 | const markdownBlockquoteBg = toMarkdownThemeColor(blockquoteBg); | 274 | const markdownBlockquoteBg = colors.blockquoteBackground; |
| 170 | const markdownBlockquoteBorder = toMarkdownThemeColor(blockquoteBorder); | 275 | const markdownBlockquoteBorder = colors.blockquoteBorder; |
| 171 | const markdownCodeBg = toMarkdownThemeColor(codeBg); | 276 | const markdownCodeBg = colors.codeBackground; |
| 172 | const markdownCodeText = toMarkdownThemeColor(codeText); | 277 | const markdownCodeText = colors.codeText; |
| 173 | const markdownHrColor = toMarkdownThemeColor(hrColor); | 278 | const markdownHrColor = colors.horizontalRule; |
| 174 | const markdownUserBodyColor = toMarkdownThemeColor(userBodyColor); | 279 | const markdownUserBodyColor = colors.userBody; |
| 175 | const markdownUserCodeBg = toMarkdownThemeColor(userCodeBg); | 280 | const markdownUserCodeBg = colors.userCodeBackground; |
| 176 | const markdownUserCodeText = toMarkdownThemeColor(userCodeText); | 281 | const markdownUserCodeText = colors.userCodeText; |
| 177 | const markdownUserFenceBg = toMarkdownThemeColor(userFenceBg); | 282 | const markdownUserFenceBg = colors.userFenceBackground; |
| 178 | const markdownUserFenceText = toMarkdownThemeColor(userFenceText); | 283 | const markdownUserFenceText = colors.userFenceText; |
| 179 | 284 | ||
| 180 | const baseTheme: PartialMarkdownTheme = { | 285 | const baseTheme: PartialMarkdownTheme = { |
| 181 | colors: { | 286 | colors: { |
| ... | @@ -202,12 +307,12 @@ function useMarkdownStyles(): MarkdownStyleSets { | ... | @@ -202,12 +307,12 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 202 | fontSizes: { | 307 | fontSizes: { |
| 203 | s: 13, | 308 | s: 13, |
| 204 | m: 15, | 309 | m: 15, |
| 205 | h1: 22, | 310 | h1: 20, |
| 206 | h2: 19, | 311 | h2: 18, |
| 207 | h3: 17, | 312 | h3: 16, |
| 208 | h4: 15, | 313 | h4: 14, |
| 209 | h5: 15, | 314 | h5: 14, |
| 210 | h6: 15, | 315 | h6: 14, |
| 211 | }, | 316 | }, |
| 212 | fontFamilies: { | 317 | fontFamilies: { |
| 213 | regular: "DMSans_400Regular", | 318 | regular: "DMSans_400Regular", |
| ... | @@ -225,8 +330,8 @@ function useMarkdownStyles(): MarkdownStyleSets { | ... | @@ -225,8 +330,8 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 225 | 330 | ||
| 226 | const baseStyles: NodeStyleOverrides = { | 331 | const baseStyles: NodeStyleOverrides = { |
| 227 | document: { flexShrink: 1 }, | 332 | document: { flexShrink: 1 }, |
| 228 | paragraph: { marginTop: 0, marginBottom: 8 }, | 333 | paragraph: { marginTop: 0, marginBottom: 10 }, |
| 229 | list: { marginTop: 4, marginBottom: 4 }, | 334 | list: { marginTop: 4, marginBottom: 8 }, |
| 230 | list_item: { marginTop: 0, marginBottom: 4 }, | 335 | list_item: { marginTop: 0, marginBottom: 4 }, |
| 231 | task_list_item: { marginTop: 0, marginBottom: 4 }, | 336 | task_list_item: { marginTop: 0, marginBottom: 4 }, |
| 232 | text: { lineHeight: 22 }, | 337 | text: { lineHeight: 22 }, |
| ... | @@ -241,20 +346,18 @@ function useMarkdownStyles(): MarkdownStyleSets { | ... | @@ -241,20 +346,18 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 241 | textDecorationLine: "underline" as const, | 346 | textDecorationLine: "underline" as const, |
| 242 | }, | 347 | }, |
| 243 | blockquote: { | 348 | blockquote: { |
| 244 | borderLeftWidth: 3, | 349 | borderLeftWidth: 2, |
| 245 | borderLeftColor: markdownBlockquoteBorder, | 350 | borderLeftColor: markdownBlockquoteBorder, |
| 246 | backgroundColor: markdownBlockquoteBg, | 351 | paddingLeft: 11, |
| 247 | paddingLeft: 12, | 352 | paddingVertical: 2, |
| 248 | paddingVertical: 6, | ||
| 249 | marginLeft: 0, | 353 | marginLeft: 0, |
| 250 | marginVertical: 4, | 354 | marginVertical: 10, |
| 251 | borderRadius: 4, | ||
| 252 | }, | 355 | }, |
| 253 | heading: { | 356 | heading: { |
| 254 | fontFamily: "DMSans_700Bold", | 357 | fontFamily: "DMSans_700Bold", |
| 255 | color: markdownStrongColor, | 358 | color: markdownStrongColor, |
| 256 | marginTop: 12, | 359 | marginTop: 18, |
| 257 | marginBottom: 6, | 360 | marginBottom: 8, |
| 258 | }, | 361 | }, |
| 259 | horizontal_rule: { | 362 | horizontal_rule: { |
| 260 | backgroundColor: markdownHrColor, | 363 | backgroundColor: markdownHrColor, |
| ... | @@ -263,44 +366,173 @@ function useMarkdownStyles(): MarkdownStyleSets { | ... | @@ -263,44 +366,173 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 263 | }, | 366 | }, |
| 264 | }; | 367 | }; |
| 265 | 368 | ||
| 266 | const createCodeRenderers = ( | 369 | const createMarkdownRenderers = ( |
| 267 | inlineBackgroundColor: string, | 370 | inlineBackgroundColor: string, |
| 268 | inlineTextColor: string, | 371 | inlineTextColor: string, |
| 269 | blockBackgroundColor: string, | 372 | blockBackgroundColor: string, |
| 270 | blockTextColor: string, | 373 | blockTextColor: string, |
| 271 | ): CustomRenderers => ({ | 374 | ): CustomRenderers => ({ |
| 272 | code_inline: ({ content }) => ( | 375 | link: ({ children, href = "" }) => { |
| 273 | <NativeText | 376 | const presentation = resolveMarkdownLinkPresentation(href); |
| 274 | style={{ | 377 | if (presentation.kind === "file") { |
| 275 | backgroundColor: inlineBackgroundColor, | 378 | return ( |
| 276 | color: inlineTextColor, | 379 | <NativeText |
| 277 | borderRadius: 5, | 380 | style={[ |
| 278 | paddingHorizontal: 5, | 381 | markdownLinkStyles.file, |
| 279 | paddingVertical: 1, | 382 | { |
| 280 | fontFamily: "ui-monospace", | 383 | backgroundColor: inlineBackgroundColor, |
| 281 | fontSize: 13, | 384 | borderColor: markdownHrColor, |
| 282 | }} | 385 | color: inlineTextColor, |
| 283 | > | 386 | }, |
| 284 | {content} | 387 | ]} |
| 285 | </NativeText> | 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 | <View | 491 | <View |
| 289 | style={{ | 492 | style={{ |
| 290 | backgroundColor: blockBackgroundColor, | 493 | backgroundColor: blockBackgroundColor, |
| 291 | borderRadius: 12, | 494 | borderRadius: 10, |
| 292 | padding: 12, | 495 | borderWidth: 1, |
| 293 | marginVertical: 8, | 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 | <NativeText | 529 | <NativeText |
| 298 | selectable | 530 | selectable |
| 299 | style={{ | 531 | style={{ |
| 300 | color: blockTextColor, | 532 | color: blockTextColor, |
| 301 | fontFamily: "ui-monospace", | 533 | fontFamily: "ui-monospace", |
| 302 | fontSize: 13, | 534 | fontSize: 12, |
| 303 | lineHeight: 19, | 535 | lineHeight: 18, |
| 304 | }} | 536 | }} |
| 305 | > | 537 | > |
| 306 | {content} | 538 | {content} |
| ... | @@ -333,6 +565,8 @@ function useMarkdownStyles(): MarkdownStyleSets { | ... | @@ -333,6 +565,8 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 333 | heading: { | 565 | heading: { |
| 334 | ...baseStyles.heading, | 566 | ...baseStyles.heading, |
| 335 | color: markdownUserBodyColor, | 567 | color: markdownUserBodyColor, |
| 568 | marginTop: 8, | ||
| 569 | marginBottom: 4, | ||
| 336 | }, | 570 | }, |
| 337 | link: { | 571 | link: { |
| 338 | color: markdownUserBodyColor, | 572 | color: markdownUserBodyColor, |
| ... | @@ -357,48 +591,79 @@ function useMarkdownStyles(): MarkdownStyleSets { | ... | @@ -357,48 +591,79 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 357 | user: { | 591 | user: { |
| 358 | theme: userTheme, | 592 | theme: userTheme, |
| 359 | styles: userStyles, | 593 | styles: userStyles, |
| 360 | renderers: createCodeRenderers( | 594 | renderers: createMarkdownRenderers( |
| 361 | markdownUserCodeBg, | 595 | markdownUserCodeBg, |
| 362 | markdownUserCodeText, | 596 | markdownUserCodeText, |
| 363 | markdownUserFenceBg, | 597 | markdownUserFenceBg, |
| 364 | markdownUserFenceText, | 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 | assistant: { | 621 | assistant: { |
| 368 | theme: assistantTheme, | 622 | theme: assistantTheme, |
| 369 | styles: assistantStyles, | 623 | styles: assistantStyles, |
| 370 | renderers: createCodeRenderers( | 624 | renderers: createMarkdownRenderers( |
| 371 | markdownCodeBg, | 625 | markdownCodeBg, |
| 372 | markdownCodeText, | 626 | markdownCodeText, |
| 373 | markdownCodeBg, | 627 | markdownCodeBg, |
| 374 | markdownCodeText, | 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 | }, [ | 652 | }, [colors, inlineChipBackground, inlineSkillBackground, inlineSkillForeground]); |
| 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 | ]); | ||
| 393 | } | 653 | } |
| 394 | 654 | ||
| 395 | function renderFeedEntry( | 655 | function renderFeedEntry( |
| 396 | info: { item: ThreadFeedEntry; index: number }, | 656 | info: { item: ThreadFeedEntry; index: number }, |
| 397 | props: Pick<ThreadFeedProps, "bearerToken" | "httpBaseUrl"> & { | 657 | props: Pick<ThreadFeedProps, "bearerToken" | "httpBaseUrl" | "skills"> & { |
| 398 | readonly copiedRowId: string | null; | 658 | readonly copiedRowId: string | null; |
| 399 | readonly expandedWorkGroups: Record<string, boolean>; | 659 | readonly expandedWorkGroups: Record<string, boolean>; |
| 660 | readonly expandedWorkRows: Record<string, boolean>; | ||
| 661 | readonly terminalAssistantMessageIds: ReadonlySet<string>; | ||
| 662 | readonly unsettledTurnId: TurnId | null; | ||
| 400 | readonly onCopyWorkRow: (rowId: string, value: string) => void; | 663 | readonly onCopyWorkRow: (rowId: string, value: string) => void; |
| 401 | readonly onToggleWorkGroup: (groupId: string) => void; | 664 | readonly onToggleWorkGroup: (groupId: string) => void; |
| 665 | readonly onToggleWorkRow: (rowId: string) => void; | ||
| 666 | readonly onToggleTurnFold: (turnId: TurnId) => void; | ||
| 402 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; | 667 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; |
| 403 | readonly iconSubtleColor: string | import("react-native").ColorValue; | 668 | readonly iconSubtleColor: string | import("react-native").ColorValue; |
| 404 | readonly userBubbleColor: string | import("react-native").ColorValue; | 669 | readonly userBubbleColor: string | import("react-native").ColorValue; |
| ... | @@ -410,19 +675,50 @@ function renderFeedEntry( | ... | @@ -410,19 +675,50 @@ function renderFeedEntry( |
| 410 | const entry = info.item; | 675 | const entry = info.item; |
| 411 | const { markdownStyles, iconSubtleColor, userBubbleColor } = props; | 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 | if (entry.type === "message") { | 700 | if (entry.type === "message") { |
| 414 | const { message } = entry; | 701 | const { message } = entry; |
| 415 | const isUser = message.role === "user"; | 702 | const isUser = message.role === "user"; |
| 416 | const styles = isUser ? markdownStyles.user : markdownStyles.assistant; | 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 | const attachments = message.attachments ?? []; | 705 | const attachments = message.attachments ?? []; |
| 419 | const hasReviewCommentContext = message.text.includes("<review_comment"); | 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 | if (isUser) { | 717 | if (isUser) { |
| 422 | return ( | 718 | return ( |
| 423 | <View className="mb-5 items-end"> | 719 | <View className="mb-5 items-end"> |
| 424 | <View | 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 | style={{ | 722 | style={{ |
| 427 | backgroundColor: userBubbleColor, | 723 | backgroundColor: userBubbleColor, |
| 428 | ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null), | 724 | ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null), |
| ... | @@ -433,6 +729,7 @@ function renderFeedEntry( | ... | @@ -433,6 +729,7 @@ function renderFeedEntry( |
| 433 | text={message.text} | 729 | text={message.text} |
| 434 | markdownStyles={styles} | 730 | markdownStyles={styles} |
| 435 | reviewCommentColors={props.reviewCommentColors} | 731 | reviewCommentColors={props.reviewCommentColors} |
| 732 | skills={props.skills} | ||
| 436 | /> | 733 | /> |
| 437 | ) : null} | 734 | ) : null} |
| 438 | {attachments.map((attachment) => { | 735 | {attachments.map((attachment) => { |
| ... | @@ -459,9 +756,20 @@ function renderFeedEntry( | ... | @@ -459,9 +756,20 @@ function renderFeedEntry( |
| 459 | ); | 756 | ); |
| 460 | })} | 757 | })} |
| 461 | </View> | 758 | </View> |
| 462 | <Text className="mt-1.5 px-1 text-right font-t3-medium text-xs text-neutral-600 dark:text-neutral-400"> | 759 | <View className="mt-1 flex-row items-center justify-end gap-1 pr-0.5"> |
| 463 | {timestampLabel} | 760 | <Text className="font-t3-medium text-xs tabular-nums text-neutral-600 dark:text-neutral-400"> |
| 464 | </Text> | 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 | </View> | 773 | </View> |
| 466 | ); | 774 | ); |
| 467 | } | 775 | } |
| ... | @@ -473,16 +781,24 @@ function renderFeedEntry( | ... | @@ -473,16 +781,24 @@ function renderFeedEntry( |
| 473 | } | 781 | } |
| 474 | 782 | ||
| 475 | return ( | 783 | return ( |
| 476 | <View className="mb-5 px-1"> | 784 | <View className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}> |
| 477 | {message.text.trim().length > 0 ? ( | 785 | {message.text.trim().length > 0 ? ( |
| 478 | <Markdown | 786 | hasNativeSelectableMarkdownText() ? ( |
| 479 | options={{ gfm: true }} | 787 | <SelectableMarkdownText |
| 480 | renderers={styles.renderers} | 788 | markdown={message.text} |
| 481 | styles={styles.styles} | 789 | skills={props.skills} |
| 482 | theme={styles.theme} | 790 | textStyle={styles.nativeTextStyle} |
| 483 | > | 791 | /> |
| 484 | {message.text} | 792 | ) : ( |
| 485 | </Markdown> | 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 | ) : null} | 802 | ) : null} |
| 487 | {attachments.map((attachment) => { | 803 | {attachments.map((attachment) => { |
| 488 | const uri = messageImageUrl(props.httpBaseUrl, attachment.id); | 804 | const uri = messageImageUrl(props.httpBaseUrl, attachment.id); |
| ... | @@ -508,9 +824,20 @@ function renderFeedEntry( | ... | @@ -508,9 +824,20 @@ function renderFeedEntry( |
| 508 | </TouchableOpacity> | 824 | </TouchableOpacity> |
| 509 | ); | 825 | ); |
| 510 | })} | 826 | })} |
| 511 | <Text className="mt-1.5 font-t3-medium text-xs text-neutral-600 dark:text-neutral-400"> | 827 | {showAssistantMeta ? ( |
| 512 | {timestampLabel} | 828 | <View className="mt-1 flex-row items-center gap-1"> |
| 513 | </Text> | 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 | </View> | 841 | </View> |
| 515 | ); | 842 | ); |
| 516 | } | 843 | } |
| ... | @@ -539,67 +866,121 @@ function renderFeedEntry( | ... | @@ -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 | const isExpanded = props.expandedWorkGroups[entry.id] ?? false; | 875 | const isExpanded = props.expandedWorkGroups[entry.id] ?? false; |
| 544 | const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | 876 | const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES; |
| 545 | const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows; | 877 | const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows; |
| 546 | const hiddenCount = rows.length - visibleRows.length; | 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 | return ( | 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]"> | 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]"> |
| 551 | {showHeader ? ( | 888 | <View className="mb-1.5 flex-row items-center justify-between gap-3 px-2"> |
| 552 | <View className="mb-1.5 flex-row items-center justify-between gap-3 px-0.5"> | 889 | <Text className="font-t3-medium text-xs text-foreground">{headerTitle}</Text> |
| 553 | <Text className="font-t3-bold text-[10px] uppercase tracking-[0.8px] text-neutral-500 dark:text-neutral-500"> | 890 | {hasOverflow ? ( |
| 554 | Tool calls ({rows.length}) | 891 | <Pressable |
| 555 | </Text> | 892 | accessibilityRole="button" |
| 556 | <Pressable onPress={() => props.onToggleWorkGroup(entry.id)}> | 893 | accessibilityState={{ expanded: isExpanded }} |
| 557 | <Text className="font-t3-medium text-[10px] uppercase tracking-[0.8px] text-neutral-500 dark:text-neutral-500"> | 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 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | 898 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} |
| 559 | </Text> | 899 | </Text> |
| 900 | <SymbolView | ||
| 901 | name={isExpanded ? "chevron.up" : "chevron.down"} | ||
| 902 | size={12} | ||
| 903 | tintColor={iconSubtleColor} | ||
| 904 | type="monochrome" | ||
| 905 | /> | ||
| 560 | </Pressable> | 906 | </Pressable> |
| 561 | </View> | 907 | ) : null} |
| 562 | ) : null} | 908 | </View> |
| 563 | {visibleRows.map((row, index) => ( | 909 | {visibleRows.map((row, index) => ( |
| 564 | <View | 910 | <Pressable |
| 565 | key={row.id} | 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 | className={cn( | 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 | index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", | 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"> | 927 | <View className="flex-row items-center gap-2"> |
| 572 | <SymbolView name="terminal" size={13} tintColor={iconSubtleColor} type="monochrome" /> | 928 | <View className="w-4 items-center justify-center"> |
| 573 | </View> | 929 | <SymbolView |
| 574 | <ScrollView | 930 | name={ |
| 575 | horizontal | 931 | row.status === "failure" |
| 576 | nestedScrollEnabled | 932 | ? "xmark" |
| 577 | directionalLockEnabled | 933 | : row.status === "success" |
| 578 | showsHorizontalScrollIndicator={false} | 934 | ? "checkmark" |
| 579 | bounces={false} | 935 | : row.status === "neutral" |
| 580 | className="flex-1" | 936 | ? "minus" |
| 581 | contentContainerStyle={{ paddingRight: 12 }} | 937 | : "terminal" |
| 582 | style={{ flex: 1 }} | 938 | } |
| 583 | > | 939 | size={row.status ? 11 : 13} |
| 940 | tintColor={row.status === "failure" ? "#e11d48" : iconSubtleColor} | ||
| 941 | type="monochrome" | ||
| 942 | /> | ||
| 943 | </View> | ||
| 584 | <Text | 944 | <Text |
| 585 | className="text-[12px] leading-[18px] text-neutral-600 dark:text-neutral-400" | 945 | className="min-w-0 flex-1 text-[12px] leading-[18px] text-neutral-700 dark:text-neutral-300" |
| 586 | onLongPress={() => { | 946 | numberOfLines={props.expandedWorkRows[row.id] ? undefined : 1} |
| 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 | }} | ||
| 593 | > | 947 | > |
| 594 | {row.detail ? `${row.summary} - ${row.detail}` : row.summary} | 948 | {row.detail ? `${row.summary} - ${row.detail}` : row.summary} |
| 595 | </Text> | 949 | </Text> |
| 596 | </ScrollView> | 950 | {row.fullDetail ? ( |
| 597 | {props.copiedRowId === row.id ? ( | 951 | <SymbolView |
| 598 | <Text className="shrink-0 font-t3-medium text-[10px] uppercase tracking-[0.8px] text-emerald-600 dark:text-emerald-400"> | 952 | name={props.expandedWorkRows[row.id] ? "chevron.up" : "chevron.down"} |
| 599 | Copied | 953 | size={11} |
| 600 | </Text> | 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 | ) : null} | 982 | ) : null} |
| 602 | </View> | 983 | </Pressable> |
| 603 | ))} | 984 | ))} |
| 604 | </View> | 985 | </View> |
| 605 | ); | 986 | ); |
| ... | @@ -609,10 +990,20 @@ function UserMessageContent(props: { | ... | @@ -609,10 +990,20 @@ function UserMessageContent(props: { |
| 609 | readonly text: string; | 990 | readonly text: string; |
| 610 | readonly markdownStyles: MarkdownStyleSet; | 991 | readonly markdownStyles: MarkdownStyleSet; |
| 611 | readonly reviewCommentColors: ReviewCommentColors; | 992 | readonly reviewCommentColors: ReviewCommentColors; |
| 993 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; | ||
| 612 | }) { | 994 | }) { |
| 613 | const segments = parseReviewCommentMessageSegments(props.text); | 995 | const segments = parseReviewCommentMessageSegments(props.text); |
| 614 | const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); | 996 | const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); |
| 615 | if (!hasReviewComment) { | 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 | return ( | 1007 | return ( |
| 617 | <Markdown | 1008 | <Markdown |
| 618 | options={{ gfm: true }} | 1009 | options={{ gfm: true }} |
| ... | @@ -643,7 +1034,14 @@ function UserMessageContent(props: { | ... | @@ -643,7 +1034,14 @@ function UserMessageContent(props: { |
| 643 | return null; | 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 | <Markdown | 1045 | <Markdown |
| 648 | key={segment.id} | 1046 | key={segment.id} |
| 649 | options={{ gfm: true }} | 1047 | options={{ gfm: true }} |
| ... | @@ -819,14 +1217,30 @@ function compactFileName(filePath: string): string { | ... | @@ -819,14 +1217,30 @@ function compactFileName(filePath: string): string { |
| 819 | return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized; | 1217 | return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized; |
| 820 | } | 1218 | } |
| 821 | 1219 | ||
| 822 | const IOS_NAV_BAR_HEIGHT = 44; | ||
| 823 | |||
| 824 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | 1220 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 825 | const listRef = useRef<LegendListRef>(null); | 1221 | const listRef = useRef<LegendListRef>(null); |
| 826 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 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 | const { width: viewportWidth } = useWindowDimensions(); | 1231 | const { width: viewportWidth } = useWindowDimensions(); |
| 828 | const [copiedRowId, setCopiedRowId] = useState<string | null>(null); | 1232 | const [interactionState, setInteractionState] = useState<{ |
| 829 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); | 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 | const [expandedImage, setExpandedImage] = useState<{ | 1244 | const [expandedImage, setExpandedImage] = useState<{ |
| 831 | uri: string; | 1245 | uri: string; |
| 832 | headers?: Record<string, string>; | 1246 | headers?: Record<string, string>; |
| ... | @@ -835,47 +1249,193 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -835,47 +1249,193 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 835 | const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); | 1249 | const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); |
| 836 | const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); | 1250 | const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); |
| 837 | const insets = useSafeAreaInsets(); | 1251 | const insets = useSafeAreaInsets(); |
| 838 | const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; | 1252 | const topContentInset = props.contentTopInset ?? insets.top + 44; |
| 839 | const bottomContentInset = props.contentBottomInset ?? 18; | 1253 | const bottomContentInset = props.contentBottomInset ?? 18; |
| 840 | 1254 | ||
| 841 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); | 1255 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); |
| 842 | const userBubbleColor = useThemeColor("--color-user-bubble"); | 1256 | const userBubbleColor = useThemeColor("--color-user-bubble"); |
| 843 | const markdownStyles = useMarkdownStyles(); | 1257 | const markdownStyles = useMarkdownStyles(); |
| 844 | const reviewCommentColors = useReviewCommentColors(); | 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 | useEffect(() => { | 1335 | useEffect(() => { |
| 847 | setCopiedRowId(null); | 1336 | const previous = previousLatestTurnRef.current; |
| 848 | setExpandedWorkGroups({}); | 1337 | previousLatestTurnRef.current = props.latestTurn; |
| 849 | }, [props.threadId]); | 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 | useEffect(() => { | 1361 | useEffect(() => { |
| 852 | return () => { | 1362 | return () => { |
| 853 | if (copyFeedbackTimeoutRef.current) { | 1363 | if (copyFeedbackTimeoutRef.current) { |
| 854 | clearTimeout(copyFeedbackTimeoutRef.current); | 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 | const onCopyWorkRow = useCallback((rowId: string, value: string) => { | 1378 | const onCopyWorkRow = useCallback((rowId: string, value: string) => { |
| 860 | void Clipboard.setStringAsync(value); | 1379 | void Clipboard.setStringAsync(value); |
| 861 | void Haptics.selectionAsync(); | 1380 | void Haptics.selectionAsync(); |
| 862 | setCopiedRowId(rowId); | 1381 | setInteractionState((current) => ({ ...current, copiedRowId: rowId })); |
| 863 | if (copyFeedbackTimeoutRef.current) { | 1382 | if (copyFeedbackTimeoutRef.current) { |
| 864 | clearTimeout(copyFeedbackTimeoutRef.current); | 1383 | clearTimeout(copyFeedbackTimeoutRef.current); |
| 865 | } | 1384 | } |
| 866 | copyFeedbackTimeoutRef.current = setTimeout(() => { | 1385 | copyFeedbackTimeoutRef.current = setTimeout(() => { |
| 867 | setCopiedRowId((current) => (current === rowId ? null : current)); | 1386 | setInteractionState((current) => |
| 1387 | current.copiedRowId === rowId ? { ...current, copiedRowId: null } : current, | ||
| 1388 | ); | ||
| 868 | copyFeedbackTimeoutRef.current = null; | 1389 | copyFeedbackTimeoutRef.current = null; |
| 869 | }, 1200); | 1390 | }, 1200); |
| 870 | }, []); | 1391 | }, []); |
| 871 | 1392 | ||
| 872 | const onToggleWorkGroup = useCallback((groupId: string) => { | 1393 | const onToggleWorkGroup = useCallback((groupId: string) => { |
| 873 | setExpandedWorkGroups((current) => ({ | 1394 | setInteractionState((current) => ({ |
| 874 | ...current, | 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 | const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => { | 1439 | const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => { |
| 880 | setExpandedImage({ uri, headers }); | 1440 | setExpandedImage({ uri, headers }); |
| 881 | }, []); | 1441 | }, []); |
| ... | @@ -887,18 +1447,27 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -887,18 +1447,27 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 887 | copiedRowId, | 1447 | copiedRowId, |
| 888 | httpBaseUrl: props.httpBaseUrl, | 1448 | httpBaseUrl: props.httpBaseUrl, |
| 889 | expandedWorkGroups, | 1449 | expandedWorkGroups, |
| 1450 | expandedWorkRows, | ||
| 1451 | terminalAssistantMessageIds, | ||
| 1452 | unsettledTurnId, | ||
| 890 | onCopyWorkRow, | 1453 | onCopyWorkRow, |
| 891 | onToggleWorkGroup, | 1454 | onToggleWorkGroup, |
| 1455 | onToggleWorkRow, | ||
| 1456 | onToggleTurnFold, | ||
| 892 | onPressImage, | 1457 | onPressImage, |
| 893 | iconSubtleColor, | 1458 | iconSubtleColor, |
| 894 | userBubbleColor, | 1459 | userBubbleColor, |
| 895 | markdownStyles, | 1460 | markdownStyles, |
| 896 | reviewCommentColors, | 1461 | reviewCommentColors, |
| 897 | reviewCommentBubbleWidth, | 1462 | reviewCommentBubbleWidth, |
| 1463 | skills: props.skills, | ||
| 898 | }), | 1464 | }), |
| 899 | [ | 1465 | [ |
| 900 | copiedRowId, | 1466 | copiedRowId, |
| 901 | expandedWorkGroups, | 1467 | expandedWorkGroups, |
| 1468 | expandedWorkRows, | ||
| 1469 | terminalAssistantMessageIds, | ||
| 1470 | unsettledTurnId, | ||
| 902 | iconSubtleColor, | 1471 | iconSubtleColor, |
| 903 | userBubbleColor, | 1472 | userBubbleColor, |
| 904 | markdownStyles, | 1473 | markdownStyles, |
| ... | @@ -906,9 +1475,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -906,9 +1475,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 906 | reviewCommentBubbleWidth, | 1475 | reviewCommentBubbleWidth, |
| 907 | onCopyWorkRow, | 1476 | onCopyWorkRow, |
| 908 | onPressImage, | 1477 | onPressImage, |
| 1478 | onToggleTurnFold, | ||
| 909 | onToggleWorkGroup, | 1479 | onToggleWorkGroup, |
| 1480 | onToggleWorkRow, | ||
| 910 | props.bearerToken, | 1481 | props.bearerToken, |
| 911 | props.httpBaseUrl, | 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,33 +1507,46 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 935 | 1507 | ||
| 936 | return ( | 1508 | return ( |
| 937 | <> | 1509 | <> |
| 938 | <KeyboardAvoidingLegendList | 1510 | <View style={{ flex: 1 }}> |
| 939 | ref={listRef} | 1511 | <KeyboardAvoidingLegendList |
| 940 | key={props.threadId} | 1512 | ref={listRef} |
| 941 | style={{ flex: 1 }} | 1513 | key={props.threadId} |
| 942 | alignItemsAtEnd | 1514 | style={{ flex: 1 }} |
| 943 | contentInsetAdjustmentBehavior="never" | 1515 | automaticallyAdjustsScrollIndicatorInsets={false} |
| 944 | contentInset={{ top: topContentInset, bottom: bottomContentInset }} | 1516 | contentInset={{ top: 0, bottom: 0 }} |
| 945 | scrollIndicatorInsets={{ top: topContentInset, bottom: bottomContentInset }} | 1517 | scrollIndicatorInsets={{ top: topContentInset, bottom: 0 }} |
| 946 | data={props.feed as ThreadFeedEntry[]} | 1518 | alignItemsAtEnd |
| 947 | renderItem={renderItem} | 1519 | maintainScrollAtEnd={{ |
| 948 | keyExtractor={(entry) => `${entry.type}:${entry.id}`} | 1520 | animated: false, |
| 949 | getItemType={(entry) => | 1521 | on: { |
| 950 | entry.type === "message" ? `message:${entry.message.role}` : entry.type | 1522 | dataChange: true, |
| 951 | } | 1523 | itemLayout: true, |
| 952 | keyboardShouldPersistTaps="handled" | 1524 | layout: true, |
| 953 | estimatedItemSize={180} | 1525 | }, |
| 954 | initialScrollAtEnd | 1526 | }} |
| 955 | maintainScrollAtEnd={{ | 1527 | data={presentedFeed} |
| 956 | on: { layout: true, itemLayout: true, dataChange: true }, | 1528 | extraData={listAppearanceData} |
| 957 | }} | 1529 | renderItem={renderItem} |
| 958 | maintainScrollAtEndThreshold={0.1} | 1530 | keyExtractor={(entry) => `${entry.type}:${entry.id}`} |
| 959 | safeAreaInsetBottom={insets.bottom} | 1531 | getItemType={(entry) => |
| 960 | contentContainerStyle={{ | 1532 | entry.type === "message" ? `message:${entry.message.role}` : entry.type |
| 961 | paddingTop: 12, | 1533 | } |
| 962 | paddingHorizontal: horizontalPadding, | 1534 | keyboardShouldPersistTaps="always" |
| 963 | }} | 1535 | keyboardDismissMode="none" |
| 964 | /> | 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 | <ImageViewing | 1551 | <ImageViewing |
| 967 | images={ | 1552 | images={ |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+2-3| ... | @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; | ... | @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; |
| 5 | import { pipe } from "effect/Function"; | 5 | import { pipe } from "effect/Function"; |
| 6 | import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; | 6 | import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; |
| 7 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 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 | import { useThemeColor } from "../../lib/useThemeColor"; | 9 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 10 | import { useVcsStatus } from "../../state/use-vcs-status"; | 10 | import { useVcsStatus } from "../../state/use-vcs-status"; |
| 11 | import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; | 11 | import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; |
| ... | @@ -91,10 +91,9 @@ export function ThreadRouteScreen() { | ... | @@ -91,10 +91,9 @@ export function ThreadRouteScreen() { |
| 91 | pendingConnectionError ?? routeEnvironmentRuntime?.connectionError ?? aggregateConnectionError; | 91 | pendingConnectionError ?? routeEnvironmentRuntime?.connectionError ?? aggregateConnectionError; |
| 92 | 92 | ||
| 93 | /* ─── Native header theming ──────────────────────────────────────── */ | 93 | /* ─── Native header theming ──────────────────────────────────────── */ |
| 94 | const isDark = useColorScheme() === "dark"; | ||
| 95 | const iconColor = String(useThemeColor("--color-icon")); | 94 | const iconColor = String(useThemeColor("--color-icon")); |
| 96 | const foregroundColor = String(useThemeColor("--color-foreground")); | 95 | const foregroundColor = String(useThemeColor("--color-foreground")); |
| 97 | const secondaryFg = isDark ? "#a3a3a3" : "#525252"; | 96 | const secondaryFg = String(useThemeColor("--color-foreground-secondary")); |
| 98 | 97 | ||
| 99 | /* ─── Git status for native header trigger ───────────────────────── */ | 98 | /* ─── Git status for native header trigger ───────────────────────── */ |
| 100 | const gitStatus = useVcsStatus({ | 99 | const gitStatus = useVcsStatus({ |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+10| ... | @@ -5,6 +5,7 @@ import type { | ... | @@ -5,6 +5,7 @@ import type { |
| 5 | ModelSelection, | 5 | ModelSelection, |
| 6 | ProviderInteractionMode, | 6 | ProviderInteractionMode, |
| 7 | RuntimeMode, | 7 | RuntimeMode, |
| 8 | ServerProviderSkill, | ||
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 9 | import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts"; | 10 | import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts"; |
| 10 | import * as Arr from "effect/Array"; | 11 | import * as Arr from "effect/Array"; |
| ... | @@ -94,6 +95,7 @@ type NewTaskFlowContextValue = { | ... | @@ -94,6 +95,7 @@ type NewTaskFlowContextValue = { |
| 94 | readonly modelOptions: ReadonlyArray<ModelOption>; | 95 | readonly modelOptions: ReadonlyArray<ModelOption>; |
| 95 | readonly selectedModel: ModelSelection | null; | 96 | readonly selectedModel: ModelSelection | null; |
| 96 | readonly selectedModelOption: ModelOption | null; | 97 | readonly selectedModelOption: ModelOption | null; |
| 98 | readonly selectedProviderSkills: ReadonlyArray<ServerProviderSkill>; | ||
| 97 | readonly providerGroups: ReadonlyArray<ProviderGroup>; | 99 | readonly providerGroups: ReadonlyArray<ProviderGroup>; |
| 98 | readonly filteredBranches: ReadonlyArray<VcsRef>; | 100 | readonly filteredBranches: ReadonlyArray<VcsRef>; |
| 99 | readonly reset: () => void; | 101 | readonly reset: () => void; |
| ... | @@ -283,6 +285,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -283,6 +285,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 283 | option.selection.instanceId === selectedModel.instanceId && | 285 | option.selection.instanceId === selectedModel.instanceId && |
| 284 | option.selection.model === selectedModel.model, | 286 | option.selection.model === selectedModel.model, |
| 285 | ) ?? null; | 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 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); | 295 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); |
| 288 | const setPrompt = useCallback( | 296 | const setPrompt = useCallback( |
| ... | @@ -450,6 +458,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -450,6 +458,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 450 | modelOptions, | 458 | modelOptions, |
| 451 | selectedModel, | 459 | selectedModel, |
| 452 | selectedModelOption, | 460 | selectedModelOption, |
| 461 | selectedProviderSkills, | ||
| 453 | providerGroups, | 462 | providerGroups, |
| 454 | filteredBranches, | 463 | filteredBranches, |
| 455 | reset, | 464 | reset, |
| ... | @@ -498,6 +507,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -498,6 +507,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 498 | selectedModel, | 507 | selectedModel, |
| 499 | selectedModelKey, | 508 | selectedModelKey, |
| 500 | selectedModelOption, | 509 | selectedModelOption, |
| 510 | selectedProviderSkills, | ||
| 501 | selectedProject, | 511 | selectedProject, |
| 502 | selectedProjectKey, | 512 | selectedProjectKey, |
| 503 | selectedWorktreePath, | 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,6 +10,8 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment |
| 10 | readonly previewUri: string; | 10 | readonly previewUri: string; |
| 11 | } | 11 | } |
| 12 | 12 | ||
| 13 | const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; | ||
| 14 | |||
| 13 | function estimateBase64ByteSize(base64: string): number { | 15 | function estimateBase64ByteSize(base64: string): number { |
| 14 | const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; | 16 | const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; |
| 15 | return Math.floor((base64.length * 3) / 4) - padding; | 17 | return Math.floor((base64.length * 3) / 4) - padding; |
| ... | @@ -213,17 +215,35 @@ function mimeTypeFromUri(uri: string): string { | ... | @@ -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 | export async function convertPastedImagesToAttachments(input: { | 233 | export async function convertPastedImagesToAttachments(input: { |
| 217 | readonly uris: ReadonlyArray<string>; | 234 | readonly uris: ReadonlyArray<string>; |
| 218 | readonly existingCount: number; | 235 | readonly existingCount: number; |
| 219 | }): Promise<ReadonlyArray<DraftComposerImageAttachment>> { | 236 | }): Promise<ReadonlyArray<DraftComposerImageAttachment>> { |
| 220 | const { File } = await import("expo-file-system"); | 237 | const { File } = await import("expo-file-system"); |
| 221 | const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; | 238 | const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; |
| 222 | const uris = input.uris.slice(0, Math.max(0, remainingSlots)); | ||
| 223 | const results: DraftComposerImageAttachment[] = []; | 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 | try { | 243 | try { |
| 244 | if (index >= Math.max(0, remainingSlots)) { | ||
| 245 | continue; | ||
| 246 | } | ||
| 227 | const file = new File(uri); | 247 | const file = new File(uri); |
| 228 | const base64 = await file.base64(); | 248 | const base64 = await file.base64(); |
| 229 | const sizeBytes = estimateBase64ByteSize(base64); | 249 | const sizeBytes = estimateBase64ByteSize(base64); |
| ... | @@ -238,10 +258,21 @@ export async function convertPastedImagesToAttachments(input: { | ... | @@ -238,10 +258,21 @@ export async function convertPastedImagesToAttachments(input: { |
| 238 | mimeType, | 258 | mimeType, |
| 239 | sizeBytes, | 259 | sizeBytes, |
| 240 | dataUrl: `data:${mimeType};base64,${base64}`, | 260 | dataUrl: `data:${mimeType};base64,${base64}`, |
| 241 | previewUri: uri, | 261 | previewUri: ownedTemporaryFile ? `data:${mimeType};base64,${base64}` : uri, |
| 242 | }); | 262 | }); |
| 243 | } catch (error) { | 263 | } catch (error) { |
| 244 | console.warn("Failed to read pasted image", uri, error); | 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,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | EventId, | 4 | EventId, |
| 5 | MessageId, | ||
| 5 | ProjectId, | 6 | ProjectId, |
| 6 | ProviderInstanceId, | 7 | ProviderInstanceId, |
| 7 | ThreadId, | 8 | ThreadId, |
| ... | @@ -10,7 +11,7 @@ import { | ... | @@ -10,7 +11,7 @@ import { |
| 10 | type OrchestrationThreadActivity, | 11 | type OrchestrationThreadActivity, |
| 11 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 12 | 13 | ||
| 13 | import { buildThreadFeed } from "./threadActivity"; | 14 | import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity"; |
| 14 | 15 | ||
| 15 | function makeActivity( | 16 | function makeActivity( |
| 16 | input: Partial<OrchestrationThreadActivity> & | 17 | input: Partial<OrchestrationThreadActivity> & |
| ... | @@ -48,7 +49,7 @@ function makeThread( | ... | @@ -48,7 +49,7 @@ function makeThread( |
| 48 | } | 49 | } |
| 49 | 50 | ||
| 50 | describe("buildThreadFeed", () => { | 51 | describe("buildThreadFeed", () => { |
| 51 | it("includes runtime warnings from the latest turn", () => { | 52 | it("keeps historic work entries attributed to their turns", () => { |
| 52 | const thread = makeThread({ | 53 | const thread = makeThread({ |
| 53 | id: ThreadId.make("thread-1"), | 54 | id: ThreadId.make("thread-1"), |
| 54 | projectId: ProjectId.make("project-1"), | 55 | projectId: ProjectId.make("project-1"), |
| ... | @@ -86,22 +87,16 @@ describe("buildThreadFeed", () => { | ... | @@ -86,22 +87,16 @@ describe("buildThreadFeed", () => { |
| 86 | }); | 87 | }); |
| 87 | 88 | ||
| 88 | const feed = buildThreadFeed(thread, [], null); | 89 | const feed = buildThreadFeed(thread, [], null); |
| 89 | const group = feed[0]; | 90 | expect(feed).toMatchObject([ |
| 90 | 91 | { | |
| 91 | expect(group).toMatchObject({ | 92 | type: "activity-group", |
| 92 | type: "activity-group", | 93 | turnId: "turn-old", |
| 93 | }); | 94 | activities: [{ id: "activity-old", turnId: "turn-old" }], |
| 94 | if (!group || group.type !== "activity-group") { | 95 | }, |
| 95 | return; | ||
| 96 | } | ||
| 97 | |||
| 98 | expect(group.activities).toEqual([ | ||
| 99 | { | 96 | { |
| 100 | id: "activity-latest", | 97 | type: "activity-group", |
| 101 | createdAt: "2026-04-01T00:00:03.000Z", | 98 | turnId: "turn-latest", |
| 102 | summary: "Runtime warning", | 99 | activities: [{ id: "activity-latest", turnId: "turn-latest" }], |
| 103 | detail: null, | ||
| 104 | status: null, | ||
| 105 | }, | 100 | }, |
| 106 | ]); | 101 | ]); |
| 107 | }); | 102 | }); |
| ... | @@ -163,10 +158,201 @@ describe("buildThreadFeed", () => { | ... | @@ -163,10 +158,201 @@ describe("buildThreadFeed", () => { |
| 163 | { | 158 | { |
| 164 | id: "tool-completed", | 159 | id: "tool-completed", |
| 165 | createdAt: "2026-04-01T00:00:02.000Z", | 160 | createdAt: "2026-04-01T00:00:02.000Z", |
| 161 | turnId: "turn-1", | ||
| 166 | summary: "Run tests", | 162 | summary: "Run tests", |
| 167 | detail: "bun run test", | 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,13 +3,15 @@ import type { |
| 3 | CommandId, | 3 | CommandId, |
| 4 | EnvironmentId, | 4 | EnvironmentId, |
| 5 | MessageId, | 5 | MessageId, |
| 6 | OrchestrationLatestTurn, | ||
| 6 | OrchestrationThread, | 7 | OrchestrationThread, |
| 7 | OrchestrationThreadActivity, | 8 | OrchestrationThreadActivity, |
| 8 | TurnId, | ||
| 9 | ToolLifecycleItemType, | 9 | ToolLifecycleItemType, |
| 10 | ThreadId, | 10 | ThreadId, |
| 11 | TurnId, | ||
| 11 | UserInputQuestion, | 12 | UserInputQuestion, |
| 12 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 14 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | ||
| 13 | 15 | ||
| 14 | import type { DraftComposerImageAttachment } from "./composerImages"; | 16 | import type { DraftComposerImageAttachment } from "./composerImages"; |
| 15 | import * as Arr from "effect/Array"; | 17 | import * as Arr from "effect/Array"; |
| ... | @@ -46,14 +48,21 @@ export interface QueuedThreadMessage { | ... | @@ -46,14 +48,21 @@ export interface QueuedThreadMessage { |
| 46 | export interface ThreadFeedActivity { | 48 | export interface ThreadFeedActivity { |
| 47 | readonly id: string; | 49 | readonly id: string; |
| 48 | readonly createdAt: string; | 50 | readonly createdAt: string; |
| 51 | readonly turnId: TurnId | null; | ||
| 49 | readonly summary: string; | 52 | readonly summary: string; |
| 50 | readonly detail: string | null; | 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 | interface WorkLogEntry { | 62 | interface WorkLogEntry { |
| 55 | id: string; | 63 | id: string; |
| 56 | createdAt: string; | 64 | createdAt: string; |
| 65 | turnId: TurnId | null; | ||
| 57 | label: string; | 66 | label: string; |
| 58 | detail?: string; | 67 | detail?: string; |
| 59 | command?: string; | 68 | command?: string; |
| ... | @@ -63,6 +72,7 @@ interface WorkLogEntry { | ... | @@ -63,6 +72,7 @@ interface WorkLogEntry { |
| 63 | toolTitle?: string; | 72 | toolTitle?: string; |
| 64 | itemType?: ToolLifecycleItemType; | 73 | itemType?: ToolLifecycleItemType; |
| 65 | requestKind?: PendingApproval["requestKind"]; | 74 | requestKind?: PendingApproval["requestKind"]; |
| 75 | toolLifecycleStatus?: WorkLogToolLifecycleStatus; | ||
| 66 | } | 76 | } |
| 67 | 77 | ||
| 68 | interface DerivedWorkLogEntry extends WorkLogEntry { | 78 | interface DerivedWorkLogEntry extends WorkLogEntry { |
| ... | @@ -88,6 +98,7 @@ type RawThreadFeedEntry = | ... | @@ -88,6 +98,7 @@ type RawThreadFeedEntry = |
| 88 | readonly type: "activity"; | 98 | readonly type: "activity"; |
| 89 | readonly id: string; | 99 | readonly id: string; |
| 90 | readonly createdAt: string; | 100 | readonly createdAt: string; |
| 101 | readonly turnId: TurnId | null; | ||
| 91 | readonly activity: ThreadFeedActivity; | 102 | readonly activity: ThreadFeedActivity; |
| 92 | }; | 103 | }; |
| 93 | 104 | ||
| ... | @@ -97,9 +108,23 @@ export type ThreadFeedEntry = | ... | @@ -97,9 +108,23 @@ export type ThreadFeedEntry = |
| 97 | readonly type: "activity-group"; | 108 | readonly type: "activity-group"; |
| 98 | readonly id: string; | 109 | readonly id: string; |
| 99 | readonly createdAt: string; | 110 | readonly createdAt: string; |
| 111 | readonly turnId: TurnId | null; | ||
| 100 | readonly activities: ReadonlyArray<ThreadFeedActivity>; | 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 | function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { | 128 | function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { |
| 104 | switch (requestType) { | 129 | switch (requestType) { |
| 105 | case "command_execution_approval": | 130 | case "command_execution_approval": |
| ... | @@ -202,14 +227,12 @@ function resolvePendingUserInputAnswer( | ... | @@ -202,14 +227,12 @@ function resolvePendingUserInputAnswer( |
| 202 | 227 | ||
| 203 | function deriveWorkLogEntries( | 228 | function deriveWorkLogEntries( |
| 204 | activities: ReadonlyArray<OrchestrationThreadActivity>, | 229 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 205 | latestTurnId: TurnId | undefined, | ||
| 206 | ): WorkLogEntry[] { | 230 | ): WorkLogEntry[] { |
| 207 | const ordered = Arr.sort(activities, activityOrder); | 231 | const ordered = Arr.sort(activities, activityOrder); |
| 208 | const entries: DerivedWorkLogEntry[] = []; | 232 | const entries: DerivedWorkLogEntry[] = []; |
| 209 | for (const activity of ordered) { | 233 | for (const activity of ordered) { |
| 210 | if (latestTurnId && activity.turnId !== latestTurnId) continue; | ||
| 211 | if (activity.kind === "tool.started") continue; | 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 | if (activity.kind === "context-window.updated") continue; | 236 | if (activity.kind === "context-window.updated") continue; |
| 214 | if (activity.summary === "Checkpoint captured") continue; | 237 | if (activity.summary === "Checkpoint captured") continue; |
| 215 | if (isPlanBoundaryToolActivity(activity)) continue; | 238 | if (isPlanBoundaryToolActivity(activity)) continue; |
| ... | @@ -240,16 +263,40 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -240,16 +263,40 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 240 | const commandPreview = extractToolCommand(payload); | 263 | const commandPreview = extractToolCommand(payload); |
| 241 | const changedFiles = extractChangedFiles(payload); | 264 | const changedFiles = extractChangedFiles(payload); |
| 242 | const title = extractToolTitle(payload); | 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 | const entry: DerivedWorkLogEntry = { | 279 | const entry: DerivedWorkLogEntry = { |
| 244 | id: activity.id, | 280 | id: activity.id, |
| 245 | createdAt: activity.createdAt, | 281 | createdAt: activity.createdAt, |
| 246 | label: activity.summary, | 282 | turnId: activity.turnId, |
| 247 | tone: activity.tone === "approval" ? "info" : activity.tone, | 283 | label: taskLabel || activity.summary, |
| 284 | tone: | ||
| 285 | activity.kind === "task.progress" | ||
| 286 | ? "thinking" | ||
| 287 | : activity.tone === "approval" | ||
| 288 | ? "info" | ||
| 289 | : activity.tone, | ||
| 248 | activityKind: activity.kind, | 290 | activityKind: activity.kind, |
| 249 | }; | 291 | }; |
| 250 | const itemType = extractWorkLogItemType(payload); | 292 | const itemType = extractWorkLogItemType(payload); |
| 251 | const requestKind = extractWorkLogRequestKind(payload); | 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 | const detail = stripTrailingExitCode(payload.detail).output; | 300 | const detail = stripTrailingExitCode(payload.detail).output; |
| 254 | if (detail) { | 301 | if (detail) { |
| 255 | entry.detail = detail; | 302 | entry.detail = detail; |
| ... | @@ -273,6 +320,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -273,6 +320,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 273 | if (requestKind) { | 320 | if (requestKind) { |
| 274 | entry.requestKind = requestKind; | 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 | const collapseKey = deriveToolLifecycleCollapseKey(entry); | 330 | const collapseKey = deriveToolLifecycleCollapseKey(entry); |
| 277 | if (collapseKey) { | 331 | if (collapseKey) { |
| 278 | entry.collapseKey = collapseKey; | 332 | entry.collapseKey = collapseKey; |
| ... | @@ -323,6 +377,7 @@ function mergeDerivedWorkLogEntries( | ... | @@ -323,6 +377,7 @@ function mergeDerivedWorkLogEntries( |
| 323 | const itemType = next.itemType ?? previous.itemType; | 377 | const itemType = next.itemType ?? previous.itemType; |
| 324 | const requestKind = next.requestKind ?? previous.requestKind; | 378 | const requestKind = next.requestKind ?? previous.requestKind; |
| 325 | const collapseKey = next.collapseKey ?? previous.collapseKey; | 379 | const collapseKey = next.collapseKey ?? previous.collapseKey; |
| 380 | const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; | ||
| 326 | return { | 381 | return { |
| 327 | ...previous, | 382 | ...previous, |
| 328 | ...next, | 383 | ...next, |
| ... | @@ -334,6 +389,7 @@ function mergeDerivedWorkLogEntries( | ... | @@ -334,6 +389,7 @@ function mergeDerivedWorkLogEntries( |
| 334 | ...(itemType ? { itemType } : {}), | 389 | ...(itemType ? { itemType } : {}), |
| 335 | ...(requestKind ? { requestKind } : {}), | 390 | ...(requestKind ? { requestKind } : {}), |
| 336 | ...(collapseKey ? { collapseKey } : {}), | 391 | ...(collapseKey ? { collapseKey } : {}), |
| 392 | ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), | ||
| 337 | }; | 393 | }; |
| 338 | } | 394 | } |
| 339 | 395 | ||
| ... | @@ -365,6 +421,78 @@ function normalizeCompactToolLabel(value: string): string { | ... | @@ -365,6 +421,78 @@ function normalizeCompactToolLabel(value: string): string { |
| 365 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); | 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 | function workEntryPreview( | 496 | function workEntryPreview( |
| 369 | workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">, | 497 | workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">, |
| 370 | ): string | null { | 498 | ): string | null { |
| ... | @@ -592,6 +720,22 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul | ... | @@ -592,6 +720,22 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul |
| 592 | return asTrimmedString(payload?.title); | 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 | function stripTrailingExitCode(value: string): { | 739 | function stripTrailingExitCode(value: string): { |
| 596 | output: string | null; | 740 | output: string | null; |
| 597 | exitCode?: number | undefined; | 741 | exitCode?: number | undefined; |
| ... | @@ -743,7 +887,7 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th | ... | @@ -743,7 +887,7 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th |
| 743 | } | 887 | } |
| 744 | 888 | ||
| 745 | const previous = grouped.at(-1); | 889 | const previous = grouped.at(-1); |
| 746 | if (previous?.type === "activity-group") { | 890 | if (previous?.type === "activity-group" && previous.turnId === entry.turnId) { |
| 747 | grouped[grouped.length - 1] = { | 891 | grouped[grouped.length - 1] = { |
| 748 | ...previous, | 892 | ...previous, |
| 749 | activities: [...previous.activities, entry.activity], | 893 | activities: [...previous.activities, entry.activity], |
| ... | @@ -755,6 +899,7 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th | ... | @@ -755,6 +899,7 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th |
| 755 | type: "activity-group", | 899 | type: "activity-group", |
| 756 | id: entry.id, | 900 | id: entry.id, |
| 757 | createdAt: entry.createdAt, | 901 | createdAt: entry.createdAt, |
| 902 | turnId: entry.turnId, | ||
| 758 | activities: [entry.activity], | 903 | activities: [entry.activity], |
| 759 | }); | 904 | }); |
| 760 | } | 905 | } |
| ... | @@ -762,6 +907,179 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th | ... | @@ -762,6 +907,179 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th |
| 762 | return grouped; | 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 | export function derivePendingApprovals( | 1083 | export function derivePendingApprovals( |
| 766 | activities: ReadonlyArray<OrchestrationThreadActivity>, | 1084 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 767 | ): PendingApproval[] { | 1085 | ): PendingApproval[] { |
| ... | @@ -893,10 +1211,7 @@ export function buildThreadFeed( | ... | @@ -893,10 +1211,7 @@ export function buildThreadFeed( |
| 893 | const loadedMessages = options?.loadedMessages ?? thread.messages; | 1211 | const loadedMessages = options?.loadedMessages ?? thread.messages; |
| 894 | const oldestLoadedMessageCreatedAt = | 1212 | const oldestLoadedMessageCreatedAt = |
| 895 | options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; | 1213 | options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; |
| 896 | const workLogEntries = deriveWorkLogEntries( | 1214 | const workLogEntries = deriveWorkLogEntries(thread.activities); |
| 897 | thread.activities, | ||
| 898 | thread.latestTurn?.turnId ?? undefined, | ||
| 899 | ); | ||
| 900 | const entries = Arr.sortWith( | 1215 | const entries = Arr.sortWith( |
| 901 | [ | 1216 | [ |
| 902 | ...loadedMessages.map<RawThreadFeedEntry>((message) => ({ | 1217 | ...loadedMessages.map<RawThreadFeedEntry>((message) => ({ |
| ... | @@ -921,18 +1236,36 @@ export function buildThreadFeed( | ... | @@ -921,18 +1236,36 @@ export function buildThreadFeed( |
| 921 | oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt | 1236 | oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt |
| 922 | ); | 1237 | ); |
| 923 | }) | 1238 | }) |
| 924 | .map<RawThreadFeedEntry>((entry) => ({ | 1239 | .map<RawThreadFeedEntry>((entry) => { |
| 925 | type: "activity", | 1240 | const summary = workEntryHeading(entry); |
| 926 | id: entry.id, | 1241 | const detail = workEntryPreview(entry); |
| 927 | createdAt: entry.createdAt, | 1242 | const normalizedFullDetail = entry.detail |
| 928 | activity: { | 1243 | ? unwrapKnownShellCommandWrapper(entry.detail) |
| 1244 | : null; | ||
| 1245 | const fullDetail = | ||
| 1246 | normalizedFullDetail && normalizedFullDetail !== detail ? normalizedFullDetail : null; | ||
| 1247 | return { | ||
| 1248 | type: "activity", | ||
| 929 | id: entry.id, | 1249 | id: entry.id, |
| 930 | createdAt: entry.createdAt, | 1250 | createdAt: entry.createdAt, |
| 931 | summary: workEntryHeading(entry), | 1251 | turnId: entry.turnId, |
| 932 | detail: workEntryPreview(entry), | 1252 | activity: { |
| 933 | status: null, | 1253 | id: entry.id, |
| 934 | }, | 1254 | createdAt: entry.createdAt, |
| 935 | })), | 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 | (s) => new Date(s.createdAt), | 1270 | (s) => new Date(s.createdAt), |
| 938 | Order.Date, | 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,9 +58,9 @@ export function AgentActivity( |
| 58 | : "now"; | 58 | : "now"; |
| 59 | const activeLabel = `${props.activeCount} active`; | 59 | const activeLabel = `${props.activeCount} active`; |
| 60 | const isLight = environment.colorScheme === "light"; | 60 | const isLight = environment.colorScheme === "light"; |
| 61 | const primaryForeground = isLight ? "#0f172a" : "#ffffff"; | 61 | const primaryForeground = isLight ? "#262626" : "#f5f5f5"; |
| 62 | const secondaryForeground = isLight ? "#475569" : "#cbd5e1"; | 62 | const secondaryForeground = isLight ? "#525252" : "#a3a3a3"; |
| 63 | const mutedForeground = isLight ? "#64748b" : "#94a3b8"; | 63 | const mutedForeground = isLight ? "#737373" : "#8e8e93"; |
| 64 | const tint = environment.isLuminanceReduced | 64 | const tint = environment.isLuminanceReduced |
| 65 | ? secondaryForeground | 65 | ? secondaryForeground |
| 66 | : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input" | 66 | : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input" |
apps/web/package.json+2-2| ... | @@ -14,8 +14,8 @@ | ... | @@ -14,8 +14,8 @@ |
| 14 | }, | 14 | }, |
| 15 | "dependencies": { | 15 | "dependencies": { |
| 16 | "@base-ui/react": "^1.4.1", | 16 | "@base-ui/react": "^1.4.1", |
| 17 | "@clerk/clerk-js": "^6.13.0", | 17 | "@clerk/clerk-js": "^6.16.0", |
| 18 | "@clerk/react": "^6.7.2", | 18 | "@clerk/react": "^6.9.0", |
| 19 | "@dnd-kit/core": "^6.3.1", | 19 | "@dnd-kit/core": "^6.3.1", |
| 20 | "@dnd-kit/modifiers": "^9.0.0", | 20 | "@dnd-kit/modifiers": "^9.0.0", |
| 21 | "@dnd-kit/sortable": "^10.0.0", | 21 | "@dnd-kit/sortable": "^10.0.0", |
apps/web/src/composer-editor-mentions.ts+14-88| ... | @@ -2,6 +2,10 @@ import { | ... | @@ -2,6 +2,10 @@ import { |
| 2 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | 2 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, |
| 3 | type TerminalContextDraft, | 3 | type TerminalContextDraft, |
| 4 | } from "./lib/terminalContext"; | 4 | } from "./lib/terminalContext"; |
| 5 | import { | ||
| 6 | collectComposerInlineTokens, | ||
| 7 | type ComposerInlineToken, | ||
| 8 | } from "@t3tools/shared/composerInlineTokens"; | ||
| 5 | 9 | ||
| 6 | export type ComposerPromptSegment = | 10 | export type ComposerPromptSegment = |
| 7 | | { | 11 | | { |
| ... | @@ -22,12 +26,6 @@ export type ComposerPromptSegment = | ... | @@ -22,12 +26,6 @@ export type ComposerPromptSegment = |
| 22 | context: TerminalContextDraft | null; | 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 | function rangeIncludesIndex(start: number, end: number, index: number): boolean { | 29 | function rangeIncludesIndex(start: number, end: number, index: number): boolean { |
| 32 | return start <= index && index < end; | 30 | return start <= index && index < end; |
| 33 | } | 31 | } |
| ... | @@ -42,84 +40,6 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void | ... | @@ -42,84 +40,6 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void |
| 42 | segments.push({ type: "text", text }); | 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 | function forEachPromptSegmentSlice( | 43 | function forEachPromptSegmentSlice( |
| 124 | prompt: string, | 44 | prompt: string, |
| 125 | visitor: ( | 45 | visitor: ( |
| ... | @@ -186,10 +106,16 @@ function forEachPromptTextSlice( | ... | @@ -186,10 +106,16 @@ function forEachPromptTextSlice( |
| 186 | 106 | ||
| 187 | function forEachMentionMatch( | 107 | function forEachMentionMatch( |
| 188 | prompt: string, | 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 | ): boolean { | 113 | ): boolean { |
| 191 | return forEachPromptTextSlice(prompt, (text, promptOffset) => { | 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 | if (visitor(match, promptOffset) === true) { | 119 | if (visitor(match, promptOffset) === true) { |
| 194 | return true; | 120 | return true; |
| 195 | } | 121 | } |
| ... | @@ -204,7 +130,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen | ... | @@ -204,7 +130,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen |
| 204 | return segments; | 130 | return segments; |
| 205 | } | 131 | } |
| 206 | 132 | ||
| 207 | const tokenMatches = collectInlineTokenMatches(text); | 133 | const tokenMatches = collectComposerInlineTokens(text); |
| 208 | let cursor = 0; | 134 | let cursor = 0; |
| 209 | for (const match of tokenMatches) { | 135 | for (const match of tokenMatches) { |
| 210 | if (match.start < cursor) { | 136 | if (match.start < cursor) { |
| ... | @@ -219,7 +145,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen | ... | @@ -219,7 +145,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen |
| 219 | segments.push({ | 145 | segments.push({ |
| 220 | type: "mention", | 146 | type: "mention", |
| 221 | path: match.value, | 147 | path: match.value, |
| 222 | source: text.slice(match.start, match.end), | 148 | source: match.source, |
| 223 | }); | 149 | }); |
| 224 | } else { | 150 | } else { |
| 225 | segments.push({ type: "skill", name: match.value }); | 151 | segments.push({ type: "skill", name: match.value }); |
infra/relay/package.json+1-1| ... | @@ -9,7 +9,7 @@ | ... | @@ -9,7 +9,7 @@ |
| 9 | "typecheck": "tsgo --noEmit" | 9 | "typecheck": "tsgo --noEmit" |
| 10 | }, | 10 | }, |
| 11 | "dependencies": { | 11 | "dependencies": { |
| 12 | "@clerk/backend": "3.4.14", | 12 | "@clerk/backend": "3.6.1", |
| 13 | "@effect/sql-pg": "catalog:", | 13 | "@effect/sql-pg": "catalog:", |
| 14 | "@t3tools/client-runtime": "workspace:*", | 14 | "@t3tools/client-runtime": "workspace:*", |
| 15 | "@t3tools/contracts": "workspace:*", | 15 | "@t3tools/contracts": "workspace:*", |
packages/shared/package.json+4| ... | @@ -143,6 +143,10 @@ | ... | @@ -143,6 +143,10 @@ |
| 143 | "types": "./src/composerTrigger.ts", | 143 | "types": "./src/composerTrigger.ts", |
| 144 | "import": "./src/composerTrigger.ts" | 144 | "import": "./src/composerTrigger.ts" |
| 145 | }, | 145 | }, |
| 146 | "./composerInlineTokens": { | ||
| 147 | "types": "./src/composerInlineTokens.ts", | ||
| 148 | "import": "./src/composerInlineTokens.ts" | ||
| 149 | }, | ||
| 146 | "./terminalLabels": { | 150 | "./terminalLabels": { |
| 147 | "types": "./src/terminalLabels.ts", | 151 | "types": "./src/terminalLabels.ts", |
| 148 | "import": "./src/terminalLabels.ts" | 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,8 +179,8 @@ importers: |
| 179 | specifier: ^0.7.1 | 179 | specifier: ^0.7.1 |
| 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) | 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 | '@clerk/expo': | 181 | '@clerk/expo': |
| 182 | specifier: ^3.3.0 | 182 | specifier: ^3.4.1 |
| 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) | 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 | '@effect/atom-react': | 184 | '@effect/atom-react': |
| 185 | specifier: 4.0.0-beta.78 | 185 | specifier: 4.0.0-beta.78 |
| 186 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.3)(scheduler@0.27.0) | 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,6 +223,9 @@ importers: |
| 223 | '@t3tools/contracts': | 223 | '@t3tools/contracts': |
| 224 | specifier: workspace:* | 224 | specifier: workspace:* |
| 225 | version: link:../../packages/contracts | 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 | '@t3tools/mobile-review-diff-native': | 229 | '@t3tools/mobile-review-diff-native': |
| 227 | specifier: file:./modules/t3-review-diff | 230 | specifier: file:./modules/t3-review-diff |
| 228 | version: file:apps/mobile/modules/t3-review-diff | 231 | version: file:apps/mobile/modules/t3-review-diff |
| ... | @@ -244,6 +247,9 @@ importers: | ... | @@ -244,6 +247,9 @@ importers: |
| 244 | expo: | 247 | expo: |
| 245 | specifier: ^56.0.0 | 248 | specifier: ^56.0.0 |
| 246 | 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) | 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 | expo-auth-session: | 253 | expo-auth-session: |
| 248 | specifier: ~56.0.12 | 254 | specifier: ~56.0.12 |
| 249 | 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) | 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,6 +374,9 @@ importers: |
| 368 | '@effect/vitest': | 374 | '@effect/vitest': |
| 369 | specifier: 4.0.0-beta.78 | 375 | specifier: 4.0.0-beta.78 |
| 370 | version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | 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 | '@types/react': | 380 | '@types/react': |
| 372 | specifier: ~19.2.0 | 381 | specifier: ~19.2.0 |
| 373 | version: 19.2.16 | 382 | version: 19.2.16 |
| ... | @@ -451,11 +460,11 @@ importers: | ... | @@ -451,11 +460,11 @@ importers: |
| 451 | specifier: ^1.4.1 | 460 | specifier: ^1.4.1 |
| 452 | version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 461 | version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 453 | '@clerk/clerk-js': | 462 | '@clerk/clerk-js': |
| 454 | specifier: ^6.13.0 | 463 | specifier: ^6.16.0 |
| 455 | version: 6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 464 | version: 6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 456 | '@clerk/react': | 465 | '@clerk/react': |
| 457 | specifier: ^6.7.2 | 466 | specifier: ^6.9.0 |
| 458 | version: 6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 467 | version: 6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 459 | '@dnd-kit/core': | 468 | '@dnd-kit/core': |
| 460 | specifier: ^6.3.1 | 469 | specifier: ^6.3.1 |
| 461 | version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 470 | version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| ... | @@ -614,8 +623,8 @@ importers: | ... | @@ -614,8 +623,8 @@ importers: |
| 614 | infra/relay: | 623 | infra/relay: |
| 615 | dependencies: | 624 | dependencies: |
| 616 | '@clerk/backend': | 625 | '@clerk/backend': |
| 617 | specifier: 3.4.14 | 626 | specifier: 3.6.1 |
| 618 | version: 3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 627 | version: 3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 619 | '@effect/sql-pg': | 628 | '@effect/sql-pg': |
| 620 | specifier: 4.0.0-beta.78 | 629 | specifier: 4.0.0-beta.78 |
| 621 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | 630 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| ... | @@ -1509,16 +1518,16 @@ packages: | ... | @@ -1509,16 +1518,16 @@ packages: |
| 1509 | resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} | 1518 | resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} |
| 1510 | engines: {node: '>= 20.12.0'} | 1519 | engines: {node: '>= 20.12.0'} |
| 1511 | 1520 | ||
| 1512 | '@clerk/backend@3.4.14': | 1521 | '@clerk/backend@3.6.1': |
| 1513 | resolution: {integrity: sha512-0iaMT7k4wDk31QVC3HMaoeVFttblwsCECTHKNQpbRzIyD8j2gHdKEw/FNjffoyqyBqPw869IQlk1YokUlwVAqQ==} | 1522 | resolution: {integrity: sha512-LkfekzF/0UMXacX+17xy3ExRraO0mm+thXejC8Q32gWHd1wLdxK3YXDsLDF00E1r1InWBKIt2ZOxs6hTwZPJjA==} |
| 1514 | engines: {node: '>=20.9.0'} | 1523 | engines: {node: '>=20.9.0'} |
| 1515 | 1524 | ||
| 1516 | '@clerk/clerk-js@6.14.0': | 1525 | '@clerk/clerk-js@6.16.0': |
| 1517 | resolution: {integrity: sha512-xreDPw31OIk/VQj36qdgjzc4Rk2HwMar25nOu/ts2gf7PrbhU4XQdrtnt74g4fTmSMp8xeyjzHqa9adDXVjISw==} | 1526 | resolution: {integrity: sha512-8xv/XDsxhOZd1n4DNIRJ2EehIRUg6UiqKAnfd0L88R2t1g6sVnLi1FInJ5i8Qyx5oY/creXx6X1AZ1V5PobRkA==} |
| 1518 | engines: {node: '>=20.9.0'} | 1527 | engines: {node: '>=20.9.0'} |
| 1519 | 1528 | ||
| 1520 | '@clerk/expo@3.3.1': | 1529 | '@clerk/expo@3.4.1': |
| 1521 | resolution: {integrity: sha512-c4g64z5sgJoGYjK0NeasNwOMy9Di7cEjICq56BHSowdOuB+6UGtWBNw+yHzgS1gxi2kJgl7WQCmmXRsoZNWxAg==} | 1530 | resolution: {integrity: sha512-gpAXsuUnsUdUD0/2XjyxaC9quF5rT+2umkmV74nBLVAFurGMMMMHvnHqrQEtZ7tH5GHNXYw5+pgmnzd1HiMQbQ==} |
| 1522 | engines: {node: '>=20.9.0'} | 1531 | engines: {node: '>=20.9.0'} |
| 1523 | peerDependencies: | 1532 | peerDependencies: |
| 1524 | '@clerk/expo-passkeys': '>=0.0.6' | 1533 | '@clerk/expo-passkeys': '>=0.0.6' |
| ... | @@ -1550,16 +1559,18 @@ packages: | ... | @@ -1550,16 +1559,18 @@ packages: |
| 1550 | optional: true | 1559 | optional: true |
| 1551 | expo-web-browser: | 1560 | expo-web-browser: |
| 1552 | optional: true | 1561 | optional: true |
| 1562 | react-dom: | ||
| 1563 | optional: true | ||
| 1553 | 1564 | ||
| 1554 | '@clerk/react@6.7.3': | 1565 | '@clerk/react@6.9.0': |
| 1555 | resolution: {integrity: sha512-xdml8bFXbOQ/Egyp7iI1f0ksLjw5nYu2Db+mttHpJzet7PRXQ3jBEEc2c0AYhOJvIYxJifHGBsf75NM8SwlOag==} | 1566 | resolution: {integrity: sha512-M0QGyGS732tYBXeG+28UgElXM2TfoSZ+4mWGisC8yxJX8NjH4hEPJTAQuZmYRLNaCyGQCuzjYVQiQRC+GbDtmA==} |
| 1556 | engines: {node: '>=20.9.0'} | 1567 | engines: {node: '>=20.9.0'} |
| 1557 | peerDependencies: | 1568 | peerDependencies: |
| 1558 | react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 | 1569 | react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 |
| 1559 | react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 | 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': | 1572 | '@clerk/shared@4.17.0': |
| 1562 | resolution: {integrity: sha512-uX8nfLb69m8mA6KWKWfuPSwoVNDRyUdufeCeTEZsdZxbRUsEYT/c0KWFN28IOQCtK09tpVtzrUHvW44v5Dc5OA==} | 1573 | resolution: {integrity: sha512-YeQ+6zDmqyor1mPHjZx18j+LssL6Pobvid8hb7HQMioSo8sGDBEVi/Z12bs+gUhe9KbdP+ygHsKOqqeGAPuPZA==} |
| 1563 | engines: {node: '>=20.9.0'} | 1574 | engines: {node: '>=20.9.0'} |
| 1564 | peerDependencies: | 1575 | peerDependencies: |
| 1565 | react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 | 1576 | react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 |
| ... | @@ -4168,6 +4179,17 @@ packages: | ... | @@ -4168,6 +4179,17 @@ packages: |
| 4168 | resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} | 4179 | resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} |
| 4169 | engines: {node: '>=10'} | 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 | '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': | 4193 | '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': |
| 4172 | resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory} | 4194 | resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory} |
| 4173 | 4195 | ||
| ... | @@ -10829,18 +10851,18 @@ snapshots: | ... | @@ -10829,18 +10851,18 @@ snapshots: |
| 10829 | fast-wrap-ansi: 0.2.2 | 10851 | fast-wrap-ansi: 0.2.2 |
| 10830 | sisteransi: 1.0.5 | 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 | dependencies: | 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 | standardwebhooks: 1.0.0 | 10857 | standardwebhooks: 1.0.0 |
| 10836 | tslib: 2.8.1 | 10858 | tslib: 2.8.1 |
| 10837 | transitivePeerDependencies: | 10859 | transitivePeerDependencies: |
| 10838 | - react | 10860 | - react |
| 10839 | - react-dom | 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 | dependencies: | 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 | '@stripe/stripe-js': 5.6.0 | 10866 | '@stripe/stripe-js': 5.6.0 |
| 10845 | '@swc/helpers': 0.5.21 | 10867 | '@swc/helpers': 0.5.21 |
| 10846 | '@tanstack/query-core': 5.100.14 | 10868 | '@tanstack/query-core': 5.100.14 |
| ... | @@ -10855,9 +10877,9 @@ snapshots: | ... | @@ -10855,9 +10877,9 @@ snapshots: |
| 10855 | - react | 10877 | - react |
| 10856 | - react-dom | 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 | dependencies: | 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 | '@stripe/stripe-js': 5.6.0 | 10883 | '@stripe/stripe-js': 5.6.0 |
| 10862 | '@swc/helpers': 0.5.21 | 10884 | '@swc/helpers': 0.5.21 |
| 10863 | '@tanstack/query-core': 5.100.14 | 10885 | '@tanstack/query-core': 5.100.14 |
| ... | @@ -10872,15 +10894,14 @@ snapshots: | ... | @@ -10872,15 +10894,14 @@ snapshots: |
| 10872 | - react | 10894 | - react |
| 10873 | - react-dom | 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 | dependencies: | 10898 | dependencies: |
| 10877 | '@clerk/clerk-js': 6.14.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) |
| 10878 | '@clerk/react': 6.7.3(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) |
| 10879 | '@clerk/shared': 4.15.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 | base-64: 1.0.0 | 10902 | base-64: 1.0.0 |
| 10881 | 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) | 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 | react: 19.2.3 | 10904 | react: 19.2.3 |
| 10883 | react-dom: 19.2.3(react@19.2.3) | ||
| 10884 | 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) | 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 | 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)) | 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 | tslib: 2.8.1 | 10907 | tslib: 2.8.1 |
| ... | @@ -10890,22 +10911,23 @@ snapshots: | ... | @@ -10890,22 +10911,23 @@ snapshots: |
| 10890 | expo-crypto: 56.0.4(expo@56.0.8) | 10911 | expo-crypto: 56.0.4(expo@56.0.8) |
| 10891 | expo-secure-store: 56.0.4(expo@56.0.8) | 10912 | expo-secure-store: 56.0.4(expo@56.0.8) |
| 10892 | 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)) | 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 | dependencies: | 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 | react: 19.2.3 | 10919 | react: 19.2.3 |
| 10898 | react-dom: 19.2.3(react@19.2.3) | 10920 | react-dom: 19.2.3(react@19.2.3) |
| 10899 | tslib: 2.8.1 | 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 | dependencies: | 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 | react: 19.2.6 | 10926 | react: 19.2.6 |
| 10905 | react-dom: 19.2.6(react@19.2.6) | 10927 | react-dom: 19.2.6(react@19.2.6) |
| 10906 | tslib: 2.8.1 | 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 | dependencies: | 10931 | dependencies: |
| 10910 | '@tanstack/query-core': 5.100.14 | 10932 | '@tanstack/query-core': 5.100.14 |
| 10911 | dequal: 2.0.3 | 10933 | dequal: 2.0.3 |
| ... | @@ -10915,7 +10937,7 @@ snapshots: | ... | @@ -10915,7 +10937,7 @@ snapshots: |
| 10915 | react: 19.2.3 | 10937 | react: 19.2.3 |
| 10916 | react-dom: 19.2.3(react@19.2.3) | 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 | dependencies: | 10941 | dependencies: |
| 10920 | '@tanstack/query-core': 5.100.14 | 10942 | '@tanstack/query-core': 5.100.14 |
| 10921 | dequal: 2.0.3 | 10943 | dequal: 2.0.3 |
| ... | @@ -12680,6 +12702,13 @@ snapshots: | ... | @@ -12680,6 +12702,13 @@ snapshots: |
| 12680 | react-dom: 19.2.6(react@19.2.6) | 12702 | react-dom: 19.2.6(react@19.2.6) |
| 12681 | shiki: 3.23.0 | 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 | '@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | 12712 | '@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': |
| 12684 | dependencies: | 12713 | dependencies: |
| 12685 | preact: 11.0.0-beta.0 | 12714 | preact: 11.0.0-beta.0 |
| ... | @@ -13452,6 +13481,16 @@ snapshots: | ... | @@ -13452,6 +13481,16 @@ snapshots: |
| 13452 | dependencies: | 13481 | dependencies: |
| 13453 | defer-to-connect: 2.0.1 | 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 | '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} | 13494 | '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} |
| 13456 | 13495 | ||
| 13457 | '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {} | 13496 | '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {} |
scripts/release-smoke.ts+1| ... | @@ -26,6 +26,7 @@ const workspaceFiles = [ | ... | @@ -26,6 +26,7 @@ const workspaceFiles = [ |
| 26 | "apps/web/package.json", | 26 | "apps/web/package.json", |
| 27 | "apps/mobile/package.json", | 27 | "apps/mobile/package.json", |
| 28 | "apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz", | 28 | "apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz", |
| 29 | "apps/mobile/modules/t3-markdown-text/package.json", | ||
| 29 | "apps/mobile/modules/t3-review-diff/package.json", | 30 | "apps/mobile/modules/t3-review-diff/package.json", |
| 30 | "apps/mobile/modules/t3-terminal/package.json", | 31 | "apps/mobile/modules/t3-terminal/package.json", |
| 31 | "apps/marketing/package.json", | 32 | "apps/marketing/package.json", |