Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

New rich text composer + autocomplete (#10159)

authored by

Eric Bailey and committed by
GitHub
0a5ae177 02b84999

+1590 -153
+3
package.json
··· 88 88 "@bsky.app/expo-image-crop-tool": "^0.5.0", 89 89 "@bsky.app/expo-translate-text": "^0.2.9", 90 90 "@bsky.app/react-native-mmkv": "2.12.5", 91 + "@bsky.app/sift": "^0.3.1", 92 + "@bsky.app/tapper": "^0.5.0", 91 93 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", 92 94 "@emoji-mart/data": "^1.2.1", 93 95 "@emoji-mart/react": "^1.1.1", ··· 179 181 "expo-web-browser": "~15.0.10", 180 182 "fast-deep-equal": "^3.1.3", 181 183 "fast-text-encoding": "^1.0.6", 184 + "fuse.js": "^7.1.0", 182 185 "hls.js": "^1.6.2", 183 186 "idb-keyval": "^6.2.2", 184 187 "js-sha256": "^0.9.0",
+37 -1
src/alf/util/flatten.ts
··· 1 - import {StyleSheet} from 'react-native' 1 + import {type DimensionValue, StyleSheet} from 'react-native' 2 2 3 3 export const flatten = StyleSheet.flatten 4 + 5 + /** 6 + * Coerce a style value to a number. Padding values are typed as 7 + * `DimensionValue` (numbers, percentages, "auto", etc.) but our ALF atoms 8 + * are always plain numbers. Non-numeric values are treated as 0. 9 + */ 10 + function num(v: unknown): number { 11 + return typeof v === 'number' ? v : 0 12 + } 13 + 14 + interface PaddingStyle { 15 + padding?: DimensionValue 16 + paddingHorizontal?: DimensionValue 17 + paddingVertical?: DimensionValue 18 + paddingTop?: DimensionValue 19 + paddingBottom?: DimensionValue 20 + paddingLeft?: DimensionValue 21 + paddingRight?: DimensionValue 22 + } 23 + 24 + /** 25 + * Extract resolved padding values from a style object. Returns numbers for 26 + * each side, resolving shorthand properties (padding → paddingVertical → 27 + * paddingTop/paddingBottom, etc.). Values are expected to be numbers — any 28 + * non-numeric `DimensionValue` (e.g. percentages) is treated as 0. 29 + */ 30 + export function extractPadding(style: PaddingStyle | PaddingStyle[]) { 31 + const s = flatten(style as any) ?? {} 32 + const base = num(s.padding) 33 + return { 34 + paddingTop: num(s.paddingTop) || num(s.paddingVertical) || base, 35 + paddingBottom: num(s.paddingBottom) || num(s.paddingVertical) || base, 36 + paddingLeft: num(s.paddingLeft) || num(s.paddingHorizontal) || base, 37 + paddingRight: num(s.paddingRight) || num(s.paddingHorizontal) || base, 38 + } 39 + }
+1
src/analytics/features/types.ts
··· 11 11 LiveNowBetaDisable = 'live_now_beta:disable', 12 12 ImageUploadsHighResolution = 'image_uploads:high_resolution', 13 13 GroupChatsEnable = 'group_chats:enable', 14 + DmsNewMessageComposerEnable = 'dms:new_message_composer:enable', 14 15 15 16 AATest = 'aa-test', 16 17 }
+78
src/components/Autocomplete/Autocomplete.tsx
··· 1 + import {useCallback} from 'react' 2 + import {View} from 'react-native' 3 + import {Sift, type UseSiftReturn} from '@bsky.app/sift' 4 + 5 + import {atoms as a, useTheme} from '#/alf' 6 + import {type AutocompleteItem} from '#/components/Autocomplete/types' 7 + import {useOnKeyboard} from '#/components/hooks/useOnKeyboard' 8 + import {Portal} from '#/components/Portal' 9 + import {IS_WEB} from '#/env' 10 + import {AutocompleteItemEmoji} from './AutocompleteItemEmoji' 11 + import {AutocompleteItemProfile} from './AutocompleteItemProfile' 12 + import {AutocompleteItemSearch} from './AutocompleteItemSearch' 13 + 14 + function renderItem( 15 + item: Parameters<Parameters<typeof Sift<AutocompleteItem>>[0]['render']>[0], 16 + ) { 17 + switch (item.item.type) { 18 + case 'profile': 19 + return <AutocompleteItemProfile {...item} /> 20 + case 'emoji': 21 + return <AutocompleteItemEmoji {...item} /> 22 + case 'search': 23 + return <AutocompleteItemSearch {...item} /> 24 + default: 25 + return <View /> 26 + } 27 + } 28 + 29 + export function Autocomplete({ 30 + inverted, 31 + sift, 32 + data, 33 + render = renderItem, 34 + onSelect, 35 + onDismiss, 36 + }: { 37 + inverted?: boolean 38 + sift: UseSiftReturn 39 + data: AutocompleteItem[] 40 + render?: Parameters<typeof Sift<AutocompleteItem>>[0]['render'] 41 + onSelect: (item: AutocompleteItem) => void 42 + onDismiss: () => void 43 + }) { 44 + const t = useTheme() 45 + 46 + const updatePosition = useCallback(() => { 47 + sift.updatePosition() 48 + }, [sift]) 49 + 50 + useOnKeyboard('keyboardDidShow', updatePosition) 51 + useOnKeyboard('keyboardDidHide', updatePosition) 52 + 53 + return ( 54 + <Portal> 55 + <Sift 56 + inverted={inverted} 57 + sift={sift} 58 + data={data} 59 + onSelect={onSelect} 60 + onDismiss={onDismiss} 61 + style={[ 62 + a.overflow_hidden, 63 + a.rounded_md, 64 + a.border, 65 + t.atoms.border_contrast_low, 66 + t.atoms.bg, 67 + a.w_full, 68 + IS_WEB 69 + ? { 70 + maxWidth: 300, 71 + } 72 + : {}, 73 + ]} 74 + render={render} 75 + /> 76 + </Portal> 77 + ) 78 + }
+30
src/components/Autocomplete/AutocompleteItemEmoji.tsx
··· 1 + import {SiftItem} from '@bsky.app/sift' 2 + 3 + import {atoms as a, useTheme} from '#/alf' 4 + import {Text} from '#/components/Typography' 5 + import {type AutocompleteItemProps} from './types' 6 + 7 + export function AutocompleteItemEmoji({ 8 + active, 9 + props, 10 + item, 11 + }: AutocompleteItemProps) { 12 + const t = useTheme() 13 + 14 + if (item.type !== 'emoji') return null 15 + 16 + return ( 17 + <SiftItem 18 + {...props} 19 + style={s => [ 20 + {paddingVertical: 6, paddingHorizontal: 10}, 21 + a.flex_row, 22 + a.align_center, 23 + a.gap_sm, 24 + active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [], 25 + ]}> 26 + <Text style={[a.text_xl, a.leading_tight]}>{item.value}</Text> 27 + <Text style={[a.text_md, a.leading_tight]}>:{item.emoji.id}:</Text> 28 + </SiftItem> 29 + ) 30 + }
+47
src/components/Autocomplete/AutocompleteItemProfile.tsx
··· 1 + import {SiftItem} from '@bsky.app/sift' 2 + 3 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 4 + import {atoms as a, useTheme} from '#/alf' 5 + import * as ProfileCard from '#/components/ProfileCard' 6 + import {type AutocompleteItemProps} from './types' 7 + 8 + export function AutocompleteItemProfile({ 9 + active, 10 + isFirst, 11 + isLast, 12 + props, 13 + item, 14 + }: AutocompleteItemProps) { 15 + const t = useTheme() 16 + const moderationOpts = useModerationOpts() 17 + 18 + if (item.type !== 'profile' || !moderationOpts) return null 19 + 20 + return ( 21 + <SiftItem 22 + {...props} 23 + style={s => [ 24 + a.py_sm, 25 + a.px_md, 26 + active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [], 27 + isFirst && { 28 + paddingTop: a.py_sm.paddingTop * 1.2, 29 + }, 30 + isLast && { 31 + paddingBottom: a.py_sm.paddingTop * 1.2, 32 + }, 33 + ]}> 34 + <ProfileCard.Header> 35 + <ProfileCard.Avatar 36 + disabledPreview 37 + profile={item.profile} 38 + moderationOpts={moderationOpts} 39 + /> 40 + <ProfileCard.NameAndHandle 41 + profile={item.profile} 42 + moderationOpts={moderationOpts} 43 + /> 44 + </ProfileCard.Header> 45 + </SiftItem> 46 + ) 47 + }
+49
src/components/Autocomplete/AutocompleteItemSearch.tsx
··· 1 + import {View} from 'react-native' 2 + import {SiftItem} from '@bsky.app/sift' 3 + 4 + import {atoms as a, useTheme} from '#/alf' 5 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass' 6 + import {Text} from '#/components/Typography' 7 + import {type AutocompleteItemProps} from './types' 8 + 9 + export function AutocompleteItemSearch({ 10 + active, 11 + isFirst, 12 + isLast, 13 + props, 14 + item, 15 + }: AutocompleteItemProps) { 16 + const t = useTheme() 17 + 18 + if (item.type !== 'search') return null 19 + 20 + return ( 21 + <SiftItem 22 + {...props} 23 + style={s => [ 24 + a.py_sm, 25 + a.px_md, 26 + a.flex_row, 27 + a.align_center, 28 + a.gap_sm, 29 + active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [], 30 + isFirst && { 31 + paddingTop: a.py_sm.paddingTop * 1.2, 32 + }, 33 + isLast && { 34 + paddingBottom: a.py_sm.paddingTop * 1.2, 35 + }, 36 + ]}> 37 + <View 38 + style={[ 39 + a.align_center, 40 + { 41 + width: 40, 42 + }, 43 + ]}> 44 + <MagnifyingGlassIcon fill={t.atoms.text_contrast_low.color} size="xl" /> 45 + </View> 46 + <Text style={[a.text_md, a.leading_snug]}>{item.value}</Text> 47 + </SiftItem> 48 + ) 49 + }
+6
src/components/Autocomplete/index.tsx
··· 1 + export * from './Autocomplete' 2 + export * from './AutocompleteItemEmoji' 3 + export * from './AutocompleteItemProfile' 4 + export * from './types' 5 + export * from './useAutocomplete' 6 + export * from './util'
+48
src/components/Autocomplete/types.ts
··· 1 + import {type Sift} from '@bsky.app/sift' 2 + import {type Emoji} from '@emoji-mart/data' 3 + 4 + import type * as bsky from '#/types/bsky' 5 + 6 + export type AutocompleteProfile = { 7 + key: string 8 + type: 'profile' 9 + value: string 10 + profile: bsky.profile.AnyProfileView 11 + } 12 + 13 + export type AutocompleteTag = { 14 + key: string 15 + type: 'tag' 16 + value: string 17 + tag: string 18 + } 19 + 20 + export type AutocompleteEmoji = { 21 + key: string 22 + type: 'emoji' 23 + value: string 24 + emoji: Emoji 25 + } 26 + 27 + export type AutocompleteSearch = { 28 + key: string 29 + type: 'search' 30 + value: string 31 + } 32 + 33 + export type AutocompleteItem = 34 + | AutocompleteProfile 35 + | AutocompleteTag 36 + | AutocompleteEmoji 37 + | AutocompleteSearch 38 + 39 + export type AutocompleteItemType = AutocompleteItem['type'] 40 + 41 + export type AutocompleteItemProps = Parameters< 42 + Parameters<typeof Sift<AutocompleteItem>>[0]['render'] 43 + >[0] 44 + 45 + export type AutocompleteApi = { 46 + query: string 47 + items: AutocompleteItem[] 48 + }
+135
src/components/Autocomplete/useAutocomplete/index.ts
··· 1 + import {useCallback} from 'react' 2 + import {moderateProfile, type ModerationOpts} from '@atproto/api' 3 + import {keepPreviousData, useQuery} from '@tanstack/react-query' 4 + 5 + import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation' 6 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 7 + import {STALE} from '#/state/queries' 8 + import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences' 9 + import {useAgent} from '#/state/session' 10 + import { 11 + type AutocompleteApi, 12 + type AutocompleteItem, 13 + type AutocompleteItemType, 14 + type AutocompleteProfile, 15 + } from '#/components/Autocomplete/types' 16 + import {useEmojiSearch} from './useEmojiSearch' 17 + 18 + const DEFAULT_MOD_OPTS = { 19 + userDid: undefined, 20 + prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, 21 + } 22 + 23 + export function useAutocomplete({ 24 + type, 25 + query: q, 26 + limit, 27 + showSearchFallback = false, 28 + }: { 29 + type: AutocompleteItemType 30 + query: string 31 + limit?: number 32 + showSearchFallback?: boolean 33 + }): AutocompleteApi { 34 + const agent = useAgent() 35 + const moderationOpts = useModerationOpts() 36 + const emojiSearch = useEmojiSearch() 37 + 38 + const query = useQuery({ 39 + staleTime: STALE.MINUTES.ONE, 40 + queryKey: [ 41 + 'autocomplete', 42 + { 43 + type, 44 + query: q, 45 + }, 46 + ], 47 + async queryFn() { 48 + if (type === 'profile') { 49 + // TODO return recents 50 + if (!q) return [] 51 + 52 + // Going from "foo" to "foo." should not clear matches. 53 + q = q.toLowerCase().trim().replace(/\.$/, '') 54 + 55 + const res = await agent.searchActorsTypeahead({ 56 + q, 57 + limit: limit || 8, 58 + }) 59 + 60 + return (res?.data.actors || []).map(profile => ({ 61 + key: profile.did, 62 + type: 'profile' as const, 63 + value: '@' + profile.handle, 64 + profile, 65 + })) 66 + } else if (type === 'emoji') { 67 + return emojiSearch(q, limit || 8) 68 + } 69 + 70 + return [] 71 + }, 72 + select: useCallback( 73 + (items: AutocompleteItem[]) => { 74 + const seen = new Set<string>() 75 + let results: AutocompleteItem[] = [] 76 + 77 + for (const item of items) { 78 + if (seen.has(item.key)) continue 79 + seen.add(item.key) 80 + 81 + if (item.type === 'profile') { 82 + const moderated = moderateProfileItem({ 83 + query: q, 84 + item, 85 + moderationOpts: moderationOpts || DEFAULT_MOD_OPTS, 86 + }) 87 + if (moderated) results.push(moderated) 88 + } else { 89 + results.push(item) 90 + } 91 + } 92 + 93 + if (showSearchFallback && q) { 94 + results.unshift({ 95 + key: `search-${q}`, 96 + type: 'search' as const, 97 + value: q, 98 + }) 99 + } 100 + 101 + return results 102 + }, 103 + [q, showSearchFallback, moderationOpts], 104 + ), 105 + placeholderData: keepPreviousData, 106 + }) 107 + 108 + return { 109 + query: q, 110 + items: query.data || [], 111 + } 112 + } 113 + 114 + function moderateProfileItem({ 115 + query, 116 + item, 117 + moderationOpts, 118 + }: { 119 + query: string 120 + item: AutocompleteProfile 121 + moderationOpts: ModerationOpts 122 + }) { 123 + const modui = moderateProfile(item.profile, moderationOpts).ui('profileList') 124 + const isExactMatch = query && item.profile.handle.toLowerCase() === query 125 + 126 + if ( 127 + (isExactMatch && !moduiContainsHideableOffense(modui)) || 128 + !modui.filter || 129 + isJustAMute(modui) 130 + ) { 131 + return item 132 + } 133 + 134 + return null 135 + }
+40
src/components/Autocomplete/useAutocomplete/useEmojiSearch.ts
··· 1 + import {useCallback} from 'react' 2 + import {type Emoji} from '@emoji-mart/data' 3 + import Fuse from 'fuse.js' 4 + 5 + import {useGetEmojis} from '#/lib/useGetEmojis' 6 + import {type AutocompleteEmoji} from '#/components/Autocomplete/types' 7 + 8 + /* 9 + * Lazily loaded Fuse instance for emoji search. Built once on first search, 10 + * then reused for all subsequent searches. 11 + */ 12 + let emojiFuseInstance: Fuse<Emoji> | null = null 13 + 14 + export function useEmojiSearch(): ( 15 + query: string, 16 + limit?: number, 17 + ) => Promise<AutocompleteEmoji[]> { 18 + const getEmojis = useGetEmojis() 19 + 20 + return useCallback( 21 + async (query: string, limit: number = 8) => { 22 + if (!emojiFuseInstance) { 23 + const data = await getEmojis() 24 + emojiFuseInstance = new Fuse(Object.values(data.emojis), { 25 + keys: ['search'], 26 + threshold: 0.3, 27 + }) 28 + } 29 + 30 + const results = emojiFuseInstance.search(query, {limit}) 31 + return results.map(result => ({ 32 + key: result.item.id, 33 + type: 'emoji' as const, 34 + value: result.item.skins[0].native, 35 + emoji: result.item, 36 + })) 37 + }, 38 + [getEmojis], 39 + ) 40 + }
+12
src/components/Autocomplete/util.ts
··· 1 + export function parseAutocompleteItemType(type: string) { 2 + switch (type) { 3 + case 'mention': 4 + return 'profile' 5 + case 'tag': 6 + return 'tag' 7 + case 'emoji': 8 + return 'emoji' 9 + default: 10 + throw new Error(`Unknown autocomplete item type: ${type}`) 11 + } 12 + }
+432
src/components/Composer/index.tsx
··· 1 + import {useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react' 2 + import { 3 + type TextInput, 4 + type TextInputSubmitEditingEvent, 5 + View, 6 + } from 'react-native' 7 + import Animated, { 8 + useAnimatedStyle, 9 + useSharedValue, 10 + } from 'react-native-reanimated' 11 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 12 + import {useSift, type UseSiftReturn} from '@bsky.app/sift' 13 + import { 14 + facets, 15 + type TapperActiveFacet, 16 + type TapperFacet, 17 + useTapper, 18 + } from '@bsky.app/tapper' 19 + 20 + import {mergeRefs} from '#/lib/merge-refs' 21 + import { 22 + atoms as a, 23 + type TextStyleProp, 24 + useAlf, 25 + type ViewStyleProp, 26 + web, 27 + } from '#/alf' 28 + import {normalizeTextStyles} from '#/alf/typography' 29 + import { 30 + Autocomplete as AutocompleteBase, 31 + AutocompleteItemEmoji, 32 + AutocompleteItemProfile, 33 + parseAutocompleteItemType, 34 + useAutocomplete, 35 + } from '#/components/Autocomplete' 36 + import { 37 + AutosizedTextarea, 38 + type AutosizedTextareaProps, 39 + } from '#/components/forms/AutosizedTextarea' 40 + import {Span, Text} from '#/components/Typography' 41 + import {IS_IOS, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' 42 + 43 + export type SubmitRequest = 44 + | { 45 + platform: 'web' 46 + shiftKey: boolean 47 + metaKey: boolean 48 + nativeEvent: KeyboardEvent 49 + } 50 + | { 51 + platform: 'native' 52 + nativeEvent: TextInputSubmitEditingEvent 53 + } 54 + 55 + /** 56 + * Imperative API exposed via `internalApiRef` prop for parent components that 57 + * need to control the composer programmatically, e.g. to clear the input or 58 + * insert text at the current cursor position. 59 + */ 60 + export type ComposerInternalApi = { 61 + input?: ReturnType<typeof useTapper>['input'] 62 + clear: () => void 63 + insert(text: string): void 64 + setAutocompleteAnchor: (node: View | null) => void 65 + } 66 + 67 + export function useComposerInternalApiRef() { 68 + return useRef<ComposerInternalApi>(null) 69 + } 70 + 71 + /* 72 + * ─── Composer ───────────────────────────────────────────────────────────────── 73 + */ 74 + 75 + export type ComposerProps = Omit< 76 + AutosizedTextareaProps, 77 + | 'value' 78 + | 'onChange' 79 + | 'onChangeText' 80 + | 'onSelectionChange' 81 + | 'selection' 82 + | 'style' 83 + | 'onSubmitEditing' 84 + > & { 85 + label: string 86 + ref?: React.RefObject<TextInput> 87 + internalApiRef?: React.Ref<ComposerInternalApi> 88 + outerStyle?: ViewStyleProp['style'] 89 + contentTextStyle?: TextStyleProp['style'] 90 + contentPaddingStyle?: { 91 + paddingTop?: number 92 + paddingBottom?: number 93 + paddingLeft?: number 94 + paddingRight?: number 95 + } 96 + onChange?: (text: string) => void 97 + onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void 98 + onFacetCommitted?: (facet: TapperFacet) => void 99 + onRequestSubmit?: (request: SubmitRequest) => void 100 + autocompletePlacement?: Exclude< 101 + Parameters<typeof useSift>[0], 102 + undefined 103 + >['placement'] 104 + disableEmojiFacets?: boolean 105 + } 106 + 107 + export function Composer({ 108 + label, 109 + ref, 110 + internalApiRef, 111 + outerStyle, 112 + contentTextStyle, 113 + contentPaddingStyle, 114 + onChange: onChangeOuter, 115 + onActiveFacet: onActiveFacetOuter, 116 + onFacetCommitted: onFacetCommittedOuter, 117 + onRequestSubmit, 118 + autocompletePlacement, 119 + defaultValue, 120 + disableEmojiFacets = !IS_WEB, 121 + ...rest 122 + }: ComposerProps) { 123 + const {theme: t, fonts} = useAlf() 124 + const insets = useSafeAreaInsets() 125 + 126 + /* 127 + * Meat and potatoes 128 + */ 129 + const tapper = useTapper({ 130 + initialText: defaultValue ?? '', 131 + facets: disableEmojiFacets 132 + ? { 133 + mention: facets.mention, 134 + tag: facets.tag, 135 + url: facets.url, 136 + } 137 + : facets, 138 + }) 139 + const sift = useSift({ 140 + offset: a.p_sm.padding, 141 + placement: autocompletePlacement, 142 + dynamicWidth: IS_WEB, 143 + insets, 144 + }) 145 + 146 + /* 147 + * Active facet state for controlling the visibility of the Autocomplete. 148 + */ 149 + const [activeFacet, setActiveFacet] = useState<TapperActiveFacet | null>(null) 150 + 151 + /* 152 + * Reanimated shared value for syncing scroll on all platforms. 153 + */ 154 + const inputScrollSharedValue = useSharedValue(0) 155 + 156 + /* 157 + * Expose imperative internal API 158 + */ 159 + useImperativeHandle( 160 + internalApiRef, 161 + () => ({ 162 + input: tapper.input, 163 + clear: () => { 164 + tapper.inputProps.onChangeText('') 165 + inputScrollSharedValue.value = 0 166 + }, 167 + insert: tapper.insert, 168 + setAutocompleteAnchor: sift.refs.setAnchor, 169 + }), 170 + [tapper.input, tapper.insert, inputScrollSharedValue, sift.refs.setAnchor], 171 + ) 172 + 173 + /* 174 + * Skip the initial mount to avoid an unnecessary re-render — the parent 175 + * already knows the initial value since it passed `initialText`. 176 + */ 177 + const isFirstRender = useRef(true) 178 + useEffect(() => { 179 + if (isFirstRender.current) { 180 + isFirstRender.current = false 181 + return 182 + } 183 + onChangeOuter?.(tapper.state.text) 184 + }, [tapper.state.text, onChangeOuter]) 185 + 186 + /* 187 + * Tapper callbacks 188 + */ 189 + const callbackRefs = useRef({ 190 + onActiveFacetOuter, 191 + onFacetCommittedOuter, 192 + }) 193 + callbackRefs.current = { 194 + onActiveFacetOuter, 195 + onFacetCommittedOuter, 196 + } 197 + useEffect(() => { 198 + const offActiveFacet = tapper.on('activeFacet', facet => { 199 + setActiveFacet(facet) 200 + callbackRefs.current.onActiveFacetOuter?.(facet) 201 + }) 202 + const offFacetCommitted = tapper.on('facetCommitted', facet => { 203 + callbackRefs.current.onFacetCommittedOuter?.(facet) 204 + }) 205 + const offAfterInsert = tapper.on('afterInsert', () => { 206 + tapper.input.focus() 207 + }) 208 + return () => { 209 + offActiveFacet() 210 + offFacetCommitted() 211 + offAfterInsert() 212 + } 213 + }, [tapper.on, tapper.input]) 214 + 215 + /* 216 + * Styles 217 + */ 218 + const previewScrollStyle = useAnimatedStyle(() => ({ 219 + transform: [{translateY: -inputScrollSharedValue.value}], 220 + })) 221 + const textStyle = useMemo(() => { 222 + const ts = normalizeTextStyles( 223 + [a.leading_snug, t.atoms.text, contentTextStyle], 224 + { 225 + fontScale: fonts.scaleMultiplier, 226 + fontFamily: fonts.family, 227 + flags: {}, 228 + }, 229 + ) 230 + /** 231 + * On iOS, having a lineHeight on the Text component causes the text to be 232 + * vertically misaligned with the TextInput. 233 + * 234 + * This only seems to be an issue on iOS, and not on Android or web. It's 235 + * possible that this is a bug in React Native's Text component on iOS, 236 + * but in the meantime, we'll just remove the lineHeight on iOS to ensure 237 + * the text is properly aligned. 238 + */ 239 + if (IS_IOS) { 240 + delete ts.lineHeight 241 + } 242 + return ts 243 + }, [contentTextStyle, fonts]) 244 + 245 + /* 246 + * Web keyboard handling 247 + */ 248 + const isComposing = useRef(false) 249 + const onKeyPressWeb = (e: React.KeyboardEvent | any) => { 250 + if (IS_WEB_TOUCH_DEVICE) return 251 + if (isComposing.current) return 252 + 253 + /* 254 + * On Safari, the final keydown to dismiss an IME is also "Enter" with 255 + * keyCode 229. Chrome/Firefox don't have this problem. 256 + * 257 + * @see https://github.com/bluesky-social/social-app/issues/4178 258 + */ 259 + if (e.key === 'Enter' && e.keyCode === 229) return 260 + 261 + if (e.key === 'Enter') { 262 + onRequestSubmit?.({ 263 + platform: 'web', 264 + shiftKey: e.shiftKey, 265 + metaKey: e.metaKey, 266 + nativeEvent: e.nativeEvent, 267 + }) 268 + } 269 + } 270 + 271 + /* 272 + * Sift popover positioning 273 + */ 274 + const updateAutocompletePosition = () => { 275 + sift.updatePosition() 276 + } 277 + 278 + const textContent = ( 279 + <Text style={[textStyle, web({whiteSpace: 'pre-wrap'})]}> 280 + {tapper.state.nodes.map((node, i) => { 281 + switch (node.type) { 282 + case 'text': 283 + return <Span key={i}>{node.value}</Span> 284 + case 'trigger': 285 + case 'facet': 286 + return ( 287 + <Span 288 + key={i} 289 + ref={IS_WEB ? sift.refs.setAnchor : undefined} 290 + style={ 291 + node.type === 'facet' && { 292 + color: t.palette.primary_500, 293 + } 294 + }> 295 + {node.raw} 296 + </Span> 297 + ) 298 + } 299 + })} 300 + </Text> 301 + ) 302 + 303 + return ( 304 + <> 305 + <View style={[a.relative, outerStyle]}> 306 + {IS_WEB && ( 307 + <View 308 + pointerEvents="none" 309 + style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}> 310 + <Animated.View 311 + style={[ 312 + contentPaddingStyle, 313 + {position: 'absolute', left: 0, right: 0}, 314 + previewScrollStyle, 315 + ]}> 316 + {textContent} 317 + </Animated.View> 318 + </View> 319 + )} 320 + <AutosizedTextarea 321 + placeholderTextColor={t.palette.contrast_500} 322 + accessibilityLabel={label} 323 + accessibilityHint={label} 324 + onSubmitEditing={e => { 325 + onRequestSubmit?.({platform: 'native', nativeEvent: e}) 326 + }} 327 + style={[ 328 + textStyle, 329 + contentPaddingStyle, 330 + a.z_20, 331 + { 332 + color: 'transparent', 333 + background: 'transparent', 334 + }, 335 + web({ 336 + caretColor: textStyle.color ?? 'black', 337 + overscrollBehavior: 'none', 338 + }), 339 + ]} 340 + {...rest} 341 + {...tapper.inputProps} 342 + {...sift.targetProps} 343 + ref={mergeRefs([ref, tapper.inputProps.ref, sift.targetProps.ref])} 344 + onBlur={e => { 345 + rest.onBlur?.(e) 346 + setActiveFacet(null) 347 + }} 348 + onKeyPress={IS_WEB ? onKeyPressWeb : undefined} 349 + onScroll={e => { 350 + if (IS_WEB) { 351 + inputScrollSharedValue.value = (e.target as any).scrollTop 352 + } else { 353 + inputScrollSharedValue.value = e.nativeEvent.contentOffset.y 354 + } 355 + }} 356 + // @ts-ignore web only 357 + onCompositionStart={() => { 358 + isComposing.current = true 359 + }} 360 + // @ts-ignore web only 361 + onCompositionEnd={() => { 362 + isComposing.current = false 363 + }} 364 + onUpdateHeight={updateAutocompletePosition}> 365 + {IS_WEB ? null : textContent} 366 + </AutosizedTextarea> 367 + </View> 368 + 369 + {activeFacet && activeFacet.type !== 'url' && ( 370 + <AutocompleteInner 371 + sift={sift} 372 + activeFacet={activeFacet} 373 + onDismiss={() => setActiveFacet(null)} 374 + /> 375 + )} 376 + </> 377 + ) 378 + } 379 + 380 + /* 381 + * ─── Autocomplete (private) ─────────────────────────────────────────────────── 382 + */ 383 + 384 + function AutocompleteInner({ 385 + sift, 386 + activeFacet, 387 + onDismiss, 388 + }: { 389 + sift: UseSiftReturn 390 + activeFacet: TapperActiveFacet 391 + onDismiss: () => void 392 + }) { 393 + const {items} = useAutocomplete({ 394 + type: parseAutocompleteItemType(activeFacet.type), 395 + query: activeFacet.value, 396 + }) 397 + 398 + useEffect(() => { 399 + if ( 400 + activeFacet?.type === 'emoji' && 401 + !!activeFacet.value.length && 402 + activeFacet.raw.endsWith(':') 403 + ) { 404 + if (items?.[0]) { 405 + activeFacet.replace(items[0].value, {noTrailingSpace: true}) 406 + onDismiss() 407 + } 408 + } 409 + }, [items, activeFacet]) 410 + 411 + return items && items.length ? ( 412 + <AutocompleteBase 413 + inverted={!IS_WEB} 414 + sift={sift} 415 + data={items} 416 + render={props => { 417 + if (props.item.type === 'profile') { 418 + return <AutocompleteItemProfile {...props} /> 419 + } 420 + if (props.item.type === 'emoji') { 421 + return <AutocompleteItemEmoji {...props} /> 422 + } 423 + return <View /> 424 + }} 425 + onSelect={item => { 426 + activeFacet.replace(item.value) 427 + onDismiss() 428 + }} 429 + onDismiss={onDismiss} 430 + /> 431 + ) : null 432 + }
+166
src/components/forms/AutosizedTextarea.tsx
··· 1 + import {useMemo, useRef, useState} from 'react' 2 + import { 3 + TextInput, 4 + type TextInputContentSizeChangeEvent, 5 + type TextInputProps, 6 + } from 'react-native' 7 + 8 + import {mergeRefs} from '#/lib/merge-refs' 9 + import {atoms as a, extractPadding, useAlf, web} from '#/alf' 10 + import {normalizeTextStyles} from '#/alf/typography' 11 + import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env' 12 + 13 + export type AutosizedTextareaProps = Omit<TextInputProps, 'multiline'> & { 14 + ref?: React.Ref<TextInput> 15 + label: string 16 + minRows?: number 17 + maxRows?: number 18 + onUpdateHeight?: (height: number) => void 19 + } 20 + 21 + export function AutosizedTextarea({ 22 + ref, 23 + label, 24 + minRows = 1, 25 + maxRows, 26 + onUpdateHeight, 27 + 28 + onChangeText: onChangeTextOuter, 29 + onContentSizeChange: onContentSizeChangeOuter, 30 + style: outerStyle, 31 + ...rest 32 + }: AutosizedTextareaProps) { 33 + const {theme: t, fonts} = useAlf() 34 + const internalRef = useRef<TextInput>(null) 35 + const {style, minInputHeight, maxInputHeight, verticalContentPadding} = 36 + useMemo(() => { 37 + const normalizedStyles = normalizeTextStyles( 38 + [a.text_md, a.leading_snug, t.atoms.text, outerStyle], 39 + { 40 + fontScale: fonts.scaleMultiplier, 41 + fontFamily: fonts.family, 42 + flags: {}, 43 + }, 44 + ) 45 + const lineHeight = normalizedStyles.lineHeight || 20 46 + const {paddingTop, paddingBottom} = extractPadding(normalizedStyles ?? {}) 47 + const verticalContentPadding = paddingTop + paddingBottom 48 + const minInputHeight = lineHeight * minRows + verticalContentPadding 49 + const maxInputHeight = maxRows 50 + ? lineHeight * maxRows + verticalContentPadding 51 + : Infinity 52 + 53 + /* 54 + * iOS: minHeight/maxHeight works fine natively. 55 + * Web + Android: we set an explicit initial height and resize dynamically 56 + * (web via DOM measurement, Android via onContentSizeChange state). 57 + * 58 + * iOS also seems to need 1px headroom to actually expand to the correct 59 + * maxHeight 60 + */ 61 + const heightConstraints = IS_IOS 62 + ? {minHeight: minInputHeight, maxHeight: maxInputHeight + 1} 63 + : {height: minInputHeight} 64 + 65 + return { 66 + style: { 67 + ...normalizedStyles, 68 + ...heightConstraints, 69 + }, 70 + minInputHeight, 71 + maxInputHeight, 72 + verticalContentPadding, 73 + } 74 + }, [t, fonts, outerStyle, minRows, maxRows]) 75 + 76 + /* 77 + * Web handling 78 + */ 79 + const prevWebHeight = useRef(0) 80 + const handleResizeWeb = () => { 81 + const el = internalRef.current as unknown as HTMLTextAreaElement 82 + if (!el) return 83 + // collapse to get natural scroll height 84 + el.style.height = '0px' 85 + const scrollHeight = Math.ceil(el.scrollHeight) 86 + const nextHeight = Math.min( 87 + Math.max(scrollHeight, minInputHeight), 88 + maxInputHeight, 89 + ) 90 + // immediately update height to prevent flicker 91 + el.style.height = `${nextHeight}px` 92 + el.style.overflowY = scrollHeight > maxInputHeight ? 'auto' : 'hidden' 93 + if (nextHeight !== prevWebHeight.current) { 94 + prevWebHeight.current = nextHeight 95 + onUpdateHeight?.(nextHeight) 96 + } 97 + } 98 + const onChangeText = (text: string) => { 99 + if (IS_WEB) handleResizeWeb() 100 + onChangeTextOuter?.(text) 101 + } 102 + 103 + /* 104 + * Native handling 105 + * 106 + * We track the height as state on native, and on Android, we use this to 107 + * directly drive the `height`. 108 + */ 109 + const [nativeHeight, setNativeHeight] = useState(minInputHeight) 110 + const onContentSizeChange = (e: TextInputContentSizeChangeEvent) => { 111 + const contentSize = Math.ceil(e.nativeEvent.contentSize.height) 112 + // ios reports the content size without padding 113 + const height = IS_IOS ? contentSize + verticalContentPadding : contentSize 114 + const nextHeight = Math.min( 115 + Math.max(height, minInputHeight), 116 + maxInputHeight, 117 + ) 118 + 119 + if (nextHeight !== nativeHeight) { 120 + setNativeHeight(nextHeight) 121 + onUpdateHeight?.(nextHeight) 122 + } 123 + 124 + onContentSizeChangeOuter?.(e) 125 + } 126 + 127 + return ( 128 + <TextInput 129 + multiline 130 + placeholderTextColor={t.palette.contrast_500} 131 + accessibilityLabel={label} 132 + accessibilityHint={label} 133 + placeholder={label} 134 + keyboardAppearance={t.scheme} 135 + submitBehavior="newline" 136 + scrollEnabled={nativeHeight >= maxInputHeight} 137 + style={[ 138 + a.relative, 139 + a.border_0, 140 + { 141 + textAlignVertical: 'top', 142 + includeFontPadding: false, 143 + }, 144 + web({ 145 + resize: 'none', 146 + outline: 'none', 147 + whiteSpace: 'pre-wrap', 148 + wordBreak: 'break-word', 149 + }), 150 + style, 151 + IS_ANDROID ? {height: nativeHeight} : {}, 152 + ]} 153 + {...rest} 154 + ref={mergeRefs([ 155 + (node: TextInput | null) => { 156 + internalRef.current = node 157 + // bop resize on first render 158 + if (IS_WEB && node) handleResizeWeb() 159 + }, 160 + ref, 161 + ])} 162 + onChangeText={onChangeText} 163 + onContentSizeChange={onContentSizeChange} 164 + /> 165 + ) 166 + }
+5 -5
src/components/forms/SearchInput.tsx
··· 3 3 import {useLingui} from '@lingui/react/macro' 4 4 5 5 import {HITSLOP_10} from '#/lib/constants' 6 + import {mergeRefs} from '#/lib/merge-refs' 6 7 import {listenFocusSearch} from '#/state/events' 7 8 import {atoms as a, useTheme} from '#/alf' 8 9 import {Button, ButtonIcon} from '#/components/Button' ··· 18 19 */ 19 20 onClearText?: () => void 20 21 hotkey?: boolean 21 - ref?: React.RefObject<TextInput | null> 22 + ref?: React.Ref<TextInput> 22 23 } 23 24 24 25 export function SearchInput({ ··· 33 34 const {t: l} = useLingui() 34 35 const showClear = value && value.length > 0 35 36 const internalRef = useRef<TextInput>(null) 36 - const inputRef = ref ?? internalRef 37 37 38 38 useEffect(() => { 39 39 if (!hotkey) return 40 40 return listenFocusSearch(() => { 41 - inputRef.current?.focus() 41 + internalRef.current?.focus() 42 42 }) 43 - }, [hotkey, inputRef]) 43 + }, [hotkey]) 44 44 45 45 return ( 46 46 <View style={[a.w_full, a.relative]}> 47 47 <TextField.Root> 48 48 <TextField.Icon icon={MagnifyingGlassIcon} /> 49 49 <TextField.Input 50 - inputRef={inputRef} 50 + inputRef={mergeRefs([internalRef, ref])} 51 51 label={label || l`Search`} 52 52 value={value} 53 53 placeholder={l`Search`}
+1 -1
src/lib/merge-refs.ts
··· 13 13 * returns a ref callback function that can be used to merge multiple refs into a single ref. 14 14 */ 15 15 export function mergeRefs<T = any>( 16 - refs: Array<React.MutableRefObject<T> | React.Ref<T>>, 16 + refs: Array<React.MutableRefObject<T> | React.Ref<T> | undefined>, 17 17 ): React.RefCallback<T> { 18 18 return value => { 19 19 refs.forEach(ref => {
+5
src/lib/useGetEmojis/getEmojis.ts
··· 1 + import Emojis, {type EmojiMartData} from '@emoji-mart/data' 2 + 3 + export async function getEmojis(): Promise<EmojiMartData> { 4 + return Emojis as EmojiMartData 5 + }
+5
src/lib/useGetEmojis/getEmojis.web.ts
··· 1 + import {type EmojiMartData} from '@emoji-mart/data' 2 + 3 + export async function getEmojis(): Promise<EmojiMartData> { 4 + return (await import('@emoji-mart/data')).default as EmojiMartData 5 + }
+12
src/lib/useGetEmojis/index.ts
··· 1 + import {useCallback} from 'react' 2 + 3 + import {getEmojis} from './getEmojis' 4 + 5 + let emojis: Awaited<ReturnType<typeof getEmojis>> | null = null 6 + 7 + export function useGetEmojis() { 8 + return useCallback(async () => { 9 + emojis ??= await getEmojis() 10 + return emojis 11 + }, []) 12 + }
+251
src/screens/Messages/components/MessageComposer.tsx
··· 1 + import {useEffect, useState} from 'react' 2 + import {Pressable, View} from 'react-native' 3 + import {useLingui} from '@lingui/react/macro' 4 + import {countGraphemes} from 'unicode-segmenter/grapheme' 5 + 6 + import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' 7 + import {useHaptics} from '#/lib/haptics' 8 + import {isBskyPostUrl} from '#/lib/strings/url-helpers' 9 + import {useEmail} from '#/state/email-verification' 10 + import { 11 + useMessageDraft, 12 + useSaveMessageDraft, 13 + } from '#/state/messages/message-drafts' 14 + import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' 15 + import { 16 + type Emoji, 17 + EmojiPicker, 18 + type EmojiPickerState, 19 + } from '#/view/com/composer/text-input/web/EmojiPicker' 20 + import {atoms as a, useTheme} from '#/alf' 21 + import {Composer, useComposerInternalApiRef} from '#/components/Composer' 22 + import {useInteractionState} from '#/components/hooks/useInteractionState' 23 + import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' 24 + import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' 25 + import * as Toast from '#/components/Toast' 26 + import {IS_WEB} from '#/env' 27 + 28 + export function MessageComposer({ 29 + onSendMessage, 30 + hasEmbed, 31 + setEmbed, 32 + children, 33 + }: { 34 + onSendMessage: (message: string) => void 35 + hasEmbed: boolean 36 + setEmbed: (embedUrl: string | undefined) => void 37 + children?: React.ReactNode 38 + }) { 39 + const t = useTheme() 40 + const {t: l} = useLingui() 41 + const playHaptic = useHaptics() 42 + const {needsEmailVerification} = useEmail() 43 + const editable = !needsEmailVerification 44 + const {getDraft, clearDraft} = useMessageDraft() 45 + const [emojiPickerState, setEmojiPickerState] = useState<EmojiPickerState>({ 46 + isOpen: false, 47 + pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, 48 + }) 49 + const composerInternalApiRef = useComposerInternalApiRef() 50 + 51 + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 52 + const { 53 + state: hovered, 54 + onIn: onHoverIn, 55 + onOut: onHoverOut, 56 + } = useInteractionState() 57 + 58 + const [text, setText] = useState(getDraft) 59 + useSaveMessageDraft(text) 60 + 61 + const openEmojiPicker = (pos: any) => { 62 + setEmojiPickerState({isOpen: true, pos}) 63 + } 64 + 65 + const onSubmit = () => { 66 + if (!editable) return 67 + if (!hasEmbed && text.trim() === '') return 68 + if (countGraphemes(text) > MAX_DM_GRAPHEME_LENGTH) { 69 + Toast.show(l`Message is too long`, { 70 + type: 'error', 71 + }) 72 + return 73 + } 74 + 75 + clearDraft() 76 + onSendMessage(text) 77 + playHaptic() 78 + setEmbed(undefined) 79 + composerInternalApiRef.current?.clear() 80 + 81 + if (IS_WEB) { 82 + composerInternalApiRef.current?.input?.focus() 83 + } 84 + } 85 + 86 + useEffect(() => { 87 + function onEmojiInserted(emoji: Emoji) { 88 + composerInternalApiRef.current?.insert(emoji.native) 89 + } 90 + textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) 91 + return () => { 92 + textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) 93 + } 94 + }, []) 95 + 96 + return ( 97 + <> 98 + <View style={[a.px_md, a.pb_sm, a.pt_xs]}> 99 + {children} 100 + 101 + <View 102 + collapsable={false} 103 + ref={ 104 + IS_WEB 105 + ? undefined 106 + : node => { 107 + composerInternalApiRef.current?.setAutocompleteAnchor(node) 108 + } 109 + } 110 + // @ts-expect-error web only 111 + onMouseEnter={onHoverIn} 112 + onMouseLeave={onHoverOut} 113 + style={[a.w_full, a.flex_row, a.gap_sm]}> 114 + {IS_WEB && ( 115 + <Pressable 116 + onPress={e => { 117 + e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => { 118 + openEmojiPicker?.({ 119 + top: py, 120 + left: px, 121 + right: px, 122 + bottom: py, 123 + nextFocusRef: { 124 + current: composerInternalApiRef.current?.input?.element, 125 + }, 126 + }) 127 + }) 128 + }} 129 + style={[ 130 + a.overflow_hidden, 131 + a.absolute, 132 + a.rounded_full, 133 + a.align_center, 134 + a.justify_center, 135 + a.z_30, 136 + { 137 + height: 30, 138 + width: 30, 139 + top: 8, 140 + left: 8, 141 + }, 142 + ]} 143 + accessibilityLabel={l`Open emoji picker`} 144 + accessibilityHint=""> 145 + {state => ( 146 + <View 147 + style={[ 148 + a.absolute, 149 + a.inset_0, 150 + a.align_center, 151 + a.justify_center, 152 + { 153 + backgroundColor: 154 + state.hovered || state.focused || state.pressed 155 + ? t.atoms.bg.backgroundColor 156 + : undefined, 157 + }, 158 + ]}> 159 + <EmojiSmile size="lg" /> 160 + </View> 161 + )} 162 + </Pressable> 163 + )} 164 + 165 + <Composer 166 + label={l`Message input field`} 167 + placeholder={l`Write a message`} 168 + autocompletePlacement="top-start" 169 + internalApiRef={composerInternalApiRef} 170 + defaultValue={text} 171 + editable={editable} 172 + autoFocus={IS_WEB} 173 + maxRows={12} 174 + outerStyle={[ 175 + a.flex_1, 176 + t.atoms.bg_contrast_25, 177 + { 178 + borderWidth: 1, 179 + borderColor: 'transparent', 180 + borderRadius: 22, 181 + }, 182 + editable && 183 + hovered && { 184 + borderColor: t.atoms.border_contrast_medium.borderColor, 185 + }, 186 + editable && 187 + focused && { 188 + borderColor: t.palette.primary_500, 189 + }, 190 + ]} 191 + contentTextStyle={[a.text_md, a.leading_snug]} 192 + contentPaddingStyle={{ 193 + paddingLeft: IS_WEB ? 30 + 12 : 12, 194 + paddingTop: 12, 195 + paddingBottom: 12, 196 + paddingRight: 12, 197 + }} 198 + onFocus={onFocus} 199 + onBlur={onBlur} 200 + onChange={setText} 201 + onFacetCommitted={facet => { 202 + if (facet.type === 'url' && isBskyPostUrl(facet.value)) { 203 + setEmbed(facet.value) 204 + } 205 + }} 206 + onRequestSubmit={req => { 207 + if (req.platform === 'web' && req.shiftKey) return 208 + req.nativeEvent.preventDefault() 209 + onSubmit() 210 + }} 211 + /> 212 + 213 + {focused || text.length ? ( 214 + <Pressable 215 + accessibilityRole="button" 216 + accessibilityLabel={l`Send message`} 217 + accessibilityHint="" 218 + hitSlop={HITSLOP_10} 219 + style={[ 220 + a.rounded_full, 221 + a.align_center, 222 + a.justify_center, 223 + a.self_end, 224 + a.z_30, 225 + { 226 + height: 44, 227 + width: 44, 228 + backgroundColor: t.palette.primary_500, 229 + }, 230 + ]} 231 + onPress={onSubmit} 232 + disabled={!editable}> 233 + <PaperPlane 234 + fill={t.palette.white} 235 + style={[a.relative, {left: 1}]} 236 + /> 237 + </Pressable> 238 + ) : null} 239 + </View> 240 + </View> 241 + 242 + {IS_WEB && ( 243 + <EmojiPicker 244 + pinToTop 245 + state={emojiPickerState} 246 + close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))} 247 + /> 248 + )} 249 + </> 250 + ) 251 + }
+20 -9
src/screens/Messages/components/MessagesList.tsx
··· 43 43 } from '#/view/com/composer/text-input/web/EmojiPicker' 44 44 import {List, type ListMethods} from '#/view/com/util/List' 45 45 import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled' 46 + import {MessageComposer} from '#/screens/Messages/components/MessageComposer' 46 47 import {MessageInput} from '#/screens/Messages/components/MessageInput' 47 48 import {MessageListError} from '#/screens/Messages/components/MessageListError' 48 49 import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' ··· 50 51 import {NewMessagesPill} from '#/components/dms/NewMessagesPill' 51 52 import {Loader} from '#/components/Loader' 52 53 import {Text} from '#/components/Typography' 53 - import {IS_NATIVE} from '#/env' 54 - import {IS_WEB} from '#/env' 54 + import {useAnalytics} from '#/analytics' 55 + import {IS_NATIVE, IS_WEB} from '#/env' 55 56 import {ChatStatusInfo} from './ChatStatusInfo' 56 57 import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' 57 58 ··· 102 103 footer?: React.ReactNode 103 104 hasAcceptOverride?: boolean 104 105 }) { 106 + const ax = useAnalytics() 105 107 const convoState = useConvoActive() 106 108 const agent = useAgent() 107 109 const getPost = useGetPost() ··· 457 459 <ConversationFooter 458 460 convoState={convoState} 459 461 hasAcceptOverride={hasAcceptOverride}> 460 - <MessageInput 461 - onSendMessage={onSendMessage} 462 - hasEmbed={!!embedUri} 463 - setEmbed={setEmbed} 464 - openEmojiPicker={onOpenEmojiPicker}> 465 - <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> 466 - </MessageInput> 462 + {ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? ( 463 + <MessageComposer 464 + onSendMessage={onSendMessage} 465 + hasEmbed={!!embedUri} 466 + setEmbed={setEmbed}> 467 + <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> 468 + </MessageComposer> 469 + ) : ( 470 + <MessageInput 471 + onSendMessage={onSendMessage} 472 + hasEmbed={!!embedUri} 473 + setEmbed={setEmbed} 474 + openEmojiPicker={onOpenEmojiPicker}> 475 + <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> 476 + </MessageInput> 477 + )} 467 478 </ConversationFooter> 468 479 )} 469 480 </Animated.View>
+58 -2
src/screens/Search/components/AutocompleteResults.tsx
··· 1 1 import {memo} from 'react' 2 - import {ActivityIndicator, View} from 'react-native' 2 + import { 3 + ActivityIndicator, 4 + TouchableOpacity, 5 + View, 6 + type ViewStyle, 7 + } from 'react-native' 3 8 import {type AppBskyActorDefs} from '@atproto/api' 4 9 import {msg} from '@lingui/core/macro' 5 10 import {useLingui} from '@lingui/react' 6 11 12 + import {usePalette} from '#/lib/hooks/usePalette' 7 13 import {useModerationOpts} from '#/state/preferences/moderation-opts' 8 - import {SearchLinkCard} from '#/view/shell/desktop/Search' 14 + import {Link} from '#/view/com/util/Link' 15 + import {Text} from '#/view/com/util/text/Text' 9 16 import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard' 10 17 import {atoms as a, native} from '#/alf' 11 18 import * as Layout from '#/components/Layout' ··· 76 83 } 77 84 AutocompleteResults = memo(AutocompleteResults) 78 85 export {AutocompleteResults} 86 + 87 + let SearchLinkCard = ({ 88 + label, 89 + to, 90 + onPress, 91 + style, 92 + }: { 93 + label: string 94 + to?: string 95 + onPress?: () => void 96 + style?: ViewStyle 97 + }): React.ReactNode => { 98 + const pal = usePalette('default') 99 + 100 + const inner = ( 101 + <View 102 + style={[pal.border, {paddingVertical: 16, paddingHorizontal: 12}, style]}> 103 + <Text type="md" style={[pal.text]}> 104 + {label} 105 + </Text> 106 + </View> 107 + ) 108 + 109 + if (onPress) { 110 + return ( 111 + <TouchableOpacity 112 + onPress={onPress} 113 + accessibilityLabel={label} 114 + accessibilityHint=""> 115 + {inner} 116 + </TouchableOpacity> 117 + ) 118 + } 119 + 120 + return ( 121 + <Link href={to} asAnchor anchorNoUnderline> 122 + <View 123 + style={[ 124 + pal.border, 125 + {paddingVertical: 16, paddingHorizontal: 12}, 126 + style, 127 + ]}> 128 + <Text type="md" style={[pal.text]}> 129 + {label} 130 + </Text> 131 + </View> 132 + </Link> 133 + ) 134 + }
+10 -3
src/view/com/home/HomeHeaderLayoutMobile.tsx
··· 3 3 import {useSafeAreaInsets} from 'react-native-safe-area-context' 4 4 import {msg} from '@lingui/core/macro' 5 5 import {useLingui} from '@lingui/react' 6 + import {useNavigation} from '@react-navigation/native' 6 7 7 8 import {HITSLOP_10} from '#/lib/constants' 8 9 import {PressableScale} from '#/lib/custom-animations/PressableScale' 9 10 import {useHaptics} from '#/lib/haptics' 10 11 import {useMinimalShellHeaderTransform} from '#/lib/hooks/useMinimalShellTransform' 12 + import {type NavigationProp} from '#/lib/routes/types' 11 13 import {emitSoftReset} from '#/state/events' 12 14 import {useSession} from '#/state/session' 13 15 import {useShellLayout} from '#/state/shell/shell-layout' ··· 17 19 import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag' 18 20 import * as Layout from '#/components/Layout' 19 21 import {Link} from '#/components/Link' 20 - import {IS_LIQUID_GLASS} from '#/env' 22 + import {IS_DEV, IS_LIQUID_GLASS} from '#/env' 21 23 22 24 export function HomeHeaderLayoutMobile({ 23 25 children, ··· 32 34 const headerMinimalShellTransform = useMinimalShellHeaderTransform() 33 35 const {hasSession} = useSession() 34 36 const playHaptic = useHaptics() 37 + const {navigate} = useNavigation<NavigationProp>() 35 38 36 39 return ( 37 40 <Animated.View ··· 59 62 <PressableScale 60 63 targetScale={0.9} 61 64 onPress={() => { 62 - playHaptic('Light') 63 - emitSoftReset() 65 + if (IS_DEV) { 66 + navigate('Debug') 67 + } else { 68 + playHaptic('Light') 69 + emitSoftReset() 70 + } 64 71 }}> 65 72 <Logo width={30} /> 66 73 </PressableScale>
+41 -1
src/view/screens/Storybook/Forms.tsx
··· 3 3 4 4 import {APP_LANGUAGES} from '#/lib/../locale/languages' 5 5 import {type CountryCode} from '#/lib/international-telephone-codes' 6 - import {atoms as a} from '#/alf' 6 + import {atoms as a, useTheme} from '#/alf' 7 7 import {Button, ButtonText} from '#/components/Button' 8 + import {AutosizedTextarea} from '#/components/forms/AutosizedTextarea' 8 9 import {DateField, LabelText} from '#/components/forms/DateField' 9 10 import * as SegmentedControl from '#/components/forms/SegmentedControl' 10 11 import * as TextField from '#/components/forms/TextField' ··· 16 17 import {H1, H3} from '#/components/Typography' 17 18 18 19 export function Forms() { 20 + const t = useTheme() 19 21 const [toggleGroupAValues, setToggleGroupAValues] = useState(['a']) 20 22 const [toggleGroupBValues, setToggleGroupBValues] = useState(['a', 'b']) 21 23 const [toggleGroupCValues, setToggleGroupCValues] = useState(['a', 'b']) ··· 35 37 return ( 36 38 <View style={[a.gap_4xl, a.align_start]}> 37 39 <H1>Forms</H1> 40 + 41 + <View style={[a.gap_md, a.align_start, a.w_full]}> 42 + <AutosizedTextarea 43 + label="minRows=1 maxRows=5" 44 + style={[ 45 + a.w_full, 46 + a.p_md, 47 + a.rounded_sm, 48 + a.border, 49 + t.atoms.border_contrast_medium, 50 + ]} 51 + maxRows={5} 52 + /> 53 + <AutosizedTextarea 54 + label="defaultValue minRows=1 maxRows=2" 55 + style={[ 56 + a.w_full, 57 + a.p_md, 58 + a.rounded_sm, 59 + a.border, 60 + t.atoms.border_contrast_medium, 61 + ]} 62 + maxRows={2} 63 + 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." 64 + /> 65 + <AutosizedTextarea 66 + label="minRows=3 maxRows=10" 67 + style={[ 68 + a.w_full, 69 + a.p_md, 70 + a.rounded_sm, 71 + a.border, 72 + t.atoms.border_contrast_medium, 73 + ]} 74 + minRows={3} 75 + maxRows={10} 76 + /> 77 + </View> 38 78 39 79 <Select.Root value={lang} onValueChange={setLang}> 40 80 <Select.Trigger label="Select app language">
+83 -131
src/view/shell/desktop/Search.tsx
··· 1 - import {memo, useCallback, useState} from 'react' 2 - import { 3 - type StyleProp, 4 - TouchableOpacity, 5 - View, 6 - type ViewStyle, 7 - } from 'react-native' 8 - import {useLingui} from '@lingui/react/macro' 1 + import {useState} from 'react' 2 + import {View} from 'react-native' 3 + import {useSift} from '@bsky.app/sift' 9 4 import {StackActions, useNavigation} from '@react-navigation/native' 10 5 11 6 import {type NavigationProp} from '#/lib/routes/types' 12 - import {useModerationOpts} from '#/state/preferences/moderation-opts' 13 - import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 14 - import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard' 15 - import {atoms as a, useTheme} from '#/alf' 7 + import {atoms as a} from '#/alf' 8 + import { 9 + Autocomplete as AutocompleteBase, 10 + type AutocompleteItem, 11 + useAutocomplete, 12 + } from '#/components/Autocomplete' 16 13 import {SearchInput} from '#/components/forms/SearchInput' 17 - import {Link} from '#/components/Link' 18 - import {Loader} from '#/components/Loader' 19 - import {Text} from '#/components/Typography' 20 - 21 - const WHITESPACE_RE = /\s+/gu 22 - 23 - let SearchLinkCard = ({ 24 - label, 25 - to, 26 - onPress, 27 - style, 28 - }: { 29 - label: string 30 - to?: string 31 - onPress?: () => void 32 - style?: StyleProp<ViewStyle> 33 - }): React.ReactNode => { 34 - const t = useTheme() 35 - 36 - const inner = ( 37 - <View style={[a.py_lg, a.px_md, t.atoms.border_contrast_low, style]}> 38 - <Text style={[a.text_md, t.atoms.text]}>{label}</Text> 39 - </View> 40 - ) 41 - 42 - if (onPress || !to) { 43 - return ( 44 - <TouchableOpacity 45 - onPress={onPress} 46 - accessibilityLabel={label} 47 - accessibilityHint=""> 48 - {inner} 49 - </TouchableOpacity> 50 - ) 51 - } 52 - 53 - return ( 54 - <Link 55 - label={label} 56 - to={to} 57 - style={[a.py_lg, a.px_md, t.atoms.border_contrast_low, style]} 58 - hoverStyle={[t.atoms.bg_contrast_25]}> 59 - <Text style={[a.text_md, t.atoms.text]}>{label}</Text> 60 - </Link> 61 - ) 62 - } 63 - SearchLinkCard = memo(SearchLinkCard) 64 - export {SearchLinkCard} 65 14 66 15 export function DesktopSearch() { 67 - const t = useTheme() 68 - const {t: l} = useLingui() 69 16 const navigation = useNavigation<NavigationProp>() 70 - const [isActive, setIsActive] = useState<boolean>(false) 17 + const [active, setActive] = useState(false) 71 18 const [query, setQuery] = useState<string>('') 72 - const {data: autocompleteData, isFetching} = useActorAutocompleteQuery( 73 - query, 74 - true, 75 - ) 76 - const tQuery = query.replace(WHITESPACE_RE, ' ').trim() 19 + const showResults = active && !!query.length 20 + 21 + const sift = useSift({ 22 + offset: a.p_sm.padding, 23 + placement: 'bottom', 24 + }) 77 25 78 - const moderationOpts = useModerationOpts() 26 + const onFocus = () => { 27 + if (query.length) setActive(true) 28 + } 79 29 80 - const onChangeText = useCallback((text: string) => { 30 + const onChangeText = (text: string) => { 81 31 setQuery(text) 82 - setIsActive(text.length > 0) 83 - }, []) 32 + if (!active) { 33 + setActive(true) 34 + } 35 + } 84 36 85 - const onPressCancelSearch = useCallback(() => { 37 + const onClearText = () => { 86 38 setQuery('') 87 - setIsActive(false) 88 - }, [setQuery]) 39 + setActive(false) 40 + } 89 41 90 - const onSubmit = useCallback(() => { 91 - setIsActive(false) 92 - if (!tQuery.length) return 93 - navigation.dispatch(StackActions.push('Search', {q: tQuery})) 94 - }, [tQuery, navigation]) 42 + const onSubmit = () => { 43 + if (!query.length) return 44 + onClearText() 45 + sift.elements.input.blur() 46 + navigation.dispatch(StackActions.push('Search', {q: query})) 47 + } 95 48 96 - const onSearchProfileCardPress = useCallback(() => { 97 - setQuery('') 98 - setIsActive(false) 99 - }, []) 49 + const onSelect = (item: AutocompleteItem) => { 50 + if (item.type === 'profile') { 51 + onClearText() 52 + sift.elements.input.blur() 53 + navigation.navigate('Profile', {name: item.profile.handle}) 54 + } else if (item.type === 'search') { 55 + onClearText() 56 + sift.elements.input.blur() 57 + navigation.navigate('Search', {q: item.value}) 58 + } 59 + } 100 60 101 61 return ( 102 - <View style={[a.relative, a.w_full, a.z_10, t.atoms.bg]}> 62 + <View collapsable={false} ref={sift.refs.setAnchor}> 103 63 <SearchInput 64 + hotkey 104 65 value={query} 66 + onFocus={onFocus} 105 67 onChangeText={onChangeText} 106 - onClearText={onPressCancelSearch} 68 + onClearText={onClearText} 107 69 onSubmitEditing={onSubmit} 108 - hotkey={true} 70 + {...sift.targetProps} 109 71 /> 110 - {tQuery !== '' && isActive && moderationOpts && ( 111 - <View 112 - style={[ 113 - a.mt_sm, 114 - a.flex_col, 115 - a.w_full, 116 - a.border, 117 - a.rounded_sm, 118 - a.zoom_fade_in, 119 - t.atoms.bg, 120 - t.atoms.shadow_sm, 121 - t.atoms.border_contrast_low, 122 - { 123 - overflow: 'hidden', 124 - position: 'absolute', 125 - top: '100%', 126 - }, 127 - ]}> 128 - <SearchLinkCard 129 - label={l`Search for “${tQuery}”`} 130 - to={`/search?q=${encodeURIComponent(tQuery)}`} 131 - style={(autocompleteData?.length ?? 0) > 0 ? a.border_b : undefined} 132 - /> 133 - {isFetching && !autocompleteData?.length ? ( 134 - <View 135 - style={[ 136 - a.py_lg, 137 - a.align_center, 138 - a.border_t, 139 - t.atoms.border_contrast_low, 140 - ]}> 141 - <Loader size="lg" /> 142 - </View> 143 - ) : ( 144 - autocompleteData?.map(item => ( 145 - <SearchProfileCard 146 - key={item.did} 147 - profile={item} 148 - moderationOpts={moderationOpts} 149 - onPress={onSearchProfileCardPress} 150 - /> 151 - )) 152 - )} 153 - </View> 72 + {showResults && ( 73 + <Inner 74 + query={query} 75 + sift={sift} 76 + onSelect={onSelect} 77 + onDismiss={() => setActive(false)} 78 + /> 154 79 )} 155 80 </View> 156 81 ) 157 82 } 83 + 84 + function Inner({ 85 + query, 86 + sift, 87 + onSelect, 88 + onDismiss, 89 + }: { 90 + query: string 91 + sift: ReturnType<typeof useSift> 92 + onSelect: (item: AutocompleteItem) => void 93 + onDismiss: () => void 94 + }) { 95 + const {items} = useAutocomplete({ 96 + type: 'profile', 97 + query, 98 + showSearchFallback: true, 99 + }) 100 + 101 + return items && items.length ? ( 102 + <AutocompleteBase 103 + sift={sift} 104 + data={items} 105 + onSelect={onSelect} 106 + onDismiss={onDismiss} 107 + /> 108 + ) : null 109 + }
+15
yarn.lock
··· 2411 2411 resolved "https://registry.yarnpkg.com/@bsky.app/react-native-mmkv/-/react-native-mmkv-2.12.5.tgz#eb17d31a6158c74393f617a1763ac223ff3f83a6" 2412 2412 integrity sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg== 2413 2413 2414 + "@bsky.app/sift@^0.3.1": 2415 + version "0.3.1" 2416 + resolved "https://registry.yarnpkg.com/@bsky.app/sift/-/sift-0.3.1.tgz#f529832001bcd64950c214e85aec055a1f2edcdb" 2417 + integrity sha512-jG9GDh0Yh4vBM98BP4HvBp8VBqnt+280tFx9Gh/bWYtZdiN1xfrTkqWqRkvxyujgpXlyoemREUn+7dGs8Bl60A== 2418 + 2419 + "@bsky.app/tapper@^0.5.0": 2420 + version "0.5.0" 2421 + resolved "https://registry.yarnpkg.com/@bsky.app/tapper/-/tapper-0.5.0.tgz#39f3814a063cc0e8ee58c05e09be3d5cb8638f22" 2422 + integrity sha512-Fb7L2CruOA/k/FgKDOGChr+JKXsf+geAOTZXDevs9oqbSYTrXuI8KrRgaDwPS+FVCp1vAYHC/3esuVv+lbUtnw== 2423 + 2414 2424 "@crowdin/cli@^4.14.1": 2415 2425 version "4.14.1" 2416 2426 resolved "https://registry.yarnpkg.com/@crowdin/cli/-/cli-4.14.1.tgz#1239922681235b6b14bcacd4fd622bc2217dd6c5" ··· 9627 9637 version "1.2.3" 9628 9638 resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" 9629 9639 integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== 9640 + 9641 + fuse.js@^7.1.0: 9642 + version "7.3.0" 9643 + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.3.0.tgz#68e1ea1c6c0ff262f1801a949a78edbe05b0bc13" 9644 + integrity sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w== 9630 9645 9631 9646 gensync@^1.0.0-beta.2: 9632 9647 version "1.0.0-beta.2"