diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index ef09af497..03c1fabbf 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -270,7 +270,7 @@ async function resolveRT(appviewClient: Client, richtext: RichText) { delete rt.facets } - rt = shortenLinks(rt, true) + rt = shortenLinks(rt, true, markdownFacets) rt = stripInvalidMentions(rt) return rt } diff --git a/src/lib/strings/rich-text-helpers.test.ts b/src/lib/strings/rich-text-helpers.test.ts index e9f4b858e..db760ff67 100644 --- a/src/lib/strings/rich-text-helpers.test.ts +++ b/src/lib/strings/rich-text-helpers.test.ts @@ -1,7 +1,11 @@ import {RichText} from '@bsky/sdk/richtext' import {richTextToRedraftString} from './rich-text-helpers' -import {applyFacetSyntax, parseMarkdownLinks} from './rich-text-manip' +import { + applyFacetSyntax, + parseMarkdownLinks, + shortenLinks, +} from './rich-text-manip' import {toShortUrl} from './url-helpers' describe('richTextToRedraftString', () => { @@ -45,8 +49,8 @@ describe('richTextToRedraftString', () => { ) }) - it('does not wrap a link whose text is already its destination', () => { - const uri = 'https://example.com/page' + it('preserves a full URL label as an explicit masked link', () => { + const uri = 'https://example.com/a/long/path/to/a/page' const richText = new RichText({ text: uri, facets: [ @@ -57,11 +61,20 @@ describe('richTextToRedraftString', () => { ], }) - expect(richTextToRedraftString(richText)).toBe(uri) + expect(richTextToRedraftString(richText)).toBe(`[${uri}](${uri})`) + + const parsed = parseMarkdownLinks(richTextToRedraftString(richText)) + const repost = new RichText({ + text: parsed.text, + facets: parsed.facets as unknown as NonNullable, + }) + const shortened = shortenLinks(repost, true, parsed.facets) + expect(shortened.text).toBe(uri) + expect(shortened.facets).toEqual(richText.facets) }) }) -it('preserves the label and destination of a truncated URL', () => { +it('restores a normally truncated URL without masked syntax', () => { const uri = 'https://example.com/a/long/path/to/a/page' const text = toShortUrl(uri) const rt = new RichText({ @@ -73,7 +86,13 @@ it('preserves the label and destination of a truncated URL', () => { }, ], }) - expect(richTextToRedraftString(rt)).toBe(`[${text}](${uri})`) + expect(richTextToRedraftString(rt)).toBe(uri) + + const repost = new RichText({text: richTextToRedraftString(rt)}) + repost.detectFacetsWithoutResolution() + const shortened = shortenLinks(repost, true) + expect(shortened.text).toBe(text) + expect(shortened.facets).toEqual(rt.facets) }) describe('richTextToRedraftString', () => { diff --git a/src/lib/strings/rich-text-helpers.ts b/src/lib/strings/rich-text-helpers.ts index 674996dda..298a15abb 100644 --- a/src/lib/strings/rich-text-helpers.ts +++ b/src/lib/strings/rich-text-helpers.ts @@ -4,7 +4,7 @@ import {RichText} from '@bsky/sdk/richtext' import {app} from '#/lexicons' import * as bsky from '#/types/bsky' import {getEnclosedFacet} from './rich-text-manip' -import {linkRequiresWarning} from './url-helpers' +import {linkRequiresWarning, toShortUrl} from './url-helpers' export function richTextToString(rt: RichText, loose: boolean): string { const {text, facets} = rt @@ -48,6 +48,20 @@ export function richTextToRedraftString(rt: RichText): string { continue } + const link = facet.features.find(AppBskyRichtextFacet.isLink) + if ( + link && + segment.text === toShortUrl(link.uri) && + /(^|\s|\()$/.test(rt.unicodeText.slice(0, facet.index.byteStart)) + ) { + result += link.uri + continue + } + if (link && segment.text === link.uri) { + result += `[${segment.text}](${link.uri})` + continue + } + const automatic = detected.facets?.some( current => current.index.byteStart === facet.index.byteStart && @@ -65,7 +79,6 @@ export function richTextToRedraftString(rt: RichText): string { continue } - const link = facet.features.find(AppBskyRichtextFacet.isLink) result += link ? `[${segment.text}](${link.uri})` : segment.text } diff --git a/src/lib/strings/rich-text-manip.test.ts b/src/lib/strings/rich-text-manip.test.ts index 3b781a2d1..cec88ce43 100644 --- a/src/lib/strings/rich-text-manip.test.ts +++ b/src/lib/strings/rich-text-manip.test.ts @@ -33,6 +33,26 @@ function facetTexts(rt: RichText) { } describe('facet syntax', () => { + it('preserves an explicit full URL label while shortening ordinary URLs', () => { + const uri = + 'https://full-link.example/a-really-long-link-that-would-normally-be-truncated' + const parsed = parseMarkdownLinks( + `[${uri}](${uri}) and https://example.com/another-long-path`, + ) + const rt = new RichText({text: parsed.text}) + rt.detectFacetsWithoutResolution() + rt.facets = [ + ...(rt.facets ?? []).filter(facet => facet.index.byteStart > uri.length), + ...parsed.facets, + ] as typeof rt.facets + applyFacetSyntax(rt, {removeSyntax: true}) + const shortened = shortenLinks(rt, true, parsed.facets) + + expect(shortened.text).toBe(`${uri} and example.com/another-long...`) + expect(facetTexts(shortened)).toEqual([uri, 'example.com/another-long...']) + expect(shortened.graphemeLength).toBe(shortened.text.length) + }) + it('preserves theme labels and byte ranges when posting masked links', () => { const labels = ['Mocha', 'Macchiato', 'Frappé', 'Latte'] const uris = labels.map( diff --git a/src/lib/strings/rich-text-manip.ts b/src/lib/strings/rich-text-manip.ts index 955a55a30..c4c3c7db4 100644 --- a/src/lib/strings/rich-text-manip.ts +++ b/src/lib/strings/rich-text-manip.ts @@ -39,14 +39,28 @@ export function restoreLinks( return parts.join('') } -export function shortenLinks(rt: RichText, preserveLabels = false): RichText { +export function shortenLinks( + rt: RichText, + preserveLabels = false, + preserveFacets: readonly AppBskyRichtextFacet.Main[] = [], +): RichText { if (!rt.facets?.length) { return rt } + /** Remember explicit labels before cloning loses their facet identities. */ + const preservedStarts = new Set( + rt.facets + .filter(facet => preserveFacets.includes(facet)) + .map(facet => facet.index.byteStart), + ) rt = rt.clone() // enumerate the link facets if (rt.facets) { + const preserved = new Set( + rt.facets.filter(facet => preservedStarts.has(facet.index.byteStart)), + ) for (const facet of rt.facets) { + if (preserved.has(facet)) continue const isLink = !!facet.features.find(f => bsky.isType(app.bsky.richtext.facet.link, f), ) diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index cfdefbee9..21b34967e 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -640,7 +640,7 @@ export function MessagesList({ rt = applyFacetSyntax(rt, {removeSyntax: true}) await resolveSyntaxMentions(rt, appviewClient) - rt = shortenLinks(rt, true) + rt = shortenLinks(rt, true, parsed.facets) rt = stripInvalidMentions(rt) if (!hasScrolled) { diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 5d1872b98..b4128ba8b 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -864,5 +864,5 @@ function getShortenedLength(rt: RichText) { ...(parsed.facets as unknown as NonNullable), ] applyFacetSyntax(measuredRt, {removeSyntax: true}) - return shortenLinks(measuredRt, true).graphemeLength + return shortenLinks(measuredRt, true, parsed.facets).graphemeLength }