tangled
alpha
login
or
join now
margin.at
/
margin
86
fork
atom
Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
86
fork
atom
overview
issues
4
pulls
1
pipelines
bug fixes + better ui/ux
scanash.com
1 month ago
98cc9c91
fda4506d
+245
-58
5 changed files
expand all
collapse all
unified
split
web
src
api
client.ts
components
common
Card.tsx
RichText.tsx
navigation
RightSidebar.tsx
types.ts
+45
-2
web/src/api/client.ts
···
139
139
name: string;
140
140
icon?: string;
141
141
};
142
142
+
context?: {
143
143
+
uri: string;
144
144
+
name: string;
145
145
+
icon?: string;
146
146
+
}[];
142
147
created?: string;
143
148
createdAt?: string;
144
149
target?: string | { source?: string; title?: string; selector?: Selector };
···
171
176
icon: raw.collection.icon,
172
177
}
173
178
: undefined,
179
179
+
context: raw.context
180
180
+
? raw.context.map((c: any) => ({
181
181
+
uri: c.uri,
182
182
+
name: c.name,
183
183
+
icon: c.icon,
184
184
+
}))
185
185
+
: undefined,
174
186
addedBy: raw.creator || raw.author,
175
175
-
createdAt: raw.created || raw.createdAt || new Date().toISOString(),
187
187
+
createdAt:
188
188
+
normalizedInner.createdAt ||
189
189
+
raw.created ||
190
190
+
raw.createdAt ||
191
191
+
new Date().toISOString(),
176
192
collectionItemUri: raw.id || raw.uri,
177
193
};
178
194
}
···
248
264
});
249
265
if (!res.ok) throw new Error("Failed to fetch feed");
250
266
const data = await res.json();
267
267
+
const normalizedItems = (data.items || []).map(normalizeItem);
268
268
+
269
269
+
const groupedItems: AnnotationItem[] = [];
270
270
+
if (normalizedItems.length > 0) {
271
271
+
groupedItems.push(normalizedItems[0]);
272
272
+
273
273
+
for (let i = 1; i < normalizedItems.length; i++) {
274
274
+
const prev = groupedItems[groupedItems.length - 1];
275
275
+
const curr = normalizedItems[i];
276
276
+
277
277
+
if (prev.collection && curr.collection) {
278
278
+
if (
279
279
+
prev.uri === curr.uri &&
280
280
+
prev.addedBy?.did === curr.addedBy?.did
281
281
+
) {
282
282
+
if (!prev.context) {
283
283
+
prev.context = [prev.collection];
284
284
+
}
285
285
+
prev.context.push(curr.collection);
286
286
+
groupedItems[groupedItems.length - 1] = prev;
287
287
+
continue;
288
288
+
}
289
289
+
}
290
290
+
groupedItems.push(curr);
291
291
+
}
292
292
+
}
293
293
+
251
294
return {
252
295
cursor: data.cursor,
253
253
-
items: (data.items || []).map(normalizeItem),
296
296
+
items: groupedItems,
254
297
};
255
298
} catch (e) {
256
299
console.error(e);
+57
-30
web/src/components/common/Card.tsx
···
274
274
275
275
return (
276
276
<article className="card p-4 hover:ring-black/10 dark:hover:ring-white/10 transition-all relative">
277
277
-
{item.collection && (
278
278
-
<div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2">
277
277
+
{(item.collection || (item.context && item.context.length > 0)) && (
278
278
+
<div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2 flex-wrap">
279
279
{item.addedBy && item.addedBy.did !== item.author?.did ? (
280
280
<>
281
281
<ProfileHoverCard did={item.addedBy.did}>
···
298
298
) : (
299
299
<span>Added to</span>
300
300
)}
301
301
-
<Link
302
302
-
to={`/${item.addedBy?.handle || ""}/collection/${(item.collection.uri || "").split("/").pop()}`}
303
303
-
className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
304
304
-
>
305
305
-
<CollectionIcon icon={item.collection.icon} size={14} />
306
306
-
<span className="font-medium">{item.collection.name}</span>
307
307
-
</Link>
301
301
+
302
302
+
{item.context && item.context.length > 0 ? (
303
303
+
item.context.map((col, index) => (
304
304
+
<React.Fragment key={col.uri}>
305
305
+
{index > 0 && index < item.context!.length - 1 && (
306
306
+
<span className="text-surface-300 dark:text-surface-600">
307
307
+
,
308
308
+
</span>
309
309
+
)}
310
310
+
{index > 0 && index === item.context!.length - 1 && (
311
311
+
<span>and</span>
312
312
+
)}
313
313
+
<Link
314
314
+
to={`/${item.addedBy?.handle || ""}/collection/${(col.uri || "").split("/").pop()}`}
315
315
+
className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
316
316
+
>
317
317
+
<CollectionIcon icon={col.icon} size={14} />
318
318
+
<span className="font-medium">{col.name}</span>
319
319
+
</Link>
320
320
+
</React.Fragment>
321
321
+
))
322
322
+
) : (
323
323
+
<Link
324
324
+
to={`/${item.addedBy?.handle || ""}/collection/${(item.collection!.uri || "").split("/").pop()}`}
325
325
+
className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
326
326
+
>
327
327
+
<CollectionIcon icon={item.collection!.icon} size={14} />
328
328
+
<span className="font-medium">{item.collection!.name}</span>
329
329
+
</Link>
330
330
+
)}
308
331
</div>
309
332
)}
310
333
···
426
449
</button>
427
450
)}
428
451
{isBookmark && (
429
429
-
<a
430
430
-
href={pageUrl || "#"}
431
431
-
target={pageUrl ? "_blank" : undefined}
432
432
-
rel="noopener noreferrer"
433
433
-
onClick={(e) => pageUrl && handleExternalClick(e, pageUrl)}
434
434
-
className="block bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-600 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all group overflow-hidden"
452
452
+
<div
453
453
+
onClick={(e) => {
454
454
+
e.preventDefault();
455
455
+
if (pageUrl) handleExternalClick(e, pageUrl);
456
456
+
}}
457
457
+
role="button"
458
458
+
tabIndex={0}
459
459
+
className="flex items-stretch bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-600 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all group overflow-hidden cursor-pointer"
435
460
>
436
461
{displayImage && !imgError && (
437
437
-
<div className="h-32 w-full overflow-hidden bg-surface-200 dark:bg-surface-700 border-b border-surface-200 dark:border-surface-700">
438
438
-
<img
439
439
-
src={displayImage}
440
440
-
alt=""
441
441
-
onError={() => setImgError(true)}
442
442
-
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
443
443
-
/>
462
462
+
<div className="w-[140px] sm:w-[180px] shrink-0 border-r border-surface-200 dark:border-surface-700 bg-surface-200 dark:bg-surface-700 relative">
463
463
+
<div className="absolute inset-0 flex items-center justify-center overflow-hidden">
464
464
+
<img
465
465
+
src={displayImage}
466
466
+
alt=""
467
467
+
onError={() => setImgError(true)}
468
468
+
className="w-full h-full object-cover"
469
469
+
/>
470
470
+
</div>
444
471
</div>
445
472
)}
446
446
-
<div className="p-4">
447
447
-
<h3 className="font-semibold text-surface-900 dark:text-white text-base leading-snug group-hover:text-primary-600 dark:group-hover:text-primary-400 mb-2 transition-colors">
473
473
+
<div className="p-3 flex-1 min-w-0 flex flex-col justify-center font-sans">
474
474
+
<h3 className="font-semibold text-surface-900 dark:text-white text-sm leading-snug group-hover:text-primary-600 dark:group-hover:text-primary-400 mb-1.5 transition-colors line-clamp-2">
448
475
{displayTitle}
449
476
</h3>
450
477
451
478
{displayDescription && (
452
452
-
<p className="text-surface-600 dark:text-surface-400 text-sm leading-relaxed mb-3 line-clamp-2">
479
479
+
<p className="text-surface-600 dark:text-surface-400 text-xs leading-relaxed mb-2 line-clamp-2">
453
480
{displayDescription}
454
481
</p>
455
482
)}
456
483
457
457
-
<div className="flex items-center gap-2 text-xs text-surface-500 dark:text-surface-500">
458
458
-
<div className="w-5 h-5 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center shrink-0 overflow-hidden">
484
484
+
<div className="flex items-center gap-2 text-[11px] text-surface-500 dark:text-surface-500 mt-auto">
485
485
+
<div className="w-4 h-4 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center shrink-0 overflow-hidden">
459
486
{ogData?.icon && !iconError ? (
460
487
<img
461
488
src={ogData.icon}
462
489
alt=""
463
490
onError={() => setIconError(true)}
464
464
-
className="w-3.5 h-3.5 object-contain"
491
491
+
className="w-3 h-3 object-contain"
465
492
/>
466
493
) : (
467
467
-
<Globe size={10} />
494
494
+
<Globe size={9} />
468
495
)}
469
496
</div>
470
497
<span className="truncate max-w-[200px]">
···
472
499
</span>
473
500
</div>
474
501
</div>
475
475
-
</a>
502
502
+
</div>
476
503
)}
477
504
478
505
{item.target?.selector?.exact && (
+137
-25
web/src/components/common/RichText.tsx
···
1
1
import React from "react";
2
2
import { Link } from "react-router-dom";
3
3
+
import ExternalLinkModal from "../modals/ExternalLinkModal";
4
4
+
import { useStore } from "@nanostores/react";
5
5
+
import { $preferences } from "../../store/preferences";
3
6
4
7
interface RichTextProps {
5
8
text: string;
···
9
12
const MENTION_REGEX =
10
13
/(^|[\s(])@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)/g;
11
14
15
15
+
const URL_REGEX = /(^|[\s(])(https?:\/\/[^\s]+)/g;
16
16
+
12
17
export default function RichText({ text, className }: RichTextProps) {
13
13
-
const parts: React.ReactNode[] = [];
14
14
-
let lastIndex = 0;
18
18
+
const urlParts: { text: string; isUrl: boolean }[] = [];
19
19
+
let lastUrlIndex = 0;
15
20
16
16
-
for (const match of text.matchAll(MENTION_REGEX)) {
21
21
+
for (const match of text.matchAll(URL_REGEX)) {
17
22
const fullMatch = match[0];
18
23
const prefix = match[1];
19
19
-
const handle = match[2];
24
24
+
const url = match[2];
20
25
const startIndex = match.index!;
21
26
22
22
-
if (startIndex > lastIndex) {
23
23
-
parts.push(text.slice(lastIndex, startIndex));
27
27
+
if (startIndex > lastUrlIndex) {
28
28
+
urlParts.push({
29
29
+
text: text.slice(lastUrlIndex, startIndex),
30
30
+
isUrl: false,
31
31
+
});
24
32
}
25
25
-
26
33
if (prefix) {
27
27
-
parts.push(prefix);
34
34
+
urlParts.push({ text: prefix, isUrl: false });
28
35
}
29
36
30
30
-
parts.push(
31
31
-
<Link
32
32
-
key={startIndex}
33
33
-
to={`/profile/${handle}`}
34
34
-
className="text-primary-600 dark:text-primary-400 hover:underline"
35
35
-
onClick={(e) => e.stopPropagation()}
36
36
-
>
37
37
-
@{handle}
38
38
-
</Link>,
39
39
-
);
37
37
+
urlParts.push({ text: url, isUrl: true });
40
38
41
41
-
lastIndex = startIndex + fullMatch.length;
39
39
+
lastUrlIndex = startIndex + fullMatch.length;
40
40
+
}
41
41
+
if (lastUrlIndex < text.length) {
42
42
+
urlParts.push({ text: text.slice(lastUrlIndex), isUrl: false });
42
43
}
43
44
44
44
-
if (lastIndex < text.length) {
45
45
-
parts.push(text.slice(lastIndex));
45
45
+
if (urlParts.length === 0) {
46
46
+
urlParts.push({ text, isUrl: false });
46
47
}
47
48
48
48
-
if (parts.length === 0) {
49
49
-
return <span className={className}>{text}</span>;
50
50
-
}
49
49
+
const [showExternalLinkModal, setShowExternalLinkModal] =
50
50
+
React.useState(false);
51
51
+
const [externalLinkUrl, setExternalLinkUrl] = React.useState<string | null>(
52
52
+
null,
53
53
+
);
54
54
+
const preferences = useStore($preferences);
51
55
52
52
-
return <span className={className}>{parts}</span>;
56
56
+
const safeUrlHostname = (url: string | null | undefined) => {
57
57
+
if (!url) return null;
58
58
+
try {
59
59
+
return new URL(url).hostname;
60
60
+
} catch {
61
61
+
return null;
62
62
+
}
63
63
+
};
64
64
+
65
65
+
const handleExternalClick = (e: React.MouseEvent, url: string) => {
66
66
+
e.preventDefault();
67
67
+
e.stopPropagation();
68
68
+
69
69
+
try {
70
70
+
const hostname = safeUrlHostname(url);
71
71
+
if (hostname) {
72
72
+
if (
73
73
+
hostname === "margin.at" ||
74
74
+
hostname.endsWith(".margin.at") ||
75
75
+
hostname === "semble.so" ||
76
76
+
hostname.endsWith(".semble.so")
77
77
+
) {
78
78
+
window.open(url, "_blank", "noopener,noreferrer");
79
79
+
return;
80
80
+
}
81
81
+
const skipped = preferences.externalLinkSkippedHostnames || [];
82
82
+
if (skipped.includes(hostname)) {
83
83
+
window.open(url, "_blank", "noopener,noreferrer");
84
84
+
return;
85
85
+
}
86
86
+
}
87
87
+
} catch (err) {
88
88
+
if (err instanceof Error && err.name !== "TypeError") {
89
89
+
console.debug("Failed to check skipped hostname:", err);
90
90
+
}
91
91
+
}
92
92
+
93
93
+
setExternalLinkUrl(url);
94
94
+
setShowExternalLinkModal(true);
95
95
+
};
96
96
+
97
97
+
const finalParts: React.ReactNode[] = [];
98
98
+
99
99
+
urlParts.forEach((part, partIndex) => {
100
100
+
if (part.isUrl) {
101
101
+
finalParts.push(
102
102
+
<a
103
103
+
key={`url-${partIndex}`}
104
104
+
href={part.text}
105
105
+
target="_blank"
106
106
+
rel="noopener noreferrer"
107
107
+
className="text-primary-600 dark:text-primary-400 hover:underline break-all cursor-pointer"
108
108
+
onClick={(e) => handleExternalClick(e, part.text)}
109
109
+
>
110
110
+
{part.text}
111
111
+
</a>,
112
112
+
);
113
113
+
} else {
114
114
+
let lastMentionIndex = 0;
115
115
+
const mentionMatches = Array.from(part.text.matchAll(MENTION_REGEX));
116
116
+
117
117
+
if (mentionMatches.length === 0) {
118
118
+
finalParts.push(part.text);
119
119
+
} else {
120
120
+
for (const match of mentionMatches) {
121
121
+
const fullMatch = match[0];
122
122
+
const prefix = match[1];
123
123
+
const handle = match[2];
124
124
+
const startIndex = match.index!;
125
125
+
126
126
+
if (startIndex > lastMentionIndex) {
127
127
+
finalParts.push(part.text.slice(lastMentionIndex, startIndex));
128
128
+
}
129
129
+
130
130
+
if (prefix) {
131
131
+
finalParts.push(prefix);
132
132
+
}
133
133
+
134
134
+
finalParts.push(
135
135
+
<Link
136
136
+
key={`mention-${partIndex}-${startIndex}`}
137
137
+
to={`/profile/${handle}`}
138
138
+
className="text-primary-600 dark:text-primary-400 hover:underline"
139
139
+
onClick={(e) => e.stopPropagation()}
140
140
+
>
141
141
+
@{handle}
142
142
+
</Link>,
143
143
+
);
144
144
+
145
145
+
lastMentionIndex = startIndex + fullMatch.length;
146
146
+
}
147
147
+
148
148
+
if (lastMentionIndex < part.text.length) {
149
149
+
finalParts.push(part.text.slice(lastMentionIndex));
150
150
+
}
151
151
+
}
152
152
+
}
153
153
+
});
154
154
+
155
155
+
return (
156
156
+
<>
157
157
+
<span className={className}>{finalParts}</span>
158
158
+
<ExternalLinkModal
159
159
+
isOpen={showExternalLinkModal}
160
160
+
onClose={() => setShowExternalLinkModal(false)}
161
161
+
url={externalLinkUrl}
162
162
+
/>
163
163
+
</>
164
164
+
);
53
165
}
+1
-1
web/src/components/navigation/RightSidebar.tsx
···
84
84
{tags.map((t) => (
85
85
<a
86
86
key={t.tag}
87
87
-
href={`/search?q=${t.tag}`}
87
87
+
href={`/home?tag=${encodeURIComponent(t.tag)}`}
88
88
className="px-2 py-2.5 hover:bg-surface-100 dark:hover:bg-surface-800/60 rounded-lg transition-colors group"
89
89
>
90
90
<div className="font-semibold text-sm text-surface-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
+5
web/src/types.ts
···
68
68
name: string;
69
69
icon?: string;
70
70
};
71
71
+
context?: {
72
72
+
uri: string;
73
73
+
name: string;
74
74
+
icon?: string;
75
75
+
}[];
71
76
addedBy?: UserProfile;
72
77
collectionItemUri?: string;
73
78
reply?: {