New rich text composer + autocomplete (#10159)

This commit is contained in:
Eric Bailey
2026-04-07 20:37:50 -05:00
committed by GitHub
parent 02b849996c
commit 0a5ae17738
26 changed files with 1596 additions and 159 deletions
+3
View File
@@ -88,6 +88,8 @@
"@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/react-native-mmkv": "2.12.5",
"@bsky.app/sift": "^0.3.1",
"@bsky.app/tapper": "^0.5.0",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
@@ -179,6 +181,7 @@
"expo-web-browser": "~15.0.10",
"fast-deep-equal": "^3.1.3",
"fast-text-encoding": "^1.0.6",
"fuse.js": "^7.1.0",
"hls.js": "^1.6.2",
"idb-keyval": "^6.2.2",
"js-sha256": "^0.9.0",
+37 -1
View File
@@ -1,3 +1,39 @@
import {StyleSheet} from 'react-native'
import {type DimensionValue, StyleSheet} from 'react-native'
export const flatten = StyleSheet.flatten
/**
* Coerce a style value to a number. Padding values are typed as
* `DimensionValue` (numbers, percentages, "auto", etc.) but our ALF atoms
* are always plain numbers. Non-numeric values are treated as 0.
*/
function num(v: unknown): number {
return typeof v === 'number' ? v : 0
}
interface PaddingStyle {
padding?: DimensionValue
paddingHorizontal?: DimensionValue
paddingVertical?: DimensionValue
paddingTop?: DimensionValue
paddingBottom?: DimensionValue
paddingLeft?: DimensionValue
paddingRight?: DimensionValue
}
/**
* Extract resolved padding values from a style object. Returns numbers for
* each side, resolving shorthand properties (padding → paddingVertical →
* paddingTop/paddingBottom, etc.). Values are expected to be numbers — any
* non-numeric `DimensionValue` (e.g. percentages) is treated as 0.
*/
export function extractPadding(style: PaddingStyle | PaddingStyle[]) {
const s = flatten(style as any) ?? {}
const base = num(s.padding)
return {
paddingTop: num(s.paddingTop) || num(s.paddingVertical) || base,
paddingBottom: num(s.paddingBottom) || num(s.paddingVertical) || base,
paddingLeft: num(s.paddingLeft) || num(s.paddingHorizontal) || base,
paddingRight: num(s.paddingRight) || num(s.paddingHorizontal) || base,
}
}
+1
View File
@@ -11,6 +11,7 @@ export enum Features {
LiveNowBetaDisable = 'live_now_beta:disable',
ImageUploadsHighResolution = 'image_uploads:high_resolution',
GroupChatsEnable = 'group_chats:enable',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
AATest = 'aa-test',
}
@@ -0,0 +1,78 @@
import {useCallback} from 'react'
import {View} from 'react-native'
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'
import {AutocompleteItemEmoji} from './AutocompleteItemEmoji'
import {AutocompleteItemProfile} from './AutocompleteItemProfile'
import {AutocompleteItemSearch} from './AutocompleteItemSearch'
function renderItem(
item: Parameters<Parameters<typeof Sift<AutocompleteItem>>[0]['render']>[0],
) {
switch (item.item.type) {
case 'profile':
return <AutocompleteItemProfile {...item} />
case 'emoji':
return <AutocompleteItemEmoji {...item} />
case 'search':
return <AutocompleteItemSearch {...item} />
default:
return <View />
}
}
export function Autocomplete({
inverted,
sift,
data,
render = renderItem,
onSelect,
onDismiss,
}: {
inverted?: boolean
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={inverted}
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,
a.w_full,
IS_WEB
? {
maxWidth: 300,
}
: {},
]}
render={render}
/>
</Portal>
)
}
@@ -0,0 +1,30 @@
import {SiftItem} from '@bsky.app/sift'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {type AutocompleteItemProps} from './types'
export function AutocompleteItemEmoji({
active,
props,
item,
}: AutocompleteItemProps) {
const t = useTheme()
if (item.type !== 'emoji') return null
return (
<SiftItem
{...props}
style={s => [
{paddingVertical: 6, paddingHorizontal: 10},
a.flex_row,
a.align_center,
a.gap_sm,
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
]}>
<Text style={[a.text_xl, a.leading_tight]}>{item.value}</Text>
<Text style={[a.text_md, a.leading_tight]}>:{item.emoji.id}:</Text>
</SiftItem>
)
}
@@ -0,0 +1,47 @@
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,
isFirst,
isLast,
props,
item,
}: AutocompleteItemProps) {
const t = useTheme()
const moderationOpts = useModerationOpts()
if (item.type !== 'profile' || !moderationOpts) return null
return (
<SiftItem
{...props}
style={s => [
a.py_sm,
a.px_md,
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
isFirst && {
paddingTop: a.py_sm.paddingTop * 1.2,
},
isLast && {
paddingBottom: a.py_sm.paddingTop * 1.2,
},
]}>
<ProfileCard.Header>
<ProfileCard.Avatar
disabledPreview
profile={item.profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={item.profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
</SiftItem>
)
}
@@ -0,0 +1,49 @@
import {View} from 'react-native'
import {SiftItem} from '@bsky.app/sift'
import {atoms as a, useTheme} from '#/alf'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass'
import {Text} from '#/components/Typography'
import {type AutocompleteItemProps} from './types'
export function AutocompleteItemSearch({
active,
isFirst,
isLast,
props,
item,
}: AutocompleteItemProps) {
const t = useTheme()
if (item.type !== 'search') return null
return (
<SiftItem
{...props}
style={s => [
a.py_sm,
a.px_md,
a.flex_row,
a.align_center,
a.gap_sm,
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
isFirst && {
paddingTop: a.py_sm.paddingTop * 1.2,
},
isLast && {
paddingBottom: a.py_sm.paddingTop * 1.2,
},
]}>
<View
style={[
a.align_center,
{
width: 40,
},
]}>
<MagnifyingGlassIcon fill={t.atoms.text_contrast_low.color} size="xl" />
</View>
<Text style={[a.text_md, a.leading_snug]}>{item.value}</Text>
</SiftItem>
)
}
+6
View File
@@ -0,0 +1,6 @@
export * from './Autocomplete'
export * from './AutocompleteItemEmoji'
export * from './AutocompleteItemProfile'
export * from './types'
export * from './useAutocomplete'
export * from './util'
+48
View File
@@ -0,0 +1,48 @@
import {type Sift} from '@bsky.app/sift'
import {type Emoji} from '@emoji-mart/data'
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: Emoji
}
export type AutocompleteSearch = {
key: string
type: 'search'
value: string
}
export type AutocompleteItem =
| AutocompleteProfile
| AutocompleteTag
| AutocompleteEmoji
| AutocompleteSearch
export type AutocompleteItemType = AutocompleteItem['type']
export type AutocompleteItemProps = Parameters<
Parameters<typeof Sift<AutocompleteItem>>[0]['render']
>[0]
export type AutocompleteApi = {
query: string
items: AutocompleteItem[]
}
@@ -0,0 +1,135 @@
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 AutocompleteApi,
type AutocompleteItem,
type AutocompleteItemType,
type AutocompleteProfile,
} from '#/components/Autocomplete/types'
import {useEmojiSearch} from './useEmojiSearch'
const DEFAULT_MOD_OPTS = {
userDid: undefined,
prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
}
export function useAutocomplete({
type,
query: q,
limit,
showSearchFallback = false,
}: {
type: AutocompleteItemType
query: string
limit?: number
showSearchFallback?: boolean
}): AutocompleteApi {
const agent = useAgent()
const moderationOpts = useModerationOpts()
const emojiSearch = useEmojiSearch()
const query = useQuery({
staleTime: STALE.MINUTES.ONE,
queryKey: [
'autocomplete',
{
type,
query: q,
},
],
async queryFn() {
if (type === 'profile') {
// TODO return recents
if (!q) return []
// Going from "foo" to "foo." should not clear matches.
q = q.toLowerCase().trim().replace(/\.$/, '')
const res = await agent.searchActorsTypeahead({
q,
limit: limit || 8,
})
return (res?.data.actors || []).map(profile => ({
key: profile.did,
type: 'profile' as const,
value: '@' + profile.handle,
profile,
}))
} else if (type === 'emoji') {
return emojiSearch(q, limit || 8)
}
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: q,
item,
moderationOpts: moderationOpts || DEFAULT_MOD_OPTS,
})
if (moderated) results.push(moderated)
} else {
results.push(item)
}
}
if (showSearchFallback && q) {
results.unshift({
key: `search-${q}`,
type: 'search' as const,
value: q,
})
}
return results
},
[q, showSearchFallback, moderationOpts],
),
placeholderData: keepPreviousData,
})
return {
query: q,
items: query.data || [],
}
}
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,40 @@
import {useCallback} from 'react'
import {type Emoji} from '@emoji-mart/data'
import Fuse from 'fuse.js'
import {useGetEmojis} from '#/lib/useGetEmojis'
import {type AutocompleteEmoji} from '#/components/Autocomplete/types'
/*
* Lazily loaded Fuse instance for emoji search. Built once on first search,
* then reused for all subsequent searches.
*/
let emojiFuseInstance: Fuse<Emoji> | null = null
export function useEmojiSearch(): (
query: string,
limit?: number,
) => Promise<AutocompleteEmoji[]> {
const getEmojis = useGetEmojis()
return useCallback(
async (query: string, limit: number = 8) => {
if (!emojiFuseInstance) {
const data = await getEmojis()
emojiFuseInstance = new Fuse(Object.values(data.emojis), {
keys: ['search'],
threshold: 0.3,
})
}
const results = emojiFuseInstance.search(query, {limit})
return results.map(result => ({
key: result.item.id,
type: 'emoji' as const,
value: result.item.skins[0].native,
emoji: result.item,
}))
},
[getEmojis],
)
}
+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}`)
}
}
+432
View File
@@ -0,0 +1,432 @@
import {useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'
import {
type TextInput,
type TextInputSubmitEditingEvent,
View,
} from 'react-native'
import Animated, {
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useSift, type UseSiftReturn} from '@bsky.app/sift'
import {
facets,
type TapperActiveFacet,
type TapperFacet,
useTapper,
} from '@bsky.app/tapper'
import {mergeRefs} from '#/lib/merge-refs'
import {
atoms as a,
type TextStyleProp,
useAlf,
type ViewStyleProp,
web,
} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography'
import {
Autocomplete as AutocompleteBase,
AutocompleteItemEmoji,
AutocompleteItemProfile,
parseAutocompleteItemType,
useAutocomplete,
} from '#/components/Autocomplete'
import {
AutosizedTextarea,
type AutosizedTextareaProps,
} from '#/components/forms/AutosizedTextarea'
import {Span, Text} from '#/components/Typography'
import {IS_IOS, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
export type SubmitRequest =
| {
platform: 'web'
shiftKey: boolean
metaKey: boolean
nativeEvent: KeyboardEvent
}
| {
platform: 'native'
nativeEvent: TextInputSubmitEditingEvent
}
/**
* Imperative API exposed via `internalApiRef` prop for parent components that
* need to control the composer programmatically, e.g. to clear the input or
* insert text at the current cursor position.
*/
export type ComposerInternalApi = {
input?: ReturnType<typeof useTapper>['input']
clear: () => void
insert(text: string): void
setAutocompleteAnchor: (node: View | null) => void
}
export function useComposerInternalApiRef() {
return useRef<ComposerInternalApi>(null)
}
/*
* ─── Composer ─────────────────────────────────────────────────────────────────
*/
export type ComposerProps = Omit<
AutosizedTextareaProps,
| 'value'
| 'onChange'
| 'onChangeText'
| 'onSelectionChange'
| 'selection'
| 'style'
| 'onSubmitEditing'
> & {
label: string
ref?: React.RefObject<TextInput>
internalApiRef?: React.Ref<ComposerInternalApi>
outerStyle?: ViewStyleProp['style']
contentTextStyle?: TextStyleProp['style']
contentPaddingStyle?: {
paddingTop?: number
paddingBottom?: number
paddingLeft?: number
paddingRight?: number
}
onChange?: (text: string) => void
onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void
onFacetCommitted?: (facet: TapperFacet) => void
onRequestSubmit?: (request: SubmitRequest) => void
autocompletePlacement?: Exclude<
Parameters<typeof useSift>[0],
undefined
>['placement']
disableEmojiFacets?: boolean
}
export function Composer({
label,
ref,
internalApiRef,
outerStyle,
contentTextStyle,
contentPaddingStyle,
onChange: onChangeOuter,
onActiveFacet: onActiveFacetOuter,
onFacetCommitted: onFacetCommittedOuter,
onRequestSubmit,
autocompletePlacement,
defaultValue,
disableEmojiFacets = !IS_WEB,
...rest
}: ComposerProps) {
const {theme: t, fonts} = useAlf()
const insets = useSafeAreaInsets()
/*
* Meat and potatoes
*/
const tapper = useTapper({
initialText: defaultValue ?? '',
facets: disableEmojiFacets
? {
mention: facets.mention,
tag: facets.tag,
url: facets.url,
}
: facets,
})
const sift = useSift({
offset: a.p_sm.padding,
placement: autocompletePlacement,
dynamicWidth: IS_WEB,
insets,
})
/*
* Active facet state for controlling the visibility of the Autocomplete.
*/
const [activeFacet, setActiveFacet] = useState<TapperActiveFacet | null>(null)
/*
* Reanimated shared value for syncing scroll on all platforms.
*/
const inputScrollSharedValue = useSharedValue(0)
/*
* Expose imperative internal API
*/
useImperativeHandle(
internalApiRef,
() => ({
input: tapper.input,
clear: () => {
tapper.inputProps.onChangeText('')
inputScrollSharedValue.value = 0
},
insert: tapper.insert,
setAutocompleteAnchor: sift.refs.setAnchor,
}),
[tapper.input, tapper.insert, inputScrollSharedValue, sift.refs.setAnchor],
)
/*
* 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])
/*
* Tapper callbacks
*/
const callbackRefs = useRef({
onActiveFacetOuter,
onFacetCommittedOuter,
})
callbackRefs.current = {
onActiveFacetOuter,
onFacetCommittedOuter,
}
useEffect(() => {
const offActiveFacet = tapper.on('activeFacet', facet => {
setActiveFacet(facet)
callbackRefs.current.onActiveFacetOuter?.(facet)
})
const offFacetCommitted = tapper.on('facetCommitted', facet => {
callbackRefs.current.onFacetCommittedOuter?.(facet)
})
const offAfterInsert = tapper.on('afterInsert', () => {
tapper.input.focus()
})
return () => {
offActiveFacet()
offFacetCommitted()
offAfterInsert()
}
}, [tapper.on, tapper.input])
/*
* Styles
*/
const previewScrollStyle = useAnimatedStyle(() => ({
transform: [{translateY: -inputScrollSharedValue.value}],
}))
const textStyle = useMemo(() => {
const ts = normalizeTextStyles(
[a.leading_snug, t.atoms.text, contentTextStyle],
{
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags: {},
},
)
/**
* On iOS, having a lineHeight on the Text component causes the text to be
* vertically misaligned with the TextInput.
*
* This only seems to be an issue on iOS, and not on Android or web. It's
* possible that this is a bug in React Native's Text component on iOS,
* but in the meantime, we'll just remove the lineHeight on iOS to ensure
* the text is properly aligned.
*/
if (IS_IOS) {
delete ts.lineHeight
}
return ts
}, [contentTextStyle, fonts])
/*
* Web keyboard handling
*/
const isComposing = useRef(false)
const onKeyPressWeb = (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,
})
}
}
/*
* Sift popover positioning
*/
const updateAutocompletePosition = () => {
sift.updatePosition()
}
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, outerStyle]}>
{IS_WEB && (
<View
pointerEvents="none"
style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}>
<Animated.View
style={[
contentPaddingStyle,
{position: 'absolute', left: 0, right: 0},
previewScrollStyle,
]}>
{textContent}
</Animated.View>
</View>
)}
<AutosizedTextarea
placeholderTextColor={t.palette.contrast_500}
accessibilityLabel={label}
accessibilityHint={label}
onSubmitEditing={e => {
onRequestSubmit?.({platform: 'native', nativeEvent: e})
}}
style={[
textStyle,
contentPaddingStyle,
a.z_20,
{
color: 'transparent',
background: 'transparent',
},
web({
caretColor: textStyle.color ?? 'black',
overscrollBehavior: 'none',
}),
]}
{...rest}
{...tapper.inputProps}
{...sift.targetProps}
ref={mergeRefs([ref, tapper.inputProps.ref, sift.targetProps.ref])}
onBlur={e => {
rest.onBlur?.(e)
setActiveFacet(null)
}}
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
}}
onUpdateHeight={updateAutocompletePosition}>
{IS_WEB ? null : textContent}
</AutosizedTextarea>
</View>
{activeFacet && activeFacet.type !== 'url' && (
<AutocompleteInner
sift={sift}
activeFacet={activeFacet}
onDismiss={() => setActiveFacet(null)}
/>
)}
</>
)
}
/*
* ─── Autocomplete (private) ───────────────────────────────────────────────────
*/
function AutocompleteInner({
sift,
activeFacet,
onDismiss,
}: {
sift: UseSiftReturn
activeFacet: TapperActiveFacet
onDismiss: () => void
}) {
const {items} = useAutocomplete({
type: parseAutocompleteItemType(activeFacet.type),
query: activeFacet.value,
})
useEffect(() => {
if (
activeFacet?.type === 'emoji' &&
!!activeFacet.value.length &&
activeFacet.raw.endsWith(':')
) {
if (items?.[0]) {
activeFacet.replace(items[0].value, {noTrailingSpace: true})
onDismiss()
}
}
}, [items, activeFacet])
return items && items.length ? (
<AutocompleteBase
inverted={!IS_WEB}
sift={sift}
data={items}
render={props => {
if (props.item.type === 'profile') {
return <AutocompleteItemProfile {...props} />
}
if (props.item.type === 'emoji') {
return <AutocompleteItemEmoji {...props} />
}
return <View />
}}
onSelect={item => {
activeFacet.replace(item.value)
onDismiss()
}}
onDismiss={onDismiss}
/>
) : null
}
+166
View File
@@ -0,0 +1,166 @@
import {useMemo, useRef, useState} from 'react'
import {
TextInput,
type TextInputContentSizeChangeEvent,
type TextInputProps,
} from 'react-native'
import {mergeRefs} from '#/lib/merge-refs'
import {atoms as a, extractPadding, useAlf, web} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
export type AutosizedTextareaProps = Omit<TextInputProps, 'multiline'> & {
ref?: React.Ref<TextInput>
label: string
minRows?: number
maxRows?: number
onUpdateHeight?: (height: number) => void
}
export function AutosizedTextarea({
ref,
label,
minRows = 1,
maxRows,
onUpdateHeight,
onChangeText: onChangeTextOuter,
onContentSizeChange: onContentSizeChangeOuter,
style: outerStyle,
...rest
}: AutosizedTextareaProps) {
const {theme: t, fonts} = useAlf()
const internalRef = useRef<TextInput>(null)
const {style, minInputHeight, maxInputHeight, verticalContentPadding} =
useMemo(() => {
const normalizedStyles = normalizeTextStyles(
[a.text_md, a.leading_snug, t.atoms.text, outerStyle],
{
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags: {},
},
)
const lineHeight = normalizedStyles.lineHeight || 20
const {paddingTop, paddingBottom} = extractPadding(normalizedStyles ?? {})
const verticalContentPadding = paddingTop + paddingBottom
const minInputHeight = lineHeight * minRows + verticalContentPadding
const maxInputHeight = maxRows
? lineHeight * maxRows + verticalContentPadding
: Infinity
/*
* iOS: minHeight/maxHeight works fine natively.
* Web + Android: we set an explicit initial height and resize dynamically
* (web via DOM measurement, Android via onContentSizeChange state).
*
* iOS also seems to need 1px headroom to actually expand to the correct
* maxHeight
*/
const heightConstraints = IS_IOS
? {minHeight: minInputHeight, maxHeight: maxInputHeight + 1}
: {height: minInputHeight}
return {
style: {
...normalizedStyles,
...heightConstraints,
},
minInputHeight,
maxInputHeight,
verticalContentPadding,
}
}, [t, fonts, outerStyle, minRows, maxRows])
/*
* Web handling
*/
const prevWebHeight = useRef(0)
const handleResizeWeb = () => {
const el = internalRef.current as unknown as HTMLTextAreaElement
if (!el) return
// collapse to get natural scroll height
el.style.height = '0px'
const scrollHeight = Math.ceil(el.scrollHeight)
const nextHeight = Math.min(
Math.max(scrollHeight, minInputHeight),
maxInputHeight,
)
// immediately update height to prevent flicker
el.style.height = `${nextHeight}px`
el.style.overflowY = scrollHeight > maxInputHeight ? 'auto' : 'hidden'
if (nextHeight !== prevWebHeight.current) {
prevWebHeight.current = nextHeight
onUpdateHeight?.(nextHeight)
}
}
const onChangeText = (text: string) => {
if (IS_WEB) handleResizeWeb()
onChangeTextOuter?.(text)
}
/*
* Native handling
*
* We track the height as state on native, and on Android, we use this to
* directly drive the `height`.
*/
const [nativeHeight, setNativeHeight] = useState(minInputHeight)
const onContentSizeChange = (e: TextInputContentSizeChangeEvent) => {
const contentSize = Math.ceil(e.nativeEvent.contentSize.height)
// ios reports the content size without padding
const height = IS_IOS ? contentSize + verticalContentPadding : contentSize
const nextHeight = Math.min(
Math.max(height, minInputHeight),
maxInputHeight,
)
if (nextHeight !== nativeHeight) {
setNativeHeight(nextHeight)
onUpdateHeight?.(nextHeight)
}
onContentSizeChangeOuter?.(e)
}
return (
<TextInput
multiline
placeholderTextColor={t.palette.contrast_500}
accessibilityLabel={label}
accessibilityHint={label}
placeholder={label}
keyboardAppearance={t.scheme}
submitBehavior="newline"
scrollEnabled={nativeHeight >= maxInputHeight}
style={[
a.relative,
a.border_0,
{
textAlignVertical: 'top',
includeFontPadding: false,
},
web({
resize: 'none',
outline: 'none',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}),
style,
IS_ANDROID ? {height: nativeHeight} : {},
]}
{...rest}
ref={mergeRefs([
(node: TextInput | null) => {
internalRef.current = node
// bop resize on first render
if (IS_WEB && node) handleResizeWeb()
},
ref,
])}
onChangeText={onChangeText}
onContentSizeChange={onContentSizeChange}
/>
)
}
+5 -5
View File
@@ -3,6 +3,7 @@ import {type TextInput, View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {HITSLOP_10} from '#/lib/constants'
import {mergeRefs} from '#/lib/merge-refs'
import {listenFocusSearch} from '#/state/events'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
@@ -18,7 +19,7 @@ type Props = Omit<TextField.InputProps, 'label'> & {
*/
onClearText?: () => void
hotkey?: boolean
ref?: React.RefObject<TextInput | null>
ref?: React.Ref<TextInput>
}
export function SearchInput({
@@ -33,21 +34,20 @@ export function SearchInput({
const {t: l} = useLingui()
const showClear = value && value.length > 0
const internalRef = useRef<TextInput>(null)
const inputRef = ref ?? internalRef
useEffect(() => {
if (!hotkey) return
return listenFocusSearch(() => {
inputRef.current?.focus()
internalRef.current?.focus()
})
}, [hotkey, inputRef])
}, [hotkey])
return (
<View style={[a.w_full, a.relative]}>
<TextField.Root>
<TextField.Icon icon={MagnifyingGlassIcon} />
<TextField.Input
inputRef={inputRef}
inputRef={mergeRefs([internalRef, ref])}
label={label || l`Search`}
value={value}
placeholder={l`Search`}
+1 -1
View File
@@ -13,7 +13,7 @@
* returns a ref callback function that can be used to merge multiple refs into a single ref.
*/
export function mergeRefs<T = any>(
refs: Array<React.MutableRefObject<T> | React.Ref<T>>,
refs: Array<React.MutableRefObject<T> | React.Ref<T> | undefined>,
): React.RefCallback<T> {
return value => {
refs.forEach(ref => {
+5
View File
@@ -0,0 +1,5 @@
import Emojis, {type EmojiMartData} from '@emoji-mart/data'
export async function getEmojis(): Promise<EmojiMartData> {
return Emojis as EmojiMartData
}
+5
View File
@@ -0,0 +1,5 @@
import {type EmojiMartData} from '@emoji-mart/data'
export async function getEmojis(): Promise<EmojiMartData> {
return (await import('@emoji-mart/data')).default as EmojiMartData
}
+12
View File
@@ -0,0 +1,12 @@
import {useCallback} from 'react'
import {getEmojis} from './getEmojis'
let emojis: Awaited<ReturnType<typeof getEmojis>> | null = null
export function useGetEmojis() {
return useCallback(async () => {
emojis ??= await getEmojis()
return emojis
}, [])
}
@@ -0,0 +1,251 @@
import {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 {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, useTheme} from '#/alf'
import {Composer, useComposerInternalApiRef} from '#/components/Composer'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import * as Toast from '#/components/Toast'
import {IS_WEB} from '#/env'
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 = (pos: any) => {
setEmojiPickerState({isOpen: true, pos})
}
const onSubmit = () => {
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()
}
}
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
collapsable={false}
ref={
IS_WEB
? undefined
: node => {
composerInternalApiRef.current?.setAutocompleteAnchor(node)
}
}
// @ts-expect-error web only
onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}
style={[a.w_full, a.flex_row, a.gap_sm]}>
{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: 8,
left: 8,
},
]}
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>
)}
<Composer
label={l`Message input field`}
placeholder={l`Write a message`}
autocompletePlacement="top-start"
internalApiRef={composerInternalApiRef}
defaultValue={text}
editable={editable}
autoFocus={IS_WEB}
maxRows={12}
outerStyle={[
a.flex_1,
t.atoms.bg_contrast_25,
{
borderWidth: 1,
borderColor: 'transparent',
borderRadius: 22,
},
editable &&
hovered && {
borderColor: t.atoms.border_contrast_medium.borderColor,
},
editable &&
focused && {
borderColor: t.palette.primary_500,
},
]}
contentTextStyle={[a.text_md, a.leading_snug]}
contentPaddingStyle={{
paddingLeft: IS_WEB ? 30 + 12 : 12,
paddingTop: 12,
paddingBottom: 12,
paddingRight: 12,
}}
onFocus={onFocus}
onBlur={onBlur}
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()
}}
/>
{focused || text.length ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Send message`}
accessibilityHint=""
hitSlop={HITSLOP_10}
style={[
a.rounded_full,
a.align_center,
a.justify_center,
a.self_end,
a.z_30,
{
height: 44,
width: 44,
backgroundColor: t.palette.primary_500,
},
]}
onPress={onSubmit}
disabled={!editable}>
<PaperPlane
fill={t.palette.white}
style={[a.relative, {left: 1}]}
/>
</Pressable>
) : null}
</View>
</View>
{IS_WEB && (
<EmojiPicker
pinToTop
state={emojiPickerState}
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
/>
)}
</>
)
}
@@ -43,6 +43,7 @@ import {
} from '#/view/com/composer/text-input/web/EmojiPicker'
import {List, type ListMethods} from '#/view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
import {MessageInput} from '#/screens/Messages/components/MessageInput'
import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
@@ -50,8 +51,8 @@ import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
import {IS_WEB} from '#/env'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE, IS_WEB} from '#/env'
import {ChatStatusInfo} from './ChatStatusInfo'
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
@@ -102,6 +103,7 @@ export function MessagesList({
footer?: React.ReactNode
hasAcceptOverride?: boolean
}) {
const ax = useAnalytics()
const convoState = useConvoActive()
const agent = useAgent()
const getPost = useGetPost()
@@ -457,13 +459,22 @@ export function MessagesList({
<ConversationFooter
convoState={convoState}
hasAcceptOverride={hasAcceptOverride}>
<MessageInput
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
openEmojiPicker={onOpenEmojiPicker}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
</MessageInput>
{ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
<MessageComposer
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
</MessageComposer>
) : (
<MessageInput
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
openEmojiPicker={onOpenEmojiPicker}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
</MessageInput>
)}
</ConversationFooter>
)}
</Animated.View>
@@ -1,11 +1,18 @@
import {memo} from 'react'
import {ActivityIndicator, View} from 'react-native'
import {
ActivityIndicator,
TouchableOpacity,
View,
type ViewStyle,
} from 'react-native'
import {type AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {usePalette} from '#/lib/hooks/usePalette'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {SearchLinkCard} from '#/view/shell/desktop/Search'
import {Link} from '#/view/com/util/Link'
import {Text} from '#/view/com/util/text/Text'
import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard'
import {atoms as a, native} from '#/alf'
import * as Layout from '#/components/Layout'
@@ -76,3 +83,52 @@ let AutocompleteResults = ({
}
AutocompleteResults = memo(AutocompleteResults)
export {AutocompleteResults}
let SearchLinkCard = ({
label,
to,
onPress,
style,
}: {
label: string
to?: string
onPress?: () => void
style?: ViewStyle
}): React.ReactNode => {
const pal = usePalette('default')
const inner = (
<View
style={[pal.border, {paddingVertical: 16, paddingHorizontal: 12}, style]}>
<Text type="md" style={[pal.text]}>
{label}
</Text>
</View>
)
if (onPress) {
return (
<TouchableOpacity
onPress={onPress}
accessibilityLabel={label}
accessibilityHint="">
{inner}
</TouchableOpacity>
)
}
return (
<Link href={to} asAnchor anchorNoUnderline>
<View
style={[
pal.border,
{paddingVertical: 16, paddingHorizontal: 12},
style,
]}>
<Text type="md" style={[pal.text]}>
{label}
</Text>
</View>
</Link>
)
}
+10 -3
View File
@@ -3,11 +3,13 @@ import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {HITSLOP_10} from '#/lib/constants'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics'
import {useMinimalShellHeaderTransform} from '#/lib/hooks/useMinimalShellTransform'
import {type NavigationProp} from '#/lib/routes/types'
import {emitSoftReset} from '#/state/events'
import {useSession} from '#/state/session'
import {useShellLayout} from '#/state/shell/shell-layout'
@@ -17,7 +19,7 @@ import {ButtonIcon} from '#/components/Button'
import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag'
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {IS_LIQUID_GLASS} from '#/env'
import {IS_DEV, IS_LIQUID_GLASS} from '#/env'
export function HomeHeaderLayoutMobile({
children,
@@ -32,6 +34,7 @@ export function HomeHeaderLayoutMobile({
const headerMinimalShellTransform = useMinimalShellHeaderTransform()
const {hasSession} = useSession()
const playHaptic = useHaptics()
const {navigate} = useNavigation<NavigationProp>()
return (
<Animated.View
@@ -59,8 +62,12 @@ export function HomeHeaderLayoutMobile({
<PressableScale
targetScale={0.9}
onPress={() => {
playHaptic('Light')
emitSoftReset()
if (IS_DEV) {
navigate('Debug')
} else {
playHaptic('Light')
emitSoftReset()
}
}}>
<Logo width={30} />
</PressableScale>
+41 -1
View File
@@ -3,8 +3,9 @@ import {type TextInput, View} from 'react-native'
import {APP_LANGUAGES} from '#/lib/../locale/languages'
import {type CountryCode} from '#/lib/international-telephone-codes'
import {atoms as a} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {AutosizedTextarea} from '#/components/forms/AutosizedTextarea'
import {DateField, LabelText} from '#/components/forms/DateField'
import * as SegmentedControl from '#/components/forms/SegmentedControl'
import * as TextField from '#/components/forms/TextField'
@@ -16,6 +17,7 @@ import * as Select from '#/components/Select'
import {H1, H3} from '#/components/Typography'
export function Forms() {
const t = useTheme()
const [toggleGroupAValues, setToggleGroupAValues] = useState(['a'])
const [toggleGroupBValues, setToggleGroupBValues] = useState(['a', 'b'])
const [toggleGroupCValues, setToggleGroupCValues] = useState(['a', 'b'])
@@ -36,6 +38,44 @@ export function Forms() {
<View style={[a.gap_4xl, a.align_start]}>
<H1>Forms</H1>
<View style={[a.gap_md, a.align_start, a.w_full]}>
<AutosizedTextarea
label="minRows=1 maxRows=5"
style={[
a.w_full,
a.p_md,
a.rounded_sm,
a.border,
t.atoms.border_contrast_medium,
]}
maxRows={5}
/>
<AutosizedTextarea
label="defaultValue minRows=1 maxRows=2"
style={[
a.w_full,
a.p_md,
a.rounded_sm,
a.border,
t.atoms.border_contrast_medium,
]}
maxRows={2}
defaultValue="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec auctor, nisl eget ultricies lacinia, nunc nisl aliquam nisl, eget aliquam nunc nisl eget nunc."
/>
<AutosizedTextarea
label="minRows=3 maxRows=10"
style={[
a.w_full,
a.p_md,
a.rounded_sm,
a.border,
t.atoms.border_contrast_medium,
]}
minRows={3}
maxRows={10}
/>
</View>
<Select.Root value={lang} onValueChange={setLang}>
<Select.Trigger label="Select app language">
<Select.ValueText />
+89 -137
View File
@@ -1,157 +1,109 @@
import {memo, useCallback, useState} from 'react'
import {
type StyleProp,
TouchableOpacity,
View,
type ViewStyle,
} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {useState} from 'react'
import {View} from 'react-native'
import {useSift} from '@bsky.app/sift'
import {StackActions, useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {
Autocomplete as AutocompleteBase,
type AutocompleteItem,
useAutocomplete,
} from '#/components/Autocomplete'
import {SearchInput} from '#/components/forms/SearchInput'
import {Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
const WHITESPACE_RE = /\s+/gu
export function DesktopSearch() {
const navigation = useNavigation<NavigationProp>()
const [active, setActive] = useState(false)
const [query, setQuery] = useState<string>('')
const showResults = active && !!query.length
let SearchLinkCard = ({
label,
to,
onPress,
style,
}: {
label: string
to?: string
onPress?: () => void
style?: StyleProp<ViewStyle>
}): React.ReactNode => {
const t = useTheme()
const sift = useSift({
offset: a.p_sm.padding,
placement: 'bottom',
})
const inner = (
<View style={[a.py_lg, a.px_md, t.atoms.border_contrast_low, style]}>
<Text style={[a.text_md, t.atoms.text]}>{label}</Text>
</View>
)
const onFocus = () => {
if (query.length) setActive(true)
}
if (onPress || !to) {
return (
<TouchableOpacity
onPress={onPress}
accessibilityLabel={label}
accessibilityHint="">
{inner}
</TouchableOpacity>
)
const onChangeText = (text: string) => {
setQuery(text)
if (!active) {
setActive(true)
}
}
const onClearText = () => {
setQuery('')
setActive(false)
}
const onSubmit = () => {
if (!query.length) return
onClearText()
sift.elements.input.blur()
navigation.dispatch(StackActions.push('Search', {q: query}))
}
const onSelect = (item: AutocompleteItem) => {
if (item.type === 'profile') {
onClearText()
sift.elements.input.blur()
navigation.navigate('Profile', {name: item.profile.handle})
} else if (item.type === 'search') {
onClearText()
sift.elements.input.blur()
navigation.navigate('Search', {q: item.value})
}
}
return (
<Link
label={label}
to={to}
style={[a.py_lg, a.px_md, t.atoms.border_contrast_low, style]}
hoverStyle={[t.atoms.bg_contrast_25]}>
<Text style={[a.text_md, t.atoms.text]}>{label}</Text>
</Link>
)
}
SearchLinkCard = memo(SearchLinkCard)
export {SearchLinkCard}
export function DesktopSearch() {
const t = useTheme()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const [isActive, setIsActive] = useState<boolean>(false)
const [query, setQuery] = useState<string>('')
const {data: autocompleteData, isFetching} = useActorAutocompleteQuery(
query,
true,
)
const tQuery = query.replace(WHITESPACE_RE, ' ').trim()
const moderationOpts = useModerationOpts()
const onChangeText = useCallback((text: string) => {
setQuery(text)
setIsActive(text.length > 0)
}, [])
const onPressCancelSearch = useCallback(() => {
setQuery('')
setIsActive(false)
}, [setQuery])
const onSubmit = useCallback(() => {
setIsActive(false)
if (!tQuery.length) return
navigation.dispatch(StackActions.push('Search', {q: tQuery}))
}, [tQuery, navigation])
const onSearchProfileCardPress = useCallback(() => {
setQuery('')
setIsActive(false)
}, [])
return (
<View style={[a.relative, a.w_full, a.z_10, t.atoms.bg]}>
<View collapsable={false} ref={sift.refs.setAnchor}>
<SearchInput
hotkey
value={query}
onFocus={onFocus}
onChangeText={onChangeText}
onClearText={onPressCancelSearch}
onClearText={onClearText}
onSubmitEditing={onSubmit}
hotkey={true}
{...sift.targetProps}
/>
{tQuery !== '' && isActive && moderationOpts && (
<View
style={[
a.mt_sm,
a.flex_col,
a.w_full,
a.border,
a.rounded_sm,
a.zoom_fade_in,
t.atoms.bg,
t.atoms.shadow_sm,
t.atoms.border_contrast_low,
{
overflow: 'hidden',
position: 'absolute',
top: '100%',
},
]}>
<SearchLinkCard
label={l`Search for “${tQuery}`}
to={`/search?q=${encodeURIComponent(tQuery)}`}
style={(autocompleteData?.length ?? 0) > 0 ? a.border_b : undefined}
/>
{isFetching && !autocompleteData?.length ? (
<View
style={[
a.py_lg,
a.align_center,
a.border_t,
t.atoms.border_contrast_low,
]}>
<Loader size="lg" />
</View>
) : (
autocompleteData?.map(item => (
<SearchProfileCard
key={item.did}
profile={item}
moderationOpts={moderationOpts}
onPress={onSearchProfileCardPress}
/>
))
)}
</View>
{showResults && (
<Inner
query={query}
sift={sift}
onSelect={onSelect}
onDismiss={() => setActive(false)}
/>
)}
</View>
)
}
function Inner({
query,
sift,
onSelect,
onDismiss,
}: {
query: string
sift: ReturnType<typeof useSift>
onSelect: (item: AutocompleteItem) => void
onDismiss: () => void
}) {
const {items} = useAutocomplete({
type: 'profile',
query,
showSearchFallback: true,
})
return items && items.length ? (
<AutocompleteBase
sift={sift}
data={items}
onSelect={onSelect}
onDismiss={onDismiss}
/>
) : null
}
+15
View File
@@ -2411,6 +2411,16 @@
resolved "https://registry.yarnpkg.com/@bsky.app/react-native-mmkv/-/react-native-mmkv-2.12.5.tgz#eb17d31a6158c74393f617a1763ac223ff3f83a6"
integrity sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==
"@bsky.app/sift@^0.3.1":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@bsky.app/sift/-/sift-0.3.1.tgz#f529832001bcd64950c214e85aec055a1f2edcdb"
integrity sha512-jG9GDh0Yh4vBM98BP4HvBp8VBqnt+280tFx9Gh/bWYtZdiN1xfrTkqWqRkvxyujgpXlyoemREUn+7dGs8Bl60A==
"@bsky.app/tapper@^0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@bsky.app/tapper/-/tapper-0.5.0.tgz#39f3814a063cc0e8ee58c05e09be3d5cb8638f22"
integrity sha512-Fb7L2CruOA/k/FgKDOGChr+JKXsf+geAOTZXDevs9oqbSYTrXuI8KrRgaDwPS+FVCp1vAYHC/3esuVv+lbUtnw==
"@crowdin/cli@^4.14.1":
version "4.14.1"
resolved "https://registry.yarnpkg.com/@crowdin/cli/-/cli-4.14.1.tgz#1239922681235b6b14bcacd4fd622bc2217dd6c5"
@@ -9628,6 +9638,11 @@ functions-have-names@^1.2.2, functions-have-names@^1.2.3:
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
fuse.js@^7.1.0:
version "7.3.0"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.3.0.tgz#68e1ea1c6c0ff262f1801a949a78edbe05b0bc13"
integrity sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==
gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"