+3
bun.lock
+3
bun.lock
···
11
11
"@atproto/oauth-client-expo": "^0.0.5",
12
12
"@expo/vector-icons": "^15.0.3",
13
13
"@formatjs/intl-segmenter": "^12.0.5",
14
+
"@legendapp/list": "^2.0.19",
14
15
"@pchmn/expo-material3-theme": "^1.3.2",
15
16
"@react-navigation/bottom-tabs": "^7.4.0",
16
17
"@react-navigation/elements": "^2.6.3",
···
432
433
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
433
434
434
435
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
436
+
437
+
"@legendapp/list": ["@legendapp/list@2.0.19", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-zDWg8yg0smKxxk+M7gwAbZAnf5uczohPA+IjqLSkImz7+e9ytxeT0Mq35RBO9RTKODOXfV/aIgm1uqUHLBEdmg=="],
435
438
436
439
"@material/material-color-utilities": ["@material/material-color-utilities@0.2.7", "", {}, "sha512-0FCeqG6WvK4/Cc06F/xXMd/pv4FeisI0c1tUpBbfhA2n9Y8eZEv4Karjbmf2ZqQCPUWMrGp8A571tCjizxoTiQ=="],
437
440
+1
package.json
+1
package.json
···
17
17
"@atproto/oauth-client-expo": "^0.0.5",
18
18
"@expo/vector-icons": "^15.0.3",
19
19
"@formatjs/intl-segmenter": "^12.0.5",
20
+
"@legendapp/list": "^2.0.19",
20
21
"@pchmn/expo-material3-theme": "^1.3.2",
21
22
"@react-navigation/bottom-tabs": "^7.4.0",
22
23
"@react-navigation/elements": "^2.6.3",
+172
-143
src/app/(authenticated)/profile/[userId].tsx
+172
-143
src/app/(authenticated)/profile/[userId].tsx
···
8
8
import { ActorIdentifier } from "@atcute/lexicons";
9
9
import { Image } from "expo-image";
10
10
import { useSafeAreaInsets } from "react-native-safe-area-context";
11
-
import { FlashList, FlashListRef } from "@shopify/flash-list";
11
+
import { LegendListRef } from "@legendapp/list";
12
+
import { AnimatedLegendList } from "@legendapp/list/reanimated";
12
13
import Post from "@/src/components/Post";
13
14
import { AppBskyLabelerDefs } from "@atcute/bluesky";
14
-
import Animated, { interpolate, useAnimatedRef, useAnimatedStyle, useScrollOffset } from "react-native-reanimated";
15
+
import Animated, { interpolate, useAnimatedRef, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
15
16
import { useState } from "react";
16
17
17
18
type AuthorFeedFilters =
···
29
30
const insets = useSafeAreaInsets();
30
31
const router = useRouter();
31
32
32
-
const flashListRef = useAnimatedRef<FlashListRef<any>>();
33
-
const scrollOffset = useScrollOffset(flashListRef);
33
+
const listRef = useAnimatedRef<LegendListRef>();
34
+
const scrollOffset = useSharedValue(0);
35
+
const headerHeight = useSharedValue(0);
36
+
const topBarHeight = useSharedValue(0);
34
37
35
38
const [feedFilter, setFeedFilter] = useState<AuthorFeedFilters>("posts_and_author_threads");
36
39
···
154
157
pointerEvents: scrollOffset.value >= 150 ? "auto" : "none",
155
158
}));
156
159
160
+
const headerAnimatedStyle = useAnimatedStyle(() => {
161
+
return {
162
+
transform: [
163
+
{
164
+
translateY: interpolate(
165
+
scrollOffset.value,
166
+
[headerHeight.value, 0, -headerHeight.value],
167
+
[headerHeight.value * 2, 0, headerHeight.value]
168
+
),
169
+
},
170
+
],
171
+
};
172
+
});
173
+
174
+
const listAnimatedStyle = useAnimatedStyle(() => {
175
+
return {
176
+
height: interpolate(
177
+
scrollOffset.value,
178
+
[headerHeight.value, 0, -headerHeight.value],
179
+
[topBarHeight.value, headerHeight.value, headerHeight.value]
180
+
),
181
+
};
182
+
});
183
+
157
184
return (
158
185
<View style={{ backgroundColor: theme[colorScheme!].background, height: "100%", maxHeight: "100%" }}>
159
186
<Animated.View
187
+
onLayout={(ev) =>
188
+
ev.nativeEvent.layout.height > topBarHeight.get() && topBarHeight.set(ev.nativeEvent.layout.height)
189
+
}
160
190
style={[
161
191
{
162
192
backgroundColor: theme[colorScheme!].elevation.level2,
···
196
226
</Pressable>
197
227
<Pressable
198
228
onPress={() => {
199
-
flashListRef.current!.scrollToTop({ animated: true });
229
+
listRef.current!.scrollToOffset({ offset: 0, animated: true });
200
230
}}
201
231
>
202
232
<Text style={{ color: colorScheme === "light" ? "black" : "white", fontWeight: 700, fontSize: 14 }}>
···
208
238
</Pressable>
209
239
</Animated.View>
210
240
<View style={{ height: "100%" }}>
211
-
<FlashList
212
-
ref={flashListRef}
213
-
scrollEventThrottle={16}
214
-
decelerationRate={"normal"}
215
-
snapToEnd={false}
216
-
snapToStart={false}
217
-
pagingEnabled={false}
218
-
data={["a", ...(authorFeedQuery.data?.pages ?? []).flatMap((val) => val.feed)]}
219
-
onEndReached={() => authorFeedQuery.fetchNextPage()}
220
-
refreshControl={<RefreshControl progressViewOffset={300} refreshing={authorFeedQuery.isRefetching} />}
221
-
ListFooterComponent={<View />}
222
-
ListFooterComponentStyle={{ height: 64 }}
223
-
onRefresh={() => authorFeedQuery.refetch()}
224
-
renderItem={({ item, target }) => {
225
-
if (typeof item === "string") {
226
-
return (
227
-
<View>
228
-
<Pressable
241
+
<Animated.View
242
+
onLayout={(ev) =>
243
+
ev.nativeEvent.layout.height > headerHeight.get() && headerHeight.set(ev.nativeEvent.layout.height)
244
+
}
245
+
style={[headerAnimatedStyle, { zIndex: 1 }]}
246
+
>
247
+
<Pressable
248
+
style={{
249
+
position: "absolute",
250
+
top: insets.top,
251
+
zIndex: 1,
252
+
backgroundColor: "#00000055",
253
+
left: 8,
254
+
borderRadius: 32,
255
+
}}
256
+
onPress={() => router.dismiss(1)}
257
+
>
258
+
<Text
259
+
style={{
260
+
fontSize: 24,
261
+
color: "white",
262
+
textAlign: "center",
263
+
textAlignVertical: "center",
264
+
aspectRatio: 1,
265
+
}}
266
+
>
267
+
←
268
+
</Text>
269
+
</Pressable>
270
+
<Image source={profileQuery.data?.banner} style={{ width: "100%", aspectRatio: 3 }} />
271
+
<View style={{ padding: 8 }}>
272
+
<Text style={{ color: colorScheme === "light" ? "black" : "white", fontWeight: 700, fontSize: 24 }}>
273
+
{profileQuery.data?.displayName}
274
+
</Text>
275
+
<Text style={{ color: colorScheme === "light" ? "black" : "white", opacity: 0.75 }}>
276
+
{"@" + profileQuery.data?.handle}
277
+
</Text>
278
+
{(profileQuery.data?.pronouns ||
279
+
(labelersQuery.isSuccess && (profileQuery.data?.labels?.length ?? 0) > 0)) && (
280
+
<View style={{ flexDirection: "row", gap: 4, flexWrap: "wrap", marginTop: 4 }}>
281
+
{profileQuery.data?.pronouns && (
282
+
<Text
229
283
style={{
230
-
position: "absolute",
231
-
top: insets.top,
232
-
zIndex: 1,
233
-
backgroundColor: "#00000055",
234
-
left: 8,
235
-
borderRadius: 32,
284
+
color: colorScheme === "light" ? "black" : "white",
285
+
backgroundColor: theme[colorScheme!].elevation.level2,
286
+
paddingVertical: 4,
287
+
paddingHorizontal: 4,
288
+
borderRadius: 4,
289
+
alignSelf: "flex-start",
290
+
fontSize: 10,
291
+
fontWeight: 700,
236
292
}}
237
-
onPress={() => router.dismiss(1)}
238
293
>
239
-
<Text
240
-
style={{
241
-
fontSize: 24,
242
-
color: "white",
243
-
textAlign: "center",
244
-
textAlignVertical: "center",
245
-
aspectRatio: 1,
246
-
}}
247
-
>
248
-
←
249
-
</Text>
250
-
</Pressable>
251
-
<Image source={profileQuery.data?.banner} style={{ width: "100%", aspectRatio: 3 }} />
252
-
<View style={{ padding: 8 }}>
253
-
<Text style={{ color: colorScheme === "light" ? "black" : "white", fontWeight: 700, fontSize: 24 }}>
254
-
{profileQuery.data?.displayName}
255
-
</Text>
256
-
<Text style={{ color: colorScheme === "light" ? "black" : "white", opacity: 0.75 }}>
257
-
{"@" + profileQuery.data?.handle}
258
-
</Text>
259
-
{(profileQuery.data?.pronouns ||
260
-
(labelersQuery.isSuccess && (profileQuery.data?.labels?.length ?? 0) > 0)) && (
261
-
<View style={{ flexDirection: "row", gap: 4, flexWrap: "wrap", marginTop: 4 }}>
262
-
{profileQuery.data?.pronouns && (
294
+
{profileQuery.data?.pronouns}
295
+
</Text>
296
+
)}
297
+
{labelersQuery.isSuccess &&
298
+
(profileQuery.data?.labels ?? [])
299
+
.filter((val) => val.src !== val.uri.slice(5).split("/")[0])
300
+
.map((val) => {
301
+
const labeler = labelersQuery.data.views.find((labeler) => labeler.creator.did === val.src);
302
+
const labelDefs = (labeler as AppBskyLabelerDefs.LabelerViewDetailed)?.policies
303
+
.labelValueDefinitions;
304
+
const label = labelDefs?.find((def) => def.identifier === val.val);
305
+
306
+
if (!label) return null;
307
+
308
+
return (
309
+
<View
310
+
key={val.src + val.val}
311
+
style={{
312
+
flexDirection: "row",
313
+
alignItems: "center",
314
+
gap: 2,
315
+
backgroundColor: theme[colorScheme!].elevation.level2,
316
+
paddingVertical: 4,
317
+
paddingHorizontal: 4,
318
+
borderRadius: 4,
319
+
}}
320
+
>
321
+
<Image style={{ width: 12, height: 12, borderRadius: 16 }} source={labeler?.creator.avatar} />
263
322
<Text
264
323
style={{
265
324
color: colorScheme === "light" ? "black" : "white",
266
-
backgroundColor: theme[colorScheme!].elevation.level2,
267
-
paddingVertical: 4,
268
-
paddingHorizontal: 4,
269
-
borderRadius: 4,
270
-
alignSelf: "flex-start",
271
325
fontSize: 10,
272
326
fontWeight: 700,
273
327
}}
274
328
>
275
-
{profileQuery.data?.pronouns}
329
+
{label?.locales.find((lc) => lc.lang === "en")?.name ?? val.val}
276
330
</Text>
277
-
)}
278
-
{labelersQuery.isSuccess &&
279
-
(profileQuery.data?.labels ?? [])
280
-
.filter((val) => val.src !== val.uri.slice(5).split("/")[0])
281
-
.map((val) => {
282
-
const labeler = labelersQuery.data.views.find(
283
-
(labeler) => labeler.creator.did === val.src
284
-
);
285
-
const labelDefs = (labeler as AppBskyLabelerDefs.LabelerViewDetailed)?.policies
286
-
.labelValueDefinitions;
287
-
const label = labelDefs?.find((def) => def.identifier === val.val);
331
+
</View>
332
+
);
333
+
})
334
+
.filter((val) => val)}
335
+
</View>
336
+
)}
337
+
</View>
338
+
<View style={{ flexDirection: "row" }}>
339
+
<Pressable
340
+
style={{ padding: 8 }}
341
+
onPress={() => {
342
+
setFeedFilter("posts_and_author_threads");
343
+
}}
344
+
>
345
+
<Text style={{ fontWeight: 700, color: colorScheme === "light" ? "black" : "white" }}>Posts</Text>
346
+
</Pressable>
288
347
289
-
if (!label) return null;
348
+
<Pressable
349
+
style={{ padding: 8 }}
350
+
onPress={() => {
351
+
setFeedFilter("posts_with_replies");
352
+
}}
353
+
>
354
+
<Text style={{ fontWeight: 700, color: colorScheme === "light" ? "black" : "white" }}>Replies</Text>
355
+
</Pressable>
290
356
291
-
return (
292
-
<View
293
-
key={val.src + val.val}
294
-
style={{
295
-
flexDirection: "row",
296
-
alignItems: "center",
297
-
gap: 2,
298
-
backgroundColor: theme[colorScheme!].elevation.level2,
299
-
paddingVertical: 4,
300
-
paddingHorizontal: 4,
301
-
borderRadius: 4,
302
-
}}
303
-
>
304
-
<Image
305
-
style={{ width: 12, height: 12, borderRadius: 16 }}
306
-
source={labeler?.creator.avatar}
307
-
/>
308
-
<Text
309
-
style={{
310
-
color: colorScheme === "light" ? "black" : "white",
311
-
fontSize: 10,
312
-
fontWeight: 700,
313
-
}}
314
-
>
315
-
{label?.locales.find((lc) => lc.lang === "en")?.name ?? val.val}
316
-
</Text>
317
-
</View>
318
-
);
319
-
})
320
-
.filter((val) => val)}
321
-
</View>
322
-
)}
323
-
</View>
324
-
<View style={{ flexDirection: "row" }}>
325
-
<Pressable
326
-
style={{ padding: 8 }}
327
-
onPress={() => {
328
-
setFeedFilter("posts_and_author_threads");
329
-
}}
330
-
>
331
-
<Text style={{ fontWeight: 700, color: colorScheme === "light" ? "black" : "white" }}>Posts</Text>
332
-
</Pressable>
333
-
334
-
<Pressable
335
-
style={{ padding: 8 }}
336
-
onPress={() => {
337
-
setFeedFilter("posts_with_replies");
338
-
}}
339
-
>
340
-
<Text style={{ fontWeight: 700, color: colorScheme === "light" ? "black" : "white" }}>
341
-
Replies
342
-
</Text>
343
-
</Pressable>
344
-
345
-
<Pressable
346
-
style={{ padding: 8 }}
347
-
onPress={() => {
348
-
setFeedFilter("posts_with_media");
349
-
}}
350
-
>
351
-
<Text style={{ fontWeight: 700, color: colorScheme === "light" ? "black" : "white" }}>Media</Text>
352
-
</Pressable>
353
-
</View>
354
-
</View>
355
-
);
356
-
} else return <Post post={item.post} />;
357
-
}}
358
-
getItemType={(item) => {
359
-
return typeof item === "string" ? "sectionHeader" : "row";
360
-
}}
361
-
/>
357
+
<Pressable
358
+
style={{ padding: 8 }}
359
+
onPress={() => {
360
+
setFeedFilter("posts_with_media");
361
+
}}
362
+
>
363
+
<Text style={{ fontWeight: 700, color: colorScheme === "light" ? "black" : "white" }}>Media</Text>
364
+
</Pressable>
365
+
</View>
366
+
</Animated.View>
367
+
<Animated.View style={{ position: "absolute", height: "100%", top: 0, left: 0, width: "100%" }}>
368
+
<AnimatedLegendList
369
+
recycleItems
370
+
ref={listRef}
371
+
onScroll={(ev) => {
372
+
scrollOffset.set(ev.nativeEvent.contentOffset.y);
373
+
}}
374
+
scrollEventThrottle={16}
375
+
decelerationRate={"normal"}
376
+
snapToEnd={false}
377
+
snapToStart={false}
378
+
pagingEnabled={false}
379
+
data={(authorFeedQuery.data?.pages ?? []).flatMap((val) => val.feed)}
380
+
onEndReached={() => authorFeedQuery.fetchNextPage()}
381
+
refreshControl={<RefreshControl progressViewOffset={300} refreshing={authorFeedQuery.isRefetching} />}
382
+
ListHeaderComponent={<Animated.View style={[listAnimatedStyle]} />}
383
+
ListFooterComponent={<View />}
384
+
ListFooterComponentStyle={{ height: 64 }}
385
+
onRefresh={() => authorFeedQuery.refetch()}
386
+
renderItem={({ item }) => {
387
+
return <Post post={item.post} />;
388
+
}}
389
+
/>
390
+
</Animated.View>
362
391
</View>
363
392
</View>
364
393
);