Skip to content

Commit 8249003

Browse files
Merge upstream develop
2 parents 861ab68 + ad532ab commit 8249003

8 files changed

Lines changed: 971 additions & 47 deletions

File tree

.gitlab/ci/build_app.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,29 @@
1616
- .gitlab/ci/build_app.yml
1717
- .gitlab/ci/deploy_app.yml
1818

19+
# Lightweight base for checks (format, tests): node + pnpm install, no
20+
# SDK build artifacts needed.
21+
.app-check-base:
22+
extends: .app-workflow
23+
image: node:24-slim
24+
before_script:
25+
- npm install -g pnpm@10.19.0
26+
- pnpm config set @polycentric:registry https://${CI_SERVER_HOST}/api/v4/projects/${CI_PROJECT_ID}/packages/npm/
27+
- pnpm install
28+
29+
app-format-check:
30+
extends: .app-check-base
31+
stage: check
32+
script:
33+
- pnpm format apps/polycentric
34+
allow_failure: true
35+
36+
app-tests:
37+
extends: .app-check-base
38+
stage: check
39+
script:
40+
- pnpm --filter polycentric-app test
41+
1942
.app-build-base:
2043
extends: .app-workflow
2144
needs:

apps/polycentric/jest.config.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/** @type {import('jest').Config} */
2+
module.exports = {
3+
preset: 'jest-expo',
4+
moduleNameMapper: {
5+
'^@/(.*)$': '<rootDir>/$1',
6+
},
7+
};

apps/polycentric/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"ios:prod": "expo run:ios",
1212
"web": "expo start --web",
1313
"build:web": "expo export --platform web",
14+
"test": "jest",
1415
"lint": "expo lint",
1516
"prebuild": "APP_VARIANT=dev expo prebuild --clean"
1617
},
@@ -79,9 +80,12 @@
7980
},
8081
"devDependencies": {
8182
"@babel/core": "^7.28.5",
83+
"@types/jest": "^29.5.14",
8284
"@types/react": "~19.2.14",
8385
"eslint": "^9.38.0",
8486
"eslint-config-expo": "~55.0.0",
87+
"jest": "^29.7.0",
88+
"jest-expo": "~55.0.18",
8589
"only-allow": "^1.2.1",
8690
"prettier": "^3.8.1",
8791
"typescript": "catalog:"
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { parseTextLinks, type TextSegment } from './parseTextLinks';
2+
3+
/** Just the link segments. */
4+
const links = (text: string) =>
5+
parseTextLinks(text).filter(
6+
(s): s is Extract<TextSegment, { type: 'link' }> => s.type === 'link',
7+
);
8+
9+
/** Display values of the link segments. */
10+
const linkValues = (text: string) => links(text).map((l) => l.value);
11+
12+
/** Resolved URLs of the link segments. */
13+
const linkUrls = (text: string) => links(text).map((l) => l.url);
14+
15+
describe('parseTextLinks', () => {
16+
describe('no links', () => {
17+
it('returns a single text segment for plain text', () => {
18+
expect(parseTextLinks('just some words')).toEqual([
19+
{ type: 'text', value: 'just some words' },
20+
]);
21+
});
22+
23+
it('returns nothing for an empty string', () => {
24+
expect(parseTextLinks('')).toEqual([]);
25+
});
26+
27+
it('does not linkify "node.js" / "e.g." / "file.txt" (unknown TLDs)', () => {
28+
expect(linkValues('see index.js and file.txt, e.g. nothing')).toEqual([]);
29+
});
30+
31+
it('does not linkify a domain with no TLD', () => {
32+
expect(linkValues('localhost and foo are not links')).toEqual([]);
33+
});
34+
});
35+
36+
describe('http(s) URLs', () => {
37+
it('detects an http URL', () => {
38+
expect(links('go http://example.com now')).toEqual([
39+
{
40+
type: 'link',
41+
value: 'http://example.com',
42+
url: 'http://example.com',
43+
},
44+
]);
45+
});
46+
47+
it('detects an https URL', () => {
48+
expect(linkUrls('https://example.com')).toEqual(['https://example.com']);
49+
});
50+
51+
it('keeps path, query, and fragment', () => {
52+
const url = 'https://example.com/a/b?x=1&y=2#frag';
53+
expect(linkUrls(url)).toEqual([url]);
54+
});
55+
56+
it('keeps a port number', () => {
57+
expect(linkUrls('http://localhost:3000/x')).toEqual([
58+
'http://localhost:3000/x',
59+
]);
60+
});
61+
62+
it('handles an uppercase scheme', () => {
63+
expect(linkUrls('HTTPS://Example.com')).toEqual(['HTTPS://Example.com']);
64+
});
65+
});
66+
67+
describe('www. and bare domains (scheme prepended)', () => {
68+
it('detects a www. domain and prepends https', () => {
69+
expect(links('visit www.example.com')).toEqual([
70+
{
71+
type: 'link',
72+
value: 'www.example.com',
73+
url: 'https://www.example.com',
74+
},
75+
]);
76+
});
77+
78+
it('detects a bare domain with a known TLD', () => {
79+
expect(links('go to example.com today')).toEqual([
80+
{ type: 'link', value: 'example.com', url: 'https://example.com' },
81+
]);
82+
});
83+
84+
it('detects a bare domain with a path', () => {
85+
expect(links('example.org/path/to/page')).toEqual([
86+
{
87+
type: 'link',
88+
value: 'example.org/path/to/page',
89+
url: 'https://example.org/path/to/page',
90+
},
91+
]);
92+
});
93+
94+
it('detects subdomains', () => {
95+
expect(linkValues('sub.docs.example.io')).toEqual([
96+
'sub.docs.example.io',
97+
]);
98+
});
99+
100+
it('detects multi-part TLDs (example.co.uk)', () => {
101+
expect(linkValues('example.co.uk/about')).toEqual([
102+
'example.co.uk/about',
103+
]);
104+
});
105+
});
106+
107+
describe('trailing punctuation', () => {
108+
it.each([
109+
['period', 'see example.com.', 'example.com', '.'],
110+
['comma', 'see example.com, then', 'example.com', ','],
111+
['exclamation', 'wow https://example.com!', 'https://example.com', '!'],
112+
['question', 'is it example.com?', 'example.com', '?'],
113+
['close paren', '(https://example.com)', 'https://example.com', ')'],
114+
])('excludes a trailing %s from the link', (_label, input, value) => {
115+
expect(linkValues(input)).toEqual([value]);
116+
});
117+
118+
it('leaves trailing punctuation in the text stream', () => {
119+
const segs = parseTextLinks('see example.com.');
120+
expect(segs).toEqual([
121+
{ type: 'text', value: 'see ' },
122+
{ type: 'link', value: 'example.com', url: 'https://example.com' },
123+
{ type: 'text', value: '.' },
124+
]);
125+
});
126+
127+
it('keeps a trailing slash (not punctuation)', () => {
128+
expect(linkValues('https://example.com/')).toEqual([
129+
'https://example.com/',
130+
]);
131+
});
132+
});
133+
134+
describe('emails', () => {
135+
it('does not linkify an email address', () => {
136+
expect(linkValues('reach me@example.com please')).toEqual([]);
137+
});
138+
139+
it('does not linkify the domain inside an email', () => {
140+
expect(parseTextLinks('a@b.com')).toEqual([
141+
{ type: 'text', value: 'a@b.com' },
142+
]);
143+
});
144+
});
145+
146+
describe('multiple links & surrounding text', () => {
147+
it('detects several links with text between them', () => {
148+
const segs = parseTextLinks(
149+
'a https://x.com b www.y.org c example.net d',
150+
);
151+
expect(segs).toEqual([
152+
{ type: 'text', value: 'a ' },
153+
{ type: 'link', value: 'https://x.com', url: 'https://x.com' },
154+
{ type: 'text', value: ' b ' },
155+
{ type: 'link', value: 'www.y.org', url: 'https://www.y.org' },
156+
{ type: 'text', value: ' c ' },
157+
{ type: 'link', value: 'example.net', url: 'https://example.net' },
158+
{ type: 'text', value: ' d' },
159+
]);
160+
});
161+
162+
it('handles a link at the very start and end', () => {
163+
expect(parseTextLinks('https://a.com')).toEqual([
164+
{ type: 'link', value: 'https://a.com', url: 'https://a.com' },
165+
]);
166+
});
167+
168+
it('preserves newlines around links', () => {
169+
const segs = parseTextLinks('line1\nhttps://example.com\nline2');
170+
expect(segs).toEqual([
171+
{ type: 'text', value: 'line1\n' },
172+
{
173+
type: 'link',
174+
value: 'https://example.com',
175+
url: 'https://example.com',
176+
},
177+
{ type: 'text', value: '\nline2' },
178+
]);
179+
});
180+
});
181+
182+
describe('losslessness', () => {
183+
it.each([
184+
'plain text only',
185+
'see https://example.com/path?x=1#y now',
186+
'(www.example.com), and example.org. done',
187+
'email a@b.com plus https://c.io end',
188+
'multi https://x.com www.y.org example.net z',
189+
'',
190+
])('rejoining all segment values reproduces the input: %s', (input) => {
191+
const joined = parseTextLinks(input)
192+
.map((s) => s.value)
193+
.join('');
194+
expect(joined).toBe(input);
195+
});
196+
});
197+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
export type TextSegment =
2+
| { type: 'text'; value: string }
3+
| { type: 'link'; value: string; url: string };
4+
5+
// Common TLDs accepted for bare (scheme-less, non-www) domains. Keeping
6+
// this curated avoids turning things like "node.js" or "e.g." into links.
7+
const TLD =
8+
'(?:com|org|net|edu|gov|mil|io|dev|app|co|me|gg|xyz|info|biz|tv|news|social|link|so|ai|sh|to|fm|fyi|page|site|blog|uk|us|ca|de|fr|nl|eu|es|it|jp|au|in|br|ru|ch|se|no|pl)';
9+
10+
// Matches, in order: http(s) URLs, `www.` domains, and bare domains that
11+
// end in a known TLD (optionally followed by a path/query).
12+
const LINK_REGEX = new RegExp(
13+
[
14+
'https?:\\/\\/[^\\s]+',
15+
'www\\.[^\\s]+',
16+
`(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+${TLD}(?:\\/[^\\s]*)?`,
17+
].join('|'),
18+
'gi',
19+
);
20+
21+
// Punctuation that commonly trails a URL in prose but isn't part of it.
22+
const TRAILING_PUNCT = /[.,!?;:'")\]}]+$/;
23+
24+
/**
25+
* Splits `text` into plain-text and link segments. Detects http(s) URLs,
26+
* `www.` domains, and bare domains with a known TLD. Trailing sentence
27+
* punctuation is excluded from links, and `@`-prefixed matches (e.g. the
28+
* domain part of an email) are left as plain text.
29+
*/
30+
export function parseTextLinks(text: string): TextSegment[] {
31+
const segments: TextSegment[] = [];
32+
let lastIndex = 0;
33+
let match: RegExpExecArray | null;
34+
35+
LINK_REGEX.lastIndex = 0;
36+
while ((match = LINK_REGEX.exec(text)) !== null) {
37+
const start = match.index;
38+
39+
// Skip emails (and other `@`-prefixed matches).
40+
if (start > 0 && text[start - 1] === '@') continue;
41+
42+
let raw = match[0];
43+
const trail = raw.match(TRAILING_PUNCT)?.[0] ?? '';
44+
if (trail) raw = raw.slice(0, raw.length - trail.length);
45+
if (!raw) continue;
46+
47+
if (start > lastIndex) {
48+
segments.push({ type: 'text', value: text.slice(lastIndex, start) });
49+
}
50+
51+
const url = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
52+
segments.push({ type: 'link', value: raw, url });
53+
54+
// Resume scanning after the link, leaving any trailing punctuation
55+
// to be picked up as plain text.
56+
lastIndex = start + raw.length;
57+
LINK_REGEX.lastIndex = lastIndex;
58+
}
59+
60+
if (lastIndex < text.length) {
61+
segments.push({ type: 'text', value: text.slice(lastIndex) });
62+
}
63+
64+
return segments;
65+
}

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { PostContentQuote } from './content/PostContentQuote';
2424
import { PostHeader } from './PostHeader';
2525
import { PostImages } from './PostImages';
2626
import PostMenu from './PostMenu';
27+
import { PostText } from './PostText';
2728
import { PostToolbar } from './toolbar/PostToolbar';
2829

2930
const PREVIEW_LIMIT = 240;
@@ -202,10 +203,10 @@ export const Post = memo(function Post({
202203
) : null}
203204

204205
{displayContent ? (
205-
<Text variant="secondary">
206-
{displayContent}
207-
{isTruncatedPreview ? '...' : ''}
208-
</Text>
206+
<PostText
207+
content={displayContent}
208+
suffix={isTruncatedPreview ? '...' : ''}
209+
/>
209210
) : null}
210211
{post.images?.length > 0 && <PostImages images={post.images} />}
211212
{post.quoteId ? <PostContentQuote quoteId={post.quoteId} /> : null}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Text } from '@/src/common/components/primitives';
2+
import { parseTextLinks } from '@/src/common/util/parseTextLinks';
3+
import { useMemo } from 'react';
4+
import { Linking } from 'react-native';
5+
6+
/**
7+
* Renders post body text with tappable hyperlinks for URLs and bare
8+
* domains. Links open in the browser; the tap is stopped from also
9+
* triggering the surrounding post-card press.
10+
*/
11+
export function PostText({
12+
content,
13+
suffix,
14+
}: {
15+
content: string;
16+
suffix?: string;
17+
}) {
18+
const segments = useMemo(() => parseTextLinks(content), [content]);
19+
20+
return (
21+
<Text variant="secondary">
22+
{segments.map((segment, i) =>
23+
segment.type === 'link' ? (
24+
<Text
25+
key={i}
26+
variant="secondary"
27+
color="primary_500"
28+
style={{ fontWeight: 'bold' }}
29+
onPress={(e) => {
30+
e.stopPropagation?.();
31+
void Linking.openURL(segment.url).catch(() => {});
32+
}}
33+
>
34+
{segment.value}
35+
</Text>
36+
) : (
37+
segment.value
38+
),
39+
)}
40+
{suffix}
41+
</Text>
42+
);
43+
}

0 commit comments

Comments
 (0)