Files
orca/mobile/src/files/AttachmentTextScrollView.tsx
NeilandKelvin Amoaba 5d61e00c6d build(mobile): upgrade to Expo SDK 58 and React Native 0.88 (#37935)
* build(mobile): upgrade to Expo SDK 58 and React Native 0.88

Expo 58 drops Zod from @expo/cli, which clears the last Zod 3 advisory
(GHSA-mrhx-c7xc-m3w5) in mobile along with its local patch and override.

- Expo 55 -> 58, React Native 0.83 -> 0.88, React 19.3, gesture-handler 3,
  webview 14, reanimated 4.7, worklets 0.13 (SDK 58 bundled versions,
  pinned below today's batch to respect minimumReleaseAge).
- Port the react-native and react-native-webview patches unchanged; drop
  expo-constants, expo-notifications, @expo/cli, expo-router, zod and
  query-string patches plus the zod/query-string/xcode>uuid overrides,
  which upstream fixed or no longer need.
- iOS minimum rises to 16.4 (Expo 58 / RN 0.88 requirement).
- Migrate to RN 0.88 strict types: instance ref types, NamedStyles,
  AppState.currentState, list header/footer nulls.
- Replace removed runtime APIs: InteractionManager -> idle callback with a
  macrotask fallback; StyleSheet.absoluteFillObject -> absoluteFill;
  FileHandle.readBytes -> readBytesSync (it became async).
- Resolve react-native/asset-registry for shared assets in Metro and alias
  the old registry path for react-native-svg in the desktop mobile-web bundle.
- Reduce the iOS scene plugin to the reconnect override; Expo 58's template
  now ships the scene delegate itself.

* fix(mobile): bridge react-native-svg web imports and libsodium runtime for RN 0.88

- Share react-native-svg web shims between the mobile-web build and its render
  checks: map the old asset-registry path to react-native-web's registry and stub
  the Android-only touch-sound TurboModule.
- Patch react-native-libsodium 1.7.0 to read the JSI runtime from the bridge proxy;
  RN 0.88 removed RCTCxxBridge.runtime.

* fix(mobile): use Expo 58's legacy promise resolver for audio permissions

* fix(mobile): skip null handles in the RPC recording timer shims for RN 0.88 types

* style(mobile): format MobilePRSidebar after rebase

* fix(mobile): name RN 0.88 text input refs for declarations; allow queued back in stack render check

- Type text input refs with an exported interface so declaration emit can name hook results
  (RN 0.88 aliases TextInputInstance to an unexported class).
- Expo Router 58 drains back() after a commit; check the interrupted push's exit from the
  frame it starts, bounded to three frames of lag.

* chore(mobile): justify the text input handle interface for React Doctor

* chore(mobile): use a nameable intersection alias for text input refs

* fix(mobile): use sync expo-file-system writes, copies and moves on Expo 58

Expo 58 made File.write/copy/move and FileHandle.writeBytes return promises.
Our callers never awaited them, so failures escaped their try/catch and the
store's tmp-then-move and PDF writes could race their close or rename.

* build(mobile): raise the page bundle budget to 10 MiB for Expo 58

Expo 58 adds about 310 KB to the page: expo-router (+258 KB, now carrying
React Navigation, net about +110 KB) and Reanimated 4.7 (+152 KB). Main had
85 KB of headroom left. 10 MiB still stays within the 10 MiB per-asset contract.

* fix(mobile): drop the window-metrics seed RN 0.88 removed from Android

RN 0.88 no longer holds window metrics: DeviceInfoModule reads the activity's
WindowMetrics on demand and setWindowDisplayMetrics is gone, so MainActivity
no longer compiled. Layout scale still comes from the screen metrics we reseed.

---------

Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
2026-11-11 23:30:02 +00:00

163 lines
5.6 KiB
TypeScript

import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Platform, ScrollView, Text, View, type ScrollViewInstance } from 'react-native'
import type {
AttachmentTextSource,
AttachmentTextWindow
} from '../../../src/shared/attachment-text/attachment-text-source'
import { AttachmentTextViewport } from '../../../src/shared/attachment-text/attachment-text-viewport'
import { chatAttachmentPreviewStyles as styles } from './mobile-file-preview-styles'
function scrollElement(view: ScrollViewInstance | null): HTMLElement | null {
if (Platform.OS !== 'web') {
return null
}
const node: unknown =
view && 'getScrollableNode' in view && typeof view.getScrollableNode === 'function'
? view.getScrollableNode()
: view
return node instanceof HTMLElement ? node : null
}
export function AttachmentTextScrollView({
source,
revision,
demand
}: {
source: AttachmentTextSource
revision: number
demand: (window: AttachmentTextWindow) => void
}) {
const scroll = useRef<ScrollViewInstance>(null)
const viewport = useRef<ScrollViewInstance>(null)
const nativeOffset = useRef(0)
const [position, setPosition] = useState({ height: 0, rowHeight: 19 })
const measured = useMemo(
() => new AttachmentTextViewport(source, position.rowHeight, position.height, revision),
[source, revision, position.rowHeight, position.height]
)
const [projection, setProjection] = useState(() => ({ layout: measured, top: 0 }))
const { layout } = projection
// Snapshot the live old track before publishing an extent that could clamp an undelivered gesture.
useLayoutEffect(() => {
if (layout !== measured) {
const live = scrollElement(scroll.current)?.scrollTop ?? nativeOffset.current
setProjection({ layout: measured, top: measured.restore(layout, live) })
}
}, [measured, layout, projection.top])
useLayoutEffect(() => {
const live = scrollElement(scroll.current)?.scrollTop ?? nativeOffset.current
if (Math.abs(live - projection.top) / layout.scale > 0.01) {
scroll.current?.scrollTo({ y: projection.top, animated: false })
nativeOffset.current = projection.top
}
const actual = scrollElement(scroll.current)?.scrollTop ?? nativeOffset.current
setProjection((old) => (old.top === actual ? old : { ...old, top: actual }))
}, [layout])
const window = layout.window(projection.top)
useEffect(() => {
if (source.size !== null) {
demand(window)
}
}, [source, demand, window.block, window.row, window.rows, window.fraction, window.tail])
const model = useRef(layout)
useLayoutEffect(() => {
model.current = layout
})
useEffect(() => {
const node = scrollElement(scroll.current)
if (!node) {
return
}
const move = (pixels: number) => {
node.scrollTop = model.current.move(model.current.anchor(node.scrollTop), pixels)
}
const wheel = (event: WheelEvent) => {
if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
return
}
event.preventDefault()
move(
event.deltaY *
(event.deltaMode === 1
? model.current.rowHeight
: event.deltaMode === 2
? node.clientHeight
: 1)
)
}
node.addEventListener('wheel', wheel, { passive: false })
return () => {
node.removeEventListener('wheel', wheel)
}
}, [])
const [width, setWidth] = useState(0)
const [cellWidth, setCellWidth] = useState(8)
const rows = layout.rows(projection.top)
const contentWidth = Math.max(width, source.displayUnits * cellWidth + 32)
return (
<ScrollView
ref={viewport}
horizontal
style={styles.scroll}
contentContainerStyle={{ flexGrow: 1 }}
onLayout={(event) => {
setWidth(event.nativeEvent.layout.width)
const height =
scrollElement(viewport.current)?.clientHeight ?? event.nativeEvent.layout.height
setPosition((old) => ({ ...old, height }))
}}
>
<ScrollView
ref={scroll}
style={{ width: contentWidth, height: layout.height, flexGrow: 0, flexShrink: 0 }}
scrollEventThrottle={16}
onScroll={(event) => {
const top = event.nativeEvent.contentOffset.y
nativeOffset.current = top
setProjection((old) => ({ ...old, top }))
}}
>
<View style={{ height: layout.total, position: 'relative', overflow: 'hidden' }}>
<Text
accessible={false}
style={[
styles.textPreview,
{ position: 'absolute', opacity: 0, alignSelf: 'flex-start' }
]}
onTextLayout={(event) => {
const line = event.nativeEvent.lines[0]
if (line?.height) {
setPosition((old) => ({ ...old, rowHeight: line.height }))
setCellWidth(line.width)
}
}}
>
M
</Text>
{rows.map((row) => (
<Text
key={row.key}
testID="attachment-text-segment"
selectable
numberOfLines={1}
style={[
styles.textPreview,
{
...(Platform.OS === 'web' ? { whiteSpace: 'pre' as const } : {}),
position: 'absolute',
top: row.top,
height: layout.rowHeight,
left: 16,
width: contentWidth - 32
}
]}
>
{row.text.replace(/\n$/, '') || '\u00a0'}
</Text>
))}
</View>
</ScrollView>
</ScrollView>
)
}