From 82d29974bdecad4aba71a2b0aacfd310ef91d1da Mon Sep 17 00:00:00 2001 From: scanash00 Date: Fri, 28 Nov 2025 16:16:25 -0900 Subject: [PATCH] fix: character limit for hyperlinks is reduced and full []() is highlighted --- src/view/com/composer/state/composer.ts | 54 ++++++++++--------- .../com/composer/text-input/TextInput.tsx | 51 +++++++++++++++++- .../com/composer/text-input/TextInput.web.tsx | 50 ++++++++++++++++- .../composer/text-input/web/LinkDecorator.ts | 15 +++++- 4 files changed, 143 insertions(+), 27 deletions(-) diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index c673f2134..f5e0e87ae 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -9,7 +9,10 @@ import {nanoid} from 'nanoid/non-secure' import {type SelfLabel} from '#/lib/moderation' import {insertMentionAt} from '#/lib/strings/mention-manip' -import {shortenLinks} from '#/lib/strings/rich-text-manip' +import { + parseMarkdownLinks, + shortenLinks, +} from '#/lib/strings/rich-text-manip' import { isBskyPostUrl, postUriToRelativePath, @@ -78,10 +81,10 @@ export type PostAction = | {type: 'embed_update_image'; image: ComposerImage} | {type: 'embed_remove_image'; image: ComposerImage} | { - type: 'embed_add_video' - asset: ImagePickerAsset - abortController: AbortController - } + type: 'embed_add_video' + asset: ImagePickerAsset + abortController: AbortController + } | {type: 'embed_remove_video'} | {type: 'embed_update_video'; videoAction: VideoAction} | {type: 'embed_add_uri'; uri: string} @@ -107,21 +110,21 @@ export type ComposerAction = | {type: 'update_postgate'; postgate: AppBskyFeedPostgate.Record} | {type: 'update_threadgate'; threadgate: ThreadgateAllowUISetting[]} | { - type: 'update_post' - postId: string - postAction: PostAction - } + type: 'update_post' + postId: string + postAction: PostAction + } | { - type: 'add_post' - } + type: 'add_post' + } | { - type: 'remove_post' - postId: string - } + type: 'remove_post' + postId: string + } | { - type: 'focus_post' - postId: string - } + type: 'focus_post' + postId: string + } export const MAX_IMAGES = 4 @@ -494,8 +497,8 @@ export function createComposerState({ initImageUris: ComposerOpts['imageUris'] initQuoteUri: string | undefined initInteractionSettings: - | BskyPreferences['postInteractionSettings'] - | undefined + | BskyPreferences['postInteractionSettings'] + | undefined }): ComposerState { let media: ImagesMedia | undefined if (initImageUris?.length) { @@ -520,10 +523,10 @@ export function createComposerState({ ? initText : initMention ? insertMentionAt( - `@${initMention}`, - initMention.length + 1, - `${initMention}`, - ) + `@${initMention}`, + initMention.length + 1, + `${initMention}`, + ) : '', }) @@ -620,5 +623,8 @@ export function createComposerState({ } function getShortenedLength(rt: RichText) { - return shortenLinks(rt).graphemeLength + const {text} = parseMarkdownLinks(rt.text) + const newRt = new RichText({text}) + newRt.detectFacetsWithoutResolution() + return shortenLinks(newRt).graphemeLength } diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 8b3e61b0e..1f182206e 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -11,7 +11,7 @@ import { type TextInputSelectionChangeEventData, View, } from 'react-native' -import {AppBskyRichtextFacet, RichText} from '@atproto/api' +import {AppBskyRichtextFacet, RichText, UnicodeString} from '@atproto/api' import PasteInput, { type PastedFile, type PasteInputRef, // @ts-expect-error no types when installing from github @@ -73,6 +73,55 @@ export function TextInput({ const newRt = new RichText({text: newText}) newRt.detectFacetsWithoutResolution() + + const markdownFacets: AppBskyRichtextFacet.Main[] = [] + const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g + let match + while ((match = regex.exec(newText)) !== null) { + const [fullMatch, _linkText, linkUrl] = match + const matchStart = match.index + const matchEnd = matchStart + fullMatch.length + const prefix = newText.slice(0, matchStart) + const matchStr = newText.slice(matchStart, matchEnd) + const byteStart = new UnicodeString(prefix).length + const byteEnd = byteStart + new UnicodeString(matchStr).length + + let validUrl = linkUrl + if ( + !validUrl.startsWith('http://') && + !validUrl.startsWith('https://') && + !validUrl.startsWith('mailto:') + ) { + validUrl = `https://${validUrl}` + } + + markdownFacets.push({ + index: {byteStart, byteEnd}, + features: [ + {$type: 'app.bsky.richtext.facet#link', uri: validUrl}, + ], + }) + } + + if (markdownFacets.length > 0) { + + const nonOverlapping = (newRt.facets || []).filter(f => { + return !markdownFacets.some(mf => { + return ( + (f.index.byteStart >= mf.index.byteStart && + f.index.byteStart < mf.index.byteEnd) || + (f.index.byteEnd > mf.index.byteStart && + f.index.byteEnd <= mf.index.byteEnd) || + (mf.index.byteStart >= f.index.byteStart && + mf.index.byteStart < f.index.byteEnd) + ) + }) + }) + newRt.facets = [...nonOverlapping, ...markdownFacets].sort( + (a, b) => a.index.byteStart - b.index.byteStart, + ) + } + setRichText(newRt) // NOTE: BinaryFiddler diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index c1efce8c5..0df2d960c 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -8,7 +8,7 @@ import { } from 'react' import {StyleSheet, View} from 'react-native' import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' -import {AppBskyRichtextFacet, RichText} from '@atproto/api' +import {AppBskyRichtextFacet, RichText, UnicodeString} from '@atproto/api' import {Trans} from '@lingui/macro' import {Document} from '@tiptap/extension-document' import Hardbreak from '@tiptap/extension-hard-break' @@ -265,6 +265,54 @@ export function TextInput({ const newRt = new RichText({text: newText}) newRt.detectFacetsWithoutResolution() + + const markdownFacets: AppBskyRichtextFacet.Main[] = [] + const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g + let match + while ((match = regex.exec(newText)) !== null) { + const [fullMatch, _linkText, linkUrl] = match + const matchStart = match.index + const matchEnd = matchStart + fullMatch.length + const prefix = newText.slice(0, matchStart) + const matchStr = newText.slice(matchStart, matchEnd) + const byteStart = new UnicodeString(prefix).length + const byteEnd = byteStart + new UnicodeString(matchStr).length + + let validUrl = linkUrl + if ( + !validUrl.startsWith('http://') && + !validUrl.startsWith('https://') && + !validUrl.startsWith('mailto:') + ) { + validUrl = `https://${validUrl}` + } + + markdownFacets.push({ + index: {byteStart, byteEnd}, + features: [ + { $type: 'app.bsky.richtext.facet#link', uri: validUrl }, + ], + }) + } + + if (markdownFacets.length > 0) { + const nonOverlapping = (newRt.facets || []).filter(f => { + return !markdownFacets.some(mf => { + return ( + (f.index.byteStart >= mf.index.byteStart && + f.index.byteStart < mf.index.byteEnd) || + (f.index.byteEnd > mf.index.byteStart && + f.index.byteEnd <= mf.index.byteEnd) || + (mf.index.byteStart >= f.index.byteStart && + mf.index.byteStart < f.index.byteEnd) + ) + }) + }) + newRt.facets = [...nonOverlapping, ...markdownFacets].sort( + (a, b) => a.index.byteStart - b.index.byteStart, + ) + } + setRichText(newRt) const nextDetectedUris = new Map() diff --git a/src/view/com/composer/text-input/web/LinkDecorator.ts b/src/view/com/composer/text-input/web/LinkDecorator.ts index 4843f0ddf..57c2bc13d 100644 --- a/src/view/com/composer/text-input/web/LinkDecorator.ts +++ b/src/view/com/composer/text-input/web/LinkDecorator.ts @@ -41,7 +41,20 @@ function getDecorations(doc: ProsemirrorNode) { if (node.isText && node.text) { const textContent = node.textContent - // links + // markdown links [text](url) + const markdownRegex = /\[([^\]]+)\]\s*\(([^)]+)\)/g + let markdownMatch + while ((markdownMatch = markdownRegex.exec(textContent)) !== null) { + const from = markdownMatch.index + const to = from + markdownMatch[0].length + decorations.push( + Decoration.inline(pos + from, pos + to, { + class: 'autolink', + }), + ) + } + + // regular links iterateUris(textContent, (from, to) => { decorations.push( Decoration.inline(pos + from, pos + to, { -- 2.50.1 (Apple Git-155)