Implement markdown links support (hyperlinks) #1

merged
opened by scanash.com targeting main
Changed files
+90 -25
src
lib
+44 -22
src/lib/api/index.ts
···
type ComAtprotoRepoStrongRef,
RichText,
} from '@atproto/api'
-
import {TID} from '@atproto/common-web'
+
import { TID } from '@atproto/common-web'
import * as dcbor from '@ipld/dag-cbor'
-
import {t} from '@lingui/macro'
-
import {type QueryClient} from '@tanstack/react-query'
-
import {sha256} from 'js-sha256'
-
import {CID} from 'multiformats/cid'
+
import { t } from '@lingui/macro'
+
import { type QueryClient } from '@tanstack/react-query'
+
import { sha256 } from 'js-sha256'
+
import { CID } from 'multiformats/cid'
import * as Hasher from 'multiformats/hashes/hasher'
-
import {isNetworkError} from '#/lib/strings/errors'
-
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
-
import {logger} from '#/logger'
-
import {compressImage} from '#/state/gallery'
+
import { isNetworkError } from '#/lib/strings/errors'
+
import { shortenLinks, stripInvalidMentions, parseMarkdownLinks } from '#/lib/strings/rich-text-manip'
+
import { logger } from '#/logger'
+
import { compressImage } from '#/state/gallery'
import {
fetchResolveGifQuery,
fetchResolveLinkQuery,
···
type PostDraft,
type ThreadDraft,
} from '#/view/com/composer/state/composer'
-
import {createGIFDescription} from '../gif-alt-text'
-
import {uploadBlob} from './upload-blob'
+
import { createGIFDescription } from '../gif-alt-text'
+
import { uploadBlob } from './upload-blob'
-
export {uploadBlob}
+
export { uploadBlob }
interface PostOpts {
thread: ThreadDraft
···
if (draft.labels.length) {
labels = {
$type: 'com.atproto.label.defs#selfLabels',
-
values: draft.labels.map(val => ({val})),
+
values: draft.labels.map(val => ({ val })),
}
}
···
}
}
-
return {uris}
+
return { uris }
}
async function resolveRT(agent: BskyAgent, richtext: RichText) {
···
.replace(/^(\s*\n)+/, '')
// Trim any trailing whitespace.
.trimEnd()
-
let rt = new RichText({text: trimmedText}, {cleanNewlines: true})
+
+
const { text: parsedText, facets: markdownFacets } =
+
parseMarkdownLinks(trimmedText)
+
+
let rt = new RichText({ text: parsedText }, { cleanNewlines: true })
await rt.detectFacets(agent)
+
if (markdownFacets.length > 0) {
+
const nonOverlapping = (rt.facets || []).filter(f => {
+
return !markdownFacets.some(mf => {
+
return (
+
(f.index.byteStart >= mf.index.byteStart &&
+
f.index.byteStart < mf.index.byteEnd) ||
+
(f.index.byteEnd > mf.index.byteStart &&
+
f.index.byteEnd <= mf.index.byteEnd) ||
+
(mf.index.byteStart >= f.index.byteStart &&
+
mf.index.byteStart < f.index.byteEnd)
+
)
+
})
+
})
+
rt.facets = [...nonOverlapping, ...markdownFacets].sort(
+
(a, b) => a.index.byteStart - b.index.byteStart,
+
)
+
}
+
rt = shortenLinks(rt)
rt = stripInvalidMentions(rt)
return rt
···
const images: AppBskyEmbedImages.Image[] = await Promise.all(
imagesDraft.map(async (image, i) => {
logger.debug(`Compressing image #${i}`)
-
const {path, width, height, mime} = await compressImage(image)
+
const { path, width, height, mime } = await compressImage(image)
logger.debug(`Uploading image #${i}`)
const res = await uploadBlob(agent, path, mime)
return {
image: res.data.blob,
alt: image.alt,
-
aspectRatio: {width, height},
+
aspectRatio: { width, height },
}
}),
)
···
videoDraft.captions
.filter(caption => caption.lang !== '')
.map(async caption => {
-
const {data} = await agent.uploadBlob(caption.file, {
+
const { data } = await agent.uploadBlob(caption.file, {
encoding: 'text/vtt',
})
-
return {lang: caption.lang, file: data.blob}
+
return { lang: caption.lang, file: data.blob }
}),
)
···
// aspect ratio values must be >0 - better to leave as unset otherwise
// posting will fail if aspect ratio is set to 0
-
const aspectRatio = width > 0 && height > 0 ? {width, height} : undefined
+
const aspectRatio = width > 0 && height > 0 ? { width, height } : undefined
if (!aspectRatio) {
logger.error(
···
let blob: BlobRef | undefined
if (resolvedGif.thumb) {
onStateChange?.(t`Uploading link thumbnail...`)
-
const {path, mime} = resolvedGif.thumb.source
+
const { path, mime } = resolvedGif.thumb.source
const response = await uploadBlob(agent, path, mime)
blob = response.data.blob
}
···
let blob: BlobRef | undefined
if (resolvedLink.thumb) {
onStateChange?.(t`Uploading link thumbnail...`)
-
const {path, mime} = resolvedLink.thumb.source
+
const { path, mime } = resolvedLink.thumb.source
const response = await uploadBlob(agent, path, mime)
blob = response.data.blob
}
+46 -3
src/lib/strings/rich-text-manip.ts
···
-
import {AppBskyRichtextFacet, type RichText, UnicodeString} from '@atproto/api'
+
import { AppBskyRichtextFacet, type RichText, UnicodeString } from '@atproto/api'
-
import {toShortUrl} from './url-helpers'
+
import { toShortUrl } from './url-helpers'
export function shortenLinks(rt: RichText): RichText {
if (!rt.facets?.length) {
···
}
// extract and shorten the URL
-
const {byteStart, byteEnd} = facet.index
+
const { byteStart, byteEnd } = facet.index
const url = rt.unicodeText.slice(byteStart, byteEnd)
const shortened = new UnicodeString(toShortUrl(url))
···
}
return rt
}
+
+
export function parseMarkdownLinks(text: string): {
+
text: string
+
facets: AppBskyRichtextFacet.Main[]
+
} {
+
const regex = /\[([^\]]+)\]\(([^)]+)\)/g
+
let match
+
let newText = ''
+
let lastIndex = 0
+
const facets: AppBskyRichtextFacet.Main[] = []
+
+
while ((match = regex.exec(text)) !== null) {
+
const [fullMatch, linkText, linkUrl] = match
+
const matchStart = match.index
+
newText += text.slice(lastIndex, matchStart)
+
const startByte = new UnicodeString(newText).length
+
newText += linkText
+
const endByte = new UnicodeString(newText).length
+
let validUrl = linkUrl
+
if (!validUrl.startsWith('http://') && !validUrl.startsWith('https://') && !validUrl.startsWith('mailto:')) {
+
validUrl = `https://${validUrl}`
+
}
+
+
facets.push({
+
index: {
+
byteStart: startByte,
+
byteEnd: endByte,
+
},
+
features: [
+
{
+
$type: 'app.bsky.richtext.facet#link',
+
uri: validUrl,
+
},
+
],
+
})
+
+
lastIndex = matchStart + fullMatch.length
+
}
+
+
newText += text.slice(lastIndex)
+
+
return { text: newText, facets }
+
}