Skip to content

Commit ff85f54

Browse files
Merge upstream develop
2 parents 066e494 + d4056c0 commit ff85f54

17 files changed

Lines changed: 743 additions & 654 deletions

File tree

apps/polycentric/app.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
4848
[
4949
'expo-splash-screen',
5050
{
51-
image: './src/common/assets/images/PolycentricLogoTransparent1024.png',
51+
image:
52+
'./src/common/assets/images/app-icons/android-icon-foreground.png',
5253
imageWidth: 200,
5354
resizeMode: 'contain',
5455
backgroundColor: '#F2F5F9',

apps/polycentric/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"prebuild": "APP_VARIANT=dev expo prebuild --clean"
1616
},
1717
"dependencies": {
18-
"@expo/metro-runtime": "~55.0.9",
18+
"@codeherence/react-native-header": "^1.0.1",
19+
"@expo/metro-runtime": "~55.0.11",
1920
"@expo/vector-icons": "^15.0.3",
2021
"@gorhom/bottom-sheet": "^5.2.8",
2122
"@lodev09/react-native-true-sheet": "^3.0.4",
@@ -25,7 +26,7 @@
2526
"@react-navigation/bottom-tabs": "^7.15.4",
2627
"@react-navigation/elements": "^2.8.0",
2728
"@react-navigation/native": "^7.1.32",
28-
"@shopify/flash-list": "2.0.2",
29+
"@shopify/flash-list": "^2.3.1",
2930
"@types/react-native-web": "^0.19.2",
3031
"better-sqlite3": "^12.6.2",
3132
"expo": "~55.0.0",
1.38 KB
Binary file not shown.
-1.66 MB
Loading
182 KB
Loading

apps/polycentric/src/features/post/FeedViewer.tsx renamed to apps/polycentric/src/common/components/List/List.tsx

Lines changed: 105 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,70 +6,111 @@ import {
66
type ListRenderItem,
77
type ListRenderItemInfo,
88
} from '@shopify/flash-list';
9-
import { isValidElement, useEffect, useRef, useState } from 'react';
10-
import { View } from 'react-native';
11-
import { Post } from './Post';
12-
13-
// Initial page size for the web list. FlashList virtualises on native;
14-
// the web fallback (`WebFeedViewer`) renders every item synchronously,
15-
// so a long feed blows up the first paint. We cap the initial render
16-
// and grow the visible window when the sentinel comes into view.
9+
import {
10+
cloneElement,
11+
isValidElement,
12+
useEffect,
13+
useRef,
14+
useState,
15+
} from 'react';
16+
import { LayoutChangeEvent, StyleSheet, View } from 'react-native';
17+
import Animated, {
18+
useAnimatedScrollHandler,
19+
useAnimatedStyle,
20+
useSharedValue,
21+
} from 'react-native-reanimated';
22+
23+
// A reanimated-compatible FlashList.
24+
const AnimatedFlashList = Animated.createAnimatedComponent(FlashList);
25+
1726
const WEB_INITIAL_VISIBLE = 12;
1827
const WEB_PAGE_SIZE = 12;
1928

2029
export type { FlashListProps, ListRenderItem, ListRenderItemInfo };
2130

22-
// `renderItem` is required by FlashList but FeedViewer defaults it to a
23-
// `Post` renderer for `PostData`, so consumers can omit it.
24-
export type FeedViewerProps<T = PostData> = Omit<
25-
FlashListProps<T>,
26-
'renderItem'
27-
> & {
28-
renderItem?: FlashListProps<T>['renderItem'];
29-
};
30-
31-
const defaultRenderItem: ListRenderItem<PostData> = ({ item }) => (
32-
<Post post={item} />
33-
);
34-
35-
const defaultKeyExtractor = (item: PostData, index: number) =>
36-
`${item.id}:${index}`;
37-
38-
export function FeedViewer<T extends PostData = PostData>(
39-
props: FeedViewerProps<T>,
40-
) {
41-
const renderItem =
42-
(props.renderItem as ListRenderItem<T>) ??
43-
(defaultRenderItem as unknown as ListRenderItem<T>);
44-
const keyExtractor =
45-
props.keyExtractor ??
46-
(defaultKeyExtractor as unknown as FlashListProps<T>['keyExtractor']);
31+
export type ListProps<T> = FlashListProps<T>;
4732

33+
export function List<T extends PostData = PostData>(props: ListProps<T>) {
4834
if (isWeb) {
49-
return (
50-
<WebFeedViewer<T>
51-
{...props}
52-
renderItem={renderItem}
53-
keyExtractor={keyExtractor}
54-
/>
55-
);
35+
return <WebFeedViewer<T> {...props} />;
5636
}
5737

38+
return <NativeList<T> {...props} />;
39+
}
40+
41+
function NativeList<T>({
42+
ListHeaderComponent,
43+
contentContainerStyle,
44+
refreshControl,
45+
onScroll: _ignoredOnScroll,
46+
...rest
47+
}: FlashListProps<T>) {
48+
const lastScrollY = useSharedValue(0);
49+
const headerTranslate = useSharedValue(0);
50+
const headerHeightShared = useSharedValue(0);
51+
const [headerHeight, setHeaderHeight] = useState(0);
52+
53+
const onScroll = useAnimatedScrollHandler((event) => {
54+
const currentY = event.contentOffset.y;
55+
const h = headerHeightShared.value;
56+
57+
const delta = currentY - lastScrollY.value;
58+
const next = headerTranslate.value - delta;
59+
headerTranslate.value = Math.min(0, Math.max(-h, next));
60+
lastScrollY.value = currentY;
61+
});
62+
63+
const headerAnimatedStyle = useAnimatedStyle(() => ({
64+
transform: [{ translateY: headerTranslate.value }],
65+
}));
66+
67+
const onHeaderLayout = (event: LayoutChangeEvent) => {
68+
const next = event.nativeEvent.layout.height;
69+
headerHeightShared.value = next;
70+
if (next !== headerHeight) setHeaderHeight(next);
71+
};
72+
73+
const renderedHeader = renderNode(ListHeaderComponent);
74+
75+
// Show below the sticky header
76+
const adjustedRefreshControl = (
77+
isValidElement(refreshControl)
78+
? cloneElement(
79+
refreshControl as React.ReactElement<{ progressViewOffset?: number }>,
80+
{
81+
progressViewOffset: headerHeight,
82+
},
83+
)
84+
: refreshControl
85+
) as FlashListProps<T>['refreshControl'];
86+
5887
return (
59-
<FlashList<T>
60-
{...props}
61-
renderItem={renderItem}
62-
keyExtractor={keyExtractor}
63-
/>
88+
<View style={styles.container}>
89+
{renderedHeader ? (
90+
<Animated.View
91+
onLayout={onHeaderLayout}
92+
style={[styles.stickyHeader, headerAnimatedStyle]}
93+
>
94+
{renderedHeader}
95+
</Animated.View>
96+
) : null}
97+
<AnimatedFlashList
98+
{...(rest as FlashListProps<unknown>)}
99+
refreshControl={adjustedRefreshControl}
100+
onScroll={onScroll}
101+
scrollEventThrottle={16}
102+
contentContainerStyle={{
103+
paddingTop: headerHeight,
104+
...(typeof contentContainerStyle === 'object' &&
105+
contentContainerStyle !== null
106+
? contentContainerStyle
107+
: {}),
108+
}}
109+
/>
110+
</View>
64111
);
65112
}
66113

67-
// Renders the FlashList contract inline so the page-level scroll on web
68-
// is preserved (the Stack content area already has `overflow: auto`).
69-
// Only the props the feed actually uses are honored. Caps the first
70-
// paint at `WEB_INITIAL_VISIBLE` items and expands the window in
71-
// `WEB_PAGE_SIZE` chunks when the sentinel scrolls into view, so a
72-
// long feed doesn't block navigation.
73114
function WebFeedViewer<T>({
74115
data,
75116
renderItem,
@@ -155,3 +196,16 @@ function renderNode(node: ReactNodeOrComponent) {
155196
const Component = node as React.ComponentType;
156197
return <Component />;
157198
}
199+
200+
const styles = StyleSheet.create({
201+
container: {
202+
flex: 1,
203+
},
204+
stickyHeader: {
205+
position: 'absolute',
206+
top: 0,
207+
left: 0,
208+
right: 0,
209+
zIndex: 1,
210+
},
211+
});

apps/polycentric/src/common/components/layout/Layout.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ function Screen({
141141
style={[
142142
Atoms.flex_row,
143143
Atoms.flex_1,
144+
{ backgroundColor: theme.palette.neutral_0 },
144145
{ paddingTop: insets.top },
145146
!isWeb && {
146147
borderBottomWidth: 1,
@@ -151,6 +152,27 @@ function Screen({
151152
>
152153
{showLeftSidebar && <LeftSidebar />}
153154
<Main>{body}</Main>
155+
{!isWeb && insets.top > 0 ? (
156+
// Opaque cap that sits on top of all descendants and visually
157+
// masks any content that overflows into the status-bar zone
158+
// (FlashList items scrolling past `paddingTop`, sliding sticky
159+
// headers, animation glitches, etc.). Avoids `overflow: hidden`
160+
// on Screen so shadows / scroll bounces aren't clipped.
161+
<View
162+
pointerEvents="none"
163+
style={[
164+
{
165+
position: 'absolute',
166+
top: 0,
167+
left: 0,
168+
right: 0,
169+
height: insets.top,
170+
backgroundColor: theme.palette.neutral_0,
171+
zIndex: 1,
172+
},
173+
]}
174+
/>
175+
) : null}
154176
</View>
155177
);
156178
}

apps/polycentric/src/common/components/layout/Topbar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function Topbar({ title, center, right }: TopbarProps) {
4343
Atoms.py_sm,
4444
Atoms.gap_md,
4545
{ height: 60 },
46+
{ backgroundColor: theme.palette.neutral_0 },
4647
{
4748
borderBottomWidth: 1,
4849
borderBottomColor: withHexOpacity(theme.palette.neutral_500, '10'),

apps/polycentric/src/features/feed/ExploreScreen.tsx

Lines changed: 5 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,22 @@ import { Ionicons } from '@expo/vector-icons';
22
import { Screen } from '@/src/common/components/layout';
33
import { Fab } from '@/src/common/components';
44
import { Text, TextInput } from '@/src/common/components/primitives';
5-
import { FeedViewer } from '@/src/features/post';
65
import { useExploreFeed } from './hooks/useExploreFeed';
7-
import { openCompose, Routes } from '@/src/common/constants';
6+
import { openCompose } from '@/src/common/constants';
87
import { isWeb } from '@/src/common/util/platform';
98
import {
109
Atoms,
1110
BorderRadius,
12-
Spacing,
1311
useTheme,
1412
withHexOpacity,
1513
} from '@/src/common/theme';
16-
import {
17-
ActivityIndicator,
18-
Alert,
19-
Pressable,
20-
RefreshControl,
21-
View,
22-
} from 'react-native';
23-
import { router, useFocusEffect } from 'expo-router';
14+
import { Alert, Pressable, View } from 'react-native';
15+
import { useFocusEffect } from 'expo-router';
2416
import { useState } from 'react';
2517
import { useFocusedRefresh } from '@/src/common/lib/navigation/useFocusedRefresh';
2618
import { ComposerInput } from '../composer';
2719
import { TopbarSettingsButton } from '@/src/common/components/layout/topbar/SettingsButton';
20+
import FeedList from './FeedList';
2821

2922
function SearchBar() {
3023
const { theme } = useTheme();
@@ -60,7 +53,6 @@ function SearchBar() {
6053
}
6154

6255
const ListHeader = () => {
63-
const { theme } = useTheme();
6456
return (
6557
<>
6658
{!isWeb ? (
@@ -75,7 +67,6 @@ const ListHeader = () => {
7567
};
7668

7769
export default function ExploreScreen() {
78-
const { theme } = useTheme();
7970
const showComposeFab = !isWeb;
8071

8172
const [enabled, setEnabled] = useState<boolean>(false);
@@ -109,47 +100,7 @@ export default function ExploreScreen() {
109100
return (
110101
<Screen>
111102
<Screen.PrimaryColumn>
112-
<FeedViewer
113-
keyExtractor={(item) => item.id}
114-
data={feed.items}
115-
ListHeaderComponent={!isWeb ? ListHeader : undefined}
116-
ListEmptyComponent={
117-
!feed.isLoading ? (
118-
<View
119-
style={[
120-
Atoms.flex_1,
121-
Atoms.items_center,
122-
Atoms.justify_center,
123-
Atoms.p_lg,
124-
]}
125-
>
126-
<Text color="neutral_500">No posts yet</Text>
127-
</View>
128-
) : null
129-
}
130-
ListFooterComponent={
131-
feed.hasMore && feed.items.length > 0 ? (
132-
<View style={[Atoms.items_center, Atoms.p_lg]}>
133-
<ActivityIndicator
134-
size="small"
135-
color={theme.palette.neutral_500}
136-
accessibilityLabel="Loading more posts"
137-
/>
138-
</View>
139-
) : null
140-
}
141-
onEndReached={feed.hasMore ? feed.loadMore : undefined}
142-
onEndReachedThreshold={0.5}
143-
refreshControl={
144-
!isWeb ? (
145-
<RefreshControl
146-
refreshing={feed.isLoading}
147-
onRefresh={feed.refresh}
148-
/>
149-
) : undefined
150-
}
151-
showsVerticalScrollIndicator={false}
152-
/>
103+
<FeedList feed={feed} ListHeaderComponent={ListHeader} />
153104
{showComposeFab ? (
154105
<Fab
155106
onPress={openCompose}

0 commit comments

Comments
 (0)