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 {
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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:') {
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,7 +356,9 @@ 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)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(url => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={`suggested-${url}`}
|
key={`suggested-${url}`}
|
||||||
testID="addLinkCardBtn"
|
testID="addLinkCardBtn"
|
||||||
|
@ -362,7 +368,8 @@ export const ComposePost = observer(function ComposePost({
|
||||||
accessibilityLabel="Add link card"
|
accessibilityLabel="Add link card"
|
||||||
accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
|
accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
|
||||||
<Text style={pal.text}>
|
<Text style={pal.text}>
|
||||||
Add link card: <Text style={pal.link}>{url}</Text>
|
Add link card:{' '}
|
||||||
|
<Text style={pal.link}>{toShortUrl(url)}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -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))
|
||||||
|
|
Loading…
Reference in New Issue