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
zio/stable
Paul Frazee 2023-08-16 10:22:50 -07:00 committed by GitHub
parent 5379561934
commit 819340dd3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 123 additions and 26 deletions

View File

@ -1,3 +1,4 @@
import {RichText} from '@atproto/api'
import { import {
getYoutubeVideoId, getYoutubeVideoId,
makeRecordUri, makeRecordUri,
@ -8,6 +9,7 @@ import {
import {pluralize, enforceLen} from '../../src/lib/strings/helpers' import {pluralize, enforceLen} from '../../src/lib/strings/helpers'
import {ago} from '../../src/lib/strings/time' import {ago} from '../../src/lib/strings/time'
import {detectLinkables} from '../../src/lib/strings/rich-text-detection' 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 {makeValidHandle, createFullHandle} from '../../src/lib/strings/handles'
import {cleanError} from '../../src/lib/strings/errors' import {cleanError} from '../../src/lib/strings/errors'
@ -296,11 +298,15 @@ describe('toShortUrl', () => {
'https://bsky.app', 'https://bsky.app',
'https://bsky.app/3jk7x4irgv52r', 'https://bsky.app/3jk7x4irgv52r',
'https://bsky.app/3jk7x4irgv52r2313y182h9', 'https://bsky.app/3jk7x4irgv52r2313y182h9',
'https://very-long-domain-name.com/foo',
'https://very-long-domain-name.com/foo?bar=baz#andsomemore',
] ]
const outputs = [ const outputs = [
'bsky.app', 'bsky.app',
'bsky.app/3jk7x4irgv52r', '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', () => { it('shortens the url', () => {
@ -352,3 +358,53 @@ describe('getYoutubeVideoId', () => {
expect(getYoutubeVideoId('https://youtu.be/videoId')).toBe('videoId') 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])
}
}
})
})

View File

@ -14,6 +14,7 @@ import {isNetworkError} from 'lib/strings/errors'
import {LinkMeta} from '../link-meta/link-meta' import {LinkMeta} from '../link-meta/link-meta'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {ImageModel} from 'state/models/media/image' import {ImageModel} from 'state/models/media/image'
import {shortenLinks} from 'lib/strings/rich-text-manip'
export interface ExternalEmbedDraft { export interface ExternalEmbedDraft {
uri: string uri: string
@ -92,7 +93,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
| AppBskyEmbedRecordWithMedia.Main | AppBskyEmbedRecordWithMedia.Main
| undefined | undefined
let reply let reply
const rt = new RichText( let rt = new RichText(
{text: opts.rawText.trim()}, {text: opts.rawText.trim()},
{ {
cleanNewlines: true, cleanNewlines: true,
@ -101,6 +102,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
opts.onStateChange?.('Processing...') opts.onStateChange?.('Processing...')
await rt.detectFacets(store.agent) await rt.detectFacets(store.agent)
rt = shortenLinks(rt)
// filter out any mention facets that didn't map to a user // filter out any mention facets that didn't map to a user
rt.facets = rt.facets?.filter(facet => { rt.facets = rt.facets?.filter(facet => {

View File

@ -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
}

View File

@ -42,15 +42,12 @@ export function toShortUrl(url: string): string {
if (urlp.protocol !== 'http:' && urlp.protocol !== 'https:') { if (urlp.protocol !== 'http:' && urlp.protocol !== 'https:') {
return url return url
} }
const shortened = const path =
urlp.host + (urlp.pathname === '/' ? '' : urlp.pathname) + urlp.search + urlp.hash
(urlp.pathname === '/' ? '' : urlp.pathname) + if (path.length > 15) {
urlp.search + return urlp.host + path.slice(0, 13) + '...'
urlp.hash
if (shortened.length > 30) {
return shortened.slice(0, 27) + '...'
} }
return shortened ? shortened : url return urlp.host + path
} catch (e) { } catch (e) {
return url return url
} }

View File

@ -32,6 +32,8 @@ import {s, colors, gradients} from 'lib/styles'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {cleanError} from 'lib/strings/errors' 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 {SelectPhotoBtn} from './photos/SelectPhotoBtn'
import {OpenCameraBtn} from './photos/OpenCameraBtn' import {OpenCameraBtn} from './photos/OpenCameraBtn'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
@ -63,7 +65,9 @@ export const ComposePost = observer(function ComposePost({
const [processingState, setProcessingState] = useState('') const [processingState, setProcessingState] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [richtext, setRichText] = useState(new RichText({text: ''})) 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>( const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
initQuote, initQuote,
) )
@ -148,7 +152,7 @@ export const ComposePost = observer(function ComposePost({
) )
const onPressPublish = async (rt: RichText) => { const onPressPublish = async (rt: RichText) => {
if (isProcessing || rt.graphemeLength > MAX_GRAPHEME_LENGTH) { if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
return return
} }
if (store.preferences.requireAltTextEnabled && gallery.needsAltText) { if (store.preferences.requireAltTextEnabled && gallery.needsAltText) {
@ -352,20 +356,23 @@ export const ComposePost = observer(function ComposePost({
</ScrollView> </ScrollView>
{!extLink && suggestedLinks.size > 0 ? ( {!extLink && suggestedLinks.size > 0 ? (
<View style={s.mb5}> <View style={s.mb5}>
{Array.from(suggestedLinks).map(url => ( {Array.from(suggestedLinks)
<TouchableOpacity .slice(0, 3)
key={`suggested-${url}`} .map(url => (
testID="addLinkCardBtn" <TouchableOpacity
style={[pal.borderDark, styles.addExtLinkBtn]} key={`suggested-${url}`}
onPress={() => onPressAddLinkCard(url)} testID="addLinkCardBtn"
accessibilityRole="button" style={[pal.borderDark, styles.addExtLinkBtn]}
accessibilityLabel="Add link card" onPress={() => onPressAddLinkCard(url)}
accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}> accessibilityRole="button"
<Text style={pal.text}> accessibilityLabel="Add link card"
Add link card: <Text style={pal.link}>{url}</Text> accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
</Text> <Text style={pal.text}>
</TouchableOpacity> Add link card:{' '}
))} <Text style={pal.link}>{toShortUrl(url)}</Text>
</Text>
</TouchableOpacity>
))}
</View> </View>
) : null} ) : null}
<View style={[pal.border, styles.bottomBar]}> <View style={[pal.border, styles.bottomBar]}>

View File

@ -107,6 +107,7 @@ export const TextInput = React.forwardRef(
const json = editorProp.getJSON() const json = editorProp.getJSON()
const newRt = new RichText({text: editorJsonToText(json).trim()}) const newRt = new RichText({text: editorJsonToText(json).trim()})
newRt.detectFacetsWithoutResolution()
setRichText(newRt) setRichText(newRt)
const newSuggestedLinks = new Set(editorJsonToLinks(json)) const newSuggestedLinks = new Set(editorJsonToLinks(json))