Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
122
fork

Configure Feed

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

Implement markdown links support

authored by

scanash00 and committed by
Tangled
f8263abe 4561c3f9

+90 -25
+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 + }