Skip to content

Commit 0ce5368

Browse files
Merge upstream develop
2 parents 2d11212 + 566ca02 commit 0ce5368

10 files changed

Lines changed: 862 additions & 55 deletions

File tree

apps/polycentric/src/features/post/ThreadList.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
import { useCallback, useMemo, useState } from 'react';
1+
import { useMemo, useState } from 'react';
22
import { RefreshControl, useWindowDimensions, View } from 'react-native';
33
import { type PostData } from '@/src/common/lib/polycentric-hooks';
44
import { Post } from './Post';
55
import { useThread } from './hooks/useThread';
66
import { Atoms } from '@/src/common/theme';
77
import { ComposerInput } from '../composer';
88
import { List, ListProps } from '@/src/common/components/List';
9+
import { isWeb } from '@/src/common/util/platform';
910

1011
type ThreadListProps = Omit<ListProps<PostData>, 'data' | 'renderItem'> & {
1112
post: PostData;
1213
};
1314

1415
export function ThreadList({ post, ...rest }: ThreadListProps) {
15-
const [refreshing, setRefreshing] = useState(false);
1616
const { height: windowHeight } = useWindowDimensions();
1717

18-
const { thread } = useThread(post);
18+
const thread = useThread(post);
1919

2020
// Mount the subject by itself first.
2121
// Other items may or may not be available.
@@ -27,16 +27,11 @@ export function ThreadList({ post, ...rest }: ThreadListProps) {
2727

2828
const items = useMemo(() => {
2929
if (!isFirstLayoutComplete) return [post];
30-
return thread.length > 0 ? thread : [post];
31-
}, [isFirstLayoutComplete, thread, post]);
30+
return thread.items.length > 0 ? thread.items : [post];
31+
}, [isFirstLayoutComplete, thread.items, post]);
3232

3333
const subjectIndex = items.findIndex((p) => p.id === post.id);
3434

35-
const handleRefresh = useCallback(() => {
36-
setRefreshing(true);
37-
setTimeout(() => setRefreshing(false), 0);
38-
}, []);
39-
4035
return (
4136
<List
4237
{...rest}
@@ -67,7 +62,12 @@ export function ThreadList({ post, ...rest }: ThreadListProps) {
6762
}}
6863
ListFooterComponent={<View style={{ height: windowHeight }} />}
6964
refreshControl={
70-
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
65+
!isWeb ? (
66+
<RefreshControl
67+
refreshing={thread.isRefreshing}
68+
onRefresh={thread.refresh}
69+
/>
70+
) : undefined
7171
}
7272
/>
7373
);

apps/polycentric/src/features/post/hooks/useThread.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import {
1212
} from '@/src/common/lib/polycentric-hooks';
1313
import {
1414
getQueryCache,
15+
RefreshStrategy,
1516
setQueryCache,
1617
useQuery,
1718
} from '@/src/common/query/hooks/useQuery';
19+
import { FeedHookResult, NOOP } from '../../feed/hooks/types';
1820

1921
const DUMMY_EVENT_KEY: EventKey = {
2022
collection: COLLECTION.FEED,
@@ -33,7 +35,7 @@ export function threadQueryKey(parentId: string, limit = 0): string[] {
3335
export function useThread(
3436
post: PostData | undefined,
3537
options?: { limit?: number },
36-
): { thread: PostData[]; isLoading: boolean; error: Error | null } {
38+
): FeedHookResult {
3739
const eventKey: EventKey = useMemo(() => {
3840
if (!post) return DUMMY_EVENT_KEY;
3941

@@ -57,7 +59,7 @@ export function useThread(
5759
!!post,
5860
);
5961

60-
const thread = useMemo(() => {
62+
const items = useMemo(() => {
6163
if (!query.data) return [];
6264
const response = v2.GetPostThreadResponse.fromBinary(
6365
new Uint8Array(query.data),
@@ -71,9 +73,14 @@ export function useThread(
7173
}, [query.data]);
7274

7375
return {
74-
thread,
76+
items,
7577
isLoading: query.isLoading,
78+
isRefreshing: query.hasPendingRefresh,
7679
error: query.error ? new Error(query.error) : null,
80+
refresh: () => query.refresh(RefreshStrategy.Lazy),
81+
// TODO: paginate threads
82+
loadMore: NOOP,
83+
hasMore: false,
7784
};
7885
}
7986

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)