Skip to content

Commit fb0fbf7

Browse files
pfuto21454markharding
authored andcommitted
[#97] Link previews for posts
Changelog: enhancement
1 parent d38b764 commit fb0fbf7

53 files changed

Lines changed: 3941 additions & 290 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitlab/ci/build_services.yml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,63 @@ push-notifications-image:
200200
rules:
201201
- !reference [.push-notifications-workflow, rules]
202202
- !reference [.release-workflow, rules]
203+
204+
########################################################################
205+
# scraper (Node/TypeScript)
206+
########################################################################
207+
208+
.scraper-workflow:
209+
image: gitlab.futo.org:5050/devops/manifest-repo/library/docker:latest
210+
rules:
211+
- if: $CI_COMMIT_TAG =~ /^scraper-/
212+
when: always
213+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
214+
changes:
215+
- pnpm-lock.yaml
216+
- pnpm-workspace.yaml
217+
- package.json
218+
- tools/**/*
219+
- patches/**/*
220+
- services/scraper/**/*
221+
- .gitlab/ci/build_services.yml
222+
- .gitlab/ci/deploy_services.yml
223+
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
224+
when: never
225+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
226+
changes:
227+
- pnpm-lock.yaml
228+
- pnpm-workspace.yaml
229+
- package.json
230+
- tools/**/*
231+
- patches/**/*
232+
- services/scraper/**/*
233+
- .gitlab/ci/build_services.yml
234+
- .gitlab/ci/deploy_services.yml
235+
variables:
236+
SERVICE_NAME: scraper
237+
SERVICE_DIR: scraper
238+
239+
# Fast-feedback typecheck. The scraper's `build` script is `tsc -p .`; the image
240+
# build also compiles, so this just surfaces type errors before the heavier
241+
# image job. PUPPETEER_SKIP_DOWNLOAD stops `pnpm install` from fetching Chromium
242+
# (the runtime image provides a system one).
243+
scraper-typecheck:
244+
extends: .scraper-workflow
245+
image: node:24-slim
246+
stage: check
247+
variables:
248+
PUPPETEER_SKIP_DOWNLOAD: '1'
249+
before_script:
250+
- npm install -g pnpm@10
251+
- pnpm config set @polycentric:registry https://${CI_SERVER_HOST}/api/v4/projects/${CI_PROJECT_ID}/packages/npm/
252+
- pnpm install
253+
script:
254+
- pnpm --filter @polycentric/scraper build
255+
256+
scraper-image:
257+
extends:
258+
- .scraper-workflow
259+
- .service-image
260+
rules:
261+
- !reference [.scraper-workflow, rules]
262+
- !reference [.release-workflow, rules]

.gitlab/ci/deploy_services.yml

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,4 +162,55 @@ push-notifications-deploy-manifest-production:
162162
variables:
163163
MANIFEST_QUICK_TARGETS: inventory/polycentric-east/notifications.yml:polycentriceast
164164
needs:
165-
- push-notifications-deploy-image-production
165+
- push-notifications-deploy-image-production
166+
167+
########################################################################
168+
# scraper
169+
#
170+
# Runtime needs (NET_ADMIN, --init, internal-only networking) must be set in
171+
# the production manifest (inventory/.../scraper.yml in devops/manifest), not
172+
# here — compose.yml only covers local dev.
173+
########################################################################
174+
175+
.scraper-deploy-workflow:
176+
rules:
177+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
178+
changes:
179+
- pnpm-lock.yaml
180+
- pnpm-workspace.yaml
181+
- package.json
182+
- tools/**/*
183+
- patches/**/*
184+
- services/scraper/**/*
185+
- .gitlab/ci/build_services.yml
186+
- .gitlab/ci/deploy_services.yml
187+
variables:
188+
SERVICE_NAME: scraper
189+
190+
########################################
191+
# Tag the image with environment label #
192+
########################################
193+
194+
scraper-deploy-image-production:
195+
extends:
196+
- .scraper-deploy-workflow
197+
- .service-deploy-image
198+
when: manual
199+
environment:
200+
name: production/scraper
201+
needs:
202+
- job: scraper-image
203+
artifacts: false
204+
205+
###############################
206+
# Rolling restart on Manifest #
207+
###############################
208+
209+
scraper-deploy-manifest-production:
210+
extends:
211+
- .scraper-deploy-workflow
212+
- .service-deploy-manifest
213+
variables:
214+
MANIFEST_QUICK_TARGETS: inventory/polycentric-east/scraper.yml:polycentriceast
215+
needs:
216+
- scraper-deploy-image-production

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/polycentric/app/_layout.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { LinkPreviewsProvider } from '@/src/common/link-previews';
12
import { PolycentricProvider } from '@/src/common/lib/polycentric-hooks';
23
import { Atoms, ThemeProvider, useTheme } from '@/src/common/theme';
34
import { isWeb } from '@/src/common/util/platform';
@@ -90,12 +91,14 @@ export default function RootLayout() {
9091
<GestureHandlerRootView style={Atoms.flex_1}>
9192
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
9293
<ThemeProvider>
93-
<PolycentricProvider onInitialized={onInitialized}>
94-
<TrueSheetProvider>
95-
<RootStack />
96-
<PortalHost />
97-
</TrueSheetProvider>
98-
</PolycentricProvider>
94+
<LinkPreviewsProvider>
95+
<PolycentricProvider onInitialized={onInitialized}>
96+
<TrueSheetProvider>
97+
<RootStack />
98+
<PortalHost />
99+
</TrueSheetProvider>
100+
</PolycentricProvider>
101+
</LinkPreviewsProvider>
99102
</ThemeProvider>
100103
</SafeAreaProvider>
101104
</GestureHandlerRootView>

apps/polycentric/jest.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,7 @@ module.exports = {
44
moduleNameMapper: {
55
'^@/(.*)$': '<rootDir>/$1',
66
},
7+
// Appends to (does not replace) the preset's setupFiles, which jest-expo uses
8+
// for native setup. setupFilesAfterEnv is undefined in the preset.
9+
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
710
};

apps/polycentric/jest.setup.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Global test setup. jest-expo's preset owns `setupFiles` (React Native + Expo
2+
// native setup), so project-wide additions live here via `setupFilesAfterEnv`
3+
// to avoid clobbering that array.
4+
5+
// AsyncStorage has no native module under Jest; use the package's official mock
6+
// so anything that persists settings (theme, link-preview toggle, …) works in
7+
// tests instead of throwing "NativeModule: AsyncStorage is null".
8+
jest.mock('@react-native-async-storage/async-storage', () =>
9+
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
10+
);

apps/polycentric/src/common/lib/polycentric-hooks/helpers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export type PostData = {
2727
/** Attached image sets, in author-provided order. */
2828
images: v2.ImageSet[];
2929

30+
/** Attached link previews (Open Graph metadata), in author-provided order. */
31+
links: v2.Link[];
32+
3033
/** Set when the underlying `v2.Post` carried a `reply`. */
3134
reply?: {
3235
/** Hex of the root post's EventKey — same encoding as `PostData.id`. */
@@ -149,6 +152,7 @@ export function decodePostBundle(bundle: v2.EventBundle): PostData | null {
149152
content: post.text,
150153
createdAt: Number(event.createdAt ?? 0),
151154
images: post.images,
155+
links: post.links,
152156
reply,
153157
quoteId,
154158
signedEvent: v2.SignedEvent.create({
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export {
2+
Context,
3+
LinkPreviewsProvider,
4+
useLinkPreviews,
5+
} from './provider';
6+
export type { LinkPreviewsContextValue } from './provider';
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import AsyncStorage from '@react-native-async-storage/async-storage';
2+
import * as React from 'react';
3+
import { act } from 'react';
4+
import TestRenderer from 'react-test-renderer';
5+
import { LinkPreviewsProvider, useLinkPreviews } from './';
6+
7+
const KEY = 'polycentric:generate-link-previews-enabled';
8+
9+
type Api = ReturnType<typeof useLinkPreviews>;
10+
11+
/** Render the hook under the provider; `result.current` tracks the latest value. */
12+
function renderHook() {
13+
const result: { current: Api } = { current: null as never };
14+
function Probe() {
15+
result.current = useLinkPreviews();
16+
return null;
17+
}
18+
act(() => {
19+
TestRenderer.create(
20+
<LinkPreviewsProvider>
21+
<Probe />
22+
</LinkPreviewsProvider>,
23+
);
24+
});
25+
return { result };
26+
}
27+
28+
/** Flush the load effect's pending microtasks. */
29+
const flush = () => act(async () => {});
30+
31+
beforeEach(async () => {
32+
await AsyncStorage.clear();
33+
});
34+
35+
describe('LinkPreviewsProvider', () => {
36+
it('defaults to enabled when nothing is stored', async () => {
37+
const { result } = renderHook();
38+
await flush();
39+
expect(result.current.enabled).toBe(true);
40+
});
41+
42+
it('hydrates the stored value', async () => {
43+
await AsyncStorage.setItem(KEY, 'false');
44+
const { result } = renderHook();
45+
await flush();
46+
expect(result.current.enabled).toBe(false);
47+
});
48+
49+
it('updates and persists when toggled', async () => {
50+
const { result } = renderHook();
51+
await flush();
52+
53+
act(() => result.current.setEnabled(false));
54+
expect(result.current.enabled).toBe(false);
55+
56+
await flush();
57+
expect(await AsyncStorage.getItem(KEY)).toBe('false');
58+
});
59+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {
2+
createContext,
3+
useCallback,
4+
useContext,
5+
useEffect,
6+
useMemo,
7+
useState,
8+
type PropsWithChildren,
9+
} from 'react';
10+
import { loadLinkPreviewsEnabled, saveLinkPreviewsEnabled } from './storage';
11+
12+
export type LinkPreviewsContextValue = {
13+
/** Whether to generate a link preview for URLs in the user's own posts. */
14+
enabled: boolean;
15+
setEnabled: (enabled: boolean) => void;
16+
};
17+
18+
export const Context = createContext<LinkPreviewsContextValue | undefined>(
19+
undefined,
20+
);
21+
Context.displayName = 'PolycentricLinkPreviewsContext';
22+
23+
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);
29+
30+
useEffect(() => {
31+
void loadLinkPreviewsEnabled().then((stored) => {
32+
if (stored !== undefined) {
33+
setEnabledState(stored);
34+
}
35+
});
36+
}, []);
37+
38+
const setEnabled = useCallback((next: boolean) => {
39+
setEnabledState(next);
40+
void saveLinkPreviewsEnabled(next);
41+
}, []);
42+
43+
const value = useMemo(() => ({ enabled, setEnabled }), [enabled, setEnabled]);
44+
45+
return <Context.Provider value={value}>{children}</Context.Provider>;
46+
}
47+
48+
export function useLinkPreviews() {
49+
const ctx = useContext(Context);
50+
if (!ctx) {
51+
throw new Error('useLinkPreviews must be used within LinkPreviewsProvider');
52+
}
53+
return ctx;
54+
}

0 commit comments

Comments
 (0)