Skip to content

Commit f42e804

Browse files
committed
chore: merge current main into composer performance branch
2 parents 86037a9 + 2fae0f9 commit f42e804

675 files changed

Lines changed: 43243 additions & 39373 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.

‎.github/VOUCHED.td‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ github:mackinleysmith
5353
github:maria-rcks
5454
github:MatthewFeroz
5555
github:maxwellyoung
56+
github:mweinbach
5657
github:mwolson
5758
github:myacoub91
5859
github:naMqe-h

‎.github/workflows/ci.yml‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,31 @@ jobs:
268268
- name: Setup Vite+
269269
uses: ./.github/actions/setup-ci-deps
270270

271+
# The browser project runs React tests in headless Chromium. Same cache key as
272+
# test_server, so both jobs share one downloaded browser.
273+
- name: Resolve Playwright version
274+
id: playwright_version
275+
run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT"
276+
277+
- name: Cache Chromium
278+
id: chromium_cache
279+
uses: actions/cache@v6
280+
with:
281+
path: ~/.cache/ms-playwright
282+
key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }}
283+
284+
- name: Install Chromium for web browser tests
285+
if: steps.chromium_cache.outputs.cache-hit != 'true'
286+
run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium
287+
288+
- name: Install missing Chromium system dependencies
289+
run: |
290+
browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit)
291+
test -n "$browser"
292+
if ldd "$browser" | grep '=> not found'; then
293+
node apps/web/node_modules/playwright/cli.js install-deps chromium
294+
fi
295+
271296
- name: Test
272297
run: vp run --filter @t3tools/web test
273298

‎.github/workflows/mobile-eas-production.yml‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,29 @@ jobs:
234234
MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
235235
run: eas build --platform ${{ inputs.platform }} --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait
236236

237+
# @expo/fingerprint swallows autolinking errors and returns no Expo module
238+
# sources, so native changes stop moving the hash and OTAs reach binaries
239+
# that lack them. Refuse to publish any OTA from such a fingerprint.
240+
- name: Verify fingerprints hash Expo native modules
241+
if: steps.expo-token.outputs.present == 'true' && (github.event_name == 'push' || inputs.mode == 'update')
242+
working-directory: apps/mobile
243+
env:
244+
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
245+
PLATFORM: ${{ inputs.platform || 'all' }}
246+
run: |
247+
if [ "$PLATFORM" = "all" ]; then platforms="ios android"; else platforms="$PLATFORM"; fi
248+
failed=0
249+
for platform in $platforms; do
250+
# eas-cli prints an environment-loaded notice to stdout before the
251+
# JSON even with --json, so discard everything before the document.
252+
expo_modules="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq --arg reason "expoAutolinking${platform^}" '[.sources[] | select((.reasons // []) | index($reason))] | length')"
253+
if [ "${expo_modules:-0}" -eq 0 ]; then
254+
failed=1
255+
echo ":x: $platform: the native fingerprint hashes no Expo native modules — OTAs and builds skipped" >> "$GITHUB_STEP_SUMMARY"
256+
fi
257+
done
258+
exit "$failed"
259+
237260
- name: Publish OTA update (manual)
238261
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update'
239262
working-directory: apps/mobile
-1.05 MB
Binary file not shown.
1.12 MB
Binary file not shown.

‎apps/mobile/modules/t3-native-controls/android/build.gradle‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ dependencies {
2727
implementation project(':expo-modules-core')
2828
implementation 'androidx.compose.ui:ui-android:1.10.6'
2929
implementation 'androidx.compose.material3:material3-android:1.5.0-alpha17'
30+
implementation 'androidx.window:window:1.5.1'
3031
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package expo.modules.t3nativecontrols
2+
3+
import android.content.Context
4+
import android.hardware.Sensor
5+
import android.hardware.SensorEvent
6+
import android.hardware.SensorEventListener
7+
import android.hardware.SensorManager
8+
import android.os.Build
9+
import androidx.window.layout.FoldingFeature
10+
import androidx.window.layout.WindowInfoTracker
11+
import expo.modules.kotlin.AppContext
12+
import expo.modules.kotlin.modules.Module
13+
import expo.modules.kotlin.modules.ModuleDefinition
14+
import expo.modules.kotlin.viewevent.EventDispatcher
15+
import expo.modules.kotlin.views.ExpoView
16+
import kotlinx.coroutines.CoroutineScope
17+
import kotlinx.coroutines.MainScope
18+
import kotlinx.coroutines.cancel
19+
import kotlinx.coroutines.launch
20+
21+
class T3FoldPostureModule : Module() {
22+
override fun definition() = ModuleDefinition {
23+
Name("T3FoldPosture")
24+
25+
View(T3FoldPostureView::class) {
26+
Events("onHingeChange")
27+
}
28+
}
29+
}
30+
31+
/**
32+
* Reports a vertical hinge, in this view's dp coordinates, while the device is bent like a book.
33+
* The hinge sensor is only registered while a vertical fold is on screen.
34+
*/
35+
class T3FoldPostureView(
36+
context: Context,
37+
appContext: AppContext
38+
) : ExpoView(context, appContext), SensorEventListener {
39+
private val onHingeChange by EventDispatcher()
40+
private val sensorManager = context.getSystemService(SensorManager::class.java)
41+
private val hingeSensor =
42+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
43+
sensorManager?.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE)
44+
} else {
45+
null
46+
}
47+
private var scope: CoroutineScope? = null
48+
private var fold: FoldingFeature? = null
49+
private var listening = false
50+
private var bent = false
51+
private var reported: Map<String, Any>? = null
52+
53+
override fun onAttachedToWindow() {
54+
super.onAttachedToWindow()
55+
observeFold()
56+
}
57+
58+
// The activity can be missing when the view first attaches, so layout passes retry.
59+
private fun observeFold() {
60+
if (scope != null || !isAttachedToWindow) return
61+
val activity = appContext.currentActivity ?: return
62+
scope = MainScope().also { scope ->
63+
scope.launch {
64+
WindowInfoTracker.getOrCreate(activity).windowLayoutInfo(activity).collect { info ->
65+
fold = info.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull {
66+
it.orientation == FoldingFeature.Orientation.VERTICAL
67+
}
68+
listen(fold != null)
69+
report()
70+
}
71+
}
72+
}
73+
}
74+
75+
override fun onDetachedFromWindow() {
76+
scope?.cancel()
77+
scope = null
78+
listen(false)
79+
super.onDetachedFromWindow()
80+
}
81+
82+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
83+
super.onLayout(changed, left, top, right, bottom)
84+
observeFold()
85+
if (changed) report()
86+
}
87+
88+
override fun onSensorChanged(event: SensorEvent) {
89+
// Pixel foldables report the hinge in 5° steps and rest at 175° when nearly flat. Snapping at
90+
// 170° and releasing at 175° keeps a resting hinge from flapping the layout.
91+
val angle = event.values[0]
92+
bent = if (bent) angle < 175f else angle <= 170f
93+
report()
94+
}
95+
96+
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
97+
98+
private fun listen(enabled: Boolean) {
99+
if (enabled == listening || hingeSensor == null) return
100+
listening = enabled
101+
if (enabled) {
102+
sensorManager.registerListener(this, hingeSensor, SensorManager.SENSOR_DELAY_UI)
103+
} else {
104+
sensorManager.unregisterListener(this)
105+
bent = false
106+
}
107+
}
108+
109+
private fun report() {
110+
val feature = fold
111+
// Without a hinge sensor, the platform's half-opened posture is the closest signal.
112+
val aligned = feature != null &&
113+
if (hingeSensor != null) bent else feature.state == FoldingFeature.State.HALF_OPENED
114+
val hinge = if (aligned && feature != null) {
115+
val origin = IntArray(2).also { getLocationInWindow(it) }
116+
val density = resources.displayMetrics.density
117+
mapOf(
118+
"x" to (feature.bounds.left - origin[0]) / density,
119+
"windowX" to feature.bounds.left / density,
120+
"width" to feature.bounds.width() / density
121+
)
122+
} else {
123+
emptyMap()
124+
}
125+
if (hinge == reported) return
126+
reported = hinge
127+
onHingeChange(hinge)
128+
}
129+
}

‎apps/mobile/modules/t3-native-controls/expo-module.config.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"modules": [
1212
"expo.modules.t3nativecontrols.T3NativeControlsModule",
1313
"expo.modules.t3nativecontrols.T3WorkspaceNavigationModule",
14-
"expo.modules.t3nativecontrols.T3KeyboardCommandsModule"
14+
"expo.modules.t3nativecontrols.T3KeyboardCommandsModule",
15+
"expo.modules.t3nativecontrols.T3FoldPostureModule"
1516
]
1617
}
1718
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ public final class T3NativeControlsModule: Module {
2020
}
2121
return ["supportsWorkspaceColumns": false]
2222
}
23+
View(T3SegmentedControlView.self) {
24+
ViewName("SegmentedControl")
25+
Events("onSelectionChange")
26+
Prop("labels") { (view: T3SegmentedControlView, value: [String]) in view.labels = value }
27+
Prop("selectedIndex") { (view: T3SegmentedControlView, value: Int) in view.selectedIndex = value }
28+
Prop("trackColor") { (view: T3SegmentedControlView, value: UIColor) in view.trackColor = value }
29+
Prop("selectedColor") { (view: T3SegmentedControlView, value: UIColor) in view.selectedColor = value }
30+
Prop("textColor") { (view: T3SegmentedControlView, value: UIColor) in view.textColor = value }
31+
Prop("mutedColor") { (view: T3SegmentedControlView, value: UIColor) in view.mutedColor = value }
32+
Prop("appearance") { (view: T3SegmentedControlView, value: String) in view.appearance = value }
33+
}
34+
2335
View(T3LayoutMetricsView.self) {
2436
ViewName("LayoutMetrics")
2537
Events("onMetricsChange")
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import ExpoModulesCore
2+
import UIKit
3+
4+
final class T3SegmentedControlView: ExpoView {
5+
let onSelectionChange = EventDispatcher()
6+
private let control = UISegmentedControl()
7+
var labels: [String] = [] {
8+
didSet {
9+
guard labels != oldValue else { return }
10+
control.removeAllSegments()
11+
for (index, label) in labels.enumerated() {
12+
control.insertSegment(withTitle: label, at: index, animated: false)
13+
}
14+
updateSelection()
15+
}
16+
}
17+
var selectedIndex = -1 { didSet { updateSelection() } }
18+
var trackColor: UIColor = .secondarySystemBackground { didSet { if trackColor != oldValue { control.backgroundColor = trackColor } } }
19+
var selectedColor: UIColor = .secondarySystemFill { didSet { if selectedColor != oldValue { control.selectedSegmentTintColor = selectedColor } } }
20+
var textColor: UIColor = .label { didSet { if textColor != oldValue { updateColors() } } }
21+
var mutedColor: UIColor = .secondaryLabel { didSet { if mutedColor != oldValue { updateColors() } } }
22+
var appearance = "light" {
23+
didSet {
24+
guard appearance != oldValue else { return }
25+
overrideUserInterfaceStyle = appearance == "dark" ? .dark : .light
26+
}
27+
}
28+
29+
required init(appContext: AppContext? = nil) {
30+
super.init(appContext: appContext)
31+
addSubview(control)
32+
control.addTarget(self, action: #selector(selectionChanged), for: .valueChanged)
33+
}
34+
35+
override func layoutSubviews() {
36+
super.layoutSubviews()
37+
guard control.frame != bounds else { return }
38+
UIView.performWithoutAnimation {
39+
control.frame = bounds
40+
}
41+
}
42+
43+
private func updateSelection() {
44+
let index = labels.indices.contains(selectedIndex) ? selectedIndex : UISegmentedControl.noSegment
45+
// React echoes native selections while UIKit is still settling the glass lens.
46+
guard control.selectedSegmentIndex != index else { return }
47+
UIView.performWithoutAnimation {
48+
control.selectedSegmentIndex = index
49+
control.layoutIfNeeded()
50+
}
51+
}
52+
53+
private func updateColors() {
54+
control.setTitleTextAttributes([.foregroundColor: mutedColor], for: .normal)
55+
control.setTitleTextAttributes([.foregroundColor: textColor], for: .selected)
56+
}
57+
58+
@objc private func selectionChanged() {
59+
onSelectionChange(["index": control.selectedSegmentIndex])
60+
}
61+
}

0 commit comments

Comments
 (0)