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 suggesterzio/stable
parent
5379561934
commit
819340dd3c
|
@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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]}>
|
||||
|
|
|
@ -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))
|
||||
|
|
Loading…
Reference in New Issue