Skip to content

Commit 8d3b061

Browse files
fix(mobile): unify draft and thread navigation and composer behavior (#23467)
1 parent 43879d9 commit 8d3b061

78 files changed

Lines changed: 2533 additions & 1829 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,8 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
467467
public required init(appContext: AppContext? = nil) {
468468
super.init(appContext: appContext)
469469

470-
clipsToBounds = false
470+
clipsToBounds = true
471+
textView.clipsToBounds = true
471472
textView.delegate = self
472473
textView.textDropDelegate = self
473474
textView.backgroundColor = .clear
@@ -1215,28 +1216,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
12151216
}
12161217

12171218
private func emitContentSizeIfNeeded() {
1218-
// The laid-out text without insets. A one-line draft reports its natural width, so
1219-
// JS can tell whether it fits a narrower row than the one it is laid out in; past
1220-
// one line only the height matters, so layout stops at the second line. The width
1221-
// comes from the line fragment because usedRect spans the container once the view
1222-
// scrolls. A trailing newline's empty line only exists as the extra fragment.
1223-
let layout = textView.layoutManager
1224-
let container = textView.textContainer
1225-
var lines = 0
1226-
var width: CGFloat = 0
1227-
var height: CGFloat = 0
1228-
let glyphs = NSRange(location: 0, length: layout.numberOfGlyphs)
1229-
layout.enumerateLineFragments(forGlyphRange: glyphs) { _, used, _, _, stop in
1230-
lines += 1
1231-
width = used.width
1232-
height = used.maxY
1233-
if lines > 1 { stop.pointee = true }
1234-
}
1235-
if lines < 2, layout.extraLineFragmentTextContainer === container {
1236-
lines += 1
1237-
height = max(height, layout.extraLineFragmentUsedRect.maxY)
1238-
}
1239-
let nextSize = CGSize(width: lines == 1 ? ceil(width) : 0, height: ceil(height))
1219+
guard bounds.width > 0 else { return }
1220+
// Fit independently of the animated viewport: when scrolling is disabled,
1221+
// its current height can clip TextKit's laid-out line fragments.
1222+
let fittedHeight = textView.sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)).height
1223+
- textView.textContainerInset.top - textView.textContainerInset.bottom
1224+
let measuredHeight = min(lineHeight * 4, fittedHeight)
1225+
let width = fittedHeight <= lineHeight + 0.5
1226+
? textView.layoutManager.usedRect(for: textView.textContainer).width
1227+
: 0
1228+
let nextSize = CGSize(width: ceil(width), height: ceil(measuredHeight))
12401229
guard abs(nextSize.width - lastContentSize.width) > 0.5 ||
12411230
abs(nextSize.height - lastContentSize.height) > 0.5 else {
12421231
return
366 Bytes
Loading
620 Bytes
Loading
1006 Bytes
Loading

‎apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ Pod::Spec.new do |s|
1515
s.pod_target_xcconfig = {
1616
'DEFINES_MODULE' => 'YES',
1717
}
18+
s.resource_bundles = { 'T3NativeControlIcons' => ['Resources/Icons/pull-request*.png'] }
1819
s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}'
1920
end

‎apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ public final class T3NativeControlsModule: Module {
3232
Prop("appearance") { (view: T3SegmentedControlView, value: String) in view.appearance = value }
3333
}
3434

35+
View(T3SidebarTabBarView.self) {
36+
ViewName("SidebarTabBar")
37+
Events("onSelectionChange")
38+
Prop("selectedIndex") { (view: T3SidebarTabBarView, value: Int) in view.selectedIndex = value }
39+
Prop("selectedColor") { (view: T3SidebarTabBarView, value: UIColor) in view.selectedColor = value }
40+
Prop("appearance") { (view: T3SidebarTabBarView, value: String) in view.appearance = value }
41+
}
42+
3543
View(T3LayoutMetricsView.self) {
3644
ViewName("LayoutMetrics")
3745
Events("onMetricsChange")
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import ExpoModulesCore
2+
import UIKit
3+
4+
final class T3SidebarTabBarView: ExpoView, UITabBarDelegate {
5+
let onSelectionChange = EventDispatcher()
6+
private let tabBar = UITabBar()
7+
var selectedIndex = 0 { didSet { updateSelection() } }
8+
var selectedColor: UIColor = .systemBlue { didSet { tabBar.tintColor = selectedColor } }
9+
var appearance = "light" {
10+
didSet { overrideUserInterfaceStyle = appearance == "dark" ? .dark : .light }
11+
}
12+
13+
required init(appContext: AppContext? = nil) {
14+
super.init(appContext: appContext)
15+
let iconBundle = Bundle(for: Self.self).url(forResource: "T3NativeControlIcons", withExtension: "bundle").flatMap(Bundle.init(url:))
16+
let pullRequestIcon = UIImage(named: "pull-request", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate)
17+
tabBar.items = [
18+
UITabBarItem(title: "Threads", image: tabIcon(UIImage(systemName: "text.bubble")), tag: 0),
19+
UITabBarItem(title: "Pulls", image: tabIcon(pullRequestIcon), tag: 1),
20+
]
21+
tabBar.items?.last?.accessibilityLabel = "Pull requests"
22+
tabBar.items?.first?.selectedImage = tabIcon(UIImage(systemName: "text.bubble.fill"))
23+
tabBar.itemPositioning = .fill
24+
tabBar.delegate = self
25+
addSubview(tabBar)
26+
updateSelection()
27+
}
28+
29+
override func layoutSubviews() {
30+
super.layoutSubviews()
31+
tabBar.frame = bounds
32+
updateSelection()
33+
}
34+
35+
override func didMoveToWindow() {
36+
super.didMoveToWindow()
37+
updateSelection()
38+
}
39+
40+
// Give symbols and bitmap assets the same image box; UIKit otherwise scales symbols independently.
41+
private func tabIcon(_ image: UIImage?) -> UIImage? {
42+
guard let image else { return nil }
43+
let canvas = CGSize(width: 28, height: 28)
44+
let scale = min(24 / image.size.width, 24 / image.size.height)
45+
let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
46+
return UIGraphicsImageRenderer(size: canvas).image { _ in
47+
image.draw(in: CGRect(x: (canvas.width - size.width) / 2, y: (canvas.height - size.height) / 2, width: size.width, height: size.height))
48+
}.withRenderingMode(.alwaysTemplate)
49+
}
50+
51+
private func updateSelection() {
52+
guard let items = tabBar.items, items.indices.contains(selectedIndex) else { return }
53+
guard tabBar.selectedItem !== items[selectedIndex] else { return }
54+
tabBar.selectedItem = items[selectedIndex]
55+
}
56+
57+
func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
58+
onSelectionChange(["index": item.tag])
59+
}
60+
}

‎apps/mobile/src/App.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { StatusBar } from "react-native";
66
import { GestureHandlerRootView } from "react-native-gesture-handler";
77
import { KeyboardProvider } from "react-native-keyboard-controller";
88
import { SafeAreaProvider } from "react-native-safe-area-context";
9-
import { createStaticNavigation } from "@react-navigation/native";
9+
import { createStaticNavigation, getStateFromPath } from "@react-navigation/native";
1010

1111
import { RegistryContext } from "@effect/atom-react";
1212
import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet";
@@ -24,7 +24,7 @@ import { subscribeComposerDraftModelReconciliation } from "./state/use-composer-
2424
import { environmentThreadShells } from "./state/threads";
2525
import { environmentShell } from "./state/shell";
2626
import { OverlayPortalHost } from "./components/ui/OverlayPortal";
27-
import { shouldHandleAppLink } from "./lib/appLinking";
27+
import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking";
2828
import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
2929
import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";
3030
import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
@@ -44,6 +44,8 @@ const appLinking = {
4444
prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"],
4545
config: { initialRouteName: "Home" },
4646
filter: shouldHandleAppLink,
47+
getStateFromPath: (path: string, options: Parameters<typeof getStateFromPath>[1]) =>
48+
restoreDraftLinkState(getStateFromPath(path, options), path),
4749
};
4850

4951
const Navigation = createStaticNavigation(RootStack);

0 commit comments

Comments
 (0)