Skip to content

Commit 4118271

Browse files
committed
[#115] Request verification can now be targetted to users via search or their follow lists
changelog: enhancement
1 parent ef8c105 commit 4118271

8 files changed

Lines changed: 841 additions & 41 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { usePolycentric } from '@/src/common/lib/polycentric-hooks';
2+
import { useQueryStore } from '@/src/common/query/hooks/useQuery';
3+
import { FetchMode, Query } from '@polycentric/react-native';
4+
import { useEffect, useMemo } from 'react';
5+
import { decodeProfile, type DecodedProfile } from '../lib/decodeProfile';
6+
import { profileQueryKey } from './useProfile';
7+
8+
export function useProfiles(
9+
identities: readonly string[],
10+
): Map<string, DecodedProfile | null> {
11+
const client = usePolycentric();
12+
13+
// Key the effect on contents, not array identity, so a re-render with an
14+
// equal list doesn't churn subscriptions.
15+
const joined = identities.join('\n');
16+
17+
useEffect(() => {
18+
if (!joined) return;
19+
const ids = joined.split('\n');
20+
const store = useQueryStore.getState();
21+
for (const identity of ids) {
22+
store.subscribe(profileQueryKey(identity).join('\0'), {
23+
client,
24+
queryKey: profileQueryKey(identity),
25+
query: new Query.GetProfile({ identity }),
26+
opts: { fetchMode: FetchMode.OfflineOnly },
27+
});
28+
}
29+
return () => {
30+
const store = useQueryStore.getState();
31+
for (const identity of ids) {
32+
store.unsubscribe(profileQueryKey(identity).join('\0'));
33+
}
34+
};
35+
}, [joined, client]);
36+
37+
const queries = useQueryStore((s) => s.queries);
38+
39+
return useMemo(() => {
40+
const map = new Map<string, DecodedProfile | null>();
41+
if (!joined) return map;
42+
for (const identity of joined.split('\n')) {
43+
const data = queries.get(profileQueryKey(identity).join('\0'))?.data;
44+
let decoded: DecodedProfile | null = null;
45+
if (data && data.byteLength > 0) {
46+
try {
47+
decoded = decodeProfile(data);
48+
} catch {
49+
decoded = null;
50+
}
51+
}
52+
map.set(identity, decoded);
53+
}
54+
return map;
55+
}, [queries, joined]);
56+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { ProfileSearchInput } from './ProfileSearchInput';
2+
export {
3+
type ProfileSuggestion,
4+
type ProfileSuggestionSource,
5+
type ProfileSuggestionsResult,
6+
useProfileSuggestions,
7+
} from './useProfileSuggestions';

0 commit comments

Comments
 (0)