a tool for shared writing and social publishing

use linkify js for urls in profiles

+58 -15
+58 -15
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 11 11 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 12 12 import { SpeedyLink } from "components/SpeedyLink"; 13 13 import { ReactNode } from "react"; 14 + import * as linkify from "linkifyjs"; 14 15 15 16 export const ProfileHeader = (props: { 16 17 profile: ProfileViewDetailed; ··· 149 150 }; 150 151 151 152 function parseDescription(description: string): ReactNode[] { 152 - const combinedRegex = /(@\S+|https?:\/\/\S+)/g; 153 + // Find all mentions using regex 154 + const mentionRegex = /@\S+/g; 155 + const mentions: { start: number; end: number; value: string }[] = []; 156 + let mentionMatch; 157 + while ((mentionMatch = mentionRegex.exec(description)) !== null) { 158 + mentions.push({ 159 + start: mentionMatch.index, 160 + end: mentionMatch.index + mentionMatch[0].length, 161 + value: mentionMatch[0], 162 + }); 163 + } 164 + 165 + // Find all URLs using linkifyjs 166 + const links = linkify.find(description).filter((link) => link.type === "url"); 167 + 168 + // Filter out URLs that overlap with mentions (mentions take priority) 169 + const nonOverlappingLinks = links.filter((link) => { 170 + return !mentions.some( 171 + (mention) => 172 + (link.start >= mention.start && link.start < mention.end) || 173 + (link.end > mention.start && link.end <= mention.end) || 174 + (link.start <= mention.start && link.end >= mention.end), 175 + ); 176 + }); 177 + 178 + // Combine into a single sorted list 179 + const allMatches: Array<{ 180 + start: number; 181 + end: number; 182 + value: string; 183 + href: string; 184 + type: "url" | "mention"; 185 + }> = [ 186 + ...nonOverlappingLinks.map((link) => ({ 187 + start: link.start, 188 + end: link.end, 189 + value: link.value, 190 + href: link.href, 191 + type: "url" as const, 192 + })), 193 + ...mentions.map((mention) => ({ 194 + start: mention.start, 195 + end: mention.end, 196 + value: mention.value, 197 + href: `/p/${mention.value.slice(1)}`, 198 + type: "mention" as const, 199 + })), 200 + ].sort((a, b) => a.start - b.start); 153 201 154 202 const parts: ReactNode[] = []; 155 203 let lastIndex = 0; 156 - let match; 157 204 let key = 0; 158 205 159 - while ((match = combinedRegex.exec(description)) !== null) { 206 + for (const match of allMatches) { 160 207 // Add text before this match 161 - if (match.index > lastIndex) { 162 - parts.push(description.slice(lastIndex, match.index)); 208 + if (match.start > lastIndex) { 209 + parts.push(description.slice(lastIndex, match.start)); 163 210 } 164 211 165 - const matched = match[0]; 166 - 167 - if (matched.startsWith("@")) { 168 - // It's a mention 169 - const handle = matched.slice(1); 212 + if (match.type === "mention") { 170 213 parts.push( 171 - <SpeedyLink key={key++} href={`/p/${handle}`}> 172 - {matched} 214 + <SpeedyLink key={key++} href={match.href}> 215 + {match.value} 173 216 </SpeedyLink>, 174 217 ); 175 218 } else { 176 219 // It's a URL 177 - const urlWithoutProtocol = matched 220 + const urlWithoutProtocol = match.value 178 221 .replace(/^https?:\/\//, "") 179 222 .replace(/\/+$/, ""); 180 223 const displayText = ··· 182 225 ? urlWithoutProtocol.slice(0, 50) + "…" 183 226 : urlWithoutProtocol; 184 227 parts.push( 185 - <a key={key++} href={matched} target="_blank" rel="noopener noreferrer"> 228 + <a key={key++} href={match.href} target="_blank" rel="noopener noreferrer"> 186 229 {displayText} 187 230 </a>, 188 231 ); 189 232 } 190 233 191 - lastIndex = match.index + matched.length; 234 + lastIndex = match.end; 192 235 } 193 236 194 237 // Add remaining text after last match