Bluesky app fork with some witchin' additions 💫

Implement markdown links support

authored by scanash.com and committed by Tangled f8263abe 4561c3f9

Changed files
+90 -25
src
lib
+44 -22
src/lib/api/index.ts
··· 14 14 type ComAtprotoRepoStrongRef, 15 15 RichText, 16 16 } from '@atproto/api' 17 - import {TID} from '@atproto/common-web' 17 + import { TID } from '@atproto/common-web' 18 18 import * as dcbor from '@ipld/dag-cbor' 19 - import {t} from '@lingui/macro' 20 - import {type QueryClient} from '@tanstack/react-query' 21 - import {sha256} from 'js-sha256' 22 - import {CID} from 'multiformats/cid' 19 + import { t } from '@lingui/macro' 20 + import { type QueryClient } from '@tanstack/react-query' 21 + import { sha256 } from 'js-sha256' 22 + import { CID } from 'multiformats/cid' 23 23 import * as Hasher from 'multiformats/hashes/hasher' 24 24 25 - import {isNetworkError} from '#/lib/strings/errors' 26 - import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' 27 - import {logger} from '#/logger' 28 - import {compressImage} from '#/state/gallery' 25 + import { isNetworkError } from '#/lib/strings/errors' 26 + import { shortenLinks, stripInvalidMentions, parseMarkdownLinks } from '#/lib/strings/rich-text-manip' 27 + import { logger } from '#/logger' 28 + import { compressImage } from '#/state/gallery' 29 29 import { 30 30 fetchResolveGifQuery, 31 31 fetchResolveLinkQuery, ··· 39 39 type PostDraft, 40 40 type ThreadDraft, 41 41 } from '#/view/com/composer/state/composer' 42 - import {createGIFDescription} from '../gif-alt-text' 43 - import {uploadBlob} from './upload-blob' 42 + import { createGIFDescription } from '../gif-alt-text' 43 + import { uploadBlob } from './upload-blob' 44 44 45 - export {uploadBlob} 45 + export { uploadBlob } 46 46 47 47 interface PostOpts { 48 48 thread: ThreadDraft ··· 96 96 if (draft.labels.length) { 97 97 labels = { 98 98 $type: 'com.atproto.label.defs#selfLabels', 99 - values: draft.labels.map(val => ({val})), 99 + values: draft.labels.map(val => ({ val })), 100 100 } 101 101 } 102 102 ··· 190 190 } 191 191 } 192 192 193 - return {uris} 193 + return { uris } 194 194 } 195 195 196 196 async function resolveRT(agent: BskyAgent, richtext: RichText) { ··· 199 199 .replace(/^(\s*\n)+/, '') 200 200 // Trim any trailing whitespace. 201 201 .trimEnd() 202 - let rt = new RichText({text: trimmedText}, {cleanNewlines: true}) 202 + 203 + const { text: parsedText, facets: markdownFacets } = 204 + parseMarkdownLinks(trimmedText) 205 + 206 + let rt = new RichText({ text: parsedText }, { cleanNewlines: true }) 203 207 await rt.detectFacets(agent) 204 208 209 + if (markdownFacets.length > 0) { 210 + const nonOverlapping = (rt.facets || []).filter(f => { 211 + return !markdownFacets.some(mf => { 212 + return ( 213 + (f.index.byteStart >= mf.index.byteStart && 214 + f.index.byteStart < mf.index.byteEnd) || 215 + (f.index.byteEnd > mf.index.byteStart && 216 + f.index.byteEnd <= mf.index.byteEnd) || 217 + (mf.index.byteStart >= f.index.byteStart && 218 + mf.index.byteStart < f.index.byteEnd) 219 + ) 220 + }) 221 + }) 222 + rt.facets = [...nonOverlapping, ...markdownFacets].sort( 223 + (a, b) => a.index.byteStart - b.index.byteStart, 224 + ) 225 + } 226 + 205 227 rt = shortenLinks(rt) 206 228 rt = stripInvalidMentions(rt) 207 229 return rt ··· 303 325 const images: AppBskyEmbedImages.Image[] = await Promise.all( 304 326 imagesDraft.map(async (image, i) => { 305 327 logger.debug(`Compressing image #${i}`) 306 - const {path, width, height, mime} = await compressImage(image) 328 + const { path, width, height, mime } = await compressImage(image) 307 329 logger.debug(`Uploading image #${i}`) 308 330 const res = await uploadBlob(agent, path, mime) 309 331 return { 310 332 image: res.data.blob, 311 333 alt: image.alt, 312 - aspectRatio: {width, height}, 334 + aspectRatio: { width, height }, 313 335 } 314 336 }), 315 337 ) ··· 327 349 videoDraft.captions 328 350 .filter(caption => caption.lang !== '') 329 351 .map(async caption => { 330 - const {data} = await agent.uploadBlob(caption.file, { 352 + const { data } = await agent.uploadBlob(caption.file, { 331 353 encoding: 'text/vtt', 332 354 }) 333 - return {lang: caption.lang, file: data.blob} 355 + return { lang: caption.lang, file: data.blob } 334 356 }), 335 357 ) 336 358 ··· 340 362 341 363 // aspect ratio values must be >0 - better to leave as unset otherwise 342 364 // posting will fail if aspect ratio is set to 0 343 - const aspectRatio = width > 0 && height > 0 ? {width, height} : undefined 365 + const aspectRatio = width > 0 && height > 0 ? { width, height } : undefined 344 366 345 367 if (!aspectRatio) { 346 368 logger.error( ··· 366 388 let blob: BlobRef | undefined 367 389 if (resolvedGif.thumb) { 368 390 onStateChange?.(t`Uploading link thumbnail...`) 369 - const {path, mime} = resolvedGif.thumb.source 391 + const { path, mime } = resolvedGif.thumb.source 370 392 const response = await uploadBlob(agent, path, mime) 371 393 blob = response.data.blob 372 394 } ··· 390 412 let blob: BlobRef | undefined 391 413 if (resolvedLink.thumb) { 392 414 onStateChange?.(t`Uploading link thumbnail...`) 393 - const {path, mime} = resolvedLink.thumb.source 415 + const { path, mime } = resolvedLink.thumb.source 394 416 const response = await uploadBlob(agent, path, mime) 395 417 blob = response.data.blob 396 418 }
+46 -3
src/lib/strings/rich-text-manip.ts
··· 1 - import {AppBskyRichtextFacet, type RichText, UnicodeString} from '@atproto/api' 1 + import { AppBskyRichtextFacet, type RichText, UnicodeString } from '@atproto/api' 2 2 3 - import {toShortUrl} from './url-helpers' 3 + import { toShortUrl } from './url-helpers' 4 4 5 5 export function shortenLinks(rt: RichText): RichText { 6 6 if (!rt.facets?.length) { ··· 16 16 } 17 17 18 18 // extract and shorten the URL 19 - const {byteStart, byteEnd} = facet.index 19 + const { byteStart, byteEnd } = facet.index 20 20 const url = rt.unicodeText.slice(byteStart, byteEnd) 21 21 const shortened = new UnicodeString(toShortUrl(url)) 22 22 ··· 49 49 } 50 50 return rt 51 51 } 52 + 53 + export function parseMarkdownLinks(text: string): { 54 + text: string 55 + facets: AppBskyRichtextFacet.Main[] 56 + } { 57 + const regex = /\[([^\]]+)\]\(([^)]+)\)/g 58 + let match 59 + let newText = '' 60 + let lastIndex = 0 61 + const facets: AppBskyRichtextFacet.Main[] = [] 62 + 63 + while ((match = regex.exec(text)) !== null) { 64 + const [fullMatch, linkText, linkUrl] = match 65 + const matchStart = match.index 66 + newText += text.slice(lastIndex, matchStart) 67 + const startByte = new UnicodeString(newText).length 68 + newText += linkText 69 + const endByte = new UnicodeString(newText).length 70 + let validUrl = linkUrl 71 + if (!validUrl.startsWith('http://') && !validUrl.startsWith('https://') && !validUrl.startsWith('mailto:')) { 72 + validUrl = `https://${validUrl}` 73 + } 74 + 75 + facets.push({ 76 + index: { 77 + byteStart: startByte, 78 + byteEnd: endByte, 79 + }, 80 + features: [ 81 + { 82 + $type: 'app.bsky.richtext.facet#link', 83 + uri: validUrl, 84 + }, 85 + ], 86 + }) 87 + 88 + lastIndex = matchStart + fullMatch.length 89 + } 90 + 91 + newText += text.slice(lastIndex) 92 + 93 + return { text: newText, facets } 94 + }