From e0fef1f2f8c21ec83b0d464ba6802f95aecb4b91 Mon Sep 17 00:00:00 2001 From: scanash00 Date: Fri, 28 Nov 2025 02:55:26 -0900 Subject: [PATCH] Implement markdown links support --- src/lib/api/index.ts | 66 ++++++++++++++++++++---------- src/lib/strings/rich-text-manip.ts | 49 ++++++++++++++++++++-- 2 files changed, 90 insertions(+), 25 deletions(-) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 0b95d6b6c..1da55a9ad 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -14,18 +14,18 @@ import { 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, @@ -39,10 +39,10 @@ import { 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 @@ -96,7 +96,7 @@ export async function post( if (draft.labels.length) { labels = { $type: 'com.atproto.label.defs#selfLabels', - values: draft.labels.map(val => ({val})), + values: draft.labels.map(val => ({ val })), } } @@ -190,7 +190,7 @@ export async function post( } } - return {uris} + return { uris } } async function resolveRT(agent: BskyAgent, richtext: RichText) { @@ -199,9 +199,31 @@ 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 @@ -303,13 +325,13 @@ async function resolveMedia( 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 }, } }), ) @@ -327,10 +349,10 @@ async function resolveMedia( 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 } }), ) @@ -340,7 +362,7 @@ async function resolveMedia( // 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( @@ -366,7 +388,7 @@ async function resolveMedia( 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 } @@ -390,7 +412,7 @@ async function resolveMedia( 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 } diff --git a/src/lib/strings/rich-text-manip.ts b/src/lib/strings/rich-text-manip.ts index 099fbffb0..6f0ea8c81 100644 --- a/src/lib/strings/rich-text-manip.ts +++ b/src/lib/strings/rich-text-manip.ts @@ -1,6 +1,6 @@ -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) { @@ -16,7 +16,7 @@ export function shortenLinks(rt: RichText): RichText { } // 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)) @@ -49,3 +49,46 @@ export function stripInvalidMentions(rt: RichText): RichText { } 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 } +} -- 2.50.1 (Apple Git-155)