From 0a5ae17738a743062b8cf19cb7848f8240140aa8 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 7 Apr 2026 20:37:50 -0500 Subject: [PATCH] New rich text composer + autocomplete (#10159) --- package.json | 3 + src/alf/util/flatten.ts | 38 +- src/analytics/features/types.ts | 1 + src/components/Autocomplete/Autocomplete.tsx | 78 ++++ .../Autocomplete/AutocompleteItemEmoji.tsx | 30 ++ .../Autocomplete/AutocompleteItemProfile.tsx | 47 ++ .../Autocomplete/AutocompleteItemSearch.tsx | 49 ++ src/components/Autocomplete/index.tsx | 6 + src/components/Autocomplete/types.ts | 48 ++ .../Autocomplete/useAutocomplete/index.ts | 135 ++++++ .../useAutocomplete/useEmojiSearch.ts | 40 ++ src/components/Autocomplete/util.ts | 12 + src/components/Composer/index.tsx | 432 ++++++++++++++++++ src/components/forms/AutosizedTextarea.tsx | 166 +++++++ src/components/forms/SearchInput.tsx | 10 +- src/lib/merge-refs.ts | 2 +- src/lib/useGetEmojis/getEmojis.ts | 5 + src/lib/useGetEmojis/getEmojis.web.ts | 5 + src/lib/useGetEmojis/index.ts | 12 + .../Messages/components/MessageComposer.tsx | 251 ++++++++++ .../Messages/components/MessagesList.tsx | 29 +- .../Search/components/AutocompleteResults.tsx | 60 ++- src/view/com/home/HomeHeaderLayoutMobile.tsx | 13 +- src/view/screens/Storybook/Forms.tsx | 42 +- src/view/shell/desktop/Search.tsx | 226 ++++----- yarn.lock | 15 + 26 files changed, 1596 insertions(+), 159 deletions(-) create mode 100644 src/components/Autocomplete/Autocomplete.tsx create mode 100644 src/components/Autocomplete/AutocompleteItemEmoji.tsx create mode 100644 src/components/Autocomplete/AutocompleteItemProfile.tsx create mode 100644 src/components/Autocomplete/AutocompleteItemSearch.tsx create mode 100644 src/components/Autocomplete/index.tsx create mode 100644 src/components/Autocomplete/types.ts create mode 100644 src/components/Autocomplete/useAutocomplete/index.ts create mode 100644 src/components/Autocomplete/useAutocomplete/useEmojiSearch.ts create mode 100644 src/components/Autocomplete/util.ts create mode 100644 src/components/Composer/index.tsx create mode 100644 src/components/forms/AutosizedTextarea.tsx create mode 100644 src/lib/useGetEmojis/getEmojis.ts create mode 100644 src/lib/useGetEmojis/getEmojis.web.ts create mode 100644 src/lib/useGetEmojis/index.ts create mode 100644 src/screens/Messages/components/MessageComposer.tsx diff --git a/package.json b/package.json index b49932895c..d5cf87164f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/alf/util/flatten.ts b/src/alf/util/flatten.ts index 448716a082..6d49ce6e51 100644 --- a/src/alf/util/flatten.ts +++ b/src/alf/util/flatten.ts @@ -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, + } +} diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 44eebf561e..5c1857990e 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -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', } diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx new file mode 100644 index 0000000000..daee24847d --- /dev/null +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -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>[0]['render']>[0], +) { + switch (item.item.type) { + case 'profile': + return + case 'emoji': + return + case 'search': + return + default: + return + } +} + +export function Autocomplete({ + inverted, + sift, + data, + render = renderItem, + onSelect, + onDismiss, +}: { + inverted?: boolean + sift: UseSiftReturn + data: AutocompleteItem[] + render?: Parameters>[0]['render'] + onSelect: (item: AutocompleteItem) => void + onDismiss: () => void +}) { + const t = useTheme() + + const updatePosition = useCallback(() => { + sift.updatePosition() + }, [sift]) + + useOnKeyboard('keyboardDidShow', updatePosition) + useOnKeyboard('keyboardDidHide', updatePosition) + + return ( + + + + ) +} diff --git a/src/components/Autocomplete/AutocompleteItemEmoji.tsx b/src/components/Autocomplete/AutocompleteItemEmoji.tsx new file mode 100644 index 0000000000..91d963f94e --- /dev/null +++ b/src/components/Autocomplete/AutocompleteItemEmoji.tsx @@ -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 ( + [ + {paddingVertical: 6, paddingHorizontal: 10}, + a.flex_row, + a.align_center, + a.gap_sm, + active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [], + ]}> + {item.value} + :{item.emoji.id}: + + ) +} diff --git a/src/components/Autocomplete/AutocompleteItemProfile.tsx b/src/components/Autocomplete/AutocompleteItemProfile.tsx new file mode 100644 index 0000000000..6214a2d091 --- /dev/null +++ b/src/components/Autocomplete/AutocompleteItemProfile.tsx @@ -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 ( + [ + 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, + }, + ]}> + + + + + + ) +} diff --git a/src/components/Autocomplete/AutocompleteItemSearch.tsx b/src/components/Autocomplete/AutocompleteItemSearch.tsx new file mode 100644 index 0000000000..f7c37b3a13 --- /dev/null +++ b/src/components/Autocomplete/AutocompleteItemSearch.tsx @@ -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 ( + [ + 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, + }, + ]}> + + + + {item.value} + + ) +} diff --git a/src/components/Autocomplete/index.tsx b/src/components/Autocomplete/index.tsx new file mode 100644 index 0000000000..b53fcb81f3 --- /dev/null +++ b/src/components/Autocomplete/index.tsx @@ -0,0 +1,6 @@ +export * from './Autocomplete' +export * from './AutocompleteItemEmoji' +export * from './AutocompleteItemProfile' +export * from './types' +export * from './useAutocomplete' +export * from './util' diff --git a/src/components/Autocomplete/types.ts b/src/components/Autocomplete/types.ts new file mode 100644 index 0000000000..fef38ab796 --- /dev/null +++ b/src/components/Autocomplete/types.ts @@ -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>[0]['render'] +>[0] + +export type AutocompleteApi = { + query: string + items: AutocompleteItem[] +} diff --git a/src/components/Autocomplete/useAutocomplete/index.ts b/src/components/Autocomplete/useAutocomplete/index.ts new file mode 100644 index 0000000000..41faad3db5 --- /dev/null +++ b/src/components/Autocomplete/useAutocomplete/index.ts @@ -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() + 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 +} diff --git a/src/components/Autocomplete/useAutocomplete/useEmojiSearch.ts b/src/components/Autocomplete/useAutocomplete/useEmojiSearch.ts new file mode 100644 index 0000000000..7770da5829 --- /dev/null +++ b/src/components/Autocomplete/useAutocomplete/useEmojiSearch.ts @@ -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 | null = null + +export function useEmojiSearch(): ( + query: string, + limit?: number, +) => Promise { + 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], + ) +} diff --git a/src/components/Autocomplete/util.ts b/src/components/Autocomplete/util.ts new file mode 100644 index 0000000000..cf16aa8839 --- /dev/null +++ b/src/components/Autocomplete/util.ts @@ -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}`) + } +} diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx new file mode 100644 index 0000000000..fe2e45cac5 --- /dev/null +++ b/src/components/Composer/index.tsx @@ -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['input'] + clear: () => void + insert(text: string): void + setAutocompleteAnchor: (node: View | null) => void +} + +export function useComposerInternalApiRef() { + return useRef(null) +} + +/* + * ─── Composer ───────────────────────────────────────────────────────────────── + */ + +export type ComposerProps = Omit< + AutosizedTextareaProps, + | 'value' + | 'onChange' + | 'onChangeText' + | 'onSelectionChange' + | 'selection' + | 'style' + | 'onSubmitEditing' +> & { + label: string + ref?: React.RefObject + internalApiRef?: React.Ref + 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[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(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 = ( + + {tapper.state.nodes.map((node, i) => { + switch (node.type) { + case 'text': + return {node.value} + case 'trigger': + case 'facet': + return ( + + {node.raw} + + ) + } + })} + + ) + + return ( + <> + + {IS_WEB && ( + + + {textContent} + + + )} + { + 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} + + + + {activeFacet && activeFacet.type !== 'url' && ( + 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 ? ( + { + if (props.item.type === 'profile') { + return + } + if (props.item.type === 'emoji') { + return + } + return + }} + onSelect={item => { + activeFacet.replace(item.value) + onDismiss() + }} + onDismiss={onDismiss} + /> + ) : null +} diff --git a/src/components/forms/AutosizedTextarea.tsx b/src/components/forms/AutosizedTextarea.tsx new file mode 100644 index 0000000000..402075ea27 --- /dev/null +++ b/src/components/forms/AutosizedTextarea.tsx @@ -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 & { + ref?: React.Ref + 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(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 ( + = 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} + /> + ) +} diff --git a/src/components/forms/SearchInput.tsx b/src/components/forms/SearchInput.tsx index 8b54b44246..edd737352f 100644 --- a/src/components/forms/SearchInput.tsx +++ b/src/components/forms/SearchInput.tsx @@ -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 & { */ onClearText?: () => void hotkey?: boolean - ref?: React.RefObject + ref?: React.Ref } export function SearchInput({ @@ -33,21 +34,20 @@ export function SearchInput({ const {t: l} = useLingui() const showClear = value && value.length > 0 const internalRef = useRef(null) - const inputRef = ref ?? internalRef useEffect(() => { if (!hotkey) return return listenFocusSearch(() => { - inputRef.current?.focus() + internalRef.current?.focus() }) - }, [hotkey, inputRef]) + }, [hotkey]) return ( ( - refs: Array | React.Ref>, + refs: Array | React.Ref | undefined>, ): React.RefCallback { return value => { refs.forEach(ref => { diff --git a/src/lib/useGetEmojis/getEmojis.ts b/src/lib/useGetEmojis/getEmojis.ts new file mode 100644 index 0000000000..3118f437e9 --- /dev/null +++ b/src/lib/useGetEmojis/getEmojis.ts @@ -0,0 +1,5 @@ +import Emojis, {type EmojiMartData} from '@emoji-mart/data' + +export async function getEmojis(): Promise { + return Emojis as EmojiMartData +} diff --git a/src/lib/useGetEmojis/getEmojis.web.ts b/src/lib/useGetEmojis/getEmojis.web.ts new file mode 100644 index 0000000000..b4fbf0dfbe --- /dev/null +++ b/src/lib/useGetEmojis/getEmojis.web.ts @@ -0,0 +1,5 @@ +import {type EmojiMartData} from '@emoji-mart/data' + +export async function getEmojis(): Promise { + return (await import('@emoji-mart/data')).default as EmojiMartData +} diff --git a/src/lib/useGetEmojis/index.ts b/src/lib/useGetEmojis/index.ts new file mode 100644 index 0000000000..4c70c724ff --- /dev/null +++ b/src/lib/useGetEmojis/index.ts @@ -0,0 +1,12 @@ +import {useCallback} from 'react' + +import {getEmojis} from './getEmojis' + +let emojis: Awaited> | null = null + +export function useGetEmojis() { + return useCallback(async () => { + emojis ??= await getEmojis() + return emojis + }, []) +} diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx new file mode 100644 index 0000000000..9d46534849 --- /dev/null +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -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({ + 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 ( + <> + + {children} + + { + 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 && ( + { + 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 => ( + + + + )} + + )} + + { + 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 ? ( + + + + ) : null} + + + + {IS_WEB && ( + setEmojiPickerState(prev => ({...prev, isOpen: false}))} + /> + )} + + ) +} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index e06c9659c4..27f1c93387 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -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({ - - - + {ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? ( + + + + ) : ( + + + + )} )} diff --git a/src/screens/Search/components/AutocompleteResults.tsx b/src/screens/Search/components/AutocompleteResults.tsx index c36c2c15ef..e672ea6397 100644 --- a/src/screens/Search/components/AutocompleteResults.tsx +++ b/src/screens/Search/components/AutocompleteResults.tsx @@ -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 = ( + + + {label} + + + ) + + if (onPress) { + return ( + + {inner} + + ) + } + + return ( + + + + {label} + + + + ) +} diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx index 3fcbc3c93c..32b95649ab 100644 --- a/src/view/com/home/HomeHeaderLayoutMobile.tsx +++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx @@ -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() return ( { - playHaptic('Light') - emitSoftReset() + if (IS_DEV) { + navigate('Debug') + } else { + playHaptic('Light') + emitSoftReset() + } }}> diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx index 5d5f4d0c3e..ff4f097fa2 100644 --- a/src/view/screens/Storybook/Forms.tsx +++ b/src/view/screens/Storybook/Forms.tsx @@ -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() {

Forms

+ + + + + + diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index a64bfb7497..189a9fafcf 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -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() + const [active, setActive] = useState(false) + const [query, setQuery] = useState('') + const showResults = active && !!query.length -let SearchLinkCard = ({ - label, - to, - onPress, - style, -}: { - label: string - to?: string - onPress?: () => void - style?: StyleProp -}): React.ReactNode => { - const t = useTheme() + const sift = useSift({ + offset: a.p_sm.padding, + placement: 'bottom', + }) - const inner = ( - - {label} - - ) + const onFocus = () => { + if (query.length) setActive(true) + } - if (onPress || !to) { - return ( - - {inner} - - ) + 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 ( - - {label} - - ) -} -SearchLinkCard = memo(SearchLinkCard) -export {SearchLinkCard} - -export function DesktopSearch() { - const t = useTheme() - const {t: l} = useLingui() - const navigation = useNavigation() - const [isActive, setIsActive] = useState(false) - const [query, setQuery] = useState('') - 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 ( - + - {tQuery !== '' && isActive && moderationOpts && ( - - 0 ? a.border_b : undefined} - /> - {isFetching && !autocompleteData?.length ? ( - - - - ) : ( - autocompleteData?.map(item => ( - - )) - )} - + {showResults && ( + setActive(false)} + /> )} ) } + +function Inner({ + query, + sift, + onSelect, + onDismiss, +}: { + query: string + sift: ReturnType + onSelect: (item: AutocompleteItem) => void + onDismiss: () => void +}) { + const {items} = useAutocomplete({ + type: 'profile', + query, + showSearchFallback: true, + }) + + return items && items.length ? ( + + ) : null +} diff --git a/yarn.lock b/yarn.lock index 5a3e3f641c..26cfe3b3b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"