Checkpoint: almost there with autocomplete

This commit is contained in:
Eric Bailey
2026-03-31 21:10:34 -05:00
parent f0671edfe9
commit 1a15717eca
9 changed files with 1597 additions and 553 deletions
@@ -0,0 +1,52 @@
import {useCallback} from 'react'
import {Sift, type UseSiftReturn} from '@bsky.app/sift'
import {atoms as a, useTheme} from '#/alf'
import {type AutocompleteItem} from '#/components/Autocomplete/types'
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
import {Portal} from '#/components/Portal'
import {IS_WEB} from '#/env'
export function Autocomplete({
sift,
data,
render,
onSelect,
onDismiss,
}: {
sift: UseSiftReturn
data: AutocompleteItem[]
render: Parameters<typeof Sift<AutocompleteItem>>[0]['render']
onSelect: (item: AutocompleteItem) => void
onDismiss: () => void
}) {
const t = useTheme()
const updatePosition = useCallback(() => {
sift.updatePosition()
}, [sift])
useOnKeyboard('keyboardDidShow', updatePosition)
useOnKeyboard('keyboardDidHide', updatePosition)
return (
<Portal>
<Sift
inverted={!IS_WEB}
sift={sift}
data={data}
onSelect={onSelect}
onDismiss={onDismiss}
style={[
a.overflow_hidden,
a.rounded_md,
a.border,
t.atoms.border_contrast_low,
t.atoms.bg,
!IS_WEB && a.w_full,
]}
render={render}
/>
</Portal>
)
}
@@ -0,0 +1,38 @@
import {SiftItem} from '@bsky.app/sift'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {atoms as a, useTheme} from '#/alf'
import * as ProfileCard from '#/components/ProfileCard'
import {type AutocompleteItemProps} from './types'
export function AutocompleteItemProfile({
active,
props,
item,
}: AutocompleteItemProps) {
const t = useTheme()
const moderationOpts = useModerationOpts()
if (item.type !== 'profile' || !moderationOpts) return null
return (
<SiftItem
{...props}
style={s => [
a.px_md,
a.py_sm,
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={item.profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={item.profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
</SiftItem>
)
}
+4
View File
@@ -0,0 +1,4 @@
export * from './Autocomplete'
export * from './AutocompleteItemProfile'
export * from './useAutocomplete'
export * from './util'
+35
View File
@@ -0,0 +1,35 @@
import {type Sift} from '@bsky.app/sift'
import type * as bsky from '#/types/bsky'
export type AutocompleteProfile = {
key: string
type: 'profile'
value: string
profile: bsky.profile.AnyProfileView
}
export type AutocompleteTag = {
key: string
type: 'tag'
value: string
tag: string
}
export type AutocompleteEmoji = {
key: string
type: 'emoji'
value: string
emoji: string
}
export type AutocompleteItem =
| AutocompleteProfile
| AutocompleteTag
| AutocompleteEmoji
export type AutocompleteItemType = AutocompleteItem['type']
export type AutocompleteItemProps = Parameters<
Parameters<typeof Sift<AutocompleteItem>>[0]['render']
>[0]
@@ -0,0 +1,112 @@
import {useCallback} from 'react'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {keepPreviousData, useQuery} from '@tanstack/react-query'
import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
import {
type AutocompleteItem,
type AutocompleteItemType,
type AutocompleteProfile,
} from '#/components/Autocomplete/types'
const DEFAULT_MOD_OPTS = {
userDid: undefined,
prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
}
export function useAutocomplete({
type,
query,
limit,
}: {
type: AutocompleteItemType
query: string
limit?: number
}) {
const agent = useAgent()
const moderationOpts = useModerationOpts()
return useQuery({
staleTime: STALE.MINUTES.ONE,
queryKey: [
'autocomplete',
{
type,
query,
},
],
async queryFn() {
if (type === 'profile') {
// TODO return recents
if (!query) return []
const res = await agent.searchActorsTypeahead({
q: query,
limit: limit || 8,
})
return (res?.data.actors || []).map(profile => ({
key: profile.did,
type: 'profile' as const,
value: '@' + profile.handle,
profile,
}))
}
return []
},
select: useCallback(
(items: AutocompleteItem[]) => {
const seen = new Set<string>()
let results: AutocompleteItem[] = []
for (const item of items) {
if (seen.has(item.key)) continue
seen.add(item.key)
if (item.type === 'profile') {
const moderated = moderateProfileItem({
query,
item,
moderationOpts: moderationOpts || DEFAULT_MOD_OPTS,
})
if (moderated) results.push(moderated)
} else {
results.push(item)
}
}
return results
},
[query, moderationOpts],
),
placeholderData: keepPreviousData,
})
}
function moderateProfileItem({
query,
item,
moderationOpts,
}: {
query: string
item: AutocompleteProfile
moderationOpts: ModerationOpts
}) {
const modui = moderateProfile(item.profile, moderationOpts).ui('profileList')
const isExactMatch = query && item.profile.handle.toLowerCase() === query
if (
(isExactMatch && !moduiContainsHideableOffense(modui)) ||
!modui.filter ||
isJustAMute(modui)
) {
return item
}
return null
}
+12
View File
@@ -0,0 +1,12 @@
export function parseAutocompleteItemType(type: string) {
switch (type) {
case 'mention':
return 'profile'
case 'tag':
return 'tag'
case 'emoji':
return 'emoji'
default:
throw new Error(`Unknown autocomplete item type: ${type}`)
}
}
+533
View File
@@ -0,0 +1,533 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {
TextInput,
type TextInputProps,
type TextInputSubmitEditingEvent,
View,
} from 'react-native'
import Animated, {
type SharedValue,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {useSift, type UseSiftReturn} from '@bsky.app/sift'
import {
type TapperActiveFacet,
type TapperFacet,
type TapperSnapshot,
useTapper,
} from '@bsky.app/tapper'
import {HITSLOP_10} from '#/lib/constants'
import {mergeRefs} from '#/lib/merge-refs'
import {
atoms as a,
extractPadding,
type TextStyleProp,
useAlf,
type ViewStyleProp,
web,
} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography'
import {
Autocomplete as AutocompleteBase,
AutocompleteItemProfile,
parseAutocompleteItemType,
useAutocomplete,
} from '#/components/Autocomplete'
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
import {Span, Text} from '#/components/Typography'
import {IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
/*
* ─── Types ────────────────────────────────────────────────────────────────────
*/
export type SubmitRequest =
| {
platform: 'web'
shiftKey: boolean
metaKey: boolean
nativeEvent: KeyboardEvent
}
| {
platform: 'native'
nativeEvent: TextInputSubmitEditingEvent
}
/**
* Bail-out API for special cases where a parent component needs to
* imperatively control the Composer (e.g. clearing the input on submit).
* Prefer props/callbacks for normal data flow.
*/
export type ComposerInternalApi = {
input?: ReturnType<typeof useTapper>['input']
clear: () => void
insert(text: string): void
}
export function useComposerInternalApiRef() {
return useRef<ComposerInternalApi>(null)
}
/*
* ─── Contexts ─────────────────────────────────────────────────────────────────
*/
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
initialText?: string
onChange?: (text: string) => void
onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void
onFacetCommitted?: (facet: TapperFacet) => void
onRequestSubmit?: (request: SubmitRequest) => void
internalApiRef?: React.Ref<ComposerInternalApi>
}
export function Root({
children,
initialText,
onChange: onChangeOuter,
onActiveFacet: onActiveFacetOuter,
onFacetCommitted: onFacetCommittedOuter,
onRequestSubmit,
internalApiRef,
}: RootProps) {
const tapper = useTapper({
initialText,
})
const sift = useSift({
offset: a.p_sm.padding,
placement: 'top-start',
dynamicWidth: IS_WEB,
})
const inputScrollSharedValue = useSharedValue(0)
const callbackRefs = useRef({
onActiveFacetOuter,
onFacetCommittedOuter,
})
callbackRefs.current = {
onActiveFacetOuter,
onFacetCommittedOuter,
}
useImperativeHandle(
internalApiRef,
() => ({
input: tapper.input,
clear: () => {
tapper.inputProps.onChangeText('')
inputScrollSharedValue.value = 0
},
insert: tapper.insert,
}),
[tapper.input, tapper.insert, inputScrollSharedValue],
)
/*
* Skip the initial mount to avoid an unnecessary re-render — the parent
* already knows the initial value since it passed `initialText`.
*/
const isFirstRender = useRef(true)
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
}
onChangeOuter?.(tapper.state.text)
}, [tapper.state.text, onChangeOuter])
useEffect(() => {
const offActiveFacet = tapper.on('activeFacet', facet => {
callbackRefs.current.onActiveFacetOuter?.(facet)
})
const offFacetCommitted = tapper.on('facetCommitted', facet => {
callbackRefs.current.onFacetCommittedOuter?.(facet)
})
return () => {
offActiveFacet()
offFacetCommitted()
}
}, [tapper.on])
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)
const {textStyle, textAreaStyle, minHeight, maxHeight} = useMemo(() => {
const ts = normalizeTextStyles(
[a.leading_snug, rawTextStyle, t.atoms.text],
{
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags: {},
},
)
const p = padding
? extractPadding(padding)
: {paddingTop: 0, paddingBottom: 0}
const lineHeight = ts.lineHeight || 20
const verticalSpace = p.paddingTop + p.paddingBottom
const mh = lineHeight * initialNumberOfLines + verticalSpace
const xh = maxNumberOfLines
? lineHeight * maxNumberOfLines + verticalSpace
: 999
const tas = IS_WEB
? {height: lineHeight + verticalSpace}
: {minHeight: mh, maxHeight: xh}
return {textStyle: ts, textAreaStyle: tas, minHeight: mh, maxHeight: xh}
}, [t, fonts, padding, rawTextStyle, initialNumberOfLines, maxNumberOfLines])
const prevHeight = useRef(0)
useEffect(() => {
if (IS_WEB) {
const el = textInputRef.current as unknown as HTMLTextAreaElement
if (!el) return
el.style.height = '0px'
const scrollHeight = el.scrollHeight
const nextHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight)
el.style.height = `${nextHeight}px`
el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
if (nextHeight !== prevHeight.current) {
prevHeight.current = nextHeight
sift.updatePosition()
}
return
}
textInputRef.current?.measure((_x, _y, _w, h) => {
if (h !== prevHeight.current) {
prevHeight.current = h
sift.updatePosition()
}
})
}, [state.text, minHeight, maxHeight, sift])
const previewScrollStyle = useAnimatedStyle(() => ({
transform: [{translateY: -inputScrollSharedValue.value}],
}))
const isComposing = useRef(false)
const onKeyPressWeb = useCallback(
(e: React.KeyboardEvent | any) => {
if (IS_WEB_TOUCH_DEVICE) return
if (isComposing.current) return
/*
* On Safari, the final keydown to dismiss an IME is also "Enter" with
* keyCode 229. Chrome/Firefox don't have this problem.
*
* @see https://github.com/bluesky-social/social-app/issues/4178
*/
if (e.key === 'Enter' && e.keyCode === 229) return
if (e.key === 'Enter') {
onRequestSubmit?.({
platform: 'web',
shiftKey: e.shiftKey,
metaKey: e.metaKey,
nativeEvent: e.nativeEvent,
})
}
},
[onRequestSubmit],
)
const textContent = (
<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>
)
return (
<View style={[a.relative, style]}>
{IS_WEB && (
<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,
]}>
{textContent}
</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,
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}
value={undefined}
{...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
}}>
{IS_WEB ? null : textContent}
</TextInput>
</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 (
<AutocompleteInner
sift={sift}
activeFacet={activeFacet}
onDismiss={() => setActiveFacet(null)}
/>
)
}
function AutocompleteInner({
sift,
activeFacet,
onDismiss,
}: {
sift: UseSiftReturn
activeFacet: TapperActiveFacet
onDismiss: () => void
}) {
const {data} = useAutocomplete({
type: parseAutocompleteItemType(activeFacet.type),
query: activeFacet.value,
})
return data ? (
<AutocompleteBase
sift={sift}
data={data}
render={props => {
if (props.item.type === 'profile') {
return <AutocompleteItemProfile {...props} />
}
return <View />
}}
onSelect={item => {
activeFacet.replace(item.value)
}}
onDismiss={onDismiss}
/>
) : null
}
@@ -0,0 +1,747 @@
import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {
Pressable,
TextInput,
type TextInputProps,
type TextInputSubmitEditingEvent,
View,
} from 'react-native'
import Animated, {
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {Sift, SiftItem, useSift} from '@bsky.app/sift'
import {
type TapperActiveFacet,
type TapperFacet,
useTapper,
} from '@bsky.app/tapper'
import {useLingui} from '@lingui/react/macro'
import {countGraphemes} from 'unicode-segmenter/grapheme'
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {mergeRefs} from '#/lib/merge-refs'
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
import {useEmail} from '#/state/email-verification'
import {
useMessageDraft,
useSaveMessageDraft,
} from '#/state/messages/message-drafts'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {
type Emoji,
EmojiPicker,
type EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker'
import {
atoms as a,
extractPadding,
type TextStyleProp,
useAlf,
useTheme,
type ViewStyleProp,
web,
} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import {Portal} from '#/components/Portal'
import * as Toast from '#/components/Toast'
import {Span, Text} from '#/components/Typography'
import {IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
export type SubmitRequest =
| {
platform: 'web'
shiftKey: boolean
metaKey: boolean
nativeEvent: KeyboardEvent
}
| {
platform: 'native'
nativeEvent: TextInputSubmitEditingEvent
}
/**
* Bail-out API for special cases where a parent component needs to
* imperatively control the Composer (e.g. clearing the input on submit).
* Prefer props/callbacks for normal data flow.
*/
export type ComposerInternalApi = {
input?: ReturnType<typeof useTapper>['input']
clear: () => void
insert(text: string): void
}
export function useComposerInternalApiRef() {
return useRef<ComposerInternalApi>(null)
}
export type ComposerProps = Omit<
TextInputProps,
'value' | 'onSelectionChange' | 'selection' | 'style' | 'onSubmitEditing'
> & {
/**
* Required a11y label, used for accessibilityHint as well unless that prop is specified.
*/
label: string
/**
* Optional forwarded ref.
*/
ref?: React.Ref<TextInput>
/**
* Styles applied to the input container. To style the text, use the
* `textStyle` prop.
*/
style?: ViewStyleProp['style']
/**
* Padding applied to the `TextInput` and the facet preview container.
*/
padding?: Parameters<typeof extractPadding>[0]
/**
* Shared text style applied to both the preview overlay and the input.
* Must match exactly for pixel-perfect alignment.
*/
textStyle?: TextStyleProp['style']
/**
* Sets a default height on the input, but still allows for expansion
*/
initialNumberOfLines?: number
/**
* Sets the max height on the input
*/
maxNumberOfLines?: number
/**
* When a facet is active (e.g. the user is typing after a trigger), this callbacks is called with the active facet info. When the facet is committed (e.g. the user selects an autocomplete suggestion or finishes typing), the `onFacetCommitted` callback is called with the committed facet info.
*/
onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void
/**
* Called when a facet is committed, either by selecting an autocomplete suggestion or by finishing typing. The committed facet info is passed as an argument.
*/
onFacetCommitted?: (facet: TapperFacet) => void
/**
* Called when the user presses Enter on web. Includes modifier key state
* and the native event for calling `preventDefault()`. On native, fired
* from a submit button press.
*/
onRequestSubmit?: (request: SubmitRequest) => void
/**
* Ref to the internal imperative API. See {@link ComposerInternalApi}.
*/
internalApiRef?: React.Ref<ComposerInternalApi>
}
function Composer({
children,
label,
placeholder,
defaultValue,
style,
padding,
textStyle: rawTextStyle,
initialNumberOfLines = 1,
maxNumberOfLines,
onChangeText: onChangeTextOuter,
onActiveFacet: onActiveFacetOuter,
onFacetCommitted: onFacetCommittedOuter,
internalApiRef,
onRequestSubmit,
...rest
}: ComposerProps) {
const {theme: t, fonts} = useAlf()
const tapper = useTapper({
initialText: defaultValue,
})
const callbackRefs = useRef({
onActiveFacetOuter,
onFacetCommittedOuter,
})
callbackRefs.current = {
onActiveFacetOuter,
onFacetCommittedOuter,
}
const scrollY = useSharedValue(0)
useImperativeHandle(
internalApiRef,
() => ({
input: tapper.input,
clear: () => {
tapper.inputProps.onChangeText('')
scrollY.value = 0
},
insert: tapper.insert,
}),
[tapper.inputProps, tapper.input, tapper.insert, scrollY],
)
const [activeFacet, setActiveFacet] = useState<TapperActiveFacet | null>(null)
const sift = useSift({
offset: a.p_sm.padding,
placement: 'top-start',
dynamicWidth: IS_WEB,
})
/*
* Skip the initial mount to avoid an unnecessary re-render — the parent
* already knows the initial value since it passed `defaultValue`.
*/
const isFirstRender = useRef(true)
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
}
onChangeTextOuter?.(tapper.state.text)
}, [tapper.state.text, onChangeTextOuter])
useEffect(() => {
const offActiveFacet = tapper.on('activeFacet', activeFacet => {
setActiveFacet(activeFacet)
callbackRefs.current.onActiveFacetOuter?.(activeFacet)
})
const offFacetCommitted = tapper.on('facetCommitted', facet => {
callbackRefs.current.onFacetCommittedOuter?.(facet)
})
return () => {
offActiveFacet()
offFacetCommitted()
}
}, [])
const {textStyle, textAreaStyle, minHeight, maxHeight} = useMemo(() => {
const textStyle = normalizeTextStyles(
[a.leading_snug, rawTextStyle, t.atoms.text],
{
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags: {},
},
)
const p = padding
? extractPadding(padding)
: {
paddingTop: 0,
paddingBottom: 0,
}
const lineHeight = textStyle.lineHeight || 20
const verticalSpace = p.paddingTop + p.paddingBottom
const minHeight = lineHeight * initialNumberOfLines + verticalSpace
const maxHeight = maxNumberOfLines
? lineHeight * maxNumberOfLines + verticalSpace
: 999
const textAreaStyle = IS_WEB
? {
height: (textStyle.lineHeight || 20) + p.paddingTop + p.paddingBottom,
}
: {minHeight, maxHeight}
/*
* 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) {
// disabled for now to eval the text as children
// delete textStyle.lineHeight
}
return {
textStyle,
textAreaStyle,
minHeight,
maxHeight,
}
}, [t, fonts, padding, rawTextStyle, initialNumberOfLines, maxNumberOfLines])
const updateAutocompletePosition = useCallback(() => {
sift.updatePosition()
}, [sift])
useOnKeyboard('keyboardDidShow', updateAutocompletePosition)
useOnKeyboard('keyboardDidHide', updateAutocompletePosition)
const prevHeight = useRef(0)
useEffect(() => {
if (IS_WEB) {
const el = tapper.input.element as unknown as HTMLTextAreaElement
if (!el) return
el.style.height = '0px'
const scrollHeight = el.scrollHeight
const nextHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight)
el.style.height = `${nextHeight}px`
el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
if (nextHeight !== prevHeight.current) {
prevHeight.current = nextHeight
updateAutocompletePosition()
}
return
}
tapper.input.element?.measure((_x, _y, _w, h) => {
if (h !== prevHeight.current) {
prevHeight.current = h
updateAutocompletePosition()
}
})
}, [tapper.state.text, minHeight, maxHeight, updateAutocompletePosition])
const previewScrollStyle = useAnimatedStyle(() => ({
transform: [{translateY: -scrollY.value}],
}))
const isComposing = useRef(false)
const onKeyPressWeb = useCallback(
(e: React.KeyboardEvent | any) => {
/*
* On mobile web phones, we want to keep the same behavior as the native
* app. Do not submit the message in these cases.
*/
if (IS_WEB_TOUCH_DEVICE) return
// Don't submit the form when the Japanese or any other IME is composing
if (isComposing.current) return
/**
* On Safari, the final keydown event to dismiss the IME - which is the
* enter key - is also "Enter" below. Obviously, this causes problems
* because the final dismissal should _not_ submit the text, but should
* just stop the IME editing. This is the behavior of Chrome and Firefox,
* but not Safari. Keycode is deprecated, however the alternative seems
* to only be to compare the timestamp from the onCompositionEnd event to
* the timestamp of the keydown event, which is not reliable. For
* example, this hack uses that method:
* https://github.com/ProseMirror/prosemirror-view/pull/44. However, from
* my 500ms resulted in far too long of a delay, and a subsequent enter
* press would often just end up doing nothing. A shorter time frame was
* also not great, since it was too short to be reliable (i.e. an older
* system might have a larger time gap between the two events firing.
*
* @see https://github.com/bluesky-social/social-app/issues/4178
* @see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/
* @see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html
*/
if (IS_WEB && e.key === 'Enter' && e.keyCode === 229) {
return
}
if (e.key === 'Enter') {
onRequestSubmit?.({
platform: 'web',
shiftKey: e.shiftKey,
metaKey: e.metaKey,
nativeEvent: e.nativeEvent,
})
}
},
[onRequestSubmit],
)
const textContent = (
<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>
)
return (
<>
<View style={[a.relative, style]}>
{IS_WEB && (
<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,
]}>
{textContent}
</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}
// TODO explain this behavior
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}
value={undefined}
{...sift.targetProps}
ref={mergeRefs([
rest.ref,
tapper.inputProps.ref,
sift.targetProps.ref,
])}
onBlur={e => {
rest.onBlur?.(e)
setActiveFacet(null)
}}
onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
onScroll={e => {
if (IS_WEB) {
scrollY.value = (e.target as any).scrollTop
} else {
scrollY.value = e.nativeEvent.contentOffset.y
}
}}
// @ts-ignore web only
onCompositionStart={() => {
isComposing.current = true
}}
// @ts-ignore web only
onCompositionEnd={() => {
isComposing.current = false
}}>
{IS_WEB ? null : textContent}
</TextInput>
{children}
</View>
{activeFacet && (
<Portal>
<Sift
inverted={!IS_WEB}
sift={sift}
data={[
{
key: 'alice',
label: '@alice.test',
value: '@alice.test',
},
{
key: 'bob',
label: '@bob.test',
value: '@bob.test',
},
{
key: 'carol',
label: '@carol.test',
value: '@carol.test',
},
]}
onSelect={item => {
activeFacet?.replace(item.value)
}}
onDismiss={() => setActiveFacet(null)}
style={[
a.overflow_hidden,
a.rounded_md,
a.border,
t.atoms.border_contrast_low,
t.atoms.bg,
!IS_WEB && a.w_full,
]}
render={({active, props, item}) => (
<SiftItem
{...props}
style={s => [
a.px_md,
a.py_sm,
(active || s.hovered) && t.atoms.bg_contrast_50,
]}>
<Text style={[a.text_md]}>{item.label}</Text>
</SiftItem>
)}
/>
</Portal>
)}
</>
)
}
export function MessageComposer({
onSendMessage,
hasEmbed,
setEmbed,
children,
}: {
onSendMessage: (message: string) => void
hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
}) {
const t = useTheme()
const {t: l} = useLingui()
const playHaptic = useHaptics()
const {needsEmailVerification} = useEmail()
const editable = !needsEmailVerification
const {getDraft, clearDraft} = useMessageDraft()
const [emojiPickerState, setEmojiPickerState] = useState<EmojiPickerState>({
isOpen: false,
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
})
const composerInternalApiRef = useComposerInternalApiRef()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const [text, setText] = useState(getDraft)
useSaveMessageDraft(text)
const openEmojiPicker = useCallback((pos: any) => {
setEmojiPickerState({isOpen: true, pos})
}, [])
const onSubmit = useCallback(() => {
if (!editable) return
if (!hasEmbed && text.trim() === '') return
if (countGraphemes(text) > MAX_DM_GRAPHEME_LENGTH) {
Toast.show(l`Message is too long`, {
type: 'error',
})
return
}
clearDraft()
onSendMessage(text)
playHaptic()
setEmbed(undefined)
composerInternalApiRef.current?.clear()
if (IS_WEB) {
composerInternalApiRef.current?.input?.focus()
}
}, [
l,
editable,
hasEmbed,
text,
clearDraft,
onSendMessage,
playHaptic,
setEmbed,
composerInternalApiRef,
])
useEffect(() => {
function onEmojiInserted(emoji: Emoji) {
composerInternalApiRef.current?.insert(emoji.native)
}
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
return () => {
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
}
}, [])
return (
<>
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
{children}
<View
// @ts-expect-error web only
onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}>
<Composer
internalApiRef={composerInternalApiRef}
editable={editable}
autoFocus={IS_WEB}
label={l`Message input field`}
placeholder={l`Write a message`}
defaultValue={text}
maxNumberOfLines={12}
style={[
t.atoms.bg_contrast_25,
{
borderWidth: 1,
borderColor: 'transparent',
borderRadius: 25,
},
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,
},
IS_WEB
? {
paddingLeft: 30 + a.p_sm.padding,
}
: {},
]}
textStyle={[a.text_md, a.leading_snug]}
onFocus={onFocus}
onBlur={onBlur}
onChangeText={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()
}}>
{IS_WEB && (
<Pressable
onPress={e => {
e.currentTarget.measure(
(_fx, _fy, _width, _height, px, py) => {
openEmojiPicker?.({
top: py,
left: px,
right: px,
bottom: py,
nextFocusRef: {
current:
composerInternalApiRef.current?.input?.element,
},
})
},
)
}}
style={[
a.overflow_hidden,
a.absolute,
a.rounded_full,
a.align_center,
a.justify_center,
a.z_30,
{
height: 30,
width: 30,
top: 7,
left: 7,
},
]}
accessibilityLabel={l`Open emoji picker`}
accessibilityHint="">
{state => (
<View
style={[
a.absolute,
a.inset_0,
a.align_center,
a.justify_center,
{
backgroundColor:
state.hovered || state.focused || state.pressed
? t.atoms.bg.backgroundColor
: undefined,
},
]}>
<EmojiSmile size="lg" />
</View>
)}
</Pressable>
)}
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Send message`}
accessibilityHint=""
hitSlop={HITSLOP_10}
style={[
a.absolute,
a.rounded_full,
a.align_center,
a.justify_center,
a.z_30,
{
height: 35,
width: 35,
backgroundColor: t.palette.primary_500,
top: 4,
right: 4,
},
]}
onPress={onSubmit}
disabled={!editable}>
<PaperPlane
fill={t.palette.white}
style={[a.relative, {left: 1}]}
/>
</Pressable>
</Composer>
</View>
</View>
{IS_WEB && (
<EmojiPicker
pinToTop
state={emojiPickerState}
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
/>
)}
</>
)
}
@@ -1,34 +1,10 @@
import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {
Pressable,
TextInput,
type TextInputProps,
type TextInputSubmitEditingEvent,
View,
} from 'react-native'
import Animated, {
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {Sift, SiftItem, useSift} from '@bsky.app/sift'
import {
type TapperActiveFacet,
type TapperFacet,
useTapper,
} from '@bsky.app/tapper'
import {useCallback, useEffect, useState} from 'react'
import {Pressable, View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {countGraphemes} from 'unicode-segmenter/grapheme'
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {mergeRefs} from '#/lib/merge-refs'
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
import {useEmail} from '#/state/email-verification'
import {
@@ -41,483 +17,13 @@ import {
EmojiPicker,
type EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker'
import {
atoms as a,
extractPadding,
type TextStyleProp,
useAlf,
useTheme,
type ViewStyleProp,
web,
} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography'
import {atoms as a, useTheme} from '#/alf'
import * as Composer from '#/components/Composer'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import {Portal} from '#/components/Portal'
import * as Toast from '#/components/Toast'
import {Span, Text} from '#/components/Typography'
import {IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
export type SubmitRequest =
| {
platform: 'web'
shiftKey: boolean
metaKey: boolean
nativeEvent: KeyboardEvent
}
| {
platform: 'native'
nativeEvent: TextInputSubmitEditingEvent
}
/**
* Bail-out API for special cases where a parent component needs to
* imperatively control the Composer (e.g. clearing the input on submit).
* Prefer props/callbacks for normal data flow.
*/
export type ComposerInternalApi = {
input?: ReturnType<typeof useTapper>['input']
clear: () => void
insert(text: string): void
}
export function useComposerInternalApiRef() {
return useRef<ComposerInternalApi>(null)
}
export type ComposerProps = Omit<
TextInputProps,
'value' | 'onSelectionChange' | 'selection' | 'style' | 'onSubmitEditing'
> & {
/**
* Required a11y label, used for accessibilityHint as well unless that prop is specified.
*/
label: string
/**
* Optional forwarded ref.
*/
ref?: React.Ref<TextInput>
/**
* Styles applied to the input container. To style the text, use the
* `textStyle` prop.
*/
style?: ViewStyleProp['style']
/**
* Padding applied to the `TextInput` and the facet preview container.
*/
padding?: Parameters<typeof extractPadding>[0]
/**
* Shared text style applied to both the preview overlay and the input.
* Must match exactly for pixel-perfect alignment.
*/
textStyle?: TextStyleProp['style']
/**
* Sets a default height on the input, but still allows for expansion
*/
initialNumberOfLines?: number
/**
* Sets the max height on the input
*/
maxNumberOfLines?: number
/**
* When a facet is active (e.g. the user is typing after a trigger), this callbacks is called with the active facet info. When the facet is committed (e.g. the user selects an autocomplete suggestion or finishes typing), the `onFacetCommitted` callback is called with the committed facet info.
*/
onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void
/**
* Called when a facet is committed, either by selecting an autocomplete suggestion or by finishing typing. The committed facet info is passed as an argument.
*/
onFacetCommitted?: (facet: TapperFacet) => void
/**
* Called when the user presses Enter on web. Includes modifier key state
* and the native event for calling `preventDefault()`. On native, fired
* from a submit button press.
*/
onRequestSubmit?: (request: SubmitRequest) => void
/**
* Ref to the internal imperative API. See {@link ComposerInternalApi}.
*/
internalApiRef?: React.Ref<ComposerInternalApi>
}
function Composer({
children,
label,
placeholder,
defaultValue,
style,
padding,
textStyle: rawTextStyle,
initialNumberOfLines = 1,
maxNumberOfLines,
onChangeText: onChangeTextOuter,
onActiveFacet: onActiveFacetOuter,
onFacetCommitted: onFacetCommittedOuter,
internalApiRef,
onRequestSubmit,
...rest
}: ComposerProps) {
const {theme: t, fonts} = useAlf()
const textInputRef = useRef<TextInput>(null)
const tapper = useTapper({
initialText: defaultValue,
})
const callbackRefs = useRef({
onActiveFacetOuter,
onFacetCommittedOuter,
})
callbackRefs.current = {
onActiveFacetOuter,
onFacetCommittedOuter,
}
const scrollY = useSharedValue(0)
useImperativeHandle(
internalApiRef,
() => ({
input: tapper.input,
clear: () => {
tapper.inputProps.onChangeText('')
scrollY.value = 0
},
insert: tapper.insert,
}),
[tapper.inputProps, tapper.input, tapper.insert, scrollY],
)
const [activeFacet, setActiveFacet] = useState<TapperActiveFacet | null>(null)
const sift = useSift({
offset: a.p_sm.padding,
placement: 'top-start',
dynamicWidth: IS_WEB,
})
/*
* Skip the initial mount to avoid an unnecessary re-render — the parent
* already knows the initial value since it passed `defaultValue`.
*/
const isFirstRender = useRef(true)
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
}
onChangeTextOuter?.(tapper.state.text)
}, [tapper.state.text, onChangeTextOuter])
useEffect(() => {
const offActiveFacet = tapper.on('activeFacet', activeFacet => {
setActiveFacet(activeFacet)
callbackRefs.current.onActiveFacetOuter?.(activeFacet)
})
const offFacetCommitted = tapper.on('facetCommitted', facet => {
callbackRefs.current.onFacetCommittedOuter?.(facet)
})
return () => {
offActiveFacet()
offFacetCommitted()
}
}, [])
const {textStyle, textAreaStyle, minHeight, maxHeight} = useMemo(() => {
const textStyle = normalizeTextStyles(
[a.leading_snug, rawTextStyle, t.atoms.text],
{
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags: {},
},
)
const p = padding
? extractPadding(padding)
: {
paddingTop: 0,
paddingBottom: 0,
}
const lineHeight = textStyle.lineHeight || 20
const verticalSpace = p.paddingTop + p.paddingBottom
const minHeight = lineHeight * initialNumberOfLines + verticalSpace
const maxHeight = maxNumberOfLines
? lineHeight * maxNumberOfLines + verticalSpace
: 999
const textAreaStyle = IS_WEB
? {
height: (textStyle.lineHeight || 20) + p.paddingTop + p.paddingBottom,
}
: {minHeight, maxHeight}
/*
* 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) {
// disabled for now to eval the text as children
// delete textStyle.lineHeight
}
return {
textStyle,
textAreaStyle,
minHeight,
maxHeight,
}
}, [t, fonts, padding, rawTextStyle, initialNumberOfLines, maxNumberOfLines])
const updateAutocompletePosition = useCallback(() => {
sift.updatePosition()
}, [sift])
useOnKeyboard('keyboardDidShow', updateAutocompletePosition)
useOnKeyboard('keyboardDidHide', updateAutocompletePosition)
const prevHeight = useRef(0)
useEffect(() => {
if (IS_WEB) {
const el = textInputRef.current as unknown as HTMLTextAreaElement
if (!el) return
el.style.height = '0px'
const scrollHeight = el.scrollHeight
const nextHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight)
el.style.height = `${nextHeight}px`
el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
if (nextHeight !== prevHeight.current) {
prevHeight.current = nextHeight
updateAutocompletePosition()
}
return
}
textInputRef.current?.measure((_x, _y, _w, h) => {
if (h !== prevHeight.current) {
prevHeight.current = h
updateAutocompletePosition()
}
})
}, [tapper.state.text, minHeight, maxHeight, updateAutocompletePosition])
const previewScrollStyle = useAnimatedStyle(() => ({
transform: [{translateY: -scrollY.value}],
}))
const isComposing = useRef(false)
const onKeyPressWeb = useCallback(
(e: React.KeyboardEvent | any) => {
/*
* On mobile web phones, we want to keep the same behavior as the native
* app. Do not submit the message in these cases.
*/
if (IS_WEB_TOUCH_DEVICE) return
// Don't submit the form when the Japanese or any other IME is composing
if (isComposing.current) return
/**
* On Safari, the final keydown event to dismiss the IME - which is the
* enter key - is also "Enter" below. Obviously, this causes problems
* because the final dismissal should _not_ submit the text, but should
* just stop the IME editing. This is the behavior of Chrome and Firefox,
* but not Safari. Keycode is deprecated, however the alternative seems
* to only be to compare the timestamp from the onCompositionEnd event to
* the timestamp of the keydown event, which is not reliable. For
* example, this hack uses that method:
* https://github.com/ProseMirror/prosemirror-view/pull/44. However, from
* my 500ms resulted in far too long of a delay, and a subsequent enter
* press would often just end up doing nothing. A shorter time frame was
* also not great, since it was too short to be reliable (i.e. an older
* system might have a larger time gap between the two events firing.
*
* @see https://github.com/bluesky-social/social-app/issues/4178
* @see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/
* @see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html
*/
if (IS_WEB && e.key === 'Enter' && e.keyCode === 229) {
return
}
if (e.key === 'Enter') {
onRequestSubmit?.({
platform: 'web',
shiftKey: e.shiftKey,
metaKey: e.metaKey,
nativeEvent: e.nativeEvent,
})
}
},
[onRequestSubmit],
)
const textContent = (
<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>
)
return (
<>
<View style={[a.relative, style]}>
{IS_WEB && (
<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,
]}>
{textContent}
</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}
// TODO explain this behavior
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}
value={undefined}
{...sift.targetProps}
ref={mergeRefs([
textInputRef,
rest.ref,
tapper.inputProps.ref,
sift.targetProps.ref,
])}
onBlur={e => {
rest.onBlur?.(e)
setActiveFacet(null)
}}
onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
onScroll={e => {
if (IS_WEB) {
scrollY.value = (e.target as any).scrollTop
} else {
scrollY.value = e.nativeEvent.contentOffset.y
}
}}
// @ts-ignore web only
onCompositionStart={() => {
isComposing.current = true
}}
// @ts-ignore web only
onCompositionEnd={() => {
isComposing.current = false
}}>
{IS_WEB ? null : textContent}
</TextInput>
{children}
</View>
{activeFacet && (
<Portal>
<Sift
inverted={!IS_WEB}
sift={sift}
data={[
{
key: 'alice',
label: '@alice.test',
value: '@alice.test',
},
{
key: 'bob',
label: '@bob.test',
value: '@bob.test',
},
{
key: 'carol',
label: '@carol.test',
value: '@carol.test',
},
]}
onSelect={item => {
activeFacet?.replace(item.value)
}}
onDismiss={() => setActiveFacet(null)}
style={[
a.overflow_hidden,
a.rounded_md,
a.border,
t.atoms.border_contrast_low,
t.atoms.bg,
!IS_WEB && a.w_full,
]}
render={({active, props, item}) => (
<SiftItem
{...props}
style={s => [
a.px_md,
a.py_sm,
(active || s.hovered) && t.atoms.bg_contrast_50,
]}>
<Text style={[a.text_md]}>{item.label}</Text>
</SiftItem>
)}
/>
</Portal>
)}
</>
)
}
import {IS_WEB} from '#/env'
export function MessageComposer({
onSendMessage,
@@ -540,7 +46,7 @@ export function MessageComposer({
isOpen: false,
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
})
const composerInternalApiRef = useComposerInternalApiRef()
const composerInternalApiRef = Composer.useComposerInternalApiRef()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
@@ -601,59 +107,62 @@ export function MessageComposer({
<>
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
{children}
<View
// @ts-expect-error web only
onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}>
<Composer
internalApiRef={composerInternalApiRef}
editable={editable}
autoFocus={IS_WEB}
label={l`Message input field`}
placeholder={l`Write a message`}
defaultValue={text}
maxNumberOfLines={12}
style={[
t.atoms.bg_contrast_25,
{
borderWidth: 1,
borderColor: 'transparent',
borderRadius: 25,
},
editable &&
hovered && {
borderColor: t.atoms.border_contrast_medium.borderColor,
<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,
},
editable &&
focused && {
borderColor: t.palette.primary_500,
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,
},
]}
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}
onChangeText={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()
}}>
IS_WEB
? {
paddingLeft: 30 + a.p_sm.padding,
}
: {},
]}
textStyle={[a.text_md, a.leading_snug]}
onFocus={onFocus}
onBlur={onBlur}
/>
{IS_WEB && (
<Pressable
onPress={e => {
@@ -733,8 +242,10 @@ export function MessageComposer({
style={[a.relative, {left: 1}]}
/>
</Pressable>
</Composer>
</View>
</View>
<Composer.Autocomplete />
</Composer.Root>
</View>
{IS_WEB && (