appview-less bluesky client
24
fork

Configure Feed

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

use facets, better-er rich text

dawn 58e54caf 9a9e2294

+521 -23
+18
deno.lock
··· 2 2 "version": "5", 3 3 "specifiers": { 4 4 "npm:@atcute/atproto@^3.1.9": "3.1.9", 5 + "npm:@atcute/bluesky-richtext-builder@^2.0.4": "2.0.4", 6 + "npm:@atcute/bluesky-richtext-segmenter@^2.0.4": "2.0.4", 5 7 "npm:@atcute/bluesky@^3.2.14": "3.2.14", 6 8 "npm:@atcute/client@^4.1.1": "4.1.1", 7 9 "npm:@atcute/identity-resolver@^1.2.1": "1.2.1_@atcute+identity@1.1.3", ··· 45 47 "@atcute/atproto@3.1.9": { 46 48 "integrity": "sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w==", 47 49 "dependencies": [ 50 + "@atcute/lexicons" 51 + ] 52 + }, 53 + "@atcute/bluesky-richtext-builder@2.0.4": { 54 + "integrity": "sha512-ydA9VWBPsBE/gbu1vYbmh7AZ8FLfxp+LE4eH5GgOTCOxwhs7Mgy1oHrHY+Er6gu6PfdoUoGso0uI3Wl3ZF/Mxg==", 55 + "dependencies": [ 56 + "@atcute/bluesky", 57 + "@atcute/lexicons" 58 + ] 59 + }, 60 + "@atcute/bluesky-richtext-segmenter@2.0.4": { 61 + "integrity": "sha512-6m5QEAv4lU3qTy5MeJXJRRG33acipYJnMW1T7W/KrMyThGhQ7jSTTh8Z48quElgivgX7MDj6o/ow1oLUsjsCKw==", 62 + "dependencies": [ 63 + "@atcute/bluesky", 48 64 "@atcute/lexicons" 49 65 ] 50 66 }, ··· 1721 1737 "packageJson": { 1722 1738 "dependencies": [ 1723 1739 "npm:@atcute/atproto@^3.1.9", 1740 + "npm:@atcute/bluesky-richtext-builder@^2.0.4", 1741 + "npm:@atcute/bluesky-richtext-segmenter@^2.0.4", 1724 1742 "npm:@atcute/bluesky@^3.2.14", 1725 1743 "npm:@atcute/client@^4.1.1", 1726 1744 "npm:@atcute/identity-resolver@^1.2.1",
+2
package.json
··· 16 16 "dependencies": { 17 17 "@atcute/atproto": "^3.1.9", 18 18 "@atcute/bluesky": "^3.2.14", 19 + "@atcute/bluesky-richtext-builder": "^2.0.4", 20 + "@atcute/bluesky-richtext-segmenter": "^2.0.4", 19 21 "@atcute/client": "^4.1.1", 20 22 "@atcute/identity": "^1.1.3", 21 23 "@atcute/identity-resolver": "^1.2.1",
+4 -23
src/components/BskyPost.svelte
··· 28 28 import * as TID from '@atcute/tid'; 29 29 import type { PostWithUri } from '$lib/at/fetch'; 30 30 import { onMount } from 'svelte'; 31 - import { isActorIdentifier, type AtprotoDid } from '@atcute/lexicons/syntax'; 31 + import { type AtprotoDid } from '@atcute/lexicons/syntax'; 32 32 import { derived } from 'svelte/store'; 33 33 import Device from 'svelte-device-info'; 34 34 import Dropdown from './Dropdown.svelte'; 35 35 import { type AppBskyEmbeds } from '$lib/at/types'; 36 36 import { settings } from '$lib/settings'; 37 + import RichText from './RichText.svelte'; 37 38 38 39 interface Props { 39 40 client: AtpClient; ··· 347 348 348 349 {#if profileDesc.length > 0} 349 350 <p class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word"> 350 - {#each profileDesc.split(/(\s)/) as line, idx (idx)} 351 - {#if line === '\n'} 352 - <br /> 353 - {:else if isActorIdentifier(line.replace(/^@/, ''))} 354 - <a 355 - target="_blank" 356 - rel="noopener noreferrer" 357 - class="text-(--nucleus-accent2)" 358 - href={`${$settings.socialAppUrl}/profile/${line.replace(/^@/, '')}`}>{line}</a 359 - > 360 - {:else if line.startsWith('https://')} 361 - <a 362 - target="_blank" 363 - rel="noopener noreferrer" 364 - class="text-(--nucleus-accent2)" 365 - href={line}>{line.replace(/https?:\/\//, '')}</a 366 - > 367 - {:else} 368 - {line} 369 - {/if} 370 - {/each} 351 + <RichText text={profileDesc} {client} /> 371 352 </p> 372 353 {/if} 373 354 </Dropdown> ··· 446 427 </span> 447 428 </div> 448 429 <p class="leading-normal text-wrap wrap-break-word"> 449 - {record.text} 430 + <RichText text={record.text} facets={record.facets ?? []} {client} /> 450 431 {#if isOnPostComposer && record.embed} 451 432 {@render embedBadge(record.embed)} 452 433 {/if}
+71
src/components/RichText.svelte
··· 1 + <script lang="ts"> 2 + import type { AtpClient } from '$lib/at/client'; 3 + import { parseToRichText } from '$lib/richtext'; 4 + import { settings } from '$lib/settings'; 5 + import type { BakedRichtext } from '@atcute/bluesky-richtext-builder'; 6 + import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter'; 7 + 8 + interface Props { 9 + text: string; 10 + facets?: Facet[]; 11 + client: AtpClient; 12 + } 13 + 14 + const { text, facets, client }: Props = $props(); 15 + 16 + const richtext: Promise<BakedRichtext> = $derived( 17 + facets ? Promise.resolve({ text, facets }) : parseToRichText(client, text) 18 + ); 19 + </script> 20 + 21 + {#snippet plainText(text: string)} 22 + {#each text.split(/(\s)/) as line, idx (idx)} 23 + {#if line === '\n'} 24 + <br /> 25 + {:else} 26 + {line} 27 + {/if} 28 + {/each} 29 + {/snippet} 30 + 31 + {#snippet segments(segments: RichtextSegment[])} 32 + {#each segments as segment, idx ([segment, idx])} 33 + {@const { text, features: _features } = segment} 34 + {@const features = _features ?? []} 35 + {#if features.length > 0} 36 + {#each features as feature, idx ([feature, idx])} 37 + {#if feature.$type === 'app.bsky.richtext.facet#mention'} 38 + <a 39 + class="text-(--nucleus-accent2)" 40 + href={`${$settings.socialAppUrl}/profile/${feature.did}`}>{@render plainText(text)}</a 41 + > 42 + {:else if feature.$type === 'app.bsky.richtext.facet#link'} 43 + {@const uri = new URL(feature.uri)} 44 + <a 45 + class="text-(--nucleus-accent2)" 46 + href={uri.href} 47 + target="_blank" 48 + rel="noopener noreferrer" 49 + >{@render plainText(uri.href.replace(`${uri.protocol}//`, ''))}</a 50 + > 51 + {:else if feature.$type === 'app.bsky.richtext.facet#tag'} 52 + <a 53 + class="text-(--nucleus-accent2)" 54 + href={`${$settings.socialAppUrl}/search?q=${encodeURIComponent('#' + feature.tag)}`} 55 + >{@render plainText(text)}</a 56 + > 57 + {:else} 58 + <span>{@render plainText(text)}</span> 59 + {/if} 60 + {/each} 61 + {:else} 62 + <span>{@render plainText(text)}</span> 63 + {/if} 64 + {/each} 65 + {/snippet} 66 + 67 + {#await richtext} 68 + {@render plainText(text)} 69 + {:then richtext} 70 + {@render segments(segmentize(richtext.text, richtext.facets))} 71 + {/await}
+77
src/lib/richtext/index.ts
··· 1 + import RichtextBuilder, { type BakedRichtext } from '@atcute/bluesky-richtext-builder'; 2 + import { tokenize, type Token } from '$lib/richtext/parser'; 3 + import type { Did, GenericUri, Handle } from '@atcute/lexicons'; 4 + import type { AtpClient } from '$lib/at/client'; 5 + 6 + export const parseToRichText = ( 7 + client: AtpClient, 8 + text: string 9 + ): ReturnType<typeof processTokens> => { 10 + const tokens = tokenize(text); 11 + return processTokens(client, tokens); 12 + }; 13 + 14 + const processTokens = async (client: AtpClient, tokens: Token[]): Promise<BakedRichtext> => { 15 + const rt = new RichtextBuilder(); 16 + 17 + for (const token of tokens) { 18 + switch (token.type) { 19 + case 'text': 20 + rt.addText(token.content); 21 + break; 22 + case 'mention': { 23 + let did: Did | undefined = token.did as Did | undefined; 24 + if (!did) { 25 + const handle = token.handle as Handle; 26 + const result = await client.resolveHandle(handle); 27 + if (result.ok) did = result.value; 28 + } 29 + if (did) rt.addMention(token.raw, did); 30 + else rt.addText(token.raw); 31 + break; 32 + } 33 + case 'topic': 34 + rt.addTag(token.name); 35 + break; 36 + case 'autolink': 37 + rt.addLink(token.url, token.url as GenericUri); 38 + break; 39 + case 'link': { 40 + // flatten children to text 41 + const text = flattenToText(token.children); 42 + rt.addLink(text, token.url as GenericUri); 43 + break; 44 + } 45 + case 'escape': 46 + rt.addText(token.escaped); 47 + break; 48 + // formatting tokens (strong, emphasis, etc.) don't map to facets 49 + // so just extract their text content 50 + case 'strong': 51 + case 'emphasis': 52 + case 'underline': 53 + case 'delete': 54 + rt.addText(flattenToText(token.children)); 55 + break; 56 + case 'code': 57 + rt.addText(token.content); 58 + break; 59 + case 'emote': 60 + // handle emotes as needed 61 + rt.addText(token.raw); 62 + break; 63 + } 64 + } 65 + 66 + return rt.build(); 67 + }; 68 + 69 + const flattenToText = (tokens: Token[]): string => { 70 + return tokens 71 + .map((t) => { 72 + if ('content' in t) return t.content; 73 + if ('children' in t) return flattenToText(t.children); 74 + return t.raw; 75 + }) 76 + .join(''); 77 + };
+349
src/lib/richtext/parser.ts
··· 1 + // taken and modified from: https://github.com/mary-ext/atcute/blob/trunk/packages/bluesky/richtext-parser/lib/index.ts 2 + 3 + const ESCAPE_RE = /^\\([^0-9A-Za-z\s])/; 4 + 5 + const MENTION_RE = /^[@@]([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))($|\s|\p{P})/u; 6 + 7 + const DID_RE = /^(did:([a-z0-9]+):([A-Za-z0-9.\-_%:]+))($|\s|\p{P})/u; 8 + 9 + const TOPIC_RE = 10 + /^(?:#(?!\ufe0f|\u20e3)|#)([\p{N}]*[\p{L}\p{M}\p{Pc}][\p{L}\p{M}\p{Pc}\p{N}]*)($|\s|\p{P})/u; 11 + 12 + const EMOTE_RE = /^:([\w-]+):/; 13 + 14 + const AUTOLINK_RE = /^https?:\/\/[\S]+/; 15 + const AUTOLINK_BACKPEDAL_RE = /(?:(?<!\(.*)\))?[.,;]*$/; 16 + 17 + const LINK_RE = 18 + /^\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*)\]\(\s*<?((?:\([^)]*\)|[^\s\\]|\\.)*?)>?(?:\s+['"]([^]*?)['"])?\s*\)/; 19 + const UNESCAPE_URL_RE = /\\([^0-9A-Za-z\s])/g; 20 + 21 + const EMPHASIS_RE = 22 + /^\b_((?:__|\\[^]|[^\\_])+?)_\b|^\*(?=\S)((?:\*\*|\\[^]|\s+(?:\\[^]|[^\s*\\]|\*\*)|[^\s*\\])+?)\*(?!\*)/; 23 + 24 + const STRONG_RE = /^\*\*((?:\\[^]|[^\\])+?)\*\*(?!\*)/; 25 + 26 + const UNDERLINE_RE = /^__((?:\\[^]|~(?!~)|[^~\\]|\s(?!~~))+?)__(?!_)/; 27 + 28 + const DELETE_RE = /^~~((?:\\[^]|~(?!~)|[^~\\]|\s(?!~~))+?)~~/; 29 + 30 + const CODE_RE = /^(`+)([^]*?[^`])\1(?!`)/; 31 + const CODE_ESCAPE_BACKTICKS_RE = /^ (?= *`)|(` *) $/g; 32 + 33 + const TEXT_RE = 34 + /^[^]+?(?:(?=$|[~*_`:\\[]|https?:\/\/)|(?<=\s|[(){}/\\[\]\-|:;'".,=+])(?=[@@##]|did:[a-z0-9]+:))/; 35 + 36 + export interface EscapeToken { 37 + type: 'escape'; 38 + raw: string; 39 + escaped: string; 40 + } 41 + 42 + export interface MentionToken { 43 + type: 'mention'; 44 + raw: string; 45 + handle?: string; 46 + did?: string; 47 + } 48 + 49 + export interface TopicToken { 50 + type: 'topic'; 51 + raw: string; 52 + name: string; 53 + } 54 + 55 + export interface EmoteToken { 56 + type: 'emote'; 57 + raw: string; 58 + name: string; 59 + } 60 + 61 + export interface AutolinkToken { 62 + type: 'autolink'; 63 + raw: string; 64 + url: string; 65 + } 66 + 67 + export interface LinkToken { 68 + type: 'link'; 69 + raw: string; 70 + url: string; 71 + children: Token[]; 72 + } 73 + 74 + export interface UnderlineToken { 75 + type: 'underline'; 76 + raw: string; 77 + children: Token[]; 78 + } 79 + 80 + export interface StrongToken { 81 + type: 'strong'; 82 + raw: string; 83 + children: Token[]; 84 + } 85 + 86 + export interface EmphasisToken { 87 + type: 'emphasis'; 88 + raw: string; 89 + children: Token[]; 90 + } 91 + 92 + export interface DeleteToken { 93 + type: 'delete'; 94 + raw: string; 95 + children: Token[]; 96 + } 97 + 98 + export interface CodeToken { 99 + type: 'code'; 100 + raw: string; 101 + content: string; 102 + } 103 + 104 + export interface TextToken { 105 + type: 'text'; 106 + raw: string; 107 + content: string; 108 + } 109 + 110 + export type Token = 111 + | EscapeToken 112 + | MentionToken 113 + | TopicToken 114 + | EmoteToken 115 + | AutolinkToken 116 + | LinkToken 117 + | StrongToken 118 + | EmphasisToken 119 + | UnderlineToken 120 + | DeleteToken 121 + | CodeToken 122 + | TextToken; 123 + 124 + const tokenizeEscape = (src: string): EscapeToken | undefined => { 125 + const match = ESCAPE_RE.exec(src); 126 + if (match) { 127 + return { 128 + type: 'escape', 129 + raw: match[0], 130 + escaped: match[1] 131 + }; 132 + } 133 + }; 134 + 135 + const tokenizeMention = (src: string): MentionToken | undefined => { 136 + const match = MENTION_RE.exec(src); 137 + if (match && match[2] !== '@') { 138 + const suffix = match[2].length; 139 + 140 + return { 141 + type: 'mention', 142 + raw: suffix > 0 ? match[0].slice(0, -suffix) : match[0], 143 + handle: match[1] 144 + }; 145 + } 146 + 147 + const didMatch = DID_RE.exec(src); 148 + if (didMatch) { 149 + const suffix = didMatch[4].length; 150 + 151 + return { 152 + type: 'mention', 153 + raw: suffix > 0 ? didMatch[0].slice(0, -suffix) : didMatch[0], 154 + did: didMatch[1] 155 + }; 156 + } 157 + }; 158 + 159 + const tokenizeTopic = (src: string): TopicToken | undefined => { 160 + const match = TOPIC_RE.exec(src); 161 + if (match && match[2] !== '#') { 162 + const suffix = match[2].length; 163 + 164 + return { 165 + type: 'topic', 166 + raw: suffix > 0 ? match[0].slice(0, -suffix) : match[0], 167 + name: match[1] 168 + }; 169 + } 170 + }; 171 + 172 + const tokenizeEmote = (src: string): EmoteToken | undefined => { 173 + const match = EMOTE_RE.exec(src); 174 + if (match) { 175 + return { 176 + type: 'emote', 177 + raw: match[0], 178 + name: match[1] 179 + }; 180 + } 181 + }; 182 + 183 + const tokenizeAutolink = (src: string): AutolinkToken | undefined => { 184 + const match = AUTOLINK_RE.exec(src); 185 + if (match) { 186 + const url = match[0].replace(AUTOLINK_BACKPEDAL_RE, ''); 187 + 188 + return { 189 + type: 'autolink', 190 + raw: url, 191 + url: url 192 + }; 193 + } 194 + }; 195 + 196 + const tokenizeLink = (src: string): LinkToken | undefined => { 197 + const match = LINK_RE.exec(src); 198 + if (match) { 199 + return { 200 + type: 'link', 201 + raw: match[0], 202 + url: match[2].replace(UNESCAPE_URL_RE, '$1'), 203 + children: tokenize(match[1]) 204 + }; 205 + } 206 + }; 207 + 208 + const _tokenizeEmphasis = (src: string): EmphasisToken | undefined => { 209 + const match = EMPHASIS_RE.exec(src); 210 + if (match) { 211 + return { 212 + type: 'emphasis', 213 + raw: match[0], 214 + children: tokenize(match[2] || match[1]) 215 + }; 216 + } 217 + }; 218 + 219 + const _tokenizeStrong = (src: string): StrongToken | undefined => { 220 + const match = STRONG_RE.exec(src); 221 + if (match) { 222 + return { 223 + type: 'strong', 224 + raw: match[0], 225 + children: tokenize(match[1]) 226 + }; 227 + } 228 + }; 229 + 230 + const _tokenizeUnderline = (src: string): UnderlineToken | undefined => { 231 + const match = UNDERLINE_RE.exec(src); 232 + if (match) { 233 + return { 234 + type: 'underline', 235 + raw: match[0], 236 + children: tokenize(match[1]) 237 + }; 238 + } 239 + }; 240 + 241 + const tokenizeEmStrongU = ( 242 + src: string 243 + ): EmphasisToken | StrongToken | UnderlineToken | undefined => { 244 + let token: EmphasisToken | StrongToken | UnderlineToken | undefined; 245 + 246 + { 247 + const match = _tokenizeEmphasis(src); 248 + if (match && (!token || match.raw.length > token.raw.length)) { 249 + token = match; 250 + } 251 + } 252 + 253 + { 254 + const match = _tokenizeStrong(src); 255 + if (match && (!token || match.raw.length > token.raw.length)) { 256 + token = match; 257 + } 258 + } 259 + 260 + { 261 + const match = _tokenizeUnderline(src); 262 + if (match && (!token || match.raw.length > token.raw.length)) { 263 + token = match; 264 + } 265 + } 266 + 267 + return token; 268 + }; 269 + 270 + const tokenizeDelete = (src: string): DeleteToken | undefined => { 271 + const match = DELETE_RE.exec(src); 272 + if (match) { 273 + return { 274 + type: 'delete', 275 + raw: match[0], 276 + children: tokenize(match[1]) 277 + }; 278 + } 279 + }; 280 + 281 + const tokenizeCode = (src: string): CodeToken | undefined => { 282 + const match = CODE_RE.exec(src); 283 + if (match) { 284 + return { 285 + type: 'code', 286 + raw: match[0], 287 + content: match[2].replace(CODE_ESCAPE_BACKTICKS_RE, '$1') 288 + }; 289 + } 290 + }; 291 + 292 + const tokenizeText = (src: string): TextToken | undefined => { 293 + const match = TEXT_RE.exec(src); 294 + if (match) { 295 + return { 296 + type: 'text', 297 + raw: match[0], 298 + content: match[0] 299 + }; 300 + } 301 + }; 302 + 303 + export const tokenize = (src: string): Token[] => { 304 + const tokens: Token[] = []; 305 + 306 + let last: Token | undefined; 307 + let token: Token | undefined; 308 + 309 + while (src) { 310 + last = token; 311 + 312 + if ( 313 + (token = 314 + tokenizeEscape(src) || 315 + tokenizeMention(src) || 316 + tokenizeAutolink(src) || 317 + tokenizeTopic(src) || 318 + tokenizeEmote(src) || 319 + tokenizeLink(src) || 320 + tokenizeEmStrongU(src) || 321 + tokenizeDelete(src) || 322 + tokenizeCode(src)) 323 + ) { 324 + src = src.slice(token.raw.length); 325 + tokens.push(token); 326 + continue; 327 + } 328 + 329 + if ((token = tokenizeText(src))) { 330 + src = src.slice(token.raw.length); 331 + 332 + if (last && last.type === 'text') { 333 + last.raw += token.raw; 334 + last.content += token.content; 335 + token = last; 336 + } else { 337 + tokens.push(token); 338 + } 339 + 340 + continue; 341 + } 342 + 343 + if (src) { 344 + throw new Error(`infinite loop encountered`); 345 + } 346 + } 347 + 348 + return tokens; 349 + };