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 {
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,137 +287,126 @@ export function Input({
)
return (
<View style={[a.relative, style]}>
<View
pointerEvents="none"
style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}>
<Animated.View
<>
<View style={[a.relative, style]}>
<View
pointerEvents="none"
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={[
textStyle,
padding,
{position: 'absolute', left: 0, right: 0},
previewScrollStyle,
]}>
<Text style={[textStyle, web({whiteSpace: 'pre-wrap'})]}>
{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>
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
}}
/>
{children}
</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={[
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>
{activeFacet && (
<AutocompleteInner
sift={sift}
activeFacet={activeFacet}
onDismiss={() => setActiveFacet(null)}
/>
)}
</>
)
}
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 (
<AutocompleteInner
sift={sift}
activeFacet={activeFacet}
onDismiss={() => setActiveFacet(null)}
/>
)
}
/*
* ─── Autocomplete (private) ───────────────────────────────────────────────────
*/
function AutocompleteInner({
sift,
@@ -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,62 +107,59 @@ 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
editable={editable}
autoFocus={IS_WEB}
label={l`Message input field`}
placeholder={l`Write a message`}
maxNumberOfLines={12}
style={[
t.atoms.bg_contrast_25,
{
borderWidth: 1,
borderColor: 'transparent',
borderRadius: 25,
<View
// @ts-expect-error web only
onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}>
<Composer
internalApiRef={composerInternalApiRef}
initialText={text}
onChange={setText}
editable={editable}
autoFocus={IS_WEB}
label={l`Message input field`}
placeholder={l`Write a message`}
maxNumberOfLines={12}
style={[
t.atoms.bg_contrast_25,
{
borderWidth: 1,
borderColor: 'transparent',
borderRadius: 25,
},
editable &&
hovered && {
borderColor: t.atoms.border_contrast_medium.borderColor,
},
editable &&
hovered && {
borderColor: t.atoms.border_contrast_medium.borderColor,
},
editable &&
focused && {
borderColor: t.palette.primary_500,
},
]}
padding={[
a.p_md,
{
paddingRight: 35 + a.p_sm.padding,
editable &&
focused && {
borderColor: t.palette.primary_500,
},
IS_WEB
? {
paddingLeft: 30 + a.p_sm.padding,
}
: {},
]}
textStyle={[a.text_md, a.leading_snug]}
onFocus={onFocus}
onBlur={onBlur}
/>
]}
padding={[
a.p_md,
{
paddingRight: 35 + a.p_sm.padding,
},
IS_WEB
? {
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 && (
<Pressable
onPress={e => {
@@ -242,10 +239,8 @@ export function MessageComposer({
style={[a.relative, {left: 1}]}
/>
</Pressable>
</View>
<Composer.Autocomplete />
</Composer.Root>
</Composer>
</View>
</View>
{IS_WEB && (