diff options
Diffstat (limited to 'src/view/com')
-rw-r--r-- | src/view/com/composer/text-input/TextInput.tsx | 3 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.web.tsx | 2 | ||||
-rw-r--r-- | src/view/com/composer/text-input/web/TagDecorator.ts | 83 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 6 | ||||
-rw-r--r-- | src/view/com/post/Post.tsx | 2 | ||||
-rw-r--r-- | src/view/com/posts/FeedItem.tsx | 2 | ||||
-rw-r--r-- | src/view/com/util/forms/NativeDropdown.web.tsx | 5 | ||||
-rw-r--r-- | src/view/com/util/forms/PostDropdownBtn.tsx | 16 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/QuoteEmbed.tsx | 2 | ||||
-rw-r--r-- | src/view/com/util/text/RichText.tsx | 66 |
10 files changed, 184 insertions, 3 deletions
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 17f9513b7..20be585c2 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -190,12 +190,11 @@ export const TextInput = forwardRef(function TextInputImpl( let i = 0 return Array.from(richtext.segments()).map(segment => { - const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0]) return ( <Text key={i++} style={[ - segment.facet && !isTag ? pal.link : pal.text, + segment.facet ? pal.link : pal.text, styles.textInputFormatting, ]}> {segment.text} diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 199f1f749..c62d11201 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -23,6 +23,7 @@ import {Portal} from '#/components/Portal' import {Text} from '../../util/text/Text' import {Trans} from '@lingui/macro' import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {TagDecorator} from './web/TagDecorator' export interface TextInputRef { focus: () => void @@ -67,6 +68,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( () => [ Document, LinkDecorator, + TagDecorator, Mention.configure({ HTMLAttributes: { class: 'mention', diff --git a/src/view/com/composer/text-input/web/TagDecorator.ts b/src/view/com/composer/text-input/web/TagDecorator.ts new file mode 100644 index 000000000..d820ec3f0 --- /dev/null +++ b/src/view/com/composer/text-input/web/TagDecorator.ts @@ -0,0 +1,83 @@ +/** + * TipTap is a stateful rich-text editor, which is extremely useful + * when you _want_ it to be stateful formatting such as bold and italics. + * + * However we also use "stateless" behaviors, specifically for URLs + * where the text itself drives the formatting. + * + * This plugin uses a regex to detect URIs and then applies + * link decorations (a <span> with the "autolink") class. That avoids + * adding any stateful formatting to TipTap's document model. + * + * We then run the URI detection again when constructing the + * RichText object from TipTap's output and merge their features into + * the facet-set. + */ + +import {Mark} from '@tiptap/core' +import {Plugin, PluginKey} from '@tiptap/pm/state' +import {Node as ProsemirrorNode} from '@tiptap/pm/model' +import {Decoration, DecorationSet} from '@tiptap/pm/view' + +function getDecorations(doc: ProsemirrorNode) { + const decorations: Decoration[] = [] + + doc.descendants((node, pos) => { + if (node.isText && node.text) { + const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g + const textContent = node.textContent + + let match + while ((match = regex.exec(textContent))) { + const [matchedString, tag] = match + + if (tag.length > 66) continue + + const [trailingPunc = ''] = tag.match(/\p{P}+$/u) || [] + + const from = match.index + matchedString.indexOf(tag) + const to = from + (tag.length - trailingPunc.length) + + decorations.push( + Decoration.inline(pos + from, pos + to, { + class: 'autolink', + }), + ) + } + } + }) + + return DecorationSet.create(doc, decorations) +} + +const tagDecoratorPlugin: Plugin = new Plugin({ + key: new PluginKey('link-decorator'), + + state: { + init: (_, {doc}) => getDecorations(doc), + apply: (transaction, decorationSet) => { + if (transaction.docChanged) { + return getDecorations(transaction.doc) + } + return decorationSet.map(transaction.mapping, transaction.doc) + }, + }, + + props: { + decorations(state) { + return tagDecoratorPlugin.getState(state) + }, + }, +}) + +export const TagDecorator = Mark.create({ + name: 'tag-decorator', + priority: 1000, + keepOnSplit: false, + inclusive() { + return true + }, + addProseMirrorPlugins() { + return [tagDecoratorPlugin] + }, +}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index ebd739839..949fcfea0 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -327,9 +327,11 @@ let PostThreadItemLoaded = ({ styles.postTextLargeContainer, ]}> <RichText + enableTags + selectable value={richText} style={[a.flex_1, a.text_xl]} - selectable + authorHandle={post.author.handle} /> </View> ) : undefined} @@ -521,9 +523,11 @@ let PostThreadItemLoaded = ({ {richText?.text ? ( <View style={styles.postTextContainer}> <RichText + enableTags value={richText} style={[a.flex_1, a.text_md]} numberOfLines={limitLines ? MAX_POST_LINES : undefined} + authorHandle={post.author.handle} /> </View> ) : undefined} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index aec916adb..5fa4da84e 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -184,10 +184,12 @@ function PostInner({ {richText.text ? ( <View style={styles.postTextContainer}> <RichText + enableTags testID="postText" value={richText} numberOfLines={limitLines ? MAX_POST_LINES : undefined} style={[a.flex_1, a.text_md]} + authorHandle={post.author.handle} /> </View> ) : undefined} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 6f64de181..47a964ab1 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -347,10 +347,12 @@ let PostContent = ({ {richText.text ? ( <View style={styles.postTextContainer}> <RichText + enableTags testID="postText" value={richText} numberOfLines={limitLines ? MAX_POST_LINES : undefined} style={[a.flex_1, a.text_md]} + authorHandle={postAuthor.handle} /> </View> ) : undefined} diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx index 9e9888ad8..052e7ca13 100644 --- a/src/view/com/util/forms/NativeDropdown.web.tsx +++ b/src/view/com/util/forms/NativeDropdown.web.tsx @@ -21,6 +21,7 @@ export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => { return ( <DropdownMenu.Item + className="nativeDropdown-item" {...props} style={StyleSheet.flatten([ styles.item, @@ -232,6 +233,10 @@ const styles = StyleSheet.create({ paddingLeft: 12, paddingRight: 12, borderRadius: 8, + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', + outline: 0, + border: 0, }, itemTitle: { fontSize: 16, diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 1dfb687df..09850a7f5 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -34,6 +34,7 @@ import {useLingui} from '@lingui/react' import {useSession} from '#/state/session' import {isWeb} from '#/platform/detection' import {richTextToString} from '#/lib/strings/rich-text-helpers' +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' let PostDropdownBtn = ({ testID, @@ -67,6 +68,7 @@ let PostDropdownBtn = ({ const {hidePost} = useHiddenPostsApi() const openLink = useOpenLink() const navigation = useNavigation() + const {mutedWordsDialogControl} = useGlobalDialogsControlContext() const rootUri = record.reply?.root?.uri || postUri const isThreadMuted = mutedThreads.includes(rootUri) @@ -210,6 +212,20 @@ let PostDropdownBtn = ({ web: 'comment-slash', }, }, + hasSession && { + label: _(msg`Mute words & tags`), + onPress() { + mutedWordsDialogControl.open() + }, + testID: 'postDropdownMuteWordsBtn', + icon: { + ios: { + name: 'speaker.slash', + }, + android: 'ic_lock_silent_mode', + web: 'filter', + }, + }, hasSession && !isAuthor && !isPostHidden && { diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index c128a6f00..35b091269 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -128,10 +128,12 @@ export function QuoteEmbed({ ) : null} {richText ? ( <RichText + enableTags value={richText} style={[a.text_md]} numberOfLines={20} disableLinks + authorHandle={quote.author.handle} /> ) : null} {embed && <PostEmbeds embed={embed} moderation={{}} />} diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index b6d461224..0ec3f3181 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -7,6 +7,9 @@ import {lh} from 'lib/styles' import {toShortUrl} from 'lib/strings/url-helpers' import {useTheme, TypographyVariant} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' +import {makeTagLink} from 'lib/routes/links' +import {TagMenu, useTagMenuControl} from '#/components/TagMenu' +import {isNative} from '#/platform/detection' const WORD_WRAP = {wordWrap: 1} @@ -82,6 +85,7 @@ export function RichText({ for (const segment of richText.segments()) { const link = segment.link const mention = segment.mention + const tag = segment.tag if ( !noLinks && mention && @@ -115,6 +119,21 @@ export function RichText({ />, ) } + } else if ( + !noLinks && + tag && + AppBskyRichtextFacet.validateTag(tag).success + ) { + els.push( + <RichTextTag + key={key} + text={segment.text} + type={type} + style={style} + lineHeightStyle={lineHeightStyle} + selectable={selectable} + />, + ) } else { els.push(segment.text) } @@ -133,3 +152,50 @@ export function RichText({ </Text> ) } + +function RichTextTag({ + text: tag, + type, + style, + lineHeightStyle, + selectable, +}: { + text: string + type?: TypographyVariant + style?: StyleProp<TextStyle> + lineHeightStyle?: TextStyle + selectable?: boolean +}) { + const pal = usePalette('default') + const control = useTagMenuControl() + + const open = React.useCallback(() => { + control.open() + }, [control]) + + return ( + <React.Fragment> + <TagMenu control={control} tag={tag}> + {isNative ? ( + <TextLink + type={type} + text={tag} + // segment.text has the leading "#" while tag.tag does not + href={makeTagLink(tag)} + style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} + dataSet={WORD_WRAP} + selectable={selectable} + onPress={open} + /> + ) : ( + <Text + selectable={selectable} + type={type} + style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}> + {tag} + </Text> + )} + </TagMenu> + </React.Fragment> + ) +} |