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