grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

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

fix: preserve bio newlines and detect bare domain links

- Add white-space: pre-wrap to profile bio
- Detect bare domains (e.g. slices.network) as links in RichText and RichTextarea
- Auto-resize bio textarea to fit content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+33 -3
+12
app/lib/components/atoms/RichText.svelte
··· 8 8 let { text }: { text: string } = $props() 9 9 10 10 const urlRe = /https?:\/\/[^\s<>[\]()]+/g 11 + const bareDomainRe = /(?<![/@\w])([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(\/[^\s<>[\]()]*)?/g 11 12 const mentionRe = /@([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 12 13 const hashtagRe = /#([a-zA-Z][a-zA-Z0-9_]*)/g 13 14 ··· 20 21 start: m.index!, 21 22 end: m.index! + m[0].length, 22 23 segment: { type: 'link', text: m[0], href: m[0] }, 24 + }) 25 + } 26 + 27 + for (const m of input.matchAll(bareDomainRe)) { 28 + const start = m.index! 29 + const end = start + m[0].length 30 + if (matches.some((x) => start < x.end && end > x.start)) continue 31 + matches.push({ 32 + start, 33 + end, 34 + segment: { type: 'link', text: m[0], href: `https://${m[0]}` }, 23 35 }) 24 36 } 25 37
+20 -2
app/lib/components/atoms/RichTextarea.svelte
··· 1 1 <script lang="ts"> 2 2 const urlRe = /https?:\/\/[^\s<>[\]()]+/g 3 + const bareDomainRe = /(?<![/@\w])([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(\/[^\s<>[\]()]*)?/g 3 4 const mentionRe = /@([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 4 5 const hashtagRe = /#([a-zA-Z][a-zA-Z0-9_]*)/g 5 6 ··· 23 24 24 25 for (const m of input.matchAll(urlRe)) { 25 26 matches.push({ start: m.index!, end: m.index! + m[0].length, type: 'link' }) 27 + } 28 + for (const m of input.matchAll(bareDomainRe)) { 29 + const s = m.index!, e = s + m[0].length 30 + if (matches.some((x) => s < x.end && e > x.start)) continue 31 + matches.push({ start: s, end: e, type: 'link' }) 26 32 } 27 33 for (const m of input.matchAll(mentionRe)) { 28 34 const s = m.index!, e = s + m[0].length ··· 56 62 57 63 let el: HTMLTextAreaElement = $state()! 58 64 65 + function autoResize() { 66 + if (!el) return 67 + el.style.height = 'auto' 68 + el.style.height = el.scrollHeight + 'px' 69 + } 70 + 59 71 function syncScroll() { 60 72 const backdrop = el?.previousElementSibling as HTMLElement | null 61 73 if (backdrop) { ··· 63 75 backdrop.scrollLeft = el.scrollLeft 64 76 } 65 77 } 78 + 79 + $effect(() => { 80 + value; 81 + autoResize(); 82 + }) 66 83 67 84 const highlighted = $derived(highlight(value)) 68 85 </script> ··· 77 94 {rows} 78 95 {disabled} 79 96 class="input" 80 - oninput={syncScroll} 97 + oninput={() => { autoResize(); syncScroll(); }} 81 98 onscroll={syncScroll} 82 99 ></textarea> 83 100 </div> ··· 122 139 background: none; 123 140 color: transparent; 124 141 caret-color: var(--text-primary); 125 - resize: vertical; 142 + resize: none; 143 + overflow: hidden; 126 144 transition: border-color 0.15s; 127 145 } 128 146 .input:focus {
+1 -1
app/routes/profile/[did]/+page.svelte
··· 151 151 .stat-row strong { color: var(--text-primary); font-weight: 600; } 152 152 .stat-link { text-decoration: none; color: inherit; } 153 153 .stat-link:hover { text-decoration: underline; } 154 - .bio { margin-top: 8px; font-size: 14px; color: var(--text-secondary); } 154 + .bio { margin-top: 8px; font-size: 14px; color: var(--text-secondary); white-space: pre-wrap; } 155 155 .links-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } 156 156 .known-followers { 157 157 display: flex;