Skip to content

Commit 7c457d1

Browse files
Merge upstream develop
2 parents fb69886 + 5bec7c3 commit 7c457d1

5 files changed

Lines changed: 201 additions & 132 deletions

File tree

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

Lines changed: 55 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,28 @@ type PrimaryColumnProps = {
7878
};
7979
function PrimaryColumn({ children }: PrimaryColumnProps) {
8080
const { theme } = useTheme();
81+
const { height: windowHeight } = useWindowDimensions();
82+
8183
return (
8284
<View
8385
testID="primaryColumn"
8486
style={[
8587
Atoms.flex_1,
8688
Atoms.pb_lg,
87-
{ borderLeftColor: theme.palette.neutral_25, borderLeftWidth: 1 },
88-
{ borderRightColor: theme.palette.neutral_25, borderRightWidth: 1 },
89-
{ maxWidth: 600 },
89+
{
90+
maxWidth: 600,
91+
borderLeftColor: theme.palette.neutral_25,
92+
borderLeftWidth: 1,
93+
borderRightColor: theme.palette.neutral_25,
94+
borderRightWidth: 1,
95+
},
96+
// Web page-scroll: let the column grow with content so the
97+
// borders span the full scrollable height. `self_start` opts
98+
// out of the row's cross-axis stretch; `minHeight` keeps
99+
// borders painting to the viewport bottom on short-content
100+
// pages.
101+
isWeb && Atoms.self_start,
102+
isWeb && { minHeight: windowHeight },
90103
]}
91104
>
92105
{children}
@@ -268,42 +281,52 @@ export const RightSidebar = memo(function RightSidebar({
268281

269282
return (
270283
<View style={{ width, marginRight }}>
284+
{/* Pin to viewport on web so it stays visible while the primary
285+
column scrolls; the outer View reserves the row space. */}
271286
<View
272-
style={[
273-
Atoms.justify_between,
274-
Atoms.align_center,
275-
Atoms.h_full,
276-
Atoms.pb_lg,
277-
]}
287+
style={
288+
isWeb
289+
? { position: 'fixed', top: 0, height: '100%', width }
290+
: undefined
291+
}
278292
>
279-
<View style={[Atoms.flex_1]}></View>
280293
<View
281294
style={[
282-
Atoms.flex_row,
283-
Atoms.items_center,
284-
Atoms.w_full,
285-
Atoms.py_sm,
286-
Atoms.px_sm,
287-
Atoms.gap_sm,
288-
Atoms.flex_wrap,
295+
Atoms.justify_between,
296+
Atoms.align_center,
297+
Atoms.h_full,
298+
Atoms.pb_lg,
289299
]}
290300
>
291-
<Pressable
292-
accessibilityLabel="Toggle color theme"
293-
accessibilityRole="button"
294-
hitSlop={8}
295-
onPress={toggleTheme}
296-
style={({ pressed }) => [pressed && { opacity: 0.65 }]}
301+
<View style={[Atoms.flex_1]}></View>
302+
<View
303+
style={[
304+
Atoms.flex_row,
305+
Atoms.items_center,
306+
Atoms.w_full,
307+
Atoms.py_sm,
308+
Atoms.px_sm,
309+
Atoms.gap_sm,
310+
Atoms.flex_wrap,
311+
]}
297312
>
298-
<Ionicons
299-
name={theme.name === 'dark' ? 'moon' : 'sunny'}
300-
size={typography.fontSize.sm}
301-
color={theme.palette.neutral_500}
302-
/>
303-
</Pressable>
304-
{LINKS.map(({ text, href }) => (
305-
<RightSidebarLink key={href} href={href} text={text} />
306-
))}
313+
<Pressable
314+
accessibilityLabel="Toggle color theme"
315+
accessibilityRole="button"
316+
hitSlop={8}
317+
onPress={toggleTheme}
318+
style={({ pressed }) => [pressed && { opacity: 0.65 }]}
319+
>
320+
<Ionicons
321+
name={theme.name === 'dark' ? 'moon' : 'sunny'}
322+
size={typography.fontSize.sm}
323+
color={theme.palette.neutral_500}
324+
/>
325+
</Pressable>
326+
{LINKS.map(({ text, href }) => (
327+
<RightSidebarLink key={href} href={href} text={text} />
328+
))}
329+
</View>
307330
</View>
308331
</View>
309332
</View>

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

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { Ionicons } from '@expo/vector-icons';
22
import { Screen } from '@/src/common/components/layout';
3-
import { View } from 'react-native';
43
import { Fab } from '@/src/common/components';
54
import { FeedViewer, type FeedType } from '@/src/features/post';
65
import { ComposerInput } from '@/src/features/composer';
76
import { useExploreFeed } from './hooks/useExploreFeed';
87
import { useFollowingFeed } from './hooks/useFollowingFeed';
98
import { openCompose } from '@/src/common/constants';
10-
import { Atoms } from '@/src/common/theme';
119
import { isWeb } from '@/src/common/util/platform';
1210
import { usePathname } from 'expo-router';
1311

@@ -41,18 +39,16 @@ export default function FeedScreen() {
4139
<Screen>
4240
<Screen.PrimaryColumn>
4341
{selectedFeed === 'following' ? <ComposerInput /> : null}
44-
<View style={[Atoms.flex_1]}>
45-
<FeedViewer
46-
key={selectedFeed}
47-
items={currentFeed.items}
48-
isLoading={currentFeed.isLoading}
49-
error={currentFeed.error}
50-
onRefresh={currentFeed.refresh}
51-
onEndReached={currentFeed.loadMore}
52-
hasMore={currentFeed.hasMore}
53-
bottomPadding={0}
54-
/>
55-
</View>
42+
<FeedViewer
43+
key={selectedFeed}
44+
items={currentFeed.items}
45+
isLoading={currentFeed.isLoading}
46+
error={currentFeed.error}
47+
onRefresh={currentFeed.refresh}
48+
onEndReached={currentFeed.loadMore}
49+
hasMore={currentFeed.hasMore}
50+
bottomPadding={0}
51+
/>
5652
{showComposeFab ? (
5753
<Fab
5854
title="New Post"

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

Lines changed: 90 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { PostData } from '@/src/common/lib/polycentric-hooks';
33
import { Atoms, useTheme } from '@/src/common/theme';
44
import { isWeb } from '@/src/common/util/platform';
55
import { FlashList } from '@shopify/flash-list';
6-
import { useCallback, useState } from 'react';
6+
import { useCallback, useEffect, useRef, useState } from 'react';
77
import {
88
ActivityIndicator,
99
type LayoutChangeEvent,
@@ -22,10 +22,94 @@ interface FeedViewerProps {
2222
bottomPadding?: number;
2323
}
2424

25-
export function FeedViewer({
25+
export function FeedViewer(props: FeedViewerProps) {
26+
if (props.error) {
27+
return (
28+
<View
29+
style={[
30+
Atoms.flex_1,
31+
Atoms.items_center,
32+
Atoms.justify_center,
33+
Atoms.p_lg,
34+
]}
35+
>
36+
<Text color="neutral_500">Failed to load feed</Text>
37+
</View>
38+
);
39+
}
40+
41+
// On web let the page scroll naturally — the Stack's content area
42+
// already has `overflow: auto`. Inline rendering keeps that scroll
43+
// chain intact instead of FlashList swallowing it.
44+
if (isWeb) {
45+
return <WebFeedViewer {...props} />;
46+
}
47+
return <NativeFeedViewer {...props} />;
48+
}
49+
50+
function WebFeedViewer({
51+
items,
52+
isLoading,
53+
onEndReached,
54+
hasMore,
55+
bottomPadding,
56+
}: FeedViewerProps) {
57+
const { theme } = useTheme();
58+
const sentinelRef = useRef<View>(null);
59+
60+
// IntersectionObserver on a sentinel at the bottom triggers
61+
// `onEndReached` when the user scrolls near it.
62+
useEffect(() => {
63+
if (!hasMore || !onEndReached) return;
64+
const node = sentinelRef.current as unknown as Element | null;
65+
if (!node || typeof IntersectionObserver === 'undefined') return;
66+
const observer = new IntersectionObserver(
67+
(entries) => {
68+
if (entries.some((e) => e.isIntersecting)) onEndReached();
69+
},
70+
{ rootMargin: '400px' },
71+
);
72+
observer.observe(node);
73+
return () => observer.disconnect();
74+
}, [hasMore, onEndReached, items.length]);
75+
76+
if (items.length === 0 && !isLoading) {
77+
return (
78+
<View
79+
style={[
80+
Atoms.flex_1,
81+
Atoms.items_center,
82+
Atoms.justify_center,
83+
Atoms.p_lg,
84+
]}
85+
>
86+
<Text color="neutral_500">No posts yet</Text>
87+
</View>
88+
);
89+
}
90+
91+
return (
92+
<View style={{ paddingBottom: bottomPadding }}>
93+
{items.map((item, i) => (
94+
<Post key={`${item.id}:${i}`} post={item} />
95+
))}
96+
{hasMore && <View ref={sentinelRef} style={{ height: 1 }} />}
97+
{isLoading && items.length > 0 ? (
98+
<View style={[Atoms.items_center, Atoms.p_lg]}>
99+
<ActivityIndicator
100+
size="small"
101+
color={theme.palette.neutral_500}
102+
accessibilityLabel="Loading more posts"
103+
/>
104+
</View>
105+
) : null}
106+
</View>
107+
);
108+
}
109+
110+
function NativeFeedViewer({
26111
items,
27112
isLoading,
28-
error,
29113
onRefresh,
30114
onEndReached,
31115
hasMore,
@@ -42,10 +126,8 @@ export function FeedViewer({
42126
setHasLayout(true);
43127
}, []);
44128

45-
// Our routing and navigation leaves
46-
// inactive screens mounted with tiny dimensions.
47-
// When navigating back, this can cause a momentary visual glitch.
48-
// Hiding the invalid layout prevents the visual glitch.
129+
// Inactive screens stay mounted with tiny dimensions. Hide the list
130+
// briefly until it has a valid layout to avoid a flash on back-nav.
49131
const layoutInvalid = hasLayout && (layoutBox.w < 2 || layoutBox.h < 2);
50132

51133
const renderItem = useCallback(
@@ -58,31 +140,9 @@ export function FeedViewer({
58140
[],
59141
);
60142

61-
if (error) {
62-
return (
63-
<View
64-
style={[
65-
Atoms.flex_1,
66-
Atoms.items_center,
67-
Atoms.justify_center,
68-
Atoms.p_lg,
69-
]}
70-
>
71-
<Text color="neutral_500">Failed to load feed</Text>
72-
</View>
73-
);
74-
}
75-
76143
return (
77144
<View
78-
style={[
79-
Atoms.flex_1,
80-
isWeb &&
81-
layoutInvalid && {
82-
opacity: 0,
83-
pointerEvents: 'none',
84-
},
85-
]}
145+
style={[Atoms.flex_1, layoutInvalid && { opacity: 0 }]}
86146
onLayout={onFeedContainerLayout}
87147
>
88148
<FlashList
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { FeedHookResult } from '@/src/features/feed/hooks/types';
2+
import { FeedViewer } from '@/src/features/post';
3+
4+
type ProfileFeedTab = {
5+
key: string;
6+
feed: FeedHookResult;
7+
bottomPadding?: number;
8+
};
9+
10+
/**
11+
* Renders a set of profile feeds and shows the active one. The feed
12+
* hooks themselves live on the parent so their data + fetch state
13+
* survive tab switches; only the viewer is swapped.
14+
*/
15+
export function ProfileFeedSwitcher({
16+
tabs,
17+
activeKey,
18+
}: {
19+
tabs: ProfileFeedTab[];
20+
activeKey: string;
21+
}) {
22+
const active = tabs.find((t) => t.key === activeKey) ?? tabs[0];
23+
if (!active) return null;
24+
return (
25+
<FeedViewer
26+
key={active.key}
27+
items={active.feed.items}
28+
isLoading={active.feed.isLoading}
29+
error={active.feed.error}
30+
onRefresh={active.feed.refresh}
31+
onEndReached={active.feed.loadMore}
32+
hasMore={active.feed.hasMore}
33+
bottomPadding={active.bottomPadding}
34+
/>
35+
);
36+
}

0 commit comments

Comments
 (0)