A social internet radio platform built on AT Protocol. atradio.fm
atproto radio
7

Configure Feed

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

feat(comments): clickable mentions + auto-linked URLs

- Restyle @mentions to synth-purple and keep them linking to the mentioned
user's profile
- Detect http(s) URLs in comment text and render them as synth-blue links that
open in a new tab (rel=noreferrer noopener nofollow), trimming trailing
punctuation and coexisting with mention facets

Tsiry Sandratraina (Jul 16, 2026, 7:03 PM +0300) fc0a663d 7e76e27f

+65 -26
+3 -1
apps/web/index.html
··· 2 2 <html lang="en" class="dark" data-theme="dark"> 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 - <link rel="icon" type="image/svg+xml" href="/radio.svg" /> 5 + <link rel="icon" type="image/png" sizes="64x64" href="/favicon-64.png" /> 6 + <link rel="icon" type="image/png" sizes="192x192" href="/pwa-192x192.png" /> 7 + <link rel="icon" type="image/png" sizes="512x512" href="/pwa-512x512.png" /> 6 8 <link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" /> 7 9 <meta 8 10 name="viewport"
-7
apps/web/public/radio.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#00e8c6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 2 - <path d="M18 8a6 6 0 0 0 -6 -6" /> 3 - <path d="M4 20m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v0a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" transform="translate(0 -8)" /> 4 - <circle cx="8" cy="14" r="2" /> 5 - <path d="M14 12h4" stroke="#00c6e8" /> 6 - <path d="M14 16h4" stroke="#00c6e8" /> 7 - </svg>
+11 -1
apps/web/src/components/CommentsPanel.tsx
··· 108 108 key={i} 109 109 to="/profile/$actor" 110 110 params={{ actor: seg.did }} 111 - className="font-medium text-synth-cyan hover:underline" 111 + className="rounded font-semibold text-synth-purple hover:underline" 112 112 > 113 113 {seg.value} 114 114 </Link> 115 + ) : seg.type === "link" ? ( 116 + <a 117 + key={i} 118 + href={seg.href} 119 + target="_blank" 120 + rel="noreferrer noopener nofollow" 121 + className="break-all font-medium text-synth-blue underline decoration-synth-blue/40 underline-offset-2 hover:decoration-synth-blue" 122 + > 123 + {seg.value} 124 + </a> 115 125 ) : ( 116 126 <span key={i}>{seg.value}</span> 117 127 ),
+23 -7
apps/web/src/components/NotificationBell.tsx
··· 6 6 IconAt, 7 7 IconMessage2, 8 8 IconMoodSmile, 9 + IconArrowLeft, 9 10 } from "@tabler/icons-react"; 10 11 import { infoToStation, type NotificationView } from "@atradio/lexicons"; 11 12 import { didAtom, isLoggedInAtom } from "@/atoms/auth"; ··· 124 125 125 126 {open && ( 126 127 <> 127 - {/* Click-away backdrop */} 128 + {/* Click-away backdrop — desktop only (mobile is a full screen). */} 128 129 <button 129 130 type="button" 130 131 aria-label="Close notifications" 131 132 onClick={() => setOpen(false)} 132 - className="fixed inset-0 z-40 cursor-default" 133 + className="fixed inset-0 z-40 hidden cursor-default sm:block" 133 134 /> 134 - <div className="absolute right-0 z-50 mt-2 flex max-h-[70vh] w-[min(92vw,22rem)] flex-col overflow-hidden rounded-2xl border border-white/10 bg-synth-surface shadow-2xl shadow-black/50"> 135 - <div className="flex items-center justify-between border-b border-white/10 px-3 py-2.5"> 136 - <span className="flex items-center gap-1.5 font-display text-sm font-semibold"> 137 - <IconBell size={15} className="text-synth-pink" /> 135 + {/* 136 + Mobile: a dedicated full-app screen. Desktop: an anchored dropdown. 137 + */} 138 + <div className="fixed inset-0 z-[100] flex flex-col bg-synth-bg sm:absolute sm:inset-auto sm:right-0 sm:top-full sm:z-50 sm:mt-2 sm:h-auto sm:max-h-[70vh] sm:w-[min(92vw,22rem)] sm:overflow-hidden sm:rounded-2xl sm:border sm:border-white/10 sm:bg-synth-surface sm:shadow-2xl sm:shadow-black/50"> 139 + <div 140 + className="flex items-center justify-between border-b border-white/10 px-4 py-3.5 sm:px-3 sm:py-2.5" 141 + style={{ paddingTop: "max(0.875rem, env(safe-area-inset-top))" }} 142 + > 143 + <span className="flex items-center gap-2 font-display text-base font-semibold sm:gap-1.5 sm:text-sm"> 144 + {/* Back button — the full-screen mobile view has no backdrop. */} 145 + <button 146 + type="button" 147 + onClick={() => setOpen(false)} 148 + aria-label="Back" 149 + className="-ml-1 flex h-8 w-8 items-center justify-center rounded-full text-foreground/70 hover:bg-white/5 hover:text-foreground sm:hidden" 150 + > 151 + <IconArrowLeft size={20} /> 152 + </button> 153 + <IconBell size={16} className="text-synth-pink" /> 138 154 Notifications 139 155 </span> 140 156 <Link ··· 145 161 Profile 146 162 </Link> 147 163 </div> 148 - <div className="overflow-y-auto"> 164 + <div className="flex-1 overflow-y-auto sm:flex-none"> 149 165 {items.length === 0 ? ( 150 166 <p className="px-3 py-10 text-center text-sm text-foreground/40"> 151 167 Nothing yet. Mentions and comments on your stations show up
+28 -9
apps/web/src/lib/atproto/richtext.ts
··· 75 75 76 76 export type CommentSegment = 77 77 | { type: "text"; value: string } 78 - | { type: "mention"; value: string; did: string }; 78 + | { type: "mention"; value: string; did: string } 79 + | { type: "link"; value: string; href: string }; 80 + 81 + /** Matches http(s) URLs; the capture excludes common trailing punctuation. */ 82 + const URL_RE = /https?:\/\/[^\s<]+[^\s<.,:;!?)\]}'"]/g; 83 + 84 + /** Split a plain-text run into text + link segments (URLs → clickable). */ 85 + function splitLinks(text: string): CommentSegment[] { 86 + const out: CommentSegment[] = []; 87 + let last = 0; 88 + for (const m of text.matchAll(URL_RE)) { 89 + const start = m.index ?? 0; 90 + const url = m[0]; 91 + if (start > last) out.push({ type: "text", value: text.slice(last, start) }); 92 + out.push({ type: "link", value: url, href: url }); 93 + last = start + url.length; 94 + } 95 + if (last < text.length) out.push({ type: "text", value: text.slice(last) }); 96 + return out; 97 + } 79 98 80 99 /** 81 - * Split a comment's text into plain + mention segments using its facets, so the 82 - * UI can render mentions as links. Byte offsets are mapped back onto the string. 100 + * Split a comment's text into plain + mention + link segments. Mentions come 101 + * from the record's facets (byte ranges); URLs are detected in the remaining 102 + * plain-text runs. Byte offsets are mapped back onto the string. 83 103 */ 84 104 export function segmentComment( 85 105 text: string, 86 106 facets: Mention[] | undefined, 87 107 ): CommentSegment[] { 88 - if (!facets?.length) return [{ type: "text", value: text }]; 108 + if (!facets?.length) return splitLinks(text); 89 109 const bytes = encoder.encode(text); 90 110 const sorted = [...facets].sort((a, b) => a.byteStart - b.byteStart); 91 111 const segments: CommentSegment[] = []; ··· 94 114 if (f.byteStart < cursor || f.byteEnd > bytes.length || f.byteEnd <= f.byteStart) 95 115 continue; 96 116 if (f.byteStart > cursor) { 97 - segments.push({ 98 - type: "text", 99 - value: decoder.decode(bytes.slice(cursor, f.byteStart)), 100 - }); 117 + segments.push( 118 + ...splitLinks(decoder.decode(bytes.slice(cursor, f.byteStart))), 119 + ); 101 120 } 102 121 segments.push({ 103 122 type: "mention", ··· 107 126 cursor = f.byteEnd; 108 127 } 109 128 if (cursor < bytes.length) { 110 - segments.push({ type: "text", value: decoder.decode(bytes.slice(cursor)) }); 129 + segments.push(...splitLinks(decoder.decode(bytes.slice(cursor)))); 111 130 } 112 131 return segments; 113 132 }
-1
apps/web/vite.config.ts
··· 155 155 VitePWA({ 156 156 registerType: "autoUpdate", 157 157 includeAssets: [ 158 - "radio.svg", 159 158 "favicon-64.png", 160 159 "apple-touch-icon-180x180.png", 161 160 ],