about summary refs log tree commit diff
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-08-16 10:22:50 -0700
committerGitHub <noreply@github.com>2023-08-16 10:22:50 -0700
commit819340dd3c34e89e8cd7126c6f1172aba7a8ebec (patch)
tree91a1a4e3f45d7a0e7c32f530319c6349c778ccfc
parent5379561934f6249fbbecf33ed0cd10d2d30128f0 (diff)
downloadvoidsky-819340dd3c34e89e8cd7126c6f1172aba7a8ebec.tar.zst
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
-rw-r--r--__tests__/lib/string.test.ts58
-rw-r--r--src/lib/api/index.ts4
-rw-r--r--src/lib/strings/rich-text-manip.ts34
-rw-r--r--src/lib/strings/url-helpers.ts13
-rw-r--r--src/view/com/composer/Composer.tsx39
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx1
6 files changed, 123 insertions, 26 deletions
diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts
index 936708cf2..726c9be94 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 bb4ff8fcb..4ecd32046 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 000000000..d9cd8c071
--- /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 1406e2af0..b509aad01 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 7d3e27571..f9629797a 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<ComposerOpts['quote'] | undefined>(
     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({
         </ScrollView>
         {!extLink && suggestedLinks.size > 0 ? (
           <View style={s.mb5}>
-            {Array.from(suggestedLinks).map(url => (
-              <TouchableOpacity
-                key={`suggested-${url}`}
-                testID="addLinkCardBtn"
-                style={[pal.borderDark, styles.addExtLinkBtn]}
-                onPress={() => onPressAddLinkCard(url)}
-                accessibilityRole="button"
-                accessibilityLabel="Add link card"
-                accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
-                <Text style={pal.text}>
-                  Add link card: <Text style={pal.link}>{url}</Text>
-                </Text>
-              </TouchableOpacity>
-            ))}
+            {Array.from(suggestedLinks)
+              .slice(0, 3)
+              .map(url => (
+                <TouchableOpacity
+                  key={`suggested-${url}`}
+                  testID="addLinkCardBtn"
+                  style={[pal.borderDark, styles.addExtLinkBtn]}
+                  onPress={() => onPressAddLinkCard(url)}
+                  accessibilityRole="button"
+                  accessibilityLabel="Add link card"
+                  accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
+                  <Text style={pal.text}>
+                    Add link card:{' '}
+                    <Text style={pal.link}>{toShortUrl(url)}</Text>
+                  </Text>
+                </TouchableOpacity>
+              ))}
           </View>
         ) : null}
         <View style={[pal.border, styles.bottomBar]}>
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 245c17b9c..da34a5b9a 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))