Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 171 lines 4.7 kB view raw
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}