From 819340dd3c34e89e8cd7126c6f1172aba7a8ebec Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 16 Aug 2023 10:22:50 -0700 Subject: [PATCH] Shorten links in composer to reduce char usage (#1188) * Modify toShortUrl() to always include the full domain * Shorten links in the composer to save on characters * Apply some limits to the link card suggester --- __tests__/lib/string.test.ts | 58 ++++++++++++++++++- src/lib/api/index.ts | 4 +- src/lib/strings/rich-text-manip.ts | 34 +++++++++++ src/lib/strings/url-helpers.ts | 13 ++--- src/view/com/composer/Composer.tsx | 39 ++++++++----- .../com/composer/text-input/TextInput.web.tsx | 1 + 6 files changed, 123 insertions(+), 26 deletions(-) create mode 100644 src/lib/strings/rich-text-manip.ts diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 936708cf..726c9be9 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -1,3 +1,4 @@ +import {RichText} from '@atproto/api' import { getYoutubeVideoId, makeRecordUri, @@ -8,6 +9,7 @@ import { import {pluralize, enforceLen} from '../../src/lib/strings/helpers' import {ago} from '../../src/lib/strings/time' import {detectLinkables} from '../../src/lib/strings/rich-text-detection' +import {shortenLinks} from '../../src/lib/strings/rich-text-manip' import {makeValidHandle, createFullHandle} from '../../src/lib/strings/handles' import {cleanError} from '../../src/lib/strings/errors' @@ -296,11 +298,15 @@ describe('toShortUrl', () => { 'https://bsky.app', 'https://bsky.app/3jk7x4irgv52r', 'https://bsky.app/3jk7x4irgv52r2313y182h9', + 'https://very-long-domain-name.com/foo', + 'https://very-long-domain-name.com/foo?bar=baz#andsomemore', ] const outputs = [ 'bsky.app', 'bsky.app/3jk7x4irgv52r', - 'bsky.app/3jk7x4irgv52r2313y...', + 'bsky.app/3jk7x4irgv52...', + 'very-long-domain-name.com/foo', + 'very-long-domain-name.com/foo?bar=baz#...', ] it('shortens the url', () => { @@ -352,3 +358,53 @@ describe('getYoutubeVideoId', () => { expect(getYoutubeVideoId('https://youtu.be/videoId')).toBe('videoId') }) }) + +describe('shortenLinks', () => { + const inputs = [ + 'start https://middle.com/foo/bar?baz=bux#hash end', + 'https://start.com/foo/bar?baz=bux#hash middle end', + 'start middle https://end.com/foo/bar?baz=bux#hash', + 'https://newline1.com/very/long/url/here\nhttps://newline2.com/very/long/url/here', + 'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/', + ] + const outputs = [ + [ + 'start middle.com/foo/bar?baz=... end', + ['https://middle.com/foo/bar?baz=bux#hash'], + ], + [ + 'start.com/foo/bar?baz=... middle end', + ['https://start.com/foo/bar?baz=bux#hash'], + ], + [ + 'start middle end.com/foo/bar?baz=...', + ['https://end.com/foo/bar?baz=bux#hash'], + ], + [ + 'newline1.com/very/long/ur...\nnewline2.com/very/long/ur...', + [ + 'https://newline1.com/very/long/url/here', + 'https://newline2.com/very/long/url/here', + ], + ], + [ + 'Classic article socket3.wordpress.com/2018/02/03/d...', + [ + 'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/', + ], + ], + ] + it('correctly shortens rich text while preserving facet URIs', () => { + for (let i = 0; i < inputs.length; i++) { + const input = inputs[i] + const inputRT = new RichText({text: input}) + inputRT.detectFacetsWithoutResolution() + const outputRT = shortenLinks(inputRT) + expect(outputRT.text).toEqual(outputs[i][0]) + expect(outputRT.facets?.length).toEqual(outputs[i][1].length) + for (let j = 0; j < outputs[i][1].length; j++) { + expect(outputRT.facets![j].features[0].uri).toEqual(outputs[i][1][j]) + } + } + }) +}) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index bb4ff8fc..4ecd3204 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -14,6 +14,7 @@ import {isNetworkError} from 'lib/strings/errors' import {LinkMeta} from '../link-meta/link-meta' import {isWeb} from 'platform/detection' import {ImageModel} from 'state/models/media/image' +import {shortenLinks} from 'lib/strings/rich-text-manip' export interface ExternalEmbedDraft { uri: string @@ -92,7 +93,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { | AppBskyEmbedRecordWithMedia.Main | undefined let reply - const rt = new RichText( + let rt = new RichText( {text: opts.rawText.trim()}, { cleanNewlines: true, @@ -101,6 +102,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { opts.onStateChange?.('Processing...') await rt.detectFacets(store.agent) + rt = shortenLinks(rt) // filter out any mention facets that didn't map to a user rt.facets = rt.facets?.filter(facet => { diff --git a/src/lib/strings/rich-text-manip.ts b/src/lib/strings/rich-text-manip.ts new file mode 100644 index 00000000..d9cd8c07 --- /dev/null +++ b/src/lib/strings/rich-text-manip.ts @@ -0,0 +1,34 @@ +import {RichText, UnicodeString} from '@atproto/api' +import {toShortUrl} from './url-helpers' + +export function shortenLinks(rt: RichText): RichText { + if (!rt.facets?.length) { + return rt + } + rt = rt.clone() + // enumerate the link facets + if (rt.facets) { + for (const facet of rt.facets) { + const isLink = !!facet.features.find( + f => f.$type === 'app.bsky.richtext.facet#link', + ) + if (!isLink) { + continue + } + + // extract and shorten the URL + const {byteStart, byteEnd} = facet.index + const url = rt.unicodeText.slice(byteStart, byteEnd) + const shortened = new UnicodeString(toShortUrl(url)) + + // insert the shorten URL + rt.insert(byteStart, shortened.utf16) + // update the facet to cover the new shortened URL + facet.index.byteStart = byteStart + facet.index.byteEnd = byteStart + shortened.length + // remove the old URL + rt.delete(byteStart + shortened.length, byteEnd + shortened.length) + } + } + return rt +} diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 1406e2af..b509aad0 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -42,15 +42,12 @@ export function toShortUrl(url: string): string { if (urlp.protocol !== 'http:' && urlp.protocol !== 'https:') { return url } - const shortened = - urlp.host + - (urlp.pathname === '/' ? '' : urlp.pathname) + - urlp.search + - urlp.hash - if (shortened.length > 30) { - return shortened.slice(0, 27) + '...' + const path = + (urlp.pathname === '/' ? '' : urlp.pathname) + urlp.search + urlp.hash + if (path.length > 15) { + return urlp.host + path.slice(0, 13) + '...' } - return shortened ? shortened : url + return urlp.host + path } catch (e) { return url } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 7d3e2757..f9629797 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -32,6 +32,8 @@ import {s, colors, gradients} from 'lib/styles' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {cleanError} from 'lib/strings/errors' +import {shortenLinks} from 'lib/strings/rich-text-manip' +import {toShortUrl} from 'lib/strings/url-helpers' import {SelectPhotoBtn} from './photos/SelectPhotoBtn' import {OpenCameraBtn} from './photos/OpenCameraBtn' import {usePalette} from 'lib/hooks/usePalette' @@ -63,7 +65,9 @@ export const ComposePost = observer(function ComposePost({ const [processingState, setProcessingState] = useState('') const [error, setError] = useState('') const [richtext, setRichText] = useState(new RichText({text: ''})) - const graphemeLength = useMemo(() => richtext.graphemeLength, [richtext]) + const graphemeLength = useMemo(() => { + return shortenLinks(richtext).graphemeLength + }, [richtext]) const [quote, setQuote] = useState( initQuote, ) @@ -148,7 +152,7 @@ export const ComposePost = observer(function ComposePost({ ) const onPressPublish = async (rt: RichText) => { - if (isProcessing || rt.graphemeLength > MAX_GRAPHEME_LENGTH) { + if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { return } if (store.preferences.requireAltTextEnabled && gallery.needsAltText) { @@ -352,20 +356,23 @@ export const ComposePost = observer(function ComposePost({ {!extLink && suggestedLinks.size > 0 ? ( - {Array.from(suggestedLinks).map(url => ( - onPressAddLinkCard(url)} - accessibilityRole="button" - accessibilityLabel="Add link card" - accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}> - - Add link card: {url} - - - ))} + {Array.from(suggestedLinks) + .slice(0, 3) + .map(url => ( + onPressAddLinkCard(url)} + accessibilityRole="button" + accessibilityLabel="Add link card" + accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}> + + Add link card:{' '} + {toShortUrl(url)} + + + ))} ) : null} diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 245c17b9..da34a5b9 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -107,6 +107,7 @@ export const TextInput = React.forwardRef( const json = editorProp.getJSON() const newRt = new RichText({text: editorJsonToText(json).trim()}) + newRt.detectFacetsWithoutResolution() setRichText(newRt) const newSuggestedLinks = new Set(editorJsonToLinks(json))