tangled
alpha
login
or
join now
margin.at
/
margin
89
fork
atom
Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
89
fork
atom
overview
issues
4
pulls
1
pipelines
various better additions
scanash.com
1 month ago
8e648f47
a226bc2e
+78
-7
4 changed files
expand all
collapse all
unified
split
web
src
components
common
Card.tsx
ProfileHoverCard.tsx
RichText.tsx
views
profile
Profile.tsx
+10
-3
web/src/components/common/Card.tsx
···
1
import React, { useState } from "react";
2
import { formatDistanceToNow } from "date-fns";
0
3
import {
4
MessageSquare,
5
Heart,
···
138
(pageUrl ? safeUrlHostname(pageUrl) : null);
139
const pageHostname = pageUrl
140
? safeUrlHostname(pageUrl)?.replace("www.", "")
0
0
0
0
0
0
141
: null;
142
const isBookmark = type === "bookmark";
143
···
284
className="inline-flex items-center gap-1 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-0.5"
285
>
286
<ExternalLink size={10} />
287
-
{pageHostname}
288
</a>
289
)}
290
</div>
···
334
)}
335
</div>
336
<span className="truncate max-w-[200px]">
337
-
{pageHostname || pageUrl}
338
</span>
339
</div>
340
</div>
···
388
389
{item.body?.value && (
390
<p className="text-surface-900 dark:text-surface-100 whitespace-pre-wrap leading-relaxed text-[15px]">
391
-
{item.body.value}
392
</p>
393
)}
394
</div>
···
1
import React, { useState } from "react";
2
import { formatDistanceToNow } from "date-fns";
3
+
import RichText from "./RichText";
4
import {
5
MessageSquare,
6
Heart,
···
139
(pageUrl ? safeUrlHostname(pageUrl) : null);
140
const pageHostname = pageUrl
141
? safeUrlHostname(pageUrl)?.replace("www.", "")
142
+
: null;
143
+
const displayUrl = pageUrl
144
+
? pageUrl
145
+
.replace(/^https?:\/\//, "")
146
+
.replace(/^www\./, "")
147
+
.replace(/\/$/, "")
148
: null;
149
const isBookmark = type === "bookmark";
150
···
291
className="inline-flex items-center gap-1 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-0.5"
292
>
293
<ExternalLink size={10} />
294
+
{displayUrl}
295
</a>
296
)}
297
</div>
···
341
)}
342
</div>
343
<span className="truncate max-w-[200px]">
344
+
{displayUrl || pageUrl}
345
</span>
346
</div>
347
</div>
···
395
396
{item.body?.value && (
397
<p className="text-surface-900 dark:text-surface-100 whitespace-pre-wrap leading-relaxed text-[15px]">
398
+
<RichText text={item.body.value} />
399
</p>
400
)}
401
</div>
+3
-2
web/src/components/common/ProfileHoverCard.tsx
···
1
import React, { useState, useEffect, useRef } from "react";
2
import { Link } from "react-router-dom";
3
import Avatar from "../ui/Avatar";
0
4
import { getProfile } from "../../api/client";
5
import type { UserProfile } from "../../types";
6
import { Loader2 } from "lucide-react";
···
134
</Link>
135
136
{profile.description && (
137
-
<p className="text-sm text-surface-600 dark:text-surface-300 line-clamp-3">
138
-
{profile.description}
139
</p>
140
)}
141
···
1
import React, { useState, useEffect, useRef } from "react";
2
import { Link } from "react-router-dom";
3
import Avatar from "../ui/Avatar";
4
+
import RichText from "./RichText";
5
import { getProfile } from "../../api/client";
6
import type { UserProfile } from "../../types";
7
import { Loader2 } from "lucide-react";
···
135
</Link>
136
137
{profile.description && (
138
+
<p className="text-sm text-surface-600 dark:text-surface-300 whitespace-pre-line line-clamp-3">
139
+
<RichText text={profile.description} />
140
</p>
141
)}
142
+53
web/src/components/common/RichText.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import React from "react";
2
+
import { Link } from "react-router-dom";
3
+
4
+
interface RichTextProps {
5
+
text: string;
6
+
className?: string;
7
+
}
8
+
9
+
const MENTION_REGEX =
10
+
/(^|[\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
+
12
+
export default function RichText({ text, className }: RichTextProps) {
13
+
const parts: React.ReactNode[] = [];
14
+
let lastIndex = 0;
15
+
16
+
for (const match of text.matchAll(MENTION_REGEX)) {
17
+
const fullMatch = match[0];
18
+
const prefix = match[1];
19
+
const handle = match[2];
20
+
const startIndex = match.index!;
21
+
22
+
if (startIndex > lastIndex) {
23
+
parts.push(text.slice(lastIndex, startIndex));
24
+
}
25
+
26
+
if (prefix) {
27
+
parts.push(prefix);
28
+
}
29
+
30
+
parts.push(
31
+
<Link
32
+
key={startIndex}
33
+
to={`/profile/${handle}`}
34
+
className="text-primary-600 dark:text-primary-400 hover:underline"
35
+
onClick={(e) => e.stopPropagation()}
36
+
>
37
+
@{handle}
38
+
</Link>,
39
+
);
40
+
41
+
lastIndex = startIndex + fullMatch.length;
42
+
}
43
+
44
+
if (lastIndex < text.length) {
45
+
parts.push(text.slice(lastIndex));
46
+
}
47
+
48
+
if (parts.length === 0) {
49
+
return <span className={className}>{text}</span>;
50
+
}
51
+
52
+
return <span className={className}>{parts}</span>;
53
+
}
+12
-2
web/src/views/profile/Profile.tsx
···
1
import React, { useEffect, useState } from "react";
2
import { getProfile, getFeed, getCollections } from "../../api/client";
3
import Card from "../../components/common/Card";
0
4
import {
5
Edit2,
6
Github,
···
123
}, []);
124
125
useEffect(() => {
0
0
0
0
0
0
0
0
0
126
const loadTabContent = async () => {
127
const isHandle = !did.startsWith("did:");
128
const resolvedDid = isHandle ? profile?.did : did;
···
244
</div>
245
246
{profile.description && (
247
-
<p className="text-surface-600 dark:text-surface-300 text-sm mt-3 line-clamp-2">
248
-
{profile.description}
249
</p>
250
)}
251
···
1
import React, { useEffect, useState } from "react";
2
import { getProfile, getFeed, getCollections } from "../../api/client";
3
import Card from "../../components/common/Card";
4
+
import RichText from "../../components/common/RichText";
5
import {
6
Edit2,
7
Github,
···
124
}, []);
125
126
useEffect(() => {
127
+
setProfile(null);
128
+
setAnnotations([]);
129
+
setHighlights([]);
130
+
setBookmarks([]);
131
+
setCollections([]);
132
+
setActiveTab("annotations");
133
+
}, [did]);
134
+
135
+
useEffect(() => {
136
const loadTabContent = async () => {
137
const isHandle = !did.startsWith("did:");
138
const resolvedDid = isHandle ? profile?.did : did;
···
254
</div>
255
256
{profile.description && (
257
+
<p className="text-surface-600 dark:text-surface-300 text-sm mt-3 whitespace-pre-line">
258
+
<RichText text={profile.description} />
259
</p>
260
)}
261