New rich text composer + autocomplete (#10159)
This commit is contained in:
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './Autocomplete'
|
||||
export * from './AutocompleteItemEmoji'
|
||||
export * from './AutocompleteItemProfile'
|
||||
export * from './types'
|
||||
export * from './useAutocomplete'
|
||||
export * from './util'
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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`}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import Emojis, {type EmojiMartData} from '@emoji-mart/data'
|
||||
|
||||
export async function getEmojis(): Promise<EmojiMartData> {
|
||||
return Emojis as EmojiMartData
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user