|
| 1 | +import { ProfileAvatar } from '@/src/common/components/Avatar/ProfileAvatar'; |
| 2 | +import { Text, TextInput } from '@/src/common/components/primitives'; |
| 3 | +import { |
| 4 | + shortenIdentityId, |
| 5 | + truncateName, |
| 6 | +} from '@/src/common/lib/polycentric-hooks'; |
| 7 | +import { Atoms, useTheme } from '@/src/common/theme'; |
| 8 | +import { useProfile } from '@/src/features/profile/hooks/useProfile'; |
| 9 | +import { FetchMode } from '@polycentric/react-native'; |
| 10 | +import { Fragment, useState } from 'react'; |
| 11 | +import { ActivityIndicator, Pressable, View } from 'react-native'; |
| 12 | +import { |
| 13 | + type ProfileSuggestion, |
| 14 | + useProfileSuggestions, |
| 15 | +} from './useProfileSuggestions'; |
| 16 | + |
| 17 | +/** |
| 18 | + * Autocomplete input for picking a person: type a name, an alias |
| 19 | + * (`user@domain`), or paste an identity id. |
| 20 | + */ |
| 21 | +export function ProfileSearchInput({ |
| 22 | + onSelect, |
| 23 | + placeholder = 'Search by name, alias, or identity id', |
| 24 | + exclude, |
| 25 | + pendingIdentity, |
| 26 | + disabled = false, |
| 27 | + autoFocus = false, |
| 28 | +}: { |
| 29 | + onSelect: (identity: string) => void; |
| 30 | + placeholder?: string; |
| 31 | + /** Identities to omit from suggestions (e.g. the current user). */ |
| 32 | + exclude?: readonly string[]; |
| 33 | + /** Row to decorate with a spinner while the parent acts on it. */ |
| 34 | + pendingIdentity?: string | null; |
| 35 | + disabled?: boolean; |
| 36 | + autoFocus?: boolean; |
| 37 | +}) { |
| 38 | + const { theme } = useTheme(); |
| 39 | + const [query, setQuery] = useState(''); |
| 40 | + const { suggestions, isLoading, isResolvingAlias } = useProfileSuggestions( |
| 41 | + query, |
| 42 | + { exclude }, |
| 43 | + ); |
| 44 | + |
| 45 | + const trimmed = query.trim(); |
| 46 | + const showFollowingLabel = |
| 47 | + !trimmed && suggestions.some((s) => s.source === 'following'); |
| 48 | + const showEmpty = suggestions.length === 0 && !isLoading && !isResolvingAlias; |
| 49 | + |
| 50 | + return ( |
| 51 | + <View style={Atoms.gap_sm}> |
| 52 | + <View style={Atoms.px_lg}> |
| 53 | + <TextInput |
| 54 | + value={query} |
| 55 | + onChangeText={setQuery} |
| 56 | + placeholder={placeholder} |
| 57 | + editable={!disabled} |
| 58 | + autoFocus={autoFocus} |
| 59 | + autoCapitalize="none" |
| 60 | + autoCorrect={false} |
| 61 | + returnKeyType="search" |
| 62 | + accessibilityLabel="Search profiles" |
| 63 | + /> |
| 64 | + </View> |
| 65 | + |
| 66 | + {isResolvingAlias && ( |
| 67 | + <View |
| 68 | + style={[ |
| 69 | + Atoms.flex_row, |
| 70 | + Atoms.align_center, |
| 71 | + Atoms.gap_sm, |
| 72 | + Atoms.px_lg, |
| 73 | + ]} |
| 74 | + > |
| 75 | + <ActivityIndicator |
| 76 | + size="small" |
| 77 | + color={theme.palette.neutral_500} |
| 78 | + accessibilityLabel="Resolving alias" |
| 79 | + /> |
| 80 | + <Text variant="small" color="neutral_500"> |
| 81 | + Looking up {trimmed}… |
| 82 | + </Text> |
| 83 | + </View> |
| 84 | + )} |
| 85 | + |
| 86 | + {showFollowingLabel && ( |
| 87 | + <Text |
| 88 | + variant="small" |
| 89 | + fontWeight="semibold" |
| 90 | + style={[theme.atoms.text_neutral_medium, Atoms.px_lg]} |
| 91 | + > |
| 92 | + People you follow |
| 93 | + </Text> |
| 94 | + )} |
| 95 | + |
| 96 | + <View> |
| 97 | + {suggestions.map((suggestion, i) => ( |
| 98 | + <Fragment key={suggestion.identity}> |
| 99 | + {i > 0 && ( |
| 100 | + <View |
| 101 | + style={{ |
| 102 | + height: 1, |
| 103 | + backgroundColor: theme.palette.neutral_25, |
| 104 | + }} |
| 105 | + /> |
| 106 | + )} |
| 107 | + <SuggestionRow |
| 108 | + suggestion={suggestion} |
| 109 | + pending={pendingIdentity === suggestion.identity} |
| 110 | + disabled={disabled || !!pendingIdentity} |
| 111 | + onPress={() => onSelect(suggestion.identity)} |
| 112 | + /> |
| 113 | + </Fragment> |
| 114 | + ))} |
| 115 | + </View> |
| 116 | + |
| 117 | + {showEmpty && ( |
| 118 | + <Text variant="small" color="neutral_500" style={Atoms.px_lg}> |
| 119 | + {trimmed |
| 120 | + ? 'No matches. Try a full alias (user@domain) or identity id.' |
| 121 | + : 'Not following anyone yet — enter an alias (user@domain) or identity id.'} |
| 122 | + </Text> |
| 123 | + )} |
| 124 | + </View> |
| 125 | + ); |
| 126 | +} |
| 127 | + |
| 128 | +function SuggestionRow({ |
| 129 | + suggestion, |
| 130 | + onPress, |
| 131 | + pending, |
| 132 | + disabled, |
| 133 | +}: { |
| 134 | + suggestion: ProfileSuggestion; |
| 135 | + onPress: () => void; |
| 136 | + pending: boolean; |
| 137 | + disabled: boolean; |
| 138 | +}) { |
| 139 | + const { theme } = useTheme(); |
| 140 | + // Followed profiles read from cache like every other list; alias/id |
| 141 | + // matches are usually strangers, so fetch their profile to show a name. |
| 142 | + const profile = useProfile(suggestion.identity, { |
| 143 | + fetchMode: |
| 144 | + suggestion.source === 'following' |
| 145 | + ? FetchMode.OfflineOnly |
| 146 | + : FetchMode.Default, |
| 147 | + }); |
| 148 | + const name = profile.name ?? suggestion.name; |
| 149 | + const alias = profile.alias ?? suggestion.alias; |
| 150 | + |
| 151 | + return ( |
| 152 | + <Pressable |
| 153 | + onPress={disabled ? undefined : onPress} |
| 154 | + style={({ hovered, pressed }) => [ |
| 155 | + (hovered || pressed) && { |
| 156 | + backgroundColor: theme.palette.neutral_25, |
| 157 | + }, |
| 158 | + ]} |
| 159 | + > |
| 160 | + <View |
| 161 | + style={[ |
| 162 | + Atoms.flex_row, |
| 163 | + Atoms.align_center, |
| 164 | + Atoms.gap_md, |
| 165 | + Atoms.px_lg, |
| 166 | + Atoms.py_md, |
| 167 | + ]} |
| 168 | + > |
| 169 | + <ProfileAvatar identityKey={suggestion.identity} size="md" /> |
| 170 | + <View style={Atoms.flex_1}> |
| 171 | + <Text |
| 172 | + variant="secondary" |
| 173 | + fontWeight="semibold" |
| 174 | + numberOfLines={1} |
| 175 | + selectable={false} |
| 176 | + > |
| 177 | + {name ? truncateName(name, 32) : 'Anonymous'} |
| 178 | + </Text> |
| 179 | + <Text |
| 180 | + variant="small" |
| 181 | + color="neutral_500" |
| 182 | + numberOfLines={1} |
| 183 | + selectable={false} |
| 184 | + style={alias ? undefined : { fontFamily: 'monospace' }} |
| 185 | + > |
| 186 | + {alias ?? shortenIdentityId(suggestion.identity)} |
| 187 | + </Text> |
| 188 | + </View> |
| 189 | + {pending && ( |
| 190 | + <ActivityIndicator |
| 191 | + size="small" |
| 192 | + color={theme.palette.primary_500} |
| 193 | + accessibilityLabel="Working" |
| 194 | + /> |
| 195 | + )} |
| 196 | + </View> |
| 197 | + </Pressable> |
| 198 | + ); |
| 199 | +} |
0 commit comments