(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React from "react";
2import ExternalLinkModal from "../modals/ExternalLinkModal";
3import { useStore } from "@nanostores/react";
4import { $preferences } from "../../store/preferences";
5
6interface RichTextProps {
7 text: string;
8 className?: string;
9}
10
11const MENTION_REGEX =
12 /(^|[\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;
13
14const URL_REGEX = /(^|[\s(])(https?:\/\/[^\s]+)/g;
15
16export default function RichText({ text, className }: RichTextProps) {
17 const urlParts: { text: string; isUrl: boolean }[] = [];
18 let lastUrlIndex = 0;
19
20 for (const match of text.matchAll(URL_REGEX)) {
21 const fullMatch = match[0];
22 const prefix = match[1];
23 const url = match[2];
24 const startIndex = match.index!;
25
26 if (startIndex > lastUrlIndex) {
27 urlParts.push({
28 text: text.slice(lastUrlIndex, startIndex),
29 isUrl: false,
30 });
31 }
32 if (prefix) {
33 urlParts.push({ text: prefix, isUrl: false });
34 }
35
36 urlParts.push({ text: url, isUrl: true });
37
38 lastUrlIndex = startIndex + fullMatch.length;
39 }
40 if (lastUrlIndex < text.length) {
41 urlParts.push({ text: text.slice(lastUrlIndex), isUrl: false });
42 }
43
44 if (urlParts.length === 0) {
45 urlParts.push({ text, isUrl: false });
46 }
47
48 const [showExternalLinkModal, setShowExternalLinkModal] =
49 React.useState(false);
50 const [externalLinkUrl, setExternalLinkUrl] = React.useState<string | null>(
51 null,
52 );
53 const preferences = useStore($preferences);
54
55 const safeUrlHostname = (url: string | null | undefined) => {
56 if (!url) return null;
57 try {
58 return new URL(url).hostname;
59 } catch {
60 return null;
61 }
62 };
63
64 const handleExternalClick = (
65 e: React.MouseEvent,
66 url: string,
67 isBareUrl: boolean = false,
68 ) => {
69 e.preventDefault();
70 e.stopPropagation();
71
72 try {
73 const hostname = safeUrlHostname(url);
74 if (hostname) {
75 if (
76 hostname === "margin.at" ||
77 hostname.endsWith(".margin.at") ||
78 hostname === "semble.so" ||
79 hostname.endsWith(".semble.so")
80 ) {
81 window.open(url, "_blank", "noopener,noreferrer");
82 return;
83 }
84
85 if (isBareUrl || preferences.disableExternalLinkWarning) {
86 window.open(url, "_blank", "noopener,noreferrer");
87 return;
88 }
89
90 const skipped = preferences.externalLinkSkippedHostnames || [];
91 if (skipped.includes(hostname)) {
92 window.open(url, "_blank", "noopener,noreferrer");
93 return;
94 }
95 }
96 } catch (err) {
97 if (err instanceof Error && err.name !== "TypeError") {
98 console.debug("Failed to check skipped hostname:", err);
99 }
100 }
101
102 setExternalLinkUrl(url);
103 setShowExternalLinkModal(true);
104 };
105
106 const finalParts: React.ReactNode[] = [];
107
108 urlParts.forEach((part, partIndex) => {
109 if (part.isUrl) {
110 finalParts.push(
111 <a
112 key={`url-${partIndex}`}
113 href={part.text}
114 target="_blank"
115 rel="noopener noreferrer"
116 className="text-primary-600 dark:text-primary-400 hover:underline break-all cursor-pointer"
117 onClick={(e) => handleExternalClick(e, part.text, true)}
118 >
119 {part.text}
120 </a>,
121 );
122 } else {
123 let lastMentionIndex = 0;
124 const mentionMatches = Array.from(part.text.matchAll(MENTION_REGEX));
125
126 if (mentionMatches.length === 0) {
127 finalParts.push(part.text);
128 } else {
129 for (const match of mentionMatches) {
130 const fullMatch = match[0];
131 const prefix = match[1];
132 const handle = match[2];
133 const startIndex = match.index!;
134
135 if (startIndex > lastMentionIndex) {
136 finalParts.push(part.text.slice(lastMentionIndex, startIndex));
137 }
138
139 if (prefix) {
140 finalParts.push(prefix);
141 }
142
143 finalParts.push(
144 <a
145 key={`mention-${partIndex}-${startIndex}`}
146 href={`/profile/${handle}`}
147 className="text-primary-600 dark:text-primary-400 hover:underline"
148 onClick={(e) => e.stopPropagation()}
149 >
150 @{handle}
151 </a>,
152 );
153
154 lastMentionIndex = startIndex + fullMatch.length;
155 }
156
157 if (lastMentionIndex < part.text.length) {
158 finalParts.push(part.text.slice(lastMentionIndex));
159 }
160 }
161 }
162 });
163
164 return (
165 <>
166 <span className={className}>{finalParts}</span>
167 <ExternalLinkModal
168 isOpen={showExternalLinkModal}
169 onClose={() => setShowExternalLinkModal(false)}
170 url={externalLinkUrl}
171 />
172 </>
173 );
174}