Skip to content

Commit 73f19f3

Browse files
Merge upstream develop
2 parents dec950e + 262041d commit 73f19f3

35 files changed

Lines changed: 2368 additions & 510 deletions

.gitlab/ci/scan_dependencies.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
trivy:
22
stage: check
33
image:
4-
name: aquasec/trivy:0.71.2
4+
name: aquasec/trivy:0.72.0
55
entrypoint: ['']
66
rules:
77
- if: $CI_PIPELINE_SOURCE == "merge_request_event"

apps/polycentric/src/common/components/HoverCard.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,24 @@ import { isWeb } from '../util/platform';
55

66
export function HoverCardContent({
77
children,
8+
animated = true,
89
...props
9-
}: HoverCardPrimitive.ContentProps) {
10+
}: HoverCardPrimitive.ContentProps & {
11+
/** Animate the reveal (BounceIn). Set false for an instant, static card. */
12+
animated?: boolean;
13+
}) {
1014
const content = (
1115
<HoverCardPrimitive.Content {...props}>
12-
<Animated.View
13-
entering={BounceIn.duration(450)}
14-
exiting={FadeOut.duration(100)}
15-
>
16-
{children}
17-
</Animated.View>
16+
{animated ? (
17+
<Animated.View
18+
entering={BounceIn.duration(450)}
19+
exiting={FadeOut.duration(100)}
20+
>
21+
{children}
22+
</Animated.View>
23+
) : (
24+
children
25+
)}
1826
</HoverCardPrimitive.Content>
1927
);
2028

apps/polycentric/src/common/components/Icon.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const IconsMap = {
2424
add: defineIcon(Ionicons, 'add'),
2525
addOutline: defineIcon(Ionicons, 'add-circle-outline'),
2626
arrowBack: defineIcon(Ionicons, 'arrow-back'),
27+
at: defineIcon(Ionicons, 'at'),
2728
ban: defineIcon(Ionicons, 'ban'),
2829
bookOutline: defineIcon(MaterialCommunityIcons, 'book-open-outline'),
2930
briefcaseOutline: defineIcon(MaterialCommunityIcons, 'briefcase-outline'),
@@ -49,6 +50,8 @@ export const IconsMap = {
4950
home: defineIcon(Ionicons, 'home-outline'),
5051
image: defineIcon(Ionicons, 'image-outline'),
5152
images: defineIcon(Ionicons, 'images-outline'),
53+
infoOutline: defineIcon(Ionicons, 'information-circle-outline'),
54+
key: defineIcon(MaterialIcons, 'vpn-key'),
5255
more: defineIcon(Ionicons, 'ellipsis-horizontal'),
5356
notification: defineIcon(MaterialCommunityIcons, 'bell-outline'),
5457
personAdd: defineIcon(Ionicons, 'person-add'),
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { useId, useRef, useState } from 'react';
2+
import { Dimensions, Pressable, View } from 'react-native';
3+
import { Portal } from '@rn-primitives/portal';
4+
import { Atoms, useTheme } from '@/src/common/theme';
5+
import { isWeb } from '@/src/common/util/platform';
6+
import Icon from './Icon';
7+
import { Text } from './primitives';
8+
9+
const BUBBLE_WIDTH = 260;
10+
const EDGE_MARGIN = 8;
11+
12+
/**
13+
* A small information icon that reveals an explanatory bubble. Opens on hover
14+
* (web) and on tap (native).
15+
*
16+
* NOTE: this deliberately does not use the shared HoverCard. HoverCard portals
17+
* its content to the app-root host and positions it in absolute window
18+
* coordinates — but this tooltip lives inside the edit-profile TrueSheet, whose
19+
* content is offset from the window origin, so a portaled card would render
20+
* either behind the sheet (root host) or mispositioned (sheet-local host).
21+
* Instead: web portals out (to clear the sheet's `overflow: hidden`), native
22+
* renders the bubble in place relative to the icon (within the sheet).
23+
*/
24+
export function InfoTooltip({
25+
text,
26+
size = 15,
27+
}: {
28+
text: string;
29+
size?: number;
30+
}) {
31+
const { theme } = useTheme();
32+
const portalName = `info-tooltip-${useId()}`;
33+
const triggerRef = useRef<View>(null);
34+
const [open, setOpen] = useState(false);
35+
const [anchor, setAnchor] = useState({ x: 0, y: 0, h: 0 });
36+
37+
const show = () => {
38+
if (isWeb && triggerRef.current) {
39+
triggerRef.current.measureInWindow((x, y, _w, h) => {
40+
setAnchor({ x, y, h });
41+
setOpen(true);
42+
});
43+
} else {
44+
setOpen(true);
45+
}
46+
};
47+
const hide = () => setOpen(false);
48+
49+
const bubbleStyle = [
50+
Atoms.p_sm,
51+
{
52+
width: BUBBLE_WIDTH,
53+
maxWidth: BUBBLE_WIDTH,
54+
borderRadius: 8,
55+
borderWidth: 1,
56+
borderColor: theme.palette.neutral_300,
57+
backgroundColor: theme.palette.background_secondary,
58+
},
59+
] as const;
60+
61+
const bubbleBody = (
62+
<Text variant="small" color="neutral_900">
63+
{text}
64+
</Text>
65+
);
66+
67+
return (
68+
<View ref={triggerRef} collapsable={false} style={{ position: 'relative' }}>
69+
<Pressable
70+
onHoverIn={show}
71+
onHoverOut={hide}
72+
onPress={() => (open ? hide() : show())}
73+
accessibilityRole="button"
74+
accessibilityLabel="More information"
75+
hitSlop={6}
76+
>
77+
<Icon name="infoOutline" size={size} color="neutral_500" />
78+
</Pressable>
79+
80+
{open && isWeb ? (
81+
<Portal name={portalName}>
82+
<View
83+
style={[
84+
bubbleStyle,
85+
{
86+
position: 'fixed' as 'absolute',
87+
top: anchor.y + anchor.h + 6,
88+
left: Math.max(
89+
EDGE_MARGIN,
90+
Math.min(
91+
anchor.x,
92+
Dimensions.get('window').width - BUBBLE_WIDTH - EDGE_MARGIN,
93+
),
94+
),
95+
zIndex: 10000,
96+
},
97+
]}
98+
>
99+
{bubbleBody}
100+
</View>
101+
</Portal>
102+
) : null}
103+
104+
{open && !isWeb ? (
105+
<View
106+
style={[
107+
bubbleStyle,
108+
{
109+
position: 'absolute',
110+
top: size + 6,
111+
left: 0,
112+
zIndex: 1000,
113+
elevation: 8,
114+
},
115+
]}
116+
>
117+
{bubbleBody}
118+
</View>
119+
) : null}
120+
</View>
121+
);
122+
}

apps/polycentric/src/common/util/parseTextLinks.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@ const linkValues = (text: string) => links(text).map((l) => l.value);
1212
/** Resolved URLs of the link segments. */
1313
const linkUrls = (text: string) => links(text).map((l) => l.url);
1414

15+
/** Just the alias segments. */
16+
const aliases = (text: string) =>
17+
parseTextLinks(text).filter(
18+
(s): s is Extract<TextSegment, { type: 'alias' }> => s.type === 'alias',
19+
);
20+
21+
/** Just the identity segments. */
22+
const identities = (text: string) =>
23+
parseTextLinks(text).filter(
24+
(s): s is Extract<TextSegment, { type: 'identity' }> =>
25+
s.type === 'identity',
26+
);
27+
28+
const HEX64 =
29+
'0a2abecb223dbd572729018f8d201f32471e2a5b71e2032c052f6830846c4722';
30+
1531
describe('parseTextLinks', () => {
1632
describe('no links', () => {
1733
it('returns a single text segment for plain text', () => {
@@ -143,6 +159,90 @@ describe('parseTextLinks', () => {
143159
});
144160
});
145161

162+
describe('alias mentions', () => {
163+
it('detects an `@user@domain.com` mention', () => {
164+
expect(parseTextLinks('@user@domain.com')).toEqual([
165+
{ type: 'alias', value: '@user@domain.com', alias: 'user@domain.com' },
166+
]);
167+
});
168+
169+
it('detects a mention within surrounding text', () => {
170+
expect(parseTextLinks('hey @user@domain.com bye')).toEqual([
171+
{ type: 'text', value: 'hey ' },
172+
{ type: 'alias', value: '@user@domain.com', alias: 'user@domain.com' },
173+
{ type: 'text', value: ' bye' },
174+
]);
175+
});
176+
177+
it('excludes trailing punctuation from the mention', () => {
178+
expect(parseTextLinks('see @user@domain.com.')).toEqual([
179+
{ type: 'text', value: 'see ' },
180+
{ type: 'alias', value: '@user@domain.com', alias: 'user@domain.com' },
181+
{ type: 'text', value: '.' },
182+
]);
183+
});
184+
185+
it('preserves case (normalisation happens downstream)', () => {
186+
expect(aliases('@User@Domain.com')).toEqual([
187+
{ type: 'alias', value: '@User@Domain.com', alias: 'User@Domain.com' },
188+
]);
189+
});
190+
191+
it('allows dotted/underscored local parts', () => {
192+
expect(aliases('@first.last_1@domain.io')).toEqual([
193+
{
194+
type: 'alias',
195+
value: '@first.last_1@domain.io',
196+
alias: 'first.last_1@domain.io',
197+
},
198+
]);
199+
});
200+
201+
it('does not treat a plain email as a mention', () => {
202+
expect(aliases('reach me@example.com please')).toEqual([]);
203+
});
204+
205+
it('does not match `@user@` with no TLD', () => {
206+
expect(aliases('@user@localhost here')).toEqual([]);
207+
});
208+
});
209+
210+
describe('identity mentions', () => {
211+
it('detects an `@<64-hex>` mention', () => {
212+
expect(parseTextLinks(`@${HEX64}`)).toEqual([
213+
{ type: 'identity', value: `@${HEX64}`, identity: HEX64 },
214+
]);
215+
});
216+
217+
it('detects a mention within surrounding text', () => {
218+
expect(parseTextLinks(`hi @${HEX64} ok`)).toEqual([
219+
{ type: 'text', value: 'hi ' },
220+
{ type: 'identity', value: `@${HEX64}`, identity: HEX64 },
221+
{ type: 'text', value: ' ok' },
222+
]);
223+
});
224+
225+
it('excludes trailing punctuation from the mention', () => {
226+
expect(parseTextLinks(`see @${HEX64}.`)).toEqual([
227+
{ type: 'text', value: 'see ' },
228+
{ type: 'identity', value: `@${HEX64}`, identity: HEX64 },
229+
{ type: 'text', value: '.' },
230+
]);
231+
});
232+
233+
it('does not match fewer than 64 hex chars', () => {
234+
expect(identities('@deadbeef here')).toEqual([]);
235+
});
236+
237+
it('does not match a longer hex run (not exactly 64)', () => {
238+
expect(identities(`@${HEX64}ab`)).toEqual([]);
239+
});
240+
241+
it('does not match 64 non-hex chars', () => {
242+
expect(identities(`@${'g'.repeat(64)} here`)).toEqual([]);
243+
});
244+
});
245+
146246
describe('multiple links & surrounding text', () => {
147247
it('detects several links with text between them', () => {
148248
const segs = parseTextLinks(
@@ -185,6 +285,8 @@ describe('parseTextLinks', () => {
185285
'see https://example.com/path?x=1#y now',
186286
'(www.example.com), and example.org. done',
187287
'email a@b.com plus https://c.io end',
288+
'hey @user@domain.com and a@b.com and example.net',
289+
`mention @${HEX64} mid sentence`,
188290
'multi https://x.com www.y.org example.net z',
189291
'',
190292
])('rejoining all segment values reproduces the input: %s', (input) => {

apps/polycentric/src/common/util/parseTextLinks.ts

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
11
export type TextSegment =
22
| { type: 'text'; value: string }
3-
| { type: 'link'; value: string; url: string };
3+
| { type: 'link'; value: string; url: string }
4+
| { type: 'alias'; value: string; alias: string }
5+
| { type: 'identity'; value: string; identity: string };
46

57
// Common TLDs accepted for bare (scheme-less, non-www) domains. Keeping
68
// this curated avoids turning things like "node.js" or "e.g." into links.
79
const TLD =
810
'(?: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)';
911

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+
// A dotted hostname whose final label is a known TLD.
13+
const DOMAIN = `(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+${TLD}`;
14+
15+
// A polycentric identity: exactly 64 hex chars (a SHA-256 hash). The lookahead
16+
// rejects a longer hex run rather than matching a 64-char prefix of it.
17+
const IDENTITY = '[0-9a-fA-F]{64}(?![0-9a-fA-F])';
18+
19+
// Matches, in order: alias mentions (`@user@domain.tld`), identity mentions
20+
// (`@<64-hex>`), http(s) URLs, `www.` domains, and bare domains that end in a
21+
// known TLD (optionally followed by a path/query). Mention alternatives come
22+
// first so `@user@domain.com` is taken whole instead of the bare domain inside.
1223
const LINK_REGEX = new RegExp(
1324
[
25+
`@[a-z0-9._-]+@${DOMAIN}`,
26+
`@${IDENTITY}`,
1427
'https?:\\/\\/[^\\s]+',
1528
'www\\.[^\\s]+',
16-
`(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+${TLD}(?:\\/[^\\s]*)?`,
29+
`${DOMAIN}(?:\\/[^\\s]*)?`,
1730
].join('|'),
1831
'gi',
1932
);
@@ -22,10 +35,11 @@ const LINK_REGEX = new RegExp(
2235
const TRAILING_PUNCT = /[.,!?;:'")\]}]+$/;
2336

2437
/**
25-
* Splits `text` into plain-text and link segments. Detects http(s) URLs,
38+
* Splits `text` into plain-text, link, and mention segments. Detects alias
39+
* mentions (`@user@domain.com`), identity mentions (`@<64-hex>`), http(s) URLs,
2640
* `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.
41+
* punctuation is excluded, and a bare domain preceded by `@` (an email's domain
42+
* part) is left as plain text.
2943
*/
3044
export function parseTextLinks(text: string): TextSegment[] {
3145
const segments: TextSegment[] = [];
@@ -35,11 +49,12 @@ export function parseTextLinks(text: string): TextSegment[] {
3549
LINK_REGEX.lastIndex = 0;
3650
while ((match = LINK_REGEX.exec(text)) !== null) {
3751
const start = match.index;
52+
let raw = match[0];
53+
const isMention = raw[0] === '@';
3854

39-
// Skip emails (and other `@`-prefixed matches).
40-
if (start > 0 && text[start - 1] === '@') continue;
55+
// Skip an email's domain part; mentions start with `@` themselves.
56+
if (!isMention && start > 0 && text[start - 1] === '@') continue;
4157

42-
let raw = match[0];
4358
const trail = raw.match(TRAILING_PUNCT)?.[0] ?? '';
4459
if (trail) raw = raw.slice(0, raw.length - trail.length);
4560
if (!raw) continue;
@@ -48,10 +63,21 @@ export function parseTextLinks(text: string): TextSegment[] {
4863
segments.push({ type: 'text', value: text.slice(lastIndex, start) });
4964
}
5065

51-
const url = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
52-
segments.push({ type: 'link', value: raw, url });
66+
if (isMention) {
67+
// Drop the leading `@` for routing; keep it in `value` for display. A
68+
// second `@` distinguishes an alias (`@user@domain`) from an identity.
69+
const body = raw.slice(1);
70+
if (body.includes('@')) {
71+
segments.push({ type: 'alias', value: raw, alias: body });
72+
} else {
73+
segments.push({ type: 'identity', value: raw, identity: body });
74+
}
75+
} else {
76+
const url = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
77+
segments.push({ type: 'link', value: raw, url });
78+
}
5379

54-
// Resume scanning after the link, leaving any trailing punctuation
80+
// Resume scanning after the match, leaving any trailing punctuation
5581
// to be picked up as plain text.
5682
lastIndex = start + raw.length;
5783
LINK_REGEX.lastIndex = lastIndex;

0 commit comments

Comments
 (0)