Checkpoint: switch back to monolith

This commit is contained in:
Eric Bailey
2026-03-31 21:37:53 -05:00
parent 7f74dfd143
commit 00a6207fd8
2 changed files with 231 additions and 329 deletions
+76 -169
View File
@@ -1,7 +1,5 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useMemo,
@@ -15,7 +13,6 @@ import {
View,
} from 'react-native'
import Animated, {
type SharedValue,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
@@ -23,7 +20,6 @@ import {useSift, type UseSiftReturn} from '@bsky.app/sift'
import {
type TapperActiveFacet,
type TapperFacet,
type TapperSnapshot,
useTapper,
} from '@bsky.app/tapper'
@@ -80,57 +76,27 @@ export function useComposerInternalApiRef() {
}
/*
* ─── Contexts ─────────────────────────────────────────────────────────────────
* ─── Composer ─────────────────────────────────────────────────────────────────
*/
type ComposerContextValue = {
tapper: {
on: ReturnType<typeof useTapper>['on']
insert: ReturnType<typeof useTapper>['insert']
input: ReturnType<typeof useTapper>['input']
inputProps: ReturnType<typeof useTapper>['inputProps']
}
sift: UseSiftReturn
inputScrollSharedValue: SharedValue<number>
onRequestSubmit?: (request: SubmitRequest) => void
}
const ComposerContext = createContext<ComposerContextValue | null>(null)
ComposerContext.displayName = 'ComposerContext'
export function useComposerContext() {
const ctx = useContext(ComposerContext)
if (!ctx) {
throw new Error('useComposerContext must be used within a Composer.Root')
}
return ctx
}
type ComposerStateContextValue = {
state: TapperSnapshot
}
const ComposerStateContext = createContext<ComposerStateContextValue | null>(
null,
)
ComposerStateContext.displayName = 'ComposerStateContext'
export function useComposerStateContext() {
const ctx = useContext(ComposerStateContext)
if (!ctx) {
throw new Error(
'useComposerStateContext must be used within a Composer.Root',
)
}
return ctx
}
/*
* ─── Root ─────────────────────────────────────────────────────────────────────
*/
export type RootProps = {
children: React.ReactNode
export type ComposerProps = Omit<
TextInputProps,
| 'value'
| 'onChange'
| 'onChangeText'
| 'onSelectionChange'
| 'selection'
| 'style'
| 'onSubmitEditing'
> & {
children?: React.ReactNode
label: string
ref?: React.Ref<TextInput>
style?: ViewStyleProp['style']
padding?: Parameters<typeof extractPadding>[0]
textStyle?: TextStyleProp['style']
initialNumberOfLines?: number
maxNumberOfLines?: number
initialText?: string
onChange?: (text: string) => void
onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void
@@ -139,34 +105,42 @@ export type RootProps = {
internalApiRef?: React.Ref<ComposerInternalApi>
}
export function Root({
export function Composer({
children,
label,
placeholder,
style,
padding,
textStyle: rawTextStyle,
initialNumberOfLines = 1,
maxNumberOfLines,
initialText,
onChange: onChangeOuter,
onActiveFacet: onActiveFacetOuter,
onFacetCommitted: onFacetCommittedOuter,
onRequestSubmit,
internalApiRef,
}: RootProps) {
const tapper = useTapper({
initialText,
})
...rest
}: ComposerProps) {
const {theme: t, fonts} = useAlf()
const textInputRef = useRef<TextInput>(null)
const tapper = useTapper({initialText})
const sift = useSift({
offset: a.p_sm.padding,
placement: 'top-start',
dynamicWidth: IS_WEB,
})
const inputScrollSharedValue = useSharedValue(0)
const [activeFacet, setActiveFacet] = useState<TapperActiveFacet | null>(null)
const callbackRefs = useRef({
onActiveFacetOuter,
onFacetCommittedOuter,
focus: tapper.input.focus,
})
callbackRefs.current = {
onActiveFacetOuter,
onFacetCommittedOuter,
focus: tapper.input.focus,
}
useImperativeHandle(
@@ -197,95 +171,23 @@ export function Root({
useEffect(() => {
const offActiveFacet = tapper.on('activeFacet', facet => {
setActiveFacet(facet)
callbackRefs.current.onActiveFacetOuter?.(facet)
})
const offFacetCommitted = tapper.on('facetCommitted', facet => {
callbackRefs.current.onFacetCommittedOuter?.(facet)
})
const offAfterInsert = tapper.on('afterInsert', () => {
callbackRefs.current?.focus()
tapper.input.focus()
})
return () => {
offActiveFacet()
offFacetCommitted()
offAfterInsert()
}
}, [tapper.on])
}, [tapper.on, tapper.input])
const composerCtx = useMemo<ComposerContextValue>(
() => ({
tapper: {
on: tapper.on,
insert: tapper.insert,
input: tapper.input,
inputProps: tapper.inputProps,
},
sift,
inputScrollSharedValue,
onRequestSubmit,
}),
[
tapper.on,
tapper.insert,
tapper.input,
tapper.inputProps,
sift,
inputScrollSharedValue,
onRequestSubmit,
],
)
const stateCtx = useMemo<ComposerStateContextValue>(
() => ({state: tapper.state}),
[tapper.state],
)
return (
<ComposerContext.Provider value={composerCtx}>
<ComposerStateContext.Provider value={stateCtx}>
{children}
</ComposerStateContext.Provider>
</ComposerContext.Provider>
)
}
/*
* ─── Input ────────────────────────────────────────────────────────────────────
*/
export type InputProps = Omit<
TextInputProps,
| 'value'
| 'onChangeText'
| 'onSelectionChange'
| 'selection'
| 'style'
| 'onSubmitEditing'
> & {
label: string
ref?: React.Ref<TextInput>
style?: ViewStyleProp['style']
padding?: Parameters<typeof extractPadding>[0]
textStyle?: TextStyleProp['style']
initialNumberOfLines?: number
maxNumberOfLines?: number
}
export function Input({
label,
placeholder,
style,
padding,
textStyle: rawTextStyle,
initialNumberOfLines = 1,
maxNumberOfLines,
...rest
}: InputProps) {
const {theme: t, fonts} = useAlf()
const {tapper, sift, inputScrollSharedValue, onRequestSubmit} =
useComposerContext()
const {state} = useComposerStateContext()
const textInputRef = useRef<TextInput>(null)
// ─── Text style computation ───────────────────────────────────────────
const {textStyle, textAreaStyle, minHeight, maxHeight} = useMemo(() => {
const ts = normalizeTextStyles(
@@ -309,11 +211,6 @@ export function Input({
? {height: lineHeight + verticalSpace}
: {minHeight: mh, maxHeight: xh}
/*
* On iOS especially, TextInput and Text line height does not render the
* same way, but setting this to undefined and using the default font
* metrics works fine.
*/
if (!IS_WEB) {
delete ts.lineHeight
}
@@ -321,6 +218,15 @@ export function Input({
return {textStyle: ts, textAreaStyle: tas, minHeight: mh, maxHeight: xh}
}, [t, fonts, padding, rawTextStyle, initialNumberOfLines, maxNumberOfLines])
// ─── Height auto-resize + sift positioning ────────────────────────────
const updateAutocompletePosition = useCallback(() => {
sift.updatePosition()
}, [sift])
useOnKeyboard('keyboardDidShow', updateAutocompletePosition)
useOnKeyboard('keyboardDidHide', updateAutocompletePosition)
const prevHeight = useRef(0)
useEffect(() => {
if (IS_WEB) {
@@ -333,7 +239,7 @@ export function Input({
el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
if (nextHeight !== prevHeight.current) {
prevHeight.current = nextHeight
sift.updatePosition()
updateAutocompletePosition()
}
return
}
@@ -341,15 +247,19 @@ export function Input({
textInputRef.current?.measure((_x, _y, _w, h) => {
if (h !== prevHeight.current) {
prevHeight.current = h
sift.updatePosition()
updateAutocompletePosition()
}
})
}, [state.text, minHeight, maxHeight, sift])
}, [tapper.state.text, minHeight, maxHeight, updateAutocompletePosition])
// ─── Scroll sync ──────────────────────────────────────────────────────
const previewScrollStyle = useAnimatedStyle(() => ({
transform: [{translateY: -inputScrollSharedValue.value}],
}))
// ─── Web keyboard handling ────────────────────────────────────────────
const isComposing = useRef(false)
const onKeyPressWeb = useCallback(
(e: React.KeyboardEvent | any) => {
@@ -377,6 +287,7 @@ export function Input({
)
return (
<>
<View style={[a.relative, style]}>
<View
pointerEvents="none"
@@ -388,7 +299,7 @@ export function Input({
previewScrollStyle,
]}>
<Text style={[textStyle, web({whiteSpace: 'pre-wrap'})]}>
{state.nodes.map((node, i) => {
{tapper.state.nodes.map((node, i) => {
switch (node.type) {
case 'text':
return <Span key={i}>{node.value}</Span>
@@ -399,7 +310,9 @@ export function Input({
key={i}
ref={IS_WEB ? sift.refs.setAnchor : undefined}
style={
node.type === 'facet' && {color: t.palette.primary_500}
node.type === 'facet' && {
color: t.palette.primary_500,
}
}>
{node.raw}
</Span>
@@ -476,39 +389,25 @@ export function Input({
isComposing.current = false
}}
/>
{children}
</View>
)
}
export function Autocomplete() {
const {tapper, sift} = useComposerContext()
const [activeFacet, setActiveFacet] = useState<TapperActiveFacet | null>(null)
useEffect(() => {
const off = tapper.on('activeFacet', facet => {
setActiveFacet(facet)
})
return off
}, [tapper.on])
const updatePosition = useCallback(() => {
sift.updatePosition()
}, [sift])
useOnKeyboard('keyboardDidShow', updatePosition)
useOnKeyboard('keyboardDidHide', updatePosition)
if (!activeFacet) return null
return (
{activeFacet && (
<AutocompleteInner
sift={sift}
activeFacet={activeFacet}
onDismiss={() => setActiveFacet(null)}
/>
)}
</>
)
}
/*
* ─── Autocomplete (private) ───────────────────────────────────────────────────
*/
function AutocompleteInner({
sift,
activeFacet,
@@ -523,6 +422,13 @@ function AutocompleteInner({
query: activeFacet.value,
})
const updatePosition = useCallback(() => {
sift.updatePosition()
}, [sift])
useOnKeyboard('keyboardDidShow', updatePosition)
useOnKeyboard('keyboardDidHide', updatePosition)
return data && data.length ? (
<AutocompleteBase
sift={sift}
@@ -535,6 +441,7 @@ function AutocompleteInner({
}}
onSelect={item => {
activeFacet.replace(item.value)
onDismiss()
}}
onDismiss={onDismiss}
/>
@@ -18,7 +18,7 @@ import {
type EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker'
import {atoms as a, useTheme} from '#/alf'
import * as Composer from '#/components/Composer'
import {Composer, useComposerInternalApiRef} from '#/components/Composer'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
@@ -46,7 +46,7 @@ export function MessageComposer({
isOpen: false,
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
})
const composerInternalApiRef = Composer.useComposerInternalApiRef()
const composerInternalApiRef = useComposerInternalApiRef()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
@@ -107,25 +107,14 @@ export function MessageComposer({
<>
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
{children}
<Composer.Root
internalApiRef={composerInternalApiRef}
initialText={text}
onChange={setText}
onFacetCommitted={facet => {
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
setEmbed(facet.value)
}
}}
onRequestSubmit={req => {
if (req.platform === 'web' && req.shiftKey) return
req.nativeEvent.preventDefault()
onSubmit()
}}>
<View
// @ts-expect-error web only
onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}>
<Composer.Input
<Composer
internalApiRef={composerInternalApiRef}
initialText={text}
onChange={setText}
editable={editable}
autoFocus={IS_WEB}
label={l`Message input field`}
@@ -161,8 +150,16 @@ export function MessageComposer({
textStyle={[a.text_md, a.leading_snug]}
onFocus={onFocus}
onBlur={onBlur}
/>
onFacetCommitted={facet => {
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
setEmbed(facet.value)
}
}}
onRequestSubmit={req => {
if (req.platform === 'web' && req.shiftKey) return
req.nativeEvent.preventDefault()
onSubmit()
}}>
{IS_WEB && (
<Pressable
onPress={e => {
@@ -242,10 +239,8 @@ export function MessageComposer({
style={[a.relative, {left: 1}]}
/>
</Pressable>
</Composer>
</View>
<Composer.Autocomplete />
</Composer.Root>
</View>
{IS_WEB && (