Skip to content

Commit 2d11212

Browse files
Merge upstream develop
2 parents 4d981e6 + ef8c105 commit 2d11212

39 files changed

Lines changed: 2947 additions & 132 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import FollowListScreen from '@/src/features/follow/FollowListScreen';
2+
3+
export default function FollowersRoute() {
4+
return <FollowListScreen mode="followers" />;
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import FollowListScreen from '@/src/features/follow/FollowListScreen';
2+
3+
export default function FollowingRoute() {
4+
return <FollowListScreen mode="following" />;
5+
}

apps/polycentric/src/common/constants/routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export const Routes = {
3838
profile: (identityId: string) => `/${identityId}` as const,
3939
profileVerifications: (identityId: string) =>
4040
`/${identityId}/verifications` as const,
41+
profileFollowing: (identityId: string) =>
42+
`/${identityId}/following` as const,
43+
profileFollowers: (identityId: string) =>
44+
`/${identityId}/followers` as const,
4145
editProfile: (identityId: string) => `/${identityId}/edit` as const,
4246
post: (identityId: string, keyFingerprint: string, sequence: string) =>
4347
`/${identityId}/post/${keyFingerprint}/${sequence}` as const,
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
jest.mock('@/src/common/theme', () => ({
2+
useTheme: () => ({
3+
theme: {
4+
palette: new Proxy({}, { get: () => '#000' }),
5+
atoms: new Proxy({}, { get: () => ({}) }),
6+
},
7+
}),
8+
Atoms: new Proxy({}, { get: () => ({}) }),
9+
Spacing: new Proxy({}, { get: () => 8 }),
10+
}));
11+
12+
jest.mock('@/src/common/components', () => {
13+
const react = require('react');
14+
const { Text } = require('react-native');
15+
return {
16+
Text: ({ children }: { children?: unknown }) =>
17+
react.createElement(Text, null, children),
18+
};
19+
});
20+
21+
jest.mock('@/src/common/components/layout', () => {
22+
const react = require('react');
23+
const Screen = ({ children }: { children?: unknown }) =>
24+
react.createElement(react.Fragment, null, children);
25+
Screen.PrimaryColumn = ({ children }: { children?: unknown }) =>
26+
react.createElement(react.Fragment, null, children);
27+
return { Screen };
28+
});
29+
30+
jest.mock('@/src/common/components/layout/Topbar', () => {
31+
const react = require('react');
32+
const { View } = require('react-native');
33+
return {
34+
__esModule: true,
35+
default: ({ center }: { center?: unknown }) =>
36+
react.createElement(View, { testID: 'topbar' }, center),
37+
};
38+
});
39+
40+
// Renders rows, headers, and footers so list composition is observable.
41+
type ListProps = {
42+
HeaderComponent?: () => unknown;
43+
ListHeaderComponent?: unknown;
44+
ListEmptyComponent?: unknown;
45+
ListFooterComponent?: unknown;
46+
data: { identity: string }[];
47+
renderItem: (info: { item: unknown; index: number }) => unknown;
48+
onEndReached?: () => void;
49+
};
50+
let mockListProps: ListProps | null = null;
51+
jest.mock('@/src/common/components/List', () => {
52+
const react = require('react');
53+
const { View } = require('react-native');
54+
return {
55+
List: (props: ListProps) => {
56+
mockListProps = props;
57+
return react.createElement(
58+
View,
59+
null,
60+
props.HeaderComponent ? props.HeaderComponent() : null,
61+
props.ListHeaderComponent,
62+
props.data.length === 0
63+
? props.ListEmptyComponent
64+
: props.data.map((item, index) =>
65+
react.createElement(
66+
View,
67+
{ key: item.identity },
68+
props.renderItem({ item, index }),
69+
),
70+
),
71+
props.ListFooterComponent,
72+
);
73+
},
74+
};
75+
});
76+
77+
jest.mock('@/src/common/components/ListEmpty', () => {
78+
const react = require('react');
79+
const { Text } = require('react-native');
80+
return {
81+
ListEmpty: ({ children }: { children?: unknown }) =>
82+
react.createElement(Text, null, children),
83+
};
84+
});
85+
86+
jest.mock('@/src/common/components/Tabs', () => {
87+
const react = require('react');
88+
const { Text, View } = require('react-native');
89+
const Tabs = ({ children }: { children?: unknown }) =>
90+
react.createElement(View, null, children);
91+
Tabs.Tab = ({
92+
children,
93+
active,
94+
onPress,
95+
}: {
96+
children: string;
97+
active?: boolean;
98+
onPress: () => void;
99+
}) =>
100+
react.createElement(
101+
Text,
102+
{ onPress, accessibilityState: { selected: active } },
103+
children,
104+
);
105+
return { Tabs };
106+
});
107+
108+
jest.mock('@/src/common/components/Avatar/ProfileAvatar', () => ({
109+
ProfileAvatar: () => null,
110+
}));
111+
112+
const mockPush = jest.fn();
113+
const mockReplace = jest.fn();
114+
jest.mock('expo-router', () => ({
115+
router: {
116+
push: (...args: unknown[]) => mockPush(...args),
117+
replace: (...args: unknown[]) => mockReplace(...args),
118+
},
119+
useLocalSearchParams: () => ({ identityId: 'profile-id' }),
120+
}));
121+
122+
jest.mock('react-native-safe-area-context', () => ({
123+
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
124+
}));
125+
126+
jest.mock('@/src/common/lib/polycentric-hooks', () => ({
127+
shortenIdentityId: (id: string) => `short-${id}`,
128+
truncateName: (name: string) => name,
129+
useCurrentIdentity: () => ({ identityKey: 'me' }),
130+
useUsername: () => 'fallback',
131+
}));
132+
133+
let mockProfileAlias: string | null = null;
134+
jest.mock('@/src/features/profile/hooks/useProfile', () => ({
135+
useProfile: () => ({ name: 'Alice', alias: mockProfileAlias }),
136+
}));
137+
138+
jest.mock('./FollowButton', () => {
139+
const react = require('react');
140+
const { Text } = require('react-native');
141+
return {
142+
__esModule: true,
143+
default: ({ identity }: { identity: string }) =>
144+
react.createElement(Text, { testID: `follow-${identity}` }, 'Follow'),
145+
};
146+
});
147+
148+
const mockLoadMore = jest.fn();
149+
let mockEntries: { identity: string; createdAt: bigint }[] = [];
150+
let mockHasMore = false;
151+
jest.mock('./hooks/useFollowList', () => ({
152+
useFollowList: () => ({
153+
entries: mockEntries,
154+
isLoading: false,
155+
error: null,
156+
hasMore: mockHasMore,
157+
loadMore: mockLoadMore,
158+
refresh: jest.fn(),
159+
}),
160+
}));
161+
162+
import { fireEvent, render } from '@testing-library/react-native';
163+
import * as React from 'react';
164+
import FollowListScreen from './FollowListScreen';
165+
166+
function entry(identity: string) {
167+
return { identity, createdAt: 0n };
168+
}
169+
170+
beforeEach(() => {
171+
jest.clearAllMocks();
172+
mockListProps = null;
173+
mockEntries = [];
174+
mockHasMore = false;
175+
mockProfileAlias = null;
176+
});
177+
178+
describe('FollowListScreen header', () => {
179+
it('shows the display name and identity prefix', async () => {
180+
const screen = await render(<FollowListScreen mode="following" />);
181+
182+
expect(screen.getByText('Alice')).toBeTruthy();
183+
expect(screen.getByText(/short-profile-id/)).toBeTruthy();
184+
});
185+
186+
it('appends the alias when available', async () => {
187+
mockProfileAlias = 'alice@domain.com';
188+
const screen = await render(<FollowListScreen mode="following" />);
189+
190+
expect(screen.getByText(/alice@domain.com/)).toBeTruthy();
191+
});
192+
});
193+
194+
describe('FollowListScreen tabs', () => {
195+
it('switches to the sibling route', async () => {
196+
const screen = await render(<FollowListScreen mode="following" />);
197+
198+
await fireEvent.press(screen.getByText('Followers'));
199+
expect(mockReplace).toHaveBeenCalledWith('/profile-id/followers');
200+
});
201+
202+
it('does not navigate for the active tab', async () => {
203+
const screen = await render(<FollowListScreen mode="followers" />);
204+
205+
await fireEvent.press(screen.getByText('Followers'));
206+
expect(mockReplace).not.toHaveBeenCalled();
207+
});
208+
});
209+
210+
describe('FollowListScreen rows', () => {
211+
it('renders a row per identity linking to their profile', async () => {
212+
mockEntries = [entry('alice-id'), entry('bob-id')];
213+
const screen = await render(<FollowListScreen mode="followers" />);
214+
215+
await fireEvent.press(screen.getByText('short-alice-id'));
216+
expect(mockPush).toHaveBeenCalledWith('/alice-id');
217+
expect(screen.getByTestId('follow-bob-id')).toBeTruthy();
218+
});
219+
220+
it('hides the follow button on your own row', async () => {
221+
mockEntries = [entry('me')];
222+
const screen = await render(<FollowListScreen mode="followers" />);
223+
224+
expect(screen.queryByTestId('follow-me')).toBeNull();
225+
});
226+
227+
it('shows an empty state per mode', async () => {
228+
const following = await render(<FollowListScreen mode="following" />);
229+
expect(following.getByText('Not following anyone yet.')).toBeTruthy();
230+
231+
const followers = await render(<FollowListScreen mode="followers" />);
232+
expect(followers.getByText('No followers yet.')).toBeTruthy();
233+
});
234+
});
235+
236+
describe('FollowListScreen paging', () => {
237+
it('wires end-reached to loadMore and shows the footer spinner', async () => {
238+
mockEntries = [entry('alice-id')];
239+
mockHasMore = true;
240+
const screen = await render(<FollowListScreen mode="followers" />);
241+
242+
expect(screen.getByLabelText('Loading more')).toBeTruthy();
243+
mockListProps!.onEndReached!();
244+
expect(mockLoadMore).toHaveBeenCalled();
245+
});
246+
247+
it('does not page when there is no more data', async () => {
248+
mockEntries = [entry('alice-id')];
249+
const screen = await render(<FollowListScreen mode="followers" />);
250+
251+
expect(screen.queryByLabelText('Loading more')).toBeNull();
252+
expect(mockListProps!.onEndReached).toBeUndefined();
253+
});
254+
});

0 commit comments

Comments
 (0)