Skip to content

Commit 74377fd

Browse files
authored
Merge pull request #233 from pylon-code/upstream/2026-09-01-voice-input
feat(mobile): add offline iPhone voice input
2 parents 81bdef6 + b21cb8f commit 74377fd

36 files changed

Lines changed: 3269 additions & 411 deletions

apps/mobile/app.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,15 @@ const config: ExpoConfig = {
338338
},
339339
},
340340
],
341+
[
342+
"expo-audio",
343+
{
344+
microphonePermission: "Allow Pylon to use your microphone for voice input.",
345+
recordAudioAndroid: false,
346+
enableBackgroundPlayback: false,
347+
enableBackgroundRecording: false,
348+
},
349+
],
341350
[
342351
"expo-camera",
343352
{

apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ public class T3ComposerEditorModule: Module {
2929
Prop("editable") { (view: T3ComposerEditorView, editable: Bool) in
3030
view.setEditable(editable)
3131
}
32+
Prop("readOnly") { (view: T3ComposerEditorView, readOnly: Bool) in
33+
view.setReadOnly(readOnly)
34+
}
3235
Prop("scrollEnabled") { (view: T3ComposerEditorView, scrollEnabled: Bool) in
3336
view.setScrollEnabled(scrollEnabled)
3437
}

apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,21 @@ private final class ComposerTextAttachment: NSTextAttachment {
6060
private final class ComposerTextView: UITextView {
6161
private static let pastedImageDirectoryName = "t3-composer-paste"
6262
private static let stalePastedImageAge: TimeInterval = 60 * 60
63+
private static let readOnlyActions = Set([
64+
"cut:",
65+
"delete:",
66+
"paste:",
67+
"redo:",
68+
"toggleBoldface:",
69+
"toggleItalics:",
70+
"toggleUnderline:",
71+
"undo:",
72+
])
6373

6474
var onPasteImages: (([String]) -> Void)?
6575
var onAttributedMutation: (() -> Void)?
6676
var onSubmit: (() -> Void)?
77+
var isReadOnly = false
6778

6879
override var keyCommands: [UIKeyCommand]? {
6980
var commands = super.keyCommands ?? []
@@ -83,6 +94,9 @@ private final class ComposerTextView: UITextView {
8394
}
8495

8596
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
97+
if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) {
98+
return false
99+
}
86100
if action == #selector(paste(_:)) {
87101
let pasteboard = UIPasteboard.general
88102
if pasteboard.hasImages ||
@@ -96,6 +110,9 @@ private final class ComposerTextView: UITextView {
96110
}
97111

98112
override func paste(_ sender: Any?) {
113+
guard !isReadOnly else {
114+
return
115+
}
99116
let pasteboard = UIPasteboard.general
100117
let imageProviders = pasteboard.itemProviders.filter {
101118
$0.canLoadObject(ofClass: UIImage.self)
@@ -117,6 +134,9 @@ private final class ComposerTextView: UITextView {
117134
}
118135

119136
override func deleteBackward() {
137+
guard !isReadOnly else {
138+
return
139+
}
120140
guard selectedRange.length == 0, selectedRange.location > 0 else {
121141
super.deleteBackward()
122142
return
@@ -160,9 +180,12 @@ private final class ComposerTextView: UITextView {
160180
}
161181

162182
group.notify(queue: .main) { [weak self] in
183+
guard let self, !self.isReadOnly else {
184+
return
185+
}
163186
let urls = images.compactMap { $0 }.compactMap(Self.writeTemporaryImage)
164187
if !urls.isEmpty {
165-
self?.onPasteImages?(urls)
188+
self.onPasteImages?(urls)
166189
}
167190
}
168191
}
@@ -175,6 +198,9 @@ private final class ComposerTextView: UITextView {
175198
}
176199

177200
override func cut(_ sender: Any?) {
201+
guard !isReadOnly else {
202+
return
203+
}
178204
guard isEditable, selectedRange.length > 0 else {
179205
return super.cut(sender)
180206
}
@@ -306,6 +332,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
306332
private var contentInsetVertical: CGFloat = 0
307333
private var shouldAutoFocus = false
308334
private var didAutoFocus = false
335+
private var isReadOnly = false
309336
private var isApplyingControlledValue = false
310337
private var nativeEventCount = 0
311338
private var lastContentSize = CGSize.zero
@@ -451,6 +478,11 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
451478
textView.isEditable = editable
452479
}
453480

481+
func setReadOnly(_ readOnly: Bool) {
482+
isReadOnly = readOnly
483+
textView.isReadOnly = readOnly
484+
}
485+
454486
func setScrollEnabled(_ scrollEnabled: Bool) {
455487
textView.isScrollEnabled = scrollEnabled
456488
}
@@ -504,13 +536,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
504536
replacementText text: String
505537
) -> Bool {
506538
restoreBaseTypingAttributes()
507-
return true
539+
return !isReadOnly
508540
}
509541

510542
public func textDroppableView(
511543
_ textDroppableView: UIView & UITextDroppable,
512544
proposalForDrop drop: UITextDropRequest
513545
) -> UITextDropProposal {
546+
guard !isReadOnly else {
547+
return UITextDropProposal(operation: .cancel)
548+
}
514549
guard droppedImageProviders(in: drop) != nil else {
515550
return drop.suggestedProposal
516551
}
@@ -527,6 +562,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
527562
_ textDroppableView: UIView & UITextDroppable,
528563
willPerformDrop drop: UITextDropRequest
529564
) {
565+
guard !isReadOnly else {
566+
return
567+
}
530568
guard let imageProviders = droppedImageProviders(in: drop) else {
531569
return
532570
}

apps/mobile/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"@noble/curves": "catalog:",
5454
"@noble/hashes": "catalog:",
5555
"@pierre/diffs": "catalog:",
56+
"@react-native-ai/apple": "0.12.0",
5657
"@react-native-menu/menu": "^2.0.0",
5758
"@react-navigation/elements": "2.9.26",
5859
"@react-navigation/native": "7.3.4",
@@ -73,6 +74,7 @@
7374
"effect": "catalog:",
7475
"expo": "~57.0.18",
7576
"expo-asset": "~57.0.15",
77+
"expo-audio": "~57.0.4",
7678
"expo-auth-session": "~57.0.10",
7779
"expo-blur": "~57.0.2",
7880
"expo-build-properties": "~57.0.15",
@@ -141,7 +143,8 @@
141143
"autolinking": {
142144
"buildFromSource": [
143145
"react-native-screens",
144-
"@react-native-menu/menu"
146+
"@react-native-menu/menu",
147+
"expo-audio"
145148
]
146149
}
147150
},

apps/mobile/src/components/GlassSurface.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";
2-
import type { ReactNode } from "react";
2+
import type { ReactNode, Ref } from "react";
33
import {
44
Platform,
55
useColorScheme,
@@ -20,6 +20,7 @@ const ThemedGlassView = withUniwind(GlassView, {
2020
});
2121

2222
interface GlassSurfaceProps extends ViewProps {
23+
readonly ref?: Ref<View>;
2324
readonly children: ReactNode;
2425
readonly glassEffectStyle?: "clear" | "regular" | "none";
2526
readonly tintColor?: ColorValue;
@@ -32,6 +33,7 @@ interface GlassSurfaceProps extends ViewProps {
3233
}
3334

3435
export function GlassSurface({
36+
ref,
3537
children,
3638
glassEffectStyle = "regular",
3739
chrome = "default",
@@ -68,6 +70,7 @@ export function GlassSurface({
6870
return (
6971
<ThemedGlassView
7072
{...props}
73+
ref={ref}
7174
className={cn(
7275
chrome === "none"
7376
? "border-0 border-transparent bg-transparent"
@@ -90,6 +93,7 @@ export function GlassSurface({
9093
return (
9194
<View
9295
{...props}
96+
ref={ref}
9397
className={cn(
9498
chrome === "none"
9599
? "border-0 border-transparent bg-transparent"

0 commit comments

Comments
 (0)