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
+176 -269
View File
@@ -1,7 +1,5 @@
import { import {
createContext,
useCallback, useCallback,
useContext,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo, useMemo,
@@ -15,7 +13,6 @@ import {
View, View,
} from 'react-native' } from 'react-native'
import Animated, { import Animated, {
type SharedValue,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
} from 'react-native-reanimated' } from 'react-native-reanimated'
@@ -23,7 +20,6 @@ import {useSift, type UseSiftReturn} from '@bsky.app/sift'
import { import {
type TapperActiveFacet, type TapperActiveFacet,
type TapperFacet, type TapperFacet,
type TapperSnapshot,
useTapper, useTapper,
} from '@bsky.app/tapper' } from '@bsky.app/tapper'
@@ -80,57 +76,27 @@ export function useComposerInternalApiRef() {
} }
/* /*
* ─── Contexts ───────────────────────────────────────────────────────────────── * ─── Composer ─────────────────────────────────────────────────────────────────
*/ */
type ComposerContextValue = { export type ComposerProps = Omit<
tapper: { TextInputProps,
on: ReturnType<typeof useTapper>['on'] | 'value'
insert: ReturnType<typeof useTapper>['insert'] | 'onChange'
input: ReturnType<typeof useTapper>['input'] | 'onChangeText'
inputProps: ReturnType<typeof useTapper>['inputProps'] | 'onSelectionChange'
} | 'selection'
sift: UseSiftReturn | 'style'
inputScrollSharedValue: SharedValue<number> | 'onSubmitEditing'
onRequestSubmit?: (request: SubmitRequest) => void > & {
} children?: React.ReactNode
label: string
const ComposerContext = createContext<ComposerContextValue | null>(null) ref?: React.Ref<TextInput>
ComposerContext.displayName = 'ComposerContext' style?: ViewStyleProp['style']
padding?: Parameters<typeof extractPadding>[0]
export function useComposerContext() { textStyle?: TextStyleProp['style']
const ctx = useContext(ComposerContext) initialNumberOfLines?: number
if (!ctx) { maxNumberOfLines?: number
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
initialText?: string initialText?: string
onChange?: (text: string) => void onChange?: (text: string) => void
onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void
@@ -139,34 +105,42 @@ export type RootProps = {
internalApiRef?: React.Ref<ComposerInternalApi> internalApiRef?: React.Ref<ComposerInternalApi>
} }
export function Root({ export function Composer({
children, children,
label,
placeholder,
style,
padding,
textStyle: rawTextStyle,
initialNumberOfLines = 1,
maxNumberOfLines,
initialText, initialText,
onChange: onChangeOuter, onChange: onChangeOuter,
onActiveFacet: onActiveFacetOuter, onActiveFacet: onActiveFacetOuter,
onFacetCommitted: onFacetCommittedOuter, onFacetCommitted: onFacetCommittedOuter,
onRequestSubmit, onRequestSubmit,
internalApiRef, internalApiRef,
}: RootProps) { ...rest
const tapper = useTapper({ }: ComposerProps) {
initialText, const {theme: t, fonts} = useAlf()
}) const textInputRef = useRef<TextInput>(null)
const tapper = useTapper({initialText})
const sift = useSift({ const sift = useSift({
offset: a.p_sm.padding, offset: a.p_sm.padding,
placement: 'top-start', placement: 'top-start',
dynamicWidth: IS_WEB, dynamicWidth: IS_WEB,
}) })
const inputScrollSharedValue = useSharedValue(0) const inputScrollSharedValue = useSharedValue(0)
const [activeFacet, setActiveFacet] = useState<TapperActiveFacet | null>(null)
const callbackRefs = useRef({ const callbackRefs = useRef({
onActiveFacetOuter, onActiveFacetOuter,
onFacetCommittedOuter, onFacetCommittedOuter,
focus: tapper.input.focus,
}) })
callbackRefs.current = { callbackRefs.current = {
onActiveFacetOuter, onActiveFacetOuter,
onFacetCommittedOuter, onFacetCommittedOuter,
focus: tapper.input.focus,
} }
useImperativeHandle( useImperativeHandle(
@@ -197,95 +171,23 @@ export function Root({
useEffect(() => { useEffect(() => {
const offActiveFacet = tapper.on('activeFacet', facet => { const offActiveFacet = tapper.on('activeFacet', facet => {
setActiveFacet(facet)
callbackRefs.current.onActiveFacetOuter?.(facet) callbackRefs.current.onActiveFacetOuter?.(facet)
}) })
const offFacetCommitted = tapper.on('facetCommitted', facet => { const offFacetCommitted = tapper.on('facetCommitted', facet => {
callbackRefs.current.onFacetCommittedOuter?.(facet) callbackRefs.current.onFacetCommittedOuter?.(facet)
}) })
const offAfterInsert = tapper.on('afterInsert', () => { const offAfterInsert = tapper.on('afterInsert', () => {
callbackRefs.current?.focus() tapper.input.focus()
}) })
return () => { return () => {
offActiveFacet() offActiveFacet()
offFacetCommitted() offFacetCommitted()
offAfterInsert() offAfterInsert()
} }
}, [tapper.on]) }, [tapper.on, tapper.input])
const composerCtx = useMemo<ComposerContextValue>( // ─── Text style computation ───────────────────────────────────────────
() => ({
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)
const {textStyle, textAreaStyle, minHeight, maxHeight} = useMemo(() => { const {textStyle, textAreaStyle, minHeight, maxHeight} = useMemo(() => {
const ts = normalizeTextStyles( const ts = normalizeTextStyles(
@@ -309,11 +211,6 @@ export function Input({
? {height: lineHeight + verticalSpace} ? {height: lineHeight + verticalSpace}
: {minHeight: mh, maxHeight: xh} : {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) { if (!IS_WEB) {
delete ts.lineHeight delete ts.lineHeight
} }
@@ -321,6 +218,15 @@ export function Input({
return {textStyle: ts, textAreaStyle: tas, minHeight: mh, maxHeight: xh} return {textStyle: ts, textAreaStyle: tas, minHeight: mh, maxHeight: xh}
}, [t, fonts, padding, rawTextStyle, initialNumberOfLines, maxNumberOfLines]) }, [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) const prevHeight = useRef(0)
useEffect(() => { useEffect(() => {
if (IS_WEB) { if (IS_WEB) {
@@ -333,7 +239,7 @@ export function Input({
el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden' el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
if (nextHeight !== prevHeight.current) { if (nextHeight !== prevHeight.current) {
prevHeight.current = nextHeight prevHeight.current = nextHeight
sift.updatePosition() updateAutocompletePosition()
} }
return return
} }
@@ -341,15 +247,19 @@ export function Input({
textInputRef.current?.measure((_x, _y, _w, h) => { textInputRef.current?.measure((_x, _y, _w, h) => {
if (h !== prevHeight.current) { if (h !== prevHeight.current) {
prevHeight.current = h prevHeight.current = h
sift.updatePosition() updateAutocompletePosition()
} }
}) })
}, [state.text, minHeight, maxHeight, sift]) }, [tapper.state.text, minHeight, maxHeight, updateAutocompletePosition])
// ─── Scroll sync ──────────────────────────────────────────────────────
const previewScrollStyle = useAnimatedStyle(() => ({ const previewScrollStyle = useAnimatedStyle(() => ({
transform: [{translateY: -inputScrollSharedValue.value}], transform: [{translateY: -inputScrollSharedValue.value}],
})) }))
// ─── Web keyboard handling ────────────────────────────────────────────
const isComposing = useRef(false) const isComposing = useRef(false)
const onKeyPressWeb = useCallback( const onKeyPressWeb = useCallback(
(e: React.KeyboardEvent | any) => { (e: React.KeyboardEvent | any) => {
@@ -377,137 +287,126 @@ export function Input({
) )
return ( return (
<View style={[a.relative, style]}> <>
<View <View style={[a.relative, style]}>
pointerEvents="none" <View
style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}> pointerEvents="none"
<Animated.View style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}>
<Animated.View
style={[
padding,
{position: 'absolute', left: 0, right: 0},
previewScrollStyle,
]}>
<Text style={[textStyle, web({whiteSpace: 'pre-wrap'})]}>
{tapper.state.nodes.map((node, i) => {
switch (node.type) {
case 'text':
return <Span key={i}>{node.value}</Span>
case 'trigger':
case 'facet':
return (
<Span
key={i}
ref={IS_WEB ? sift.refs.setAnchor : undefined}
style={
node.type === 'facet' && {
color: t.palette.primary_500,
}
}>
{node.raw}
</Span>
)
}
})}
</Text>
</Animated.View>
</View>
<TextInput
dirName="ltr"
autoCapitalize="none"
autoCorrect={false}
multiline={true}
hitSlop={HITSLOP_10}
placeholder={placeholder}
placeholderTextColor={t.palette.contrast_500}
accessibilityLabel={label}
accessibilityHint={label}
keyboardAppearance={t.scheme}
submitBehavior="newline"
onSubmitEditing={e => {
onRequestSubmit?.({platform: 'native', nativeEvent: e})
}}
style={[ style={[
textStyle,
padding, padding,
{position: 'absolute', left: 0, right: 0}, a.relative,
previewScrollStyle, a.z_20,
]}> a.border_0,
<Text style={[textStyle, web({whiteSpace: 'pre-wrap'})]}> {
{state.nodes.map((node, i) => { color: 'transparent',
switch (node.type) { background: 'transparent',
case 'text': textAlignVertical: 'top',
return <Span key={i}>{node.value}</Span> includeFontPadding: false,
case 'trigger': },
case 'facet': textAreaStyle,
return ( web({
<Span resize: 'none',
key={i} outline: 'none',
ref={IS_WEB ? sift.refs.setAnchor : undefined} caretColor: textStyle.color ?? 'black',
style={ whiteSpace: 'pre-wrap',
node.type === 'facet' && {color: t.palette.primary_500} wordBreak: 'break-word',
}> overscrollBehavior: 'none',
{node.raw} ...textAreaStyle,
</Span> }),
) ]}
} {...rest}
})} {...tapper.inputProps}
</Text> {...sift.targetProps}
</Animated.View> ref={mergeRefs([
textInputRef,
rest.ref,
tapper.inputProps.ref,
sift.targetProps.ref,
])}
onBlur={e => {
rest.onBlur?.(e)
}}
onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
onScroll={e => {
if (IS_WEB) {
inputScrollSharedValue.value = (e.target as any).scrollTop
} else {
inputScrollSharedValue.value = e.nativeEvent.contentOffset.y
}
}}
// @ts-ignore web only
onCompositionStart={() => {
isComposing.current = true
}}
// @ts-ignore web only
onCompositionEnd={() => {
isComposing.current = false
}}
/>
{children}
</View> </View>
<TextInput
dirName="ltr" {activeFacet && (
autoCapitalize="none" <AutocompleteInner
autoCorrect={false} sift={sift}
multiline={true} activeFacet={activeFacet}
hitSlop={HITSLOP_10} onDismiss={() => setActiveFacet(null)}
placeholder={placeholder} />
placeholderTextColor={t.palette.contrast_500} )}
accessibilityLabel={label} </>
accessibilityHint={label}
keyboardAppearance={t.scheme}
submitBehavior="newline"
onSubmitEditing={e => {
onRequestSubmit?.({platform: 'native', nativeEvent: e})
}}
style={[
textStyle,
padding,
a.relative,
a.z_20,
a.border_0,
{
color: 'transparent',
background: 'transparent',
textAlignVertical: 'top',
includeFontPadding: false,
},
textAreaStyle,
web({
resize: 'none',
outline: 'none',
caretColor: textStyle.color ?? 'black',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overscrollBehavior: 'none',
...textAreaStyle,
}),
]}
{...rest}
{...tapper.inputProps}
{...sift.targetProps}
ref={mergeRefs([
textInputRef,
rest.ref,
tapper.inputProps.ref,
sift.targetProps.ref,
])}
onBlur={e => {
rest.onBlur?.(e)
}}
onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
onScroll={e => {
if (IS_WEB) {
inputScrollSharedValue.value = (e.target as any).scrollTop
} else {
inputScrollSharedValue.value = e.nativeEvent.contentOffset.y
}
}}
// @ts-ignore web only
onCompositionStart={() => {
isComposing.current = true
}}
// @ts-ignore web only
onCompositionEnd={() => {
isComposing.current = false
}}
/>
</View>
) )
} }
export function Autocomplete() { /*
const {tapper, sift} = useComposerContext() * ─── Autocomplete (private) ───────────────────────────────────────────────────
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 (
<AutocompleteInner
sift={sift}
activeFacet={activeFacet}
onDismiss={() => setActiveFacet(null)}
/>
)
}
function AutocompleteInner({ function AutocompleteInner({
sift, sift,
@@ -523,6 +422,13 @@ function AutocompleteInner({
query: activeFacet.value, query: activeFacet.value,
}) })
const updatePosition = useCallback(() => {
sift.updatePosition()
}, [sift])
useOnKeyboard('keyboardDidShow', updatePosition)
useOnKeyboard('keyboardDidHide', updatePosition)
return data && data.length ? ( return data && data.length ? (
<AutocompleteBase <AutocompleteBase
sift={sift} sift={sift}
@@ -535,6 +441,7 @@ function AutocompleteInner({
}} }}
onSelect={item => { onSelect={item => {
activeFacet.replace(item.value) activeFacet.replace(item.value)
onDismiss()
}} }}
onDismiss={onDismiss} onDismiss={onDismiss}
/> />
@@ -18,7 +18,7 @@ import {
type EmojiPickerState, type EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker' } from '#/view/com/composer/text-input/web/EmojiPicker'
import {atoms as a, useTheme} from '#/alf' 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 {useInteractionState} from '#/components/hooks/useInteractionState'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
@@ -46,7 +46,7 @@ export function MessageComposer({
isOpen: false, isOpen: false,
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, 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 {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const { const {
@@ -107,62 +107,59 @@ export function MessageComposer({
<> <>
<View style={[a.px_md, a.pb_sm, a.pt_xs]}> <View style={[a.px_md, a.pb_sm, a.pt_xs]}>
{children} {children}
<Composer.Root <View
internalApiRef={composerInternalApiRef} // @ts-expect-error web only
initialText={text} onMouseEnter={onHoverIn}
onChange={setText} onMouseLeave={onHoverOut}>
onFacetCommitted={facet => { <Composer
if (facet.type === 'url' && isBskyPostUrl(facet.value)) { internalApiRef={composerInternalApiRef}
setEmbed(facet.value) initialText={text}
} onChange={setText}
}} editable={editable}
onRequestSubmit={req => { autoFocus={IS_WEB}
if (req.platform === 'web' && req.shiftKey) return label={l`Message input field`}
req.nativeEvent.preventDefault() placeholder={l`Write a message`}
onSubmit() maxNumberOfLines={12}
}}> style={[
<View t.atoms.bg_contrast_25,
// @ts-expect-error web only {
onMouseEnter={onHoverIn} borderWidth: 1,
onMouseLeave={onHoverOut}> borderColor: 'transparent',
<Composer.Input borderRadius: 25,
editable={editable} },
autoFocus={IS_WEB} editable &&
label={l`Message input field`} hovered && {
placeholder={l`Write a message`} borderColor: t.atoms.border_contrast_medium.borderColor,
maxNumberOfLines={12}
style={[
t.atoms.bg_contrast_25,
{
borderWidth: 1,
borderColor: 'transparent',
borderRadius: 25,
}, },
editable && editable &&
hovered && { focused && {
borderColor: t.atoms.border_contrast_medium.borderColor, borderColor: t.palette.primary_500,
},
editable &&
focused && {
borderColor: t.palette.primary_500,
},
]}
padding={[
a.p_md,
{
paddingRight: 35 + a.p_sm.padding,
}, },
IS_WEB ]}
? { padding={[
paddingLeft: 30 + a.p_sm.padding, a.p_md,
} {
: {}, paddingRight: 35 + a.p_sm.padding,
]} },
textStyle={[a.text_md, a.leading_snug]} IS_WEB
onFocus={onFocus} ? {
onBlur={onBlur} paddingLeft: 30 + a.p_sm.padding,
/> }
: {},
]}
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 && ( {IS_WEB && (
<Pressable <Pressable
onPress={e => { onPress={e => {
@@ -242,10 +239,8 @@ export function MessageComposer({
style={[a.relative, {left: 1}]} style={[a.relative, {left: 1}]}
/> />
</Pressable> </Pressable>
</View> </Composer>
</View>
<Composer.Autocomplete />
</Composer.Root>
</View> </View>
{IS_WEB && ( {IS_WEB && (