+3
bun.lock
+3
bun.lock
···
11
"@atproto/oauth-client-expo": "^0.0.5",
12
"@expo/vector-icons": "^15.0.3",
13
"@formatjs/intl-segmenter": "^12.0.5",
14
"@pchmn/expo-material3-theme": "^1.3.2",
15
"@react-navigation/bottom-tabs": "^7.4.0",
16
"@react-navigation/elements": "^2.6.3",
···
432
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
433
434
"@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=="],
435
436
"@material/material-color-utilities": ["@material/material-color-utilities@0.2.7", "", {}, "sha512-0FCeqG6WvK4/Cc06F/xXMd/pv4FeisI0c1tUpBbfhA2n9Y8eZEv4Karjbmf2ZqQCPUWMrGp8A571tCjizxoTiQ=="],
437
···
11
"@atproto/oauth-client-expo": "^0.0.5",
12
"@expo/vector-icons": "^15.0.3",
13
"@formatjs/intl-segmenter": "^12.0.5",
14
+
"@legendapp/list": "^2.0.19",
15
"@pchmn/expo-material3-theme": "^1.3.2",
16
"@react-navigation/bottom-tabs": "^7.4.0",
17
"@react-navigation/elements": "^2.6.3",
···
433
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
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=="],
438
439
"@material/material-color-utilities": ["@material/material-color-utilities@0.2.7", "", {}, "sha512-0FCeqG6WvK4/Cc06F/xXMd/pv4FeisI0c1tUpBbfhA2n9Y8eZEv4Karjbmf2ZqQCPUWMrGp8A571tCjizxoTiQ=="],
440
+1
package.json
+1
package.json
+172
-143
src/app/(authenticated)/profile/[userId].tsx
+172
-143
src/app/(authenticated)/profile/[userId].tsx
···
8
import { ActorIdentifier } from "@atcute/lexicons";
9
import { Image } from "expo-image";
10
import { useSafeAreaInsets } from "react-native-safe-area-context";
11
-
import { FlashList, FlashListRef } from "@shopify/flash-list";
12
import Post from "@/src/components/Post";
13
import { AppBskyLabelerDefs } from "@atcute/bluesky";
14
-
import Animated, { interpolate, useAnimatedRef, useAnimatedStyle, useScrollOffset } from "react-native-reanimated";
15
import { useState } from "react";
16
17
type AuthorFeedFilters =
···
29
const insets = useSafeAreaInsets();
30
const router = useRouter();
31
32
-
const flashListRef = useAnimatedRef<FlashListRef<any>>();
33
-
const scrollOffset = useScrollOffset(flashListRef);
34
35
const [feedFilter, setFeedFilter] = useState<AuthorFeedFilters>("posts_and_author_threads");
36
···
154
pointerEvents: scrollOffset.value >= 150 ? "auto" : "none",
155
}));
156
157
return (
158
<View style={{ backgroundColor: theme[colorScheme!].background, height: "100%", maxHeight: "100%" }}>
159
<Animated.View
160
style={[
161
{
162
backgroundColor: theme[colorScheme!].elevation.level2,
···
196
</Pressable>
197
<Pressable
198
onPress={() => {
199
-
flashListRef.current!.scrollToTop({ animated: true });
200
}}
201
>
202
<Text style={{ color: colorScheme === "light" ? "black" : "white", fontWeight: 700, fontSize: 14 }}>
···
208
</Pressable>
209
</Animated.View>
210
<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
229
style={{
230
-
position: "absolute",
231
-
top: insets.top,
232
-
zIndex: 1,
233
-
backgroundColor: "#00000055",
234
-
left: 8,
235
-
borderRadius: 32,
236
}}
237
-
onPress={() => router.dismiss(1)}
238
>
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 && (
263
<Text
264
style={{
265
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
fontSize: 10,
272
fontWeight: 700,
273
}}
274
>
275
-
{profileQuery.data?.pronouns}
276
</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);
288
289
-
if (!label) return null;
290
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
-
/>
362
</View>
363
</View>
364
);
···
8
import { ActorIdentifier } from "@atcute/lexicons";
9
import { Image } from "expo-image";
10
import { useSafeAreaInsets } from "react-native-safe-area-context";
11
+
import { LegendListRef } from "@legendapp/list";
12
+
import { AnimatedLegendList } from "@legendapp/list/reanimated";
13
import Post from "@/src/components/Post";
14
import { AppBskyLabelerDefs } from "@atcute/bluesky";
15
+
import Animated, { interpolate, useAnimatedRef, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
16
import { useState } from "react";
17
18
type AuthorFeedFilters =
···
30
const insets = useSafeAreaInsets();
31
const router = useRouter();
32
33
+
const listRef = useAnimatedRef<LegendListRef>();
34
+
const scrollOffset = useSharedValue(0);
35
+
const headerHeight = useSharedValue(0);
36
+
const topBarHeight = useSharedValue(0);
37
38
const [feedFilter, setFeedFilter] = useState<AuthorFeedFilters>("posts_and_author_threads");
39
···
157
pointerEvents: scrollOffset.value >= 150 ? "auto" : "none",
158
}));
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
+
184
return (
185
<View style={{ backgroundColor: theme[colorScheme!].background, height: "100%", maxHeight: "100%" }}>
186
<Animated.View
187
+
onLayout={(ev) =>
188
+
ev.nativeEvent.layout.height > topBarHeight.get() && topBarHeight.set(ev.nativeEvent.layout.height)
189
+
}
190
style={[
191
{
192
backgroundColor: theme[colorScheme!].elevation.level2,
···
226
</Pressable>
227
<Pressable
228
onPress={() => {
229
+
listRef.current!.scrollToOffset({ offset: 0, animated: true });
230
}}
231
>
232
<Text style={{ color: colorScheme === "light" ? "black" : "white", fontWeight: 700, fontSize: 14 }}>
···
238
</Pressable>
239
</Animated.View>
240
<View style={{ height: "100%" }}>
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
283
style={{
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,
292
}}
293
>
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} />
322
<Text
323
style={{
324
color: colorScheme === "light" ? "black" : "white",
325
fontSize: 10,
326
fontWeight: 700,
327
}}
328
>
329
+
{label?.locales.find((lc) => lc.lang === "en")?.name ?? val.val}
330
</Text>
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>
347
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>
356
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>
391
</View>
392
</View>
393
);