Skip to content

Commit 2213f98

Browse files
Merge upstream develop
2 parents 42e682e + 7ec86da commit 2213f98

18 files changed

Lines changed: 536 additions & 537 deletions

File tree

apps/polycentric/src/common/link-previews/provider.test.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
22
import * as React from 'react';
33
import { act } from 'react';
44
import TestRenderer from 'react-test-renderer';
5+
import { useSettings } from '@/src/common/settings';
56
import { LinkPreviewsProvider, useLinkPreviews } from './';
67

7-
const KEY = 'polycentric:generate-link-previews-enabled';
8+
const SETTINGS_KEY = 'polycentric:settings';
89

910
type Api = ReturnType<typeof useLinkPreviews>;
1011

@@ -25,35 +26,47 @@ function renderHook() {
2526
return { result };
2627
}
2728

28-
/** Flush the load effect's pending microtasks. */
29+
/** Flush pending microtasks (hydration, state updates). */
2930
const flush = () => act(async () => {});
3031

3132
beforeEach(async () => {
3233
await AsyncStorage.clear();
34+
useSettings.setState({ linkPreviewsEnabled: true });
3335
});
3436

3537
describe('LinkPreviewsProvider', () => {
3638
it('defaults to enabled when nothing is stored', async () => {
39+
await useSettings.persist.rehydrate();
3740
const { result } = renderHook();
3841
await flush();
3942
expect(result.current.enabled).toBe(true);
4043
});
4144

4245
it('hydrates the stored value', async () => {
43-
await AsyncStorage.setItem(KEY, 'false');
46+
await AsyncStorage.setItem(
47+
SETTINGS_KEY,
48+
JSON.stringify({ state: { linkPreviewsEnabled: false } }),
49+
);
50+
await useSettings.persist.rehydrate();
51+
4452
const { result } = renderHook();
4553
await flush();
4654
expect(result.current.enabled).toBe(false);
4755
});
4856

4957
it('updates and persists when toggled', async () => {
58+
await useSettings.persist.rehydrate();
59+
5060
const { result } = renderHook();
5161
await flush();
5262

5363
act(() => result.current.setEnabled(false));
5464
expect(result.current.enabled).toBe(false);
5565

56-
await flush();
57-
expect(await AsyncStorage.getItem(KEY)).toBe('false');
66+
await useSettings.persist.rehydrate();
67+
68+
const stored = await AsyncStorage.getItem(SETTINGS_KEY);
69+
const parsed = stored ? JSON.parse(stored) : {};
70+
expect(parsed.state.linkPreviewsEnabled).toBe(false);
5871
});
5972
});

apps/polycentric/src/common/link-previews/provider.tsx

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
useState,
88
type PropsWithChildren,
99
} from 'react';
10-
import { loadLinkPreviewsEnabled, saveLinkPreviewsEnabled } from './storage';
10+
import { useSettings } from '@/src/common/settings';
1111

1212
export type LinkPreviewsContextValue = {
1313
/** Whether to generate a link preview for URLs in the user's own posts. */
@@ -21,23 +21,19 @@ export const Context = createContext<LinkPreviewsContextValue | undefined>(
2121
Context.displayName = 'PolycentricLinkPreviewsContext';
2222

2323
export function LinkPreviewsProvider({ children }: PropsWithChildren) {
24-
// Default on: matches the prior always-generate behavior. Unlike the theme
25-
// provider we don't block render until the stored value loads — a brief
26-
// default-on before hydration is harmless (it only affects composing a post,
27-
// not the initial paint).
28-
const [enabled, setEnabledState] = useState(true);
24+
const stored = useSettings((s) => s.linkPreviewsEnabled);
25+
const [hydrated, setHydrated] = useState(useSettings.persist.hasHydrated());
2926

3027
useEffect(() => {
31-
void loadLinkPreviewsEnabled().then((stored) => {
32-
if (stored !== undefined) {
33-
setEnabledState(stored);
34-
}
35-
});
28+
const unsub = useSettings.persist.onFinishHydration(() =>
29+
setHydrated(true),
30+
);
31+
return unsub;
3632
}, []);
3733

34+
const enabled = hydrated ? stored : true;
3835
const setEnabled = useCallback((next: boolean) => {
39-
setEnabledState(next);
40-
void saveLinkPreviewsEnabled(next);
36+
useSettings.getState().setLinkPreviewsEnabled(next);
4137
}, []);
4238

4339
const value = useMemo(() => ({ enabled, setEnabled }), [enabled, setEnabled]);
Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,54 @@
11
import AsyncStorage from '@react-native-async-storage/async-storage';
2-
import { loadLinkPreviewsEnabled, saveLinkPreviewsEnabled } from './storage';
2+
import { useSettings } from '@/src/common/settings';
33

4-
const KEY = 'polycentric:generate-link-previews-enabled';
4+
const SETTINGS_KEY = 'polycentric:settings';
55

66
beforeEach(async () => {
77
await AsyncStorage.clear();
8+
useSettings.setState({ linkPreviewsEnabled: true });
89
jest.clearAllMocks();
910
});
1011

11-
describe('link-previews storage', () => {
12-
it('returns undefined when nothing is stored', async () => {
13-
expect(await loadLinkPreviewsEnabled()).toBeUndefined();
12+
describe('settings store — linkPreviewsEnabled', () => {
13+
it('defaults to true', () => {
14+
expect(useSettings.getState().linkPreviewsEnabled).toBe(true);
1415
});
1516

16-
it('round-trips true and false', async () => {
17-
await saveLinkPreviewsEnabled(true);
18-
expect(await loadLinkPreviewsEnabled()).toBe(true);
17+
it('round-trips true and false', () => {
18+
useSettings.getState().setLinkPreviewsEnabled(false);
19+
expect(useSettings.getState().linkPreviewsEnabled).toBe(false);
1920

20-
await saveLinkPreviewsEnabled(false);
21-
expect(await loadLinkPreviewsEnabled()).toBe(false);
21+
useSettings.getState().setLinkPreviewsEnabled(true);
22+
expect(useSettings.getState().linkPreviewsEnabled).toBe(true);
2223
});
2324

24-
it('persists as a string under the documented key', async () => {
25-
await saveLinkPreviewsEnabled(false);
26-
expect(await AsyncStorage.getItem(KEY)).toBe('false');
25+
it('persists via the settings key', async () => {
26+
useSettings.getState().setLinkPreviewsEnabled(false);
27+
28+
// The persist middleware calls AsyncStorage.setItem during setState.
29+
// Flush pending microtasks so the mock's async setItem resolves.
30+
await new Promise((resolve) => setImmediate(resolve));
31+
32+
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
33+
SETTINGS_KEY,
34+
expect.stringContaining('"linkPreviewsEnabled":false'),
35+
);
2736
});
2837

29-
it('ignores a malformed stored value', async () => {
30-
await AsyncStorage.setItem(KEY, 'maybe');
31-
expect(await loadLinkPreviewsEnabled()).toBeUndefined();
38+
it('recovers from a malformed stored value', async () => {
39+
await AsyncStorage.setItem(SETTINGS_KEY, '{broken');
40+
const { persist } = useSettings;
41+
await persist.rehydrate();
42+
// Should fall back to the default
43+
expect(useSettings.getState().linkPreviewsEnabled).toBe(true);
3244
});
3345

34-
it('returns undefined instead of throwing when storage errors', async () => {
46+
it('returns the default instead of throwing when storage errors', async () => {
3547
(AsyncStorage.getItem as jest.Mock).mockRejectedValueOnce(
3648
new Error('boom'),
3749
);
38-
expect(await loadLinkPreviewsEnabled()).toBeUndefined();
50+
const { persist } = useSettings;
51+
await persist.rehydrate();
52+
expect(useSettings.getState().linkPreviewsEnabled).toBe(true);
3953
});
4054
});

apps/polycentric/src/common/link-previews/storage.ts

Lines changed: 0 additions & 23 deletions
This file was deleted.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import AsyncStorage from '@react-native-async-storage/async-storage';
2+
import { create } from 'zustand';
3+
import { createJSONStorage, persist } from 'zustand/middleware';
4+
5+
export interface SettingsState {
6+
theme: 'light' | 'dark';
7+
linkPreviewsEnabled: boolean;
8+
}
9+
10+
export interface SettingsActions {
11+
setTheme: (theme: 'light' | 'dark') => void;
12+
setLinkPreviewsEnabled: (enabled: boolean) => void;
13+
}
14+
15+
export type SettingsStore = SettingsState & SettingsActions;
16+
17+
export const useSettings = create<SettingsStore>()(
18+
persist(
19+
(set) => ({
20+
theme: 'light',
21+
linkPreviewsEnabled: true,
22+
23+
setTheme: (theme) => set({ theme }),
24+
setLinkPreviewsEnabled: (enabled) =>
25+
set({ linkPreviewsEnabled: enabled }),
26+
}),
27+
{
28+
name: 'polycentric:settings',
29+
storage: createJSONStorage(() => AsyncStorage),
30+
},
31+
),
32+
);

apps/polycentric/src/common/theme/provider.tsx

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
type PropsWithChildren,
1515
} from 'react';
1616
import { useColorScheme } from 'react-native';
17-
import { loadThemeName, saveThemeName } from './storage';
17+
import { useSettings } from '@/src/common/settings';
1818
import { themes, type Theme, type ThemeKey } from './themes';
1919

2020
export type ThemeContextValue = {
@@ -32,25 +32,25 @@ export function ThemeProvider({ children }: PropsWithChildren) {
3232
'Inter-Italic': Fonts['Inter-Italic'],
3333
});
3434

35-
// useColorScheme returns the system theme preference.
3635
const colorScheme = useColorScheme();
37-
const [activeThemeName, setActiveThemeNameState] = useState<ThemeKey>(
38-
colorScheme === 'dark' ? 'dark' : 'light',
39-
);
40-
const [themePreferenceLoaded, setThemePreferenceLoaded] = useState(false);
36+
const storedTheme = useSettings((s) => s.theme);
37+
const [hydrated, setHydrated] = useState(useSettings.persist.hasHydrated());
4138

4239
useEffect(() => {
43-
void loadThemeName().then((themeName) => {
44-
if (themeName) {
45-
setActiveThemeNameState(themeName);
46-
}
47-
setThemePreferenceLoaded(true);
48-
});
40+
const unsub = useSettings.persist.onFinishHydration(() =>
41+
setHydrated(true),
42+
);
43+
return unsub;
4944
}, []);
5045

46+
const activeThemeName = hydrated
47+
? storedTheme
48+
: colorScheme === 'dark'
49+
? 'dark'
50+
: 'light';
51+
5152
const setActiveThemeName = useCallback((name: ThemeKey) => {
52-
setActiveThemeNameState(name);
53-
void saveThemeName(name);
53+
useSettings.getState().setTheme(name);
5454
}, []);
5555

5656
const theme = useMemo(() => themes[activeThemeName], [activeThemeName]);
@@ -64,7 +64,7 @@ export function ThemeProvider({ children }: PropsWithChildren) {
6464
throw fontError;
6565
}
6666

67-
if (!fontsLoaded || !themePreferenceLoaded) {
67+
if (!fontsLoaded || !hydrated) {
6868
return null;
6969
}
7070

apps/polycentric/src/common/theme/storage.ts

Lines changed: 0 additions & 24 deletions
This file was deleted.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export type FeedListProps = Omit<ListProps<PostData>, 'data' | 'renderItem'> & {
2424
const defaultKeyExtractor = (item: PostData) => item.repostId ?? item.id;
2525

2626
const defaultRenderItem: ListRenderItem<PostData> = ({ item }) => (
27-
<Post post={item} />
27+
<Post post={item} compactLinkPreview />
2828
);
2929

3030
const FeedList = forwardRef<ListRef, FeedListProps>(function FeedList(

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ interface PostProps {
4242
showThreadLineBelow?: boolean;
4343
/** Hide the bottom hairline (used inside conversation views where the thread line is the visual seam instead). */
4444
hideBottomBorder?: boolean;
45+
/** Render the link preview in its compact side-image layout (used in feeds). */
46+
compactLinkPreview?: boolean;
4547
}
4648

4749
export const Post = memo(function Post({
@@ -51,6 +53,7 @@ export const Post = memo(function Post({
5153
showThreadLineAbove = false,
5254
showThreadLineBelow = false,
5355
hideBottomBorder = false,
56+
compactLinkPreview = false,
5457
}: PostProps) {
5558
const { theme } = useTheme();
5659

@@ -211,7 +214,12 @@ export const Post = memo(function Post({
211214
) : null}
212215
{/* Render only the first link preview. A post may carry multiple
213216
`links` (e.g. from another client), but we cap the UI at one. */}
214-
{post.links?.[0] ? <LinkPreviewCard link={post.links[0]} /> : null}
217+
{post.links?.[0] ? (
218+
<LinkPreviewCard
219+
link={post.links[0]}
220+
compact={compactLinkPreview}
221+
/>
222+
) : null}
215223
{post.images?.length > 0 && <PostImages images={post.images} />}
216224
{post.quoteId ? <PostContentQuote quoteId={post.quoteId} /> : null}
217225
{showContentExpandToggle && (

apps/polycentric/src/features/post/content/LinkPreviewCard.test.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { fireEvent, render } from '@testing-library/react-native';
2-
import { Linking } from 'react-native';
2+
import { Linking, StyleSheet } from 'react-native';
33
import type { v2 } from '@polycentric/react-native';
4-
import { LinkPreviewCard } from './LinkPreviewCard';
4+
import { COMPACT_IMAGE_WIDTH, LinkPreviewCard } from './LinkPreviewCard';
55

66
// The card pulls its colors/atoms from the theme; under test we don't render
77
// the real ThemeProvider (it blocks on font + storage loads), so stub the
@@ -81,6 +81,22 @@ describe('LinkPreviewCard', () => {
8181
);
8282
});
8383

84+
it('renders the thumbnail as a fixed-width side image when compact', async () => {
85+
const { getByTestId } = await render(
86+
<LinkPreviewCard
87+
compact
88+
link={makeLink({ image: 'https://img.test/x.png' })}
89+
/>,
90+
);
91+
// Compact swaps the full-width banner (sized by aspect ratio) for a
92+
// fixed-width thumbnail.
93+
const style = StyleSheet.flatten(
94+
getByTestId('linkPreviewImage').props.style,
95+
);
96+
expect(style.width).toBe(COMPACT_IMAGE_WIDTH);
97+
expect(style.aspectRatio).toBeUndefined();
98+
});
99+
84100
it('renders no image and only the host line when optional fields are empty', async () => {
85101
const { queryByTestId, queryAllByText } = await render(
86102
<LinkPreviewCard link={makeLink()} />,

0 commit comments

Comments
 (0)