(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 174 lines 4.7 kB view raw
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}