Checkpoint: almost there with autocomplete
This commit is contained in:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './Autocomplete'
|
||||||
|
export * from './AutocompleteItemProfile'
|
||||||
|
export * from './useAutocomplete'
|
||||||
|
export * from './util'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
import {useCallback, useEffect, useState} from 'react'
|
||||||
useCallback,
|
import {Pressable, View} from 'react-native'
|
||||||
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 {useLingui} from '@lingui/react/macro'
|
||||||
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||||
|
|
||||||
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||||
import {useHaptics} from '#/lib/haptics'
|
import {useHaptics} from '#/lib/haptics'
|
||||||
import {mergeRefs} from '#/lib/merge-refs'
|
|
||||||
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
|
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
|
||||||
import {useEmail} from '#/state/email-verification'
|
import {useEmail} from '#/state/email-verification'
|
||||||
import {
|
import {
|
||||||
@@ -41,483 +17,13 @@ import {
|
|||||||
EmojiPicker,
|
EmojiPicker,
|
||||||
type EmojiPickerState,
|
type EmojiPickerState,
|
||||||
} from '#/view/com/composer/text-input/web/EmojiPicker'
|
} from '#/view/com/composer/text-input/web/EmojiPicker'
|
||||||
import {
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
atoms as a,
|
import * as Composer from '#/components/Composer'
|
||||||
extractPadding,
|
|
||||||
type TextStyleProp,
|
|
||||||
useAlf,
|
|
||||||
useTheme,
|
|
||||||
type ViewStyleProp,
|
|
||||||
web,
|
|
||||||
} from '#/alf'
|
|
||||||
import {normalizeTextStyles} from '#/alf/typography'
|
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
|
|
||||||
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'
|
||||||
import {Portal} from '#/components/Portal'
|
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {Span, Text} from '#/components/Typography'
|
import {IS_WEB} from '#/env'
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessageComposer({
|
export function MessageComposer({
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
@@ -540,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 = useComposerInternalApiRef()
|
const composerInternalApiRef = Composer.useComposerInternalApiRef()
|
||||||
|
|
||||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||||
const {
|
const {
|
||||||
@@ -601,59 +107,62 @@ 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}
|
||||||
<View
|
<Composer.Root
|
||||||
// @ts-expect-error web only
|
internalApiRef={composerInternalApiRef}
|
||||||
onMouseEnter={onHoverIn}
|
initialText={text}
|
||||||
onMouseLeave={onHoverOut}>
|
onChange={setText}
|
||||||
<Composer
|
onFacetCommitted={facet => {
|
||||||
internalApiRef={composerInternalApiRef}
|
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
|
||||||
editable={editable}
|
setEmbed(facet.value)
|
||||||
autoFocus={IS_WEB}
|
}
|
||||||
label={l`Message input field`}
|
}}
|
||||||
placeholder={l`Write a message`}
|
onRequestSubmit={req => {
|
||||||
defaultValue={text}
|
if (req.platform === 'web' && req.shiftKey) return
|
||||||
maxNumberOfLines={12}
|
req.nativeEvent.preventDefault()
|
||||||
style={[
|
onSubmit()
|
||||||
t.atoms.bg_contrast_25,
|
}}>
|
||||||
{
|
<View
|
||||||
borderWidth: 1,
|
// @ts-expect-error web only
|
||||||
borderColor: 'transparent',
|
onMouseEnter={onHoverIn}
|
||||||
borderRadius: 25,
|
onMouseLeave={onHoverOut}>
|
||||||
},
|
<Composer.Input
|
||||||
editable &&
|
editable={editable}
|
||||||
hovered && {
|
autoFocus={IS_WEB}
|
||||||
borderColor: t.atoms.border_contrast_medium.borderColor,
|
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 &&
|
editable &&
|
||||||
focused && {
|
hovered && {
|
||||||
borderColor: t.palette.primary_500,
|
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
|
||||||
padding={[
|
? {
|
||||||
a.p_md,
|
paddingLeft: 30 + a.p_sm.padding,
|
||||||
{
|
}
|
||||||
paddingRight: 35 + a.p_sm.padding,
|
: {},
|
||||||
},
|
]}
|
||||||
IS_WEB
|
textStyle={[a.text_md, a.leading_snug]}
|
||||||
? {
|
onFocus={onFocus}
|
||||||
paddingLeft: 30 + a.p_sm.padding,
|
onBlur={onBlur}
|
||||||
}
|
/>
|
||||||
: {},
|
|
||||||
]}
|
|
||||||
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 && (
|
{IS_WEB && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={e => {
|
onPress={e => {
|
||||||
@@ -733,8 +242,10 @@ export function MessageComposer({
|
|||||||
style={[a.relative, {left: 1}]}
|
style={[a.relative, {left: 1}]}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Composer>
|
</View>
|
||||||
</View>
|
|
||||||
|
<Composer.Autocomplete />
|
||||||
|
</Composer.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{IS_WEB && (
|
{IS_WEB && (
|
||||||
|
|||||||
Reference in New Issue
Block a user