From 47273cccb1b915cc18119d50cfa4531898efdd84 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 25 Sep 2023 10:37:53 -0500 Subject: [PATCH 01/66] first pass at a tag input in composer --- src/view/com/composer/Composer.tsx | 9 ++ src/view/com/composer/TagInput.tsx | 129 +++++++++++++++++++++++++++++ src/view/index.ts | 2 + 3 files changed, 140 insertions(+) create mode 100644 src/view/com/composer/TagInput.tsx diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 8629c4fcb3..e0d2858d06 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -48,6 +48,7 @@ import {LabelsBtn} from './labels/LabelsBtn' import {SelectLangBtn} from './select-language/SelectLangBtn' import {EmojiPickerButton} from './text-input/web/EmojiPicker.web' import {insertMentionAt} from 'lib/strings/mention-manip' +import {TagInput} from './TagInput' type Props = ComposerOpts & { onClose: () => void @@ -380,6 +381,14 @@ export const ComposePost = observer(function ComposePost({ ) : undefined} + + + + {!extLink && suggestedLinks.size > 0 ? ( diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx new file mode 100644 index 0000000000..ac374a4899 --- /dev/null +++ b/src/view/com/composer/TagInput.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import { + TextInput, + View, + StyleSheet, + NativeSyntheticEvent, + TextInputKeyPressEventData, + Pressable, +} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' + +import {Text} from 'view/com/util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' + +function Tag({ + tag, + removeTag, +}: { + tag: string + removeTag: (tag: string) => void +}) { + const pal = usePalette('default') + return ( + removeTag(tag)} + style={state => ({ + opacity: state.hovered || state.pressed || state.focused ? 0.8 : 1, + outline: 0, + })}> + + #{tag} + + + ) +} + +export function TagInput({max}: {max: number}) { + const pal = usePalette('default') + const input = React.useRef(null) + const [value, setValue] = React.useState('') + const [tags, setTags] = React.useState([]) + + const onKeyPress = React.useCallback( + (e: NativeSyntheticEvent) => { + if (e.nativeEvent.key === 'Enter') { + const _tags = value.trim().split(' ').filter(Boolean) + + if (_tags.length > 0) { + setTags(Array.from(new Set([...tags, ..._tags])).slice(0, max)) + setValue('') + } + + setTimeout(() => { + input.current?.focus() + }, 100) + } else if (e.nativeEvent.key === 'Backspace' && value === '') { + setTags(tags.slice(0, -1)) + } + }, + [max, value, tags, setValue, setTags], + ) + + const onChangeText = React.useCallback((value: string) => { + const sanitized = value.replace(/^#/, '') + setValue(sanitized) + }, []) + + const removeTag = React.useCallback( + (tag: string) => { + setTags(tags.filter(t => t !== tag)) + }, + [tags, setTags], + ) + + return ( + + {!tags.length && ( + + )} + {tags.map(tag => ( + + ))} + {tags.length >= max ? null : ( + + )} + + ) +} + +const styles = StyleSheet.create({ + outer: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + }, + input: { + flexGrow: 1, + minWidth: 100, + fontSize: 13, + paddingVertical: 4, + }, + tag: { + paddingVertical: 4, + paddingHorizontal: 8, + borderRadius: 4, + }, +}) diff --git a/src/view/index.ts b/src/view/index.ts index 1e6f274191..09177d0f75 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -98,6 +98,7 @@ import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash' import {faX} from '@fortawesome/free-solid-svg-icons/faX' import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark' import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown' +import {faTags} from '@fortawesome/free-solid-svg-icons/faTags' export function setup() { library.add( @@ -199,5 +200,6 @@ export function setup() { faX, faXmark, faChevronDown, + faTags, ) } From 763edc8fb3dc42c0c8113d86619c0b4dacd7d363 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 25 Sep 2023 14:15:47 -0500 Subject: [PATCH 02/66] add TagDecorator plugin --- .../com/composer/text-input/TextInput.web.tsx | 2 + .../composer/text-input/web/TagDecorator.ts | 80 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 src/view/com/composer/text-input/web/TagDecorator.ts diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 31e372567f..0dd72a1780 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -18,6 +18,7 @@ import {isUriImage, blobToDataUri} from 'lib/media/util' import {Emoji} from './web/EmojiPicker.web' import {LinkDecorator} from './web/LinkDecorator' import {generateJSON} from '@tiptap/html' +import {TagDecorator} from './web/TagDecorator' export interface TextInputRef { focus: () => void @@ -57,6 +58,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 0000000000..39fe521ca6 --- /dev/null +++ b/src/view/com/composer/text-input/web/TagDecorator.ts @@ -0,0 +1,80 @@ +/** + * 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 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 {findChildren} from '@tiptap/core' +import {Node as ProsemirrorNode} from '@tiptap/pm/model' +import {Decoration, DecorationSet} from '@tiptap/pm/view' + +const TAG_REGEX = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g + +function getDecorations(doc: ProsemirrorNode) { + const decorations: Decoration[] = [] + + findChildren(doc, node => node.type.name === 'paragraph').forEach( + paragraphNode => { + const textContent = paragraphNode.node.textContent + + let match + while ((match = TAG_REGEX.exec(textContent))) { + const [, m] = match + const tag = m.trim().replace(/\p{P}+$/gu, '') + const from = match.index + 1 + const to = from + tag.length + 1 + decorations.push( + Decoration.inline(paragraphNode.pos + from, paragraphNode.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] + }, +}) From f9914d6ff6a6703e75c5d137dcd896a61035199e Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 25 Sep 2023 14:28:05 -0500 Subject: [PATCH 03/66] send along tags with api request --- src/lib/api/index.ts | 2 ++ src/view/com/composer/Composer.tsx | 11 +++++++- src/view/com/composer/TagInput.tsx | 28 +++++++++++++++---- .../composer/text-input/web/TagDecorator.ts | 1 + 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 8a9389a183..7585b3371e 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -83,6 +83,7 @@ interface PostOpts { knownHandles?: Set onStateChange?: (state: string) => void langs?: string[] + tags?: string[] } export async function post(store: RootStoreModel, opts: PostOpts) { @@ -264,6 +265,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { embed, langs, labels, + tags: opts.tags?.filter(t => t.replace(/^#/, '')), }) } catch (e: any) { console.error(`Failed to create post: ${e.toString()}`) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index e0d2858d06..23e2321b12 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -91,6 +91,7 @@ export const ComposePost = observer(function ComposePost({ const [labels, setLabels] = useState([]) const [suggestedLinks, setSuggestedLinks] = useState>(new Set()) const gallery = useMemo(() => new GalleryModel(store), [store]) + const [tags, setTags] = useState([]) const autocompleteView = useMemo( () => new UserAutocompleteModel(store), @@ -167,6 +168,13 @@ export const ComposePost = observer(function ComposePost({ [gallery, track], ) + const onChangeTags = useCallback( + (tags: string[]) => { + setTags(tags) + }, + [setTags], + ) + const onPressPublish = async () => { if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { return @@ -195,6 +203,7 @@ export const ComposePost = observer(function ComposePost({ onStateChange: setProcessingState, knownHandles: autocompleteView.knownHandles, langs: store.preferences.postLanguages, + tags, }) } catch (e: any) { if (extLink) { @@ -387,7 +396,7 @@ export const ComposePost = observer(function ComposePost({ pal.border, {borderTopWidth: 1, paddingVertical: 10, marginTop: 10}, ]}> - + {!extLink && suggestedLinks.size > 0 ? ( diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index ac374a4899..9d4a80d3f2 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -38,19 +38,35 @@ function Tag({ ) } -export function TagInput({max}: {max: number}) { +export function TagInput({ + max, + onChangeTags, +}: { + max: number + onChangeTags: (tags: string[]) => void +}) { const pal = usePalette('default') const input = React.useRef(null) const [value, setValue] = React.useState('') const [tags, setTags] = React.useState([]) + const handleChangeTags = React.useCallback( + (_tags: string[]) => { + setTags(_tags) + onChangeTags(_tags) + }, + [onChangeTags, setTags], + ) + const onKeyPress = React.useCallback( (e: NativeSyntheticEvent) => { if (e.nativeEvent.key === 'Enter') { const _tags = value.trim().split(' ').filter(Boolean) if (_tags.length > 0) { - setTags(Array.from(new Set([...tags, ..._tags])).slice(0, max)) + handleChangeTags( + Array.from(new Set([...tags, ..._tags])).slice(0, max), + ) setValue('') } @@ -58,10 +74,10 @@ export function TagInput({max}: {max: number}) { input.current?.focus() }, 100) } else if (e.nativeEvent.key === 'Backspace' && value === '') { - setTags(tags.slice(0, -1)) + handleChangeTags(tags.slice(0, -1)) } }, - [max, value, tags, setValue, setTags], + [max, value, tags, setValue, handleChangeTags], ) const onChangeText = React.useCallback((value: string) => { @@ -71,9 +87,9 @@ export function TagInput({max}: {max: number}) { const removeTag = React.useCallback( (tag: string) => { - setTags(tags.filter(t => t !== tag)) + handleChangeTags(tags.filter(t => t !== tag)) }, - [tags, setTags], + [tags, handleChangeTags], ) return ( diff --git a/src/view/com/composer/text-input/web/TagDecorator.ts b/src/view/com/composer/text-input/web/TagDecorator.ts index 39fe521ca6..18b5a7e704 100644 --- a/src/view/com/composer/text-input/web/TagDecorator.ts +++ b/src/view/com/composer/text-input/web/TagDecorator.ts @@ -33,6 +33,7 @@ function getDecorations(doc: ProsemirrorNode) { while ((match = TAG_REGEX.exec(textContent))) { const [, m] = match const tag = m.trim().replace(/\p{P}+$/gu, '') + if (tag.length > 66) continue const from = match.index + 1 const to = from + tag.length + 1 decorations.push( From 439940532fd9cd27c317ad13b9c18972bd41109c Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 25 Sep 2023 15:53:10 -0500 Subject: [PATCH 04/66] add tags view --- .../composer/text-input/web/TagDecorator.ts | 5 ++-- src/view/com/post-thread/PostThreadItem.tsx | 29 ++++++++++++++++++- src/view/com/util/text/RichText.tsx | 12 ++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/view/com/composer/text-input/web/TagDecorator.ts b/src/view/com/composer/text-input/web/TagDecorator.ts index 18b5a7e704..68d187119d 100644 --- a/src/view/com/composer/text-input/web/TagDecorator.ts +++ b/src/view/com/composer/text-input/web/TagDecorator.ts @@ -31,10 +31,11 @@ function getDecorations(doc: ProsemirrorNode) { let match while ((match = TAG_REGEX.exec(textContent))) { - const [, m] = match + const [m] = match + const hasLeadingSpace = /^\s/.test(m) const tag = m.trim().replace(/\p{P}+$/gu, '') if (tag.length > 66) continue - const from = match.index + 1 + const from = match.index + (hasLeadingSpace ? 1 : 0) const to = from + tag.length + 1 decorations.push( Decoration.inline(paragraphNode.pos + from, paragraphNode.pos + to, { diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 788ce96adf..a69ff862d9 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -2,7 +2,7 @@ import React, {useMemo} from 'react' import {observer} from 'mobx-react-lite' import {Linking, StyleSheet, View} from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' -import {AtUri, AppBskyFeedDefs} from '@atproto/api' +import {AtUri, AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -328,11 +328,33 @@ export const PostThreadItem = observer(function PostThreadItem({ )} + + {AppBskyFeedPost.isRecord(item.post.record) && + item.post.record.tags?.length ? ( + + {item.post.record.tags.map(tag => ( + + #{tag} + + ))} + + ) : null} + + {hasEngagement ? ( {item.post.repostCount ? ( @@ -722,4 +744,9 @@ const styles = StyleSheet.create({ // @ts-ignore web only cursor: 'pointer', }, + tag: { + paddingVertical: 4, + paddingHorizontal: 8, + borderRadius: 4, + }, }) diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index 0dc13fd34c..b17cf1eb94 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -69,6 +69,7 @@ export function RichText({ for (const segment of richText.segments()) { const link = segment.link const mention = segment.mention + const tag = segment.tag if (mention && AppBskyRichtextFacet.validateMention(mention).success) { els.push( , ) + } else if (tag && AppBskyRichtextFacet.validateTag(tag).success) { + els.push( + , + ) } else { els.push(segment.text) } From bfdbf43119f40a80c655eee357810b28ac4e88f7 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 25 Sep 2023 16:15:27 -0500 Subject: [PATCH 05/66] link out tags --- src/view/com/composer/TagInput.tsx | 19 +++++++++++++++---- src/view/com/post-thread/PostThreadItem.tsx | 16 +++++++++++----- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 9d4a80d3f2..50f38465c7 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -6,6 +6,7 @@ import { NativeSyntheticEvent, TextInputKeyPressEventData, Pressable, + InteractionManager, } from 'react-native' import { FontAwesomeIcon, @@ -60,19 +61,19 @@ export function TagInput({ const onKeyPress = React.useCallback( (e: NativeSyntheticEvent) => { - if (e.nativeEvent.key === 'Enter') { + if (e.nativeEvent.key === 'Enter' || e.nativeEvent.key === ' ') { const _tags = value.trim().split(' ').filter(Boolean) if (_tags.length > 0) { handleChangeTags( Array.from(new Set([...tags, ..._tags])).slice(0, max), ) - setValue('') } - setTimeout(() => { + InteractionManager.runAfterInteractions(() => { + setValue('') input.current?.focus() - }, 100) + }) } else if (e.nativeEvent.key === 'Backspace' && value === '') { handleChangeTags(tags.slice(0, -1)) } @@ -85,6 +86,15 @@ export function TagInput({ setValue(sanitized) }, []) + const onBlur = React.useCallback(() => { + const _tags = value.trim().split(' ').filter(Boolean) + + if (_tags.length > 0) { + handleChangeTags(Array.from(new Set([...tags, ..._tags])).slice(0, max)) + setValue('') + } + }, [value, tags, max, handleChangeTags]) + const removeTag = React.useCallback( (tag: string) => { handleChangeTags(tags.filter(t => t !== tag)) @@ -110,6 +120,7 @@ export function TagInput({ value={value} onKeyPress={onKeyPress} onChangeText={onChangeText} + onBlur={onBlur} style={[styles.input, pal.textLight]} placeholder="Add tags..." autoCapitalize="none" diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index a69ff862d9..bc51a06d6d 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -339,12 +339,18 @@ export const PostThreadItem = observer(function PostThreadItem({ paddingBottom: 12, }}> {item.post.record.tags.map(tag => ( - - #{tag} - + asAnchor + accessible + anchorNoUnderline + href={`/search?q=${tag}`}> + + #{tag} + + ))} ) : null} From 44694abbe61ac10051e65955c94ca5a9f88f1380 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 25 Sep 2023 16:32:02 -0500 Subject: [PATCH 06/66] improve hash handling --- src/view/com/composer/TagInput.tsx | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 50f38465c7..80517b74eb 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -16,6 +16,19 @@ import { import {Text} from 'view/com/util/text/Text' import {usePalette} from 'lib/hooks/usePalette' +function uniq(tags: string[]) { + return Array.from(new Set(tags)) +} + +function sanitize(tagString: string) { + return tagString + .trim() + .split(' ') + .filter(Boolean) + .map(t => t.trim()) + .map(t => t.replace(/^#/, '')) +} + function Tag({ tag, removeTag, @@ -62,12 +75,10 @@ export function TagInput({ const onKeyPress = React.useCallback( (e: NativeSyntheticEvent) => { if (e.nativeEvent.key === 'Enter' || e.nativeEvent.key === ' ') { - const _tags = value.trim().split(' ').filter(Boolean) + const _tags = sanitize(value) if (_tags.length > 0) { - handleChangeTags( - Array.from(new Set([...tags, ..._tags])).slice(0, max), - ) + handleChangeTags(uniq([...tags, ..._tags]).slice(0, max)) } InteractionManager.runAfterInteractions(() => { @@ -82,15 +93,14 @@ export function TagInput({ ) const onChangeText = React.useCallback((value: string) => { - const sanitized = value.replace(/^#/, '') - setValue(sanitized) + setValue(value) }, []) const onBlur = React.useCallback(() => { - const _tags = value.trim().split(' ').filter(Boolean) + const _tags = sanitize(value) if (_tags.length > 0) { - handleChangeTags(Array.from(new Set([...tags, ..._tags])).slice(0, max)) + handleChangeTags(uniq([...tags, ..._tags]).slice(0, max)) setValue('') } }, [value, tags, max, handleChangeTags]) From 250e5eb3cd98d1bfcaf27a637d07dd3e65174718 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 25 Sep 2023 16:32:32 -0500 Subject: [PATCH 07/66] remove extra filter --- src/lib/api/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 7585b3371e..8bbee4d902 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -265,7 +265,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { embed, langs, labels, - tags: opts.tags?.filter(t => t.replace(/^#/, '')), + tags: opts.tags, }) } catch (e: any) { console.error(`Failed to create post: ${e.toString()}`) From dab793193e0c1dc8ab7f9bf1a6f183b95e0edd64 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 25 Sep 2023 16:33:34 -0500 Subject: [PATCH 08/66] made 8 default max --- src/view/com/composer/Composer.tsx | 2 +- src/view/com/composer/TagInput.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 23e2321b12..a589978d95 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -396,7 +396,7 @@ export const ComposePost = observer(function ComposePost({ pal.border, {borderTopWidth: 1, paddingVertical: 10, marginTop: 10}, ]}> - + {!extLink && suggestedLinks.size > 0 ? ( diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 80517b74eb..3b917ebe8d 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -53,10 +53,10 @@ function Tag({ } export function TagInput({ - max, + max = 8, onChangeTags, }: { - max: number + max?: number onChangeTags: (tags: string[]) => void }) { const pal = usePalette('default') From 9668102ceba8a386f991b074cb23bad17bcbd3f6 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 25 Sep 2023 16:35:42 -0500 Subject: [PATCH 09/66] fix helper text --- src/view/com/composer/TagInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 3b917ebe8d..5ba0fa2357 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -138,7 +138,7 @@ export function TagInput({ autoComplete="off" accessible={true} accessibilityLabel="Add tags to your post" - accessibilityHint={`You may add up to 8 tags to your post, including those used inline.`} + accessibilityHint={`Type a tag and press enter to add it. You can add up to ${max} tag.`} /> )} From 66181679a66618154a0912010519b39147507620 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 25 Sep 2023 17:47:45 -0500 Subject: [PATCH 10/66] integrate into native text input --- package.json | 2 +- src/view/com/composer/TagInput.tsx | 37 ++++++++++++++++++------------ yarn.lock | 8 +++---- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index 32ca3de7aa..4ed3efcff6 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:apk": "eas build -p android --profile dev-android-apk" }, "dependencies": { - "@atproto/api": "^0.6.16", + "@atproto/api": "^0.6.17", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@emoji-mart/react": "^1.1.1", diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 5ba0fa2357..6af4b198a1 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -6,7 +6,6 @@ import { NativeSyntheticEvent, TextInputKeyPressEventData, Pressable, - InteractionManager, } from 'react-native' import { FontAwesomeIcon, @@ -72,28 +71,33 @@ export function TagInput({ [onChangeTags, setTags], ) - const onKeyPress = React.useCallback( - (e: NativeSyntheticEvent) => { - if (e.nativeEvent.key === 'Enter' || e.nativeEvent.key === ' ') { - const _tags = sanitize(value) + const onSubmitEditing = React.useCallback(() => { + const _tags = sanitize(value) + + if (_tags.length > 0) { + handleChangeTags(uniq([...tags, ..._tags]).slice(0, max)) + } - if (_tags.length > 0) { - handleChangeTags(uniq([...tags, ..._tags]).slice(0, max)) - } + // TODO: this is a hack to get the input to clear on iOS + setTimeout(() => { + setValue('') + input.current?.focus() + }, 1) // only positive values work + }, [max, value, tags, setValue, handleChangeTags]) - InteractionManager.runAfterInteractions(() => { - setValue('') - input.current?.focus() - }) + const onKeyPress = React.useCallback( + (e: NativeSyntheticEvent) => { + if (e.nativeEvent.key === ' ') { + onSubmitEditing() } else if (e.nativeEvent.key === 'Backspace' && value === '') { handleChangeTags(tags.slice(0, -1)) } }, - [max, value, tags, setValue, handleChangeTags], + [value, tags, onSubmitEditing, handleChangeTags], ) - const onChangeText = React.useCallback((value: string) => { - setValue(value) + const onChangeText = React.useCallback((v: string) => { + setValue(v) }, []) const onBlur = React.useCallback(() => { @@ -129,8 +133,10 @@ export function TagInput({ ref={input} value={value} onKeyPress={onKeyPress} + onSubmitEditing={onSubmitEditing} onChangeText={onChangeText} onBlur={onBlur} + blurOnSubmit={false} style={[styles.input, pal.textLight]} placeholder="Add tags..." autoCapitalize="none" @@ -162,5 +168,6 @@ const styles = StyleSheet.create({ paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4, + overflow: 'hidden', }, }) diff --git a/yarn.lock b/yarn.lock index 7533bb439b..2e3fadef25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -47,10 +47,10 @@ tlds "^1.234.0" typed-emitter "^2.1.0" -"@atproto/api@^0.6.16": - version "0.6.16" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.16.tgz#0e5f259a8eb8af239b4e77bf70d7e770b33f4eeb" - integrity sha512-DpG994bdwk7NWJSb36Af+0+FRWMFZgzTcrK0rN2tvlsMh6wBF/RdErjHKuoL8wcogGzbI2yp8eOqsA00lyoisw== +"@atproto/api@^0.6.17": + version "0.6.17" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.17.tgz#d0dfefe8584ab08f706f0e0f4a1d80ab89f63f45" + integrity sha512-ux2yDEPY5n+KVnyyXXjLMyWUQ1bFBmKhw99qH6S+bx7BhkVktOMJA4nlQZwcKt3DrMXpY1E+42npQEW8px05Bw== dependencies: "@atproto/common-web" "^0.2.0" "@atproto/lexicon" "^0.2.1" From bb8a016f020f6fcb53e26173055e0c4b7e7eabce Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 25 Sep 2023 17:53:47 -0500 Subject: [PATCH 11/66] bump api package to get new richtext --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 4ed3efcff6..84d5af15a1 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:apk": "eas build -p android --profile dev-android-apk" }, "dependencies": { - "@atproto/api": "^0.6.17", + "@atproto/api": "^0.6.18", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@emoji-mart/react": "^1.1.1", diff --git a/yarn.lock b/yarn.lock index 2e3fadef25..fe58ccd718 100644 --- a/yarn.lock +++ b/yarn.lock @@ -47,10 +47,10 @@ tlds "^1.234.0" typed-emitter "^2.1.0" -"@atproto/api@^0.6.17": - version "0.6.17" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.17.tgz#d0dfefe8584ab08f706f0e0f4a1d80ab89f63f45" - integrity sha512-ux2yDEPY5n+KVnyyXXjLMyWUQ1bFBmKhw99qH6S+bx7BhkVktOMJA4nlQZwcKt3DrMXpY1E+42npQEW8px05Bw== +"@atproto/api@^0.6.18": + version "0.6.18" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.18.tgz#8fb58a88f17c1027828c67f8c24f5e6e3e8db17b" + integrity sha512-OmqZgLHDaB6Jho9Y+VA8ZYNuUYV9vnJqiOnDvCaCiQXr/UTBoccRpffqYHQ6SOt7X76nJZnAoqrs4vzxjdvhog== dependencies: "@atproto/common-web" "^0.2.0" "@atproto/lexicon" "^0.2.1" From 76eba7b881a4c0ff4c0c5b639dfa8a78d0aee261 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 26 Sep 2023 12:04:33 -0500 Subject: [PATCH 12/66] bump api package --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 84d5af15a1..1a8c5992ba 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:apk": "eas build -p android --profile dev-android-apk" }, "dependencies": { - "@atproto/api": "^0.6.18", + "@atproto/api": "^0.6.19", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@emoji-mart/react": "^1.1.1", diff --git a/yarn.lock b/yarn.lock index fe58ccd718..98b0519b2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -47,10 +47,10 @@ tlds "^1.234.0" typed-emitter "^2.1.0" -"@atproto/api@^0.6.18": - version "0.6.18" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.18.tgz#8fb58a88f17c1027828c67f8c24f5e6e3e8db17b" - integrity sha512-OmqZgLHDaB6Jho9Y+VA8ZYNuUYV9vnJqiOnDvCaCiQXr/UTBoccRpffqYHQ6SOt7X76nJZnAoqrs4vzxjdvhog== +"@atproto/api@^0.6.19": + version "0.6.19" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.19.tgz#affc606371e20b6cdcebb766a3665e0d04f3248e" + integrity sha512-/sQu47XC/K+PeEfIF0SlxEbeimkapXhZjea4aNF8utsPuv8ZAfWZumEZr/ducNr3vnG4N5YJCmrP4jhwbBtLHQ== dependencies: "@atproto/common-web" "^0.2.0" "@atproto/lexicon" "^0.2.1" From f404f52939aa66d4733853874e8cc41f40c854c1 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 26 Sep 2023 14:48:36 -0500 Subject: [PATCH 13/66] clean up handling --- src/view/com/composer/TagInput.tsx | 40 +++++++++++++++--------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 6af4b198a1..b873e0edf1 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -12,6 +12,7 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {isWeb} from 'platform/detection' import {Text} from 'view/com/util/text/Text' import {usePalette} from 'lib/hooks/usePalette' @@ -20,12 +21,7 @@ function uniq(tags: string[]) { } function sanitize(tagString: string) { - return tagString - .trim() - .split(' ') - .filter(Boolean) - .map(t => t.trim()) - .map(t => t.replace(/^#/, '')) + return tagString.trim().replace(/^#/, '') } function Tag({ @@ -72,28 +68,32 @@ export function TagInput({ ) const onSubmitEditing = React.useCallback(() => { - const _tags = sanitize(value) + const tag = sanitize(value) - if (_tags.length > 0) { - handleChangeTags(uniq([...tags, ..._tags]).slice(0, max)) + if (tag.length > 0) { + handleChangeTags(uniq([...tags, tag]).slice(0, max)) } - // TODO: this is a hack to get the input to clear on iOS - setTimeout(() => { + if (isWeb) { setValue('') input.current?.focus() - }, 1) // only positive values work + } else { + // This is a hack to get the input to clear on iOS/Android, and only + // positive values work here + setTimeout(() => { + setValue('') + input.current?.focus() + }, 1) + } }, [max, value, tags, setValue, handleChangeTags]) const onKeyPress = React.useCallback( (e: NativeSyntheticEvent) => { - if (e.nativeEvent.key === ' ') { - onSubmitEditing() - } else if (e.nativeEvent.key === 'Backspace' && value === '') { + if (e.nativeEvent.key === 'Backspace' && value === '') { handleChangeTags(tags.slice(0, -1)) } }, - [value, tags, onSubmitEditing, handleChangeTags], + [value, tags, handleChangeTags], ) const onChangeText = React.useCallback((v: string) => { @@ -101,10 +101,10 @@ export function TagInput({ }, []) const onBlur = React.useCallback(() => { - const _tags = sanitize(value) + const tag = sanitize(value) - if (_tags.length > 0) { - handleChangeTags(uniq([...tags, ..._tags]).slice(0, max)) + if (tag.length > 0) { + handleChangeTags(uniq([...tags, tag]).slice(0, max)) setValue('') } }, [value, tags, max, handleChangeTags]) @@ -138,7 +138,7 @@ export function TagInput({ onBlur={onBlur} blurOnSubmit={false} style={[styles.input, pal.textLight]} - placeholder="Add tags..." + placeholder="Enter a tag and press enter" autoCapitalize="none" autoCorrect={false} autoComplete="off" From 0648f02f663ae5466d516b1195e1ccdc396566af Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 26 Sep 2023 14:55:07 -0500 Subject: [PATCH 14/66] remove half-baked onBlur handling --- src/view/com/composer/TagInput.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index b873e0edf1..b8a18afe67 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -100,15 +100,6 @@ export function TagInput({ setValue(v) }, []) - const onBlur = React.useCallback(() => { - const tag = sanitize(value) - - if (tag.length > 0) { - handleChangeTags(uniq([...tags, tag]).slice(0, max)) - setValue('') - } - }, [value, tags, max, handleChangeTags]) - const removeTag = React.useCallback( (tag: string) => { handleChangeTags(tags.filter(t => t !== tag)) @@ -135,7 +126,6 @@ export function TagInput({ onKeyPress={onKeyPress} onSubmitEditing={onSubmitEditing} onChangeText={onChangeText} - onBlur={onBlur} blurOnSubmit={false} style={[styles.input, pal.textLight]} placeholder="Enter a tag and press enter" From f5e793d8c3fd3cbc73d6f3c07bb49ab3ec100f62 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 26 Sep 2023 15:36:08 -0500 Subject: [PATCH 15/66] fix tag index parsing --- src/view/com/composer/text-input/web/TagDecorator.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/view/com/composer/text-input/web/TagDecorator.ts b/src/view/com/composer/text-input/web/TagDecorator.ts index 68d187119d..d70e409c5d 100644 --- a/src/view/com/composer/text-input/web/TagDecorator.ts +++ b/src/view/com/composer/text-input/web/TagDecorator.ts @@ -20,22 +20,21 @@ import {findChildren} from '@tiptap/core' import {Node as ProsemirrorNode} from '@tiptap/pm/model' import {Decoration, DecorationSet} from '@tiptap/pm/view' -const TAG_REGEX = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g - function getDecorations(doc: ProsemirrorNode) { const decorations: Decoration[] = [] findChildren(doc, node => node.type.name === 'paragraph').forEach( paragraphNode => { const textContent = paragraphNode.node.textContent + const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g let match - while ((match = TAG_REGEX.exec(textContent))) { + while ((match = regex.exec(textContent))) { const [m] = match const hasLeadingSpace = /^\s/.test(m) const tag = m.trim().replace(/\p{P}+$/gu, '') if (tag.length > 66) continue - const from = match.index + (hasLeadingSpace ? 1 : 0) + const from = match.index + (hasLeadingSpace ? 2 : 1) const to = from + tag.length + 1 decorations.push( Decoration.inline(paragraphNode.pos + from, paragraphNode.pos + to, { From 4b47380cdfd10e24fcc468531460c60dd7d93b41 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 26 Sep 2023 17:43:01 -0500 Subject: [PATCH 16/66] fix node walking --- .../composer/text-input/web/TagDecorator.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/view/com/composer/text-input/web/TagDecorator.ts b/src/view/com/composer/text-input/web/TagDecorator.ts index d70e409c5d..022e0be4ca 100644 --- a/src/view/com/composer/text-input/web/TagDecorator.ts +++ b/src/view/com/composer/text-input/web/TagDecorator.ts @@ -16,34 +16,34 @@ import {Mark} from '@tiptap/core' import {Plugin, PluginKey} from '@tiptap/pm/state' -import {findChildren} from '@tiptap/core' import {Node as ProsemirrorNode} from '@tiptap/pm/model' import {Decoration, DecorationSet} from '@tiptap/pm/view' function getDecorations(doc: ProsemirrorNode) { const decorations: Decoration[] = [] - findChildren(doc, node => node.type.name === 'paragraph').forEach( - paragraphNode => { - const textContent = paragraphNode.node.textContent + 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 [m] = match - const hasLeadingSpace = /^\s/.test(m) - const tag = m.trim().replace(/\p{P}+$/gu, '') + const [matchedString, tag] = match + if (tag.length > 66) continue - const from = match.index + (hasLeadingSpace ? 2 : 1) - const to = from + tag.length + 1 + + const from = match.index + matchedString.indexOf(tag) + const to = from + tag.length + decorations.push( - Decoration.inline(paragraphNode.pos + from, paragraphNode.pos + to, { + Decoration.inline(pos + from, pos + to, { class: 'autolink', }), ) } - }, - ) + } + }) return DecorationSet.create(doc, decorations) } From a07542a60711eb3ad63ae21a393d2ffcd9895a04 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 27 Sep 2023 18:01:13 -0500 Subject: [PATCH 17/66] WIP autocomplete (cherry picked from commit 3098ebb482cfd8b27d2cae4c8d2087a1554bf73c) --- package.json | 1 + src/state/models/ui/tags-autocomplete.ts | 88 ++++++++++ src/view/com/composer/Composer.tsx | 6 + .../com/composer/text-input/TextInput.tsx | 2 + .../com/composer/text-input/TextInput.web.tsx | 12 +- .../text-input/web/TagDecoratorV2.tsx | 163 ++++++++++++++++++ yarn.lock | 5 + 7 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 src/state/models/ui/tags-autocomplete.ts create mode 100644 src/view/com/composer/text-input/web/TagDecoratorV2.tsx diff --git a/package.json b/package.json index 1a8c5992ba..9b1ed9000c 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "expo-system-ui": "~2.4.0", "expo-updates": "~0.18.12", "fast-text-encoding": "^1.0.6", + "fuse.js": "^6.6.2", "graphemer": "^1.4.0", "history": "^5.3.0", "js-sha256": "^0.9.0", diff --git a/src/state/models/ui/tags-autocomplete.ts b/src/state/models/ui/tags-autocomplete.ts new file mode 100644 index 0000000000..61c5df6282 --- /dev/null +++ b/src/state/models/ui/tags-autocomplete.ts @@ -0,0 +1,88 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import AwaitLock from 'await-lock' +import {RootStoreModel} from '../root-store' +import Fuse from 'fuse.js' + +export class TagsAutocompleteView { + // state + isLoading = false + isActive = false + prefix = '' + lock = new AwaitLock() + + searchedTags: string[] = [] + recentTags: string[] = [ + 'js', + 'javascript', + 'art', + 'music', + ] + profileTags: string[] = [ + 'bikes', + 'beer', + ] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get suggestions() { + if (!this.isActive) { + return [] + } + + const items = [ + ...this.recentTags, + ...this.profileTags, + ...this.searchedTags, + ] + + if (!this.prefix) { + return items.slice(0, 8) + } + + const fuse = new Fuse(items) + const results = fuse.search(this.prefix) + + return results.slice(0, 8).map(r => r.item) + } + + setActive(v: boolean) { + this.isActive = v + } + + async setPrefix(prefix: string) { + this.prefix = prefix.trim() + await this.lock.acquireAsync() + try { + if (this.prefix) { + if (this.prefix !== this.prefix) { + return // another prefix was set before we got our chance + } + await this._search() + } else { + // this.searchRes = [] + } + } finally { + this.lock.release() + } + } + + // internal + // = + + async _search() { + runInAction(() => { + this.searchedTags = [ + 'code', + 'dev', + ] + }) + } +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index a589978d95..e89042ad1d 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -16,6 +16,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {RichText} from '@atproto/api' import {useAnalytics} from 'lib/analytics/analytics' import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' +import {TagsAutocompleteView} from 'state/models/ui/tags-autocomplete' import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible' import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' @@ -97,6 +98,10 @@ export const ComposePost = observer(function ComposePost({ () => new UserAutocompleteModel(store), [store], ) + const tagAutoCompleteView = useMemo( + () => new TagsAutocompleteView(store), + [store], + ) const insets = useSafeAreaInsets() const viewStyles = useMemo( @@ -366,6 +371,7 @@ export const ComposePost = observer(function ComposePost({ placeholder={selectTextInputPlaceholder} suggestedLinks={suggestedLinks} autocompleteView={autocompleteView} + tagsAutocompleteView={tagAutoCompleteView} autoFocus={true} setRichText={setRichText} onPhotoPasted={onPhotoPasted} diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index c5d094ea5f..b944aad5a7 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -19,6 +19,7 @@ import PasteInput, { import {AppBskyRichtextFacet, RichText} from '@atproto/api' import isEqual from 'lodash.isequal' import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' +import {TagsAutocompleteView} from 'state/models/ui/tags-autocomplete' import {Autocomplete} from './mobile/Autocomplete' import {Text} from 'view/com/util/text/Text' import {cleanError} from 'lib/strings/errors' @@ -39,6 +40,7 @@ interface TextInputProps extends ComponentProps { placeholder: string suggestedLinks: Set autocompleteView: UserAutocompleteModel + tagsAutocompleteView: TagsAutocompleteView setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 0dd72a1780..47c3cf70af 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -12,6 +12,7 @@ import {Placeholder} from '@tiptap/extension-placeholder' import {Text} from '@tiptap/extension-text' import isEqual from 'lodash.isequal' import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' +import {TagsAutocompleteView} from 'state/models/ui/tags-autocomplete' import {createSuggestion} from './web/Autocomplete' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {isUriImage, blobToDataUri} from 'lib/media/util' @@ -19,6 +20,7 @@ import {Emoji} from './web/EmojiPicker.web' import {LinkDecorator} from './web/LinkDecorator' import {generateJSON} from '@tiptap/html' import {TagDecorator} from './web/TagDecorator' +import {Tags, createTagsSuggestion} from './web/Tags' export interface TextInputRef { focus: () => void @@ -30,6 +32,7 @@ interface TextInputProps { placeholder: string suggestedLinks: Set autocompleteView: UserAutocompleteModel + tagsAutocompleteView: TagsAutocompleteView setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise @@ -45,6 +48,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( placeholder, suggestedLinks, autocompleteView, + tagsAutocompleteView, setRichText, onPhotoPasted, onPressPublish, @@ -58,7 +62,13 @@ export const TextInput = React.forwardRef(function TextInputImpl( () => [ Document, LinkDecorator, - TagDecorator, + // TagDecorator, + Tags.configure({ + HTMLAttributes: { + class: 'autolink', + }, + suggestion: createTagsSuggestion({autocompleteView: tagsAutocompleteView}), + }), Mention.configure({ HTMLAttributes: { class: 'mention', diff --git a/src/view/com/composer/text-input/web/TagDecoratorV2.tsx b/src/view/com/composer/text-input/web/TagDecoratorV2.tsx new file mode 100644 index 0000000000..8a7eb5cac1 --- /dev/null +++ b/src/view/com/composer/text-input/web/TagDecoratorV2.tsx @@ -0,0 +1,163 @@ +import { mergeAttributes, Node } from '@tiptap/core' +import { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { PluginKey } from '@tiptap/pm/state' +import Suggestion, { SuggestionOptions } from '@tiptap/suggestion' + +export type TagOptions = { + HTMLAttributes: Record + renderLabel: (props: { options: TagOptions; node: ProseMirrorNode }) => string + suggestion: Omit +} + +export const TagsPluginKey = new PluginKey('tags') + +export const Tags = Node.create({ + name: 'tags', + + addOptions() { + return { + HTMLAttributes: {}, + renderLabel({ options, node }) { + return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + }, + suggestion: { + char: '#', + pluginKey: TagsPluginKey, + command: ({ editor, range, props }) => { + // increase range.to by one when the next node is of type "text" + // and starts with a space character + const nodeAfter = editor.view.state.selection.$to.nodeAfter + const overrideSpace = nodeAfter?.text?.startsWith(' ') + + if (overrideSpace) { + range.to += 1 + } + + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: this.name, + attrs: props, + }, + { + type: 'text', + text: ' ', + }, + ]) + .run() + + window.getSelection()?.collapseToEnd() + }, + allow: ({ state, range }) => { + const $from = state.doc.resolve(range.from) + const type = state.schema.nodes[this.name] + const allow = !!$from.parent.type.contentMatch.matchType(type) + + return allow + }, + }, + } + }, + + group: 'inline', + + inline: true, + + selectable: true, + + atom: true, + + addAttributes() { + return { + id: { + default: null, + parseHTML: element => element.getAttribute('data-id'), + renderHTML: attributes => { + if (!attributes.id) { + return {} + } + + return { + 'data-id': attributes.id, + } + }, + }, + + label: { + default: null, + parseHTML: element => element.getAttribute('data-label'), + renderHTML: attributes => { + if (!attributes.label) { + return {} + } + + return { + 'data-label': attributes.label, + } + }, + }, + } + }, + + parseHTML() { + return [ + { + tag: `span[data-type="${this.name}"]`, + }, + ] + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + 'span', + mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes), + this.options.renderLabel({ + options: this.options, + node, + }), + ] + }, + + renderText({ node }) { + return this.options.renderLabel({ + options: this.options, + node, + }) + }, + + addKeyboardShortcuts() { + return { + Backspace: () => this.editor.commands.command(({ tr, state }) => { + let isTag = false + const { selection } = state + const { empty, anchor } = selection + + if (!empty) { + return false + } + + state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => { + if (node.type.name === this.name) { + isTag = true + tr.insertText(this.options.suggestion.char || '', pos, pos + node.nodeSize) + + return false + } + }) + + return isTag + }), + } + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ] + }, +}) diff --git a/yarn.lock b/yarn.lock index 98b0519b2c..7e29758b79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9933,6 +9933,11 @@ funpermaproxy@^1.1.0: resolved "https://registry.yarnpkg.com/funpermaproxy/-/funpermaproxy-1.1.0.tgz#39cb0b8bea908051e4608d8a414f1d87b55bf557" integrity sha512-2Sp1hWuO8m5fqeFDusyhKqYPT+7rGLw34N3qonDcdRP8+n7M7Gl/yKp/q7oCxnnJ6pWCectOmLFJpsMU/++KrQ== +fuse.js@^6.6.2: + version "6.6.2" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111" + integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" From bd9c1626e6289c137b868fca7ff2ffab626e5e03 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 27 Sep 2023 19:56:50 -0500 Subject: [PATCH 18/66] some tag styles --- web/index.html | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/web/index.html b/web/index.html index 1a9d63b83e..5a4be098d6 100644 --- a/web/index.html +++ b/web/index.html @@ -48,6 +48,9 @@ --text: black; --background: white; --backgroundLight: #F3F3F8; + --blue: #0085ff; + --teal: #9AD5CA; + --yellow: #FED766; } html.colorMode--dark { --text: white; @@ -133,10 +136,42 @@ input:focus { outline: 0; } + /* TODO make this more obvious */ .tippy-content .items { width: fit-content; } + /* Hashtags in ProseMirror */ + .ProseMirror .inline-tag, + .ProseMirror .mention { + position: relative; + margin: 0 3px; + } + .ProseMirror .inline-tag::after, + .ProseMirror .mention::after { + content: ''; + position: absolute; + top: -1px; + bottom: -2px; + left: -3px; + right: -3px; + z-index: -1; + opacity: 0.3; + border-radius: 4px; + } + .ProseMirror .inline-tag { + color: var(--teal); + } + .ProseMirror .inline-tag::after { + background-color: var(--teal); + } + .ProseMirror .mention { + color: var(--yellow); + } + .ProseMirror .mention::after { + background-color: var(--yellow); + } + /* Tooltips */ [data-tooltip] { position: relative; From 6f6a34cc99f40ff150035a4856200aff6a46c8ea Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 27 Sep 2023 19:57:20 -0500 Subject: [PATCH 19/66] clean up tags autocomplete desktop --- src/state/models/root-store.ts | 6 + src/state/models/ui/tags-autocomplete.ts | 109 +++++++----- src/view/com/composer/Composer.tsx | 8 +- .../com/composer/text-input/TextInput.tsx | 4 +- .../com/composer/text-input/TextInput.web.tsx | 17 +- .../text-input/web/TagDecoratorV2.tsx | 163 ------------------ 6 files changed, 89 insertions(+), 218 deletions(-) delete mode 100644 src/view/com/composer/text-input/web/TagDecoratorV2.tsx diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 1a81072a25..63cbd33a88 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -22,6 +22,7 @@ import {resetToTab} from '../../Navigation' import {ImageSizesCache} from './cache/image-sizes' import {MutedThreads} from './muted-threads' import {reset as resetNavigation} from '../../Navigation' +import {RecentTagsModel} from './ui/tags-autocomplete' // TEMPORARY (APP-700) // remove after backend testing finishes @@ -53,6 +54,7 @@ export class RootStoreModel { linkMetas = new LinkMetasCache(this) imageSizes = new ImageSizesCache() mutedThreads = new MutedThreads() + recentTags = new RecentTagsModel() constructor(agent: BskyAgent) { this.agent = agent @@ -77,6 +79,7 @@ export class RootStoreModel { preferences: this.preferences.serialize(), invitedUsers: this.invitedUsers.serialize(), mutedThreads: this.mutedThreads.serialize(), + recentTags: this.recentTags.serialize(), } } @@ -109,6 +112,9 @@ export class RootStoreModel { if (hasProp(v, 'mutedThreads')) { this.mutedThreads.hydrate(v.mutedThreads) } + if (hasProp(v, 'recentTags')) { + this.recentTags.hydrate(v.recentTags) + } } } diff --git a/src/state/models/ui/tags-autocomplete.ts b/src/state/models/ui/tags-autocomplete.ts index 61c5df6282..86150eda37 100644 --- a/src/state/models/ui/tags-autocomplete.ts +++ b/src/state/models/ui/tags-autocomplete.ts @@ -2,25 +2,44 @@ import {makeAutoObservable, runInAction} from 'mobx' import AwaitLock from 'await-lock' import {RootStoreModel} from '../root-store' import Fuse from 'fuse.js' +import {isObj, hasProp, isStrArray} from 'lib/type-guards' -export class TagsAutocompleteView { - // state - isLoading = false - isActive = false - prefix = '' - lock = new AwaitLock() +export class RecentTagsModel { + _tags: string[] = [] + + constructor() { + makeAutoObservable(this, {}, {autoBind: true}) + } + + get tags() { + return this._tags + } + + add(tag: string) { + this._tags = Array.from(new Set([tag, ...this._tags])) + } + + remove(tag: string) { + this._tags = this._tags.filter(t => t !== tag) + } + + serialize() { + return {_tags: this._tags} + } + + hydrate(v: unknown) { + if (isObj(v) && hasProp(v, '_tags') && isStrArray(v._tags)) { + this._tags = Array.from(new Set(v._tags)) + } + } +} +export class TagsAutocompleteModel { + lock = new AwaitLock() + isActive = false + query = '' searchedTags: string[] = [] - recentTags: string[] = [ - 'js', - 'javascript', - 'art', - 'music', - ] - profileTags: string[] = [ - 'bikes', - 'beer', - ] + profileTags: string[] = [] constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -32,56 +51,64 @@ export class TagsAutocompleteView { ) } + setActive(isActive: boolean) { + this.isActive = isActive + } + + commitRecentTag(tag: string) { + this.rootStore.recentTags.add(tag) + } + get suggestions() { if (!this.isActive) { return [] } - const items = [ - ...this.recentTags, - ...this.profileTags, - ...this.searchedTags, - ] + const items = Array.from( + new Set([ + ...this.rootStore.recentTags.tags.slice(0, 3), + ...this.profileTags.slice(0, 3), + ...this.searchedTags, + ]), + ) - if (!this.prefix) { - return items.slice(0, 8) + if (!this.query) { + return items.slice(0, 9) } const fuse = new Fuse(items) - const results = fuse.search(this.prefix) + const results = fuse.search(this.query) - return results.slice(0, 8).map(r => r.item) + return results.slice(0, 9).map(r => r.item) } - setActive(v: boolean) { - this.isActive = v - } + async search(query: string) { + this.query = query.trim() - async setPrefix(prefix: string) { - this.prefix = prefix.trim() await this.lock.acquireAsync() + try { - if (this.prefix) { - if (this.prefix !== this.prefix) { - return // another prefix was set before we got our chance - } - await this._search() - } else { - // this.searchRes = [] - } + // another query was set before we got our chance + if (this.query !== this.query) return + await this._search() } finally { this.lock.release() } } - // internal - // = - async _search() { runInAction(() => { this.searchedTags = [ 'code', 'dev', + 'javascript', + 'react', + 'typescript', + 'mobx', + 'mobx-state-tree', + 'mobx-react', + 'mobx-react-lite', + 'mobx-react-form', ] }) } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index e89042ad1d..58be274637 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -16,7 +16,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {RichText} from '@atproto/api' import {useAnalytics} from 'lib/analytics/analytics' import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' -import {TagsAutocompleteView} from 'state/models/ui/tags-autocomplete' +import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible' import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' @@ -98,8 +98,8 @@ export const ComposePost = observer(function ComposePost({ () => new UserAutocompleteModel(store), [store], ) - const tagAutoCompleteView = useMemo( - () => new TagsAutocompleteView(store), + const tagsAutocompleteModel = useMemo( + () => new TagsAutocompleteModel(store), [store], ) @@ -371,7 +371,7 @@ export const ComposePost = observer(function ComposePost({ placeholder={selectTextInputPlaceholder} suggestedLinks={suggestedLinks} autocompleteView={autocompleteView} - tagsAutocompleteView={tagAutoCompleteView} + tagsAutocompleteModel={tagsAutocompleteModel} autoFocus={true} setRichText={setRichText} onPhotoPasted={onPhotoPasted} diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index b944aad5a7..49af905be5 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -19,7 +19,7 @@ import PasteInput, { import {AppBskyRichtextFacet, RichText} from '@atproto/api' import isEqual from 'lodash.isequal' import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' -import {TagsAutocompleteView} from 'state/models/ui/tags-autocomplete' +import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {Autocomplete} from './mobile/Autocomplete' import {Text} from 'view/com/util/text/Text' import {cleanError} from 'lib/strings/errors' @@ -40,7 +40,7 @@ interface TextInputProps extends ComponentProps { placeholder: string suggestedLinks: Set autocompleteView: UserAutocompleteModel - tagsAutocompleteView: TagsAutocompleteView + tagsAutocompleteModel: TagsAutocompleteModel setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 47c3cf70af..ff1b4e07c8 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -12,15 +12,14 @@ import {Placeholder} from '@tiptap/extension-placeholder' import {Text} from '@tiptap/extension-text' import isEqual from 'lodash.isequal' import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' -import {TagsAutocompleteView} from 'state/models/ui/tags-autocomplete' +import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {createSuggestion} from './web/Autocomplete' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {isUriImage, blobToDataUri} from 'lib/media/util' import {Emoji} from './web/EmojiPicker.web' import {LinkDecorator} from './web/LinkDecorator' import {generateJSON} from '@tiptap/html' -import {TagDecorator} from './web/TagDecorator' -import {Tags, createTagsSuggestion} from './web/Tags' +import {Tags, createTagsAutocomplete} from './web/Tags' export interface TextInputRef { focus: () => void @@ -32,7 +31,7 @@ interface TextInputProps { placeholder: string suggestedLinks: Set autocompleteView: UserAutocompleteModel - tagsAutocompleteView: TagsAutocompleteView + tagsAutocompleteModel: TagsAutocompleteModel setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise @@ -48,7 +47,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( placeholder, suggestedLinks, autocompleteView, - tagsAutocompleteView, + tagsAutocompleteModel, setRichText, onPhotoPasted, onPressPublish, @@ -65,9 +64,11 @@ export const TextInput = React.forwardRef(function TextInputImpl( // TagDecorator, Tags.configure({ HTMLAttributes: { - class: 'autolink', + class: 'inline-tag', }, - suggestion: createTagsSuggestion({autocompleteView: tagsAutocompleteView}), + suggestion: createTagsAutocomplete({ + autocompleteModel: tagsAutocompleteModel, + }), }), Mention.configure({ HTMLAttributes: { @@ -83,7 +84,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( History, Hardbreak, ], - [autocompleteView, placeholder], + [autocompleteView, placeholder, tagsAutocompleteModel], ) React.useEffect(() => { diff --git a/src/view/com/composer/text-input/web/TagDecoratorV2.tsx b/src/view/com/composer/text-input/web/TagDecoratorV2.tsx deleted file mode 100644 index 8a7eb5cac1..0000000000 --- a/src/view/com/composer/text-input/web/TagDecoratorV2.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { mergeAttributes, Node } from '@tiptap/core' -import { Node as ProseMirrorNode } from '@tiptap/pm/model' -import { PluginKey } from '@tiptap/pm/state' -import Suggestion, { SuggestionOptions } from '@tiptap/suggestion' - -export type TagOptions = { - HTMLAttributes: Record - renderLabel: (props: { options: TagOptions; node: ProseMirrorNode }) => string - suggestion: Omit -} - -export const TagsPluginKey = new PluginKey('tags') - -export const Tags = Node.create({ - name: 'tags', - - addOptions() { - return { - HTMLAttributes: {}, - renderLabel({ options, node }) { - return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` - }, - suggestion: { - char: '#', - pluginKey: TagsPluginKey, - command: ({ editor, range, props }) => { - // increase range.to by one when the next node is of type "text" - // and starts with a space character - const nodeAfter = editor.view.state.selection.$to.nodeAfter - const overrideSpace = nodeAfter?.text?.startsWith(' ') - - if (overrideSpace) { - range.to += 1 - } - - editor - .chain() - .focus() - .insertContentAt(range, [ - { - type: this.name, - attrs: props, - }, - { - type: 'text', - text: ' ', - }, - ]) - .run() - - window.getSelection()?.collapseToEnd() - }, - allow: ({ state, range }) => { - const $from = state.doc.resolve(range.from) - const type = state.schema.nodes[this.name] - const allow = !!$from.parent.type.contentMatch.matchType(type) - - return allow - }, - }, - } - }, - - group: 'inline', - - inline: true, - - selectable: true, - - atom: true, - - addAttributes() { - return { - id: { - default: null, - parseHTML: element => element.getAttribute('data-id'), - renderHTML: attributes => { - if (!attributes.id) { - return {} - } - - return { - 'data-id': attributes.id, - } - }, - }, - - label: { - default: null, - parseHTML: element => element.getAttribute('data-label'), - renderHTML: attributes => { - if (!attributes.label) { - return {} - } - - return { - 'data-label': attributes.label, - } - }, - }, - } - }, - - parseHTML() { - return [ - { - tag: `span[data-type="${this.name}"]`, - }, - ] - }, - - renderHTML({ node, HTMLAttributes }) { - return [ - 'span', - mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes), - this.options.renderLabel({ - options: this.options, - node, - }), - ] - }, - - renderText({ node }) { - return this.options.renderLabel({ - options: this.options, - node, - }) - }, - - addKeyboardShortcuts() { - return { - Backspace: () => this.editor.commands.command(({ tr, state }) => { - let isTag = false - const { selection } = state - const { empty, anchor } = selection - - if (!empty) { - return false - } - - state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => { - if (node.type.name === this.name) { - isTag = true - tr.insertText(this.options.suggestion.char || '', pos, pos + node.nodeSize) - - return false - } - }) - - return isTag - }), - } - }, - - addProseMirrorPlugins() { - return [ - Suggestion({ - editor: this.editor, - ...this.options.suggestion, - }), - ] - }, -}) From 4868d4864b0a465c7b78ef32c10a300523054725 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 27 Sep 2023 20:43:54 -0500 Subject: [PATCH 20/66] stick tag input to bottom --- src/view/com/composer/Composer.tsx | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 58be274637..70d817dbb9 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -396,14 +396,6 @@ export const ComposePost = observer(function ComposePost({ ) : undefined} - - - - {!extLink && suggestedLinks.size > 0 ? ( @@ -426,6 +418,20 @@ export const ComposePost = observer(function ComposePost({ ))} ) : null} + + + + + {canSelectImages ? ( <> @@ -528,8 +534,7 @@ const styles = StyleSheet.create({ bottomBar: { flexDirection: 'row', paddingVertical: 10, - paddingLeft: 15, - paddingRight: 20, + paddingRight: 15, alignItems: 'center', borderTopWidth: 1, }, From 4d367d6805410471d60d58fd28b733049535f7e0 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 27 Sep 2023 20:51:44 -0500 Subject: [PATCH 21/66] update graphemes --- src/view/com/composer/text-input/TextInput.web.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index ff1b4e07c8..4041761027 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -196,6 +196,8 @@ function editorJsonToText(json: JSONContent): string { text += json.text || '' } else if (json.type === 'mention') { text += `@${json.attrs?.id || ''}` + } else if (json.type === 'tag') { + text += `#${json.attrs?.id || ''}` } return text } From 564a654cc260109a21ac0d1b15b927d39fee89e8 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 27 Sep 2023 21:22:47 -0500 Subject: [PATCH 22/66] mobile autocomplete --- src/view/com/composer/Composer.tsx | 10 +- .../com/composer/text-input/TextInput.tsx | 37 +++++- .../text-input/mobile/TagsAutocomplete.tsx | 118 ++++++++++++++++++ 3 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 70d817dbb9..065f27cacc 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -13,7 +13,7 @@ import { import {useSafeAreaInsets} from 'react-native-safe-area-context' import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {RichText} from '@atproto/api' +import {AppBskyRichtextFacet, RichText} from '@atproto/api' import {useAnalytics} from 'lib/analytics/analytics' import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' @@ -226,6 +226,14 @@ export const ComposePost = observer(function ComposePost({ imageCount: gallery.size, }) if (replyTo && replyTo.uri) track('Post:Reply') + + for (const facet of richtext.facets || []) { + for (const feature of facet.features) { + if (AppBskyRichtextFacet.isTag(feature)) { + tagsAutocompleteModel.commitRecentTag(feature.tag) + } + } + } } if (!replyTo) { store.me.mainFeed.onPostCreated() diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 49af905be5..5218df2ac5 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -21,6 +21,11 @@ import isEqual from 'lodash.isequal' import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {Autocomplete} from './mobile/Autocomplete' +import { + TagsAutocomplete, + getHashtagAt, + insertTagAt, +} from './mobile/TagsAutocomplete' import {Text} from 'view/com/util/text/Text' import {cleanError} from 'lib/strings/errors' import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' @@ -59,6 +64,7 @@ export const TextInput = forwardRef(function TextInputImpl( placeholder, suggestedLinks, autocompleteView, + tagsAutocompleteModel, setRichText, onPhotoPasted, onSuggestedLinksChanged, @@ -96,17 +102,29 @@ export const TextInput = forwardRef(function TextInputImpl( newRt.detectFacetsWithoutResolution() setRichText(newRt) - const prefix = getMentionAt( + const mentionPrefix = getMentionAt( newText, textInputSelection.current?.start || 0, ) - if (prefix) { + + if (mentionPrefix) { autocompleteView.setActive(true) - autocompleteView.setPrefix(prefix.value) + autocompleteView.setPrefix(mentionPrefix.value) } else { autocompleteView.setActive(false) } + const hashtagPrefix = getHashtagAt( + newText, + textInputSelection.current?.start || 0, + ) + if (hashtagPrefix) { + tagsAutocompleteModel.setActive(true) + tagsAutocompleteModel.search(hashtagPrefix.value || '') + } else { + tagsAutocompleteModel.setActive(false) + } + const set: Set = new Set() if (newRt.facets) { @@ -145,6 +163,7 @@ export const TextInput = forwardRef(function TextInputImpl( suggestedLinks, onSuggestedLinksChanged, onPhotoPasted, + tagsAutocompleteModel, ], ) @@ -186,6 +205,17 @@ export const TextInput = forwardRef(function TextInputImpl( [onChangeText, richtext, autocompleteView], ) + const onSelectTag = useCallback( + (tag: string) => { + onChangeText( + insertTagAt(richtext.text, textInputSelection.current?.start || 0, tag), + ) + tagsAutocompleteModel.commitRecentTag(tag) + tagsAutocompleteModel.setActive(false) + }, + [onChangeText, richtext, tagsAutocompleteModel], + ) + const textDecorated = useMemo(() => { let i = 0 @@ -223,6 +253,7 @@ export const TextInput = forwardRef(function TextInputImpl( view={autocompleteView} onSelect={onSelectAutocompleteItem} /> + ) }) diff --git a/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx b/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx new file mode 100644 index 0000000000..8d03662f7b --- /dev/null +++ b/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx @@ -0,0 +1,118 @@ +import React, {useEffect} from 'react' +import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' +import {Text} from 'view/com/util/text/Text' + +export function getHashtagAt(text: string, position: number) { + const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/gi + + let match + while ((match = regex.exec(text))) { + const [matchedString, tag] = match + + if (tag.length > 66) continue + + const from = match.index + matchedString.indexOf(tag) + const to = from + tag.length + + if (position >= from && position <= to) { + return {value: tag, index: from} + } + } + + const hashRegex = /#/g + let hashMatch + while ((hashMatch = hashRegex.exec(text))) { + if (position >= hashMatch.index && position <= hashMatch.index + 1) { + return {value: '', index: hashMatch.index} + } + } + + return undefined +} + +export function insertTagAt(text: string, position: number, tag: string) { + const target = getHashtagAt(text, position) + if (target) { + return `${text.slice(0, target.index)}#${tag} ${text.slice( + target.index + target.value.length + 1, // add 1 to include the "@" + )}` + } + return text +} + +export const TagsAutocomplete = observer(function AutocompleteImpl({ + model, + onSelect, +}: { + model: TagsAutocompleteModel + onSelect: (item: string) => void +}) { + const pal = usePalette('default') + const positionInterp = useAnimatedValue(0) + + useEffect(() => { + Animated.timing(positionInterp, { + toValue: model.isActive ? 1 : 0, + duration: 200, + useNativeDriver: true, + }).start() + }, [positionInterp, model.isActive]) + + const topAnimStyle = { + transform: [ + { + translateY: positionInterp.interpolate({ + inputRange: [0, 1], + outputRange: [200, 0], + }), + }, + ], + } + + if (!model.suggestions.length) return null + + return ( + + {model.isActive ? ( + + {model.suggestions.slice(0, 5).map(item => { + return ( + onSelect(item)} + accessibilityLabel={`Select #${item}`} + accessibilityHint=""> + + #{item} + + + ) + })} + + ) : null} + + ) +}) + +const styles = StyleSheet.create({ + container: { + marginLeft: -50, // Composer avatar width + top: 10, + borderTopWidth: 1, + }, + item: { + borderBottomWidth: 1, + paddingVertical: 12, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 6, + }, +}) From e0c614bfb72dda3bc731b355864651b65a910839 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 28 Sep 2023 10:31:47 -0500 Subject: [PATCH 23/66] split tags on spaces in TagInput --- src/view/com/composer/TagInput.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index b8a18afe67..f575fff370 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -20,6 +20,16 @@ function uniq(tags: string[]) { return Array.from(new Set(tags)) } +// function sanitize(tagString: string, { max }: { max: number }) { +// const sanitized = tagString.replace(/^#/, '') +// .split(/\s/) +// .map(t => t.trim()) +// .map(t => t.replace(/^#/, '')) + +// return uniq(sanitized) +// .slice(0, max) +// } + function sanitize(tagString: string) { return tagString.trim().replace(/^#/, '') } @@ -91,9 +101,12 @@ export function TagInput({ (e: NativeSyntheticEvent) => { if (e.nativeEvent.key === 'Backspace' && value === '') { handleChangeTags(tags.slice(0, -1)) + } else if (e.nativeEvent.key === ' ') { + e.preventDefault() // prevents an additional space on web + onSubmitEditing() } }, - [value, tags, handleChangeTags], + [value, tags, handleChangeTags, onSubmitEditing], ) const onChangeText = React.useCallback((v: string) => { From 804a393ff86a7aab2cb5b3cdd7d90a26303b4eda Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 28 Sep 2023 11:00:50 -0500 Subject: [PATCH 24/66] move Tag, update styling --- src/view/com/Tag.tsx | 83 ++++++++++++++++++++++++++++++ src/view/com/composer/TagInput.tsx | 28 +--------- web/index.html | 21 +++++--- 3 files changed, 99 insertions(+), 33 deletions(-) create mode 100644 src/view/com/Tag.tsx diff --git a/src/view/com/Tag.tsx b/src/view/com/Tag.tsx new file mode 100644 index 0000000000..cd71a73ac0 --- /dev/null +++ b/src/view/com/Tag.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import {StyleSheet, Pressable} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' + +import {usePalette} from 'lib/hooks/usePalette' +import {Text} from 'view/com/util/text/Text' + +export function Tag({value}: {value: string}) { + const pal = usePalette('default') + + return ( + + #{value} + + ) +} + +export function EditableTag({ + value, + onRemove, +}: { + value: string + onRemove: (tag: string) => void +}) { + const pal = usePalette('default') + const [hovered, setHovered] = React.useState(false) + + const hoverIn = React.useCallback(() => { + setHovered(true) + }, [setHovered]) + + const hoverOut = React.useCallback(() => { + setHovered(false) + }, [setHovered]) + + return ( + onRemove(value)} + onPointerEnter={hoverIn} + onPointerLeave={hoverOut} + style={state => [ + pal.border, + styles.tag, + { + opacity: hovered || state.pressed || state.focused ? 0.8 : 1, + outline: 0, + paddingRight: 6, + }, + ]}> + + #{value} + + + + ) +} + +const styles = StyleSheet.create({ + tag: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingVertical: 3, + paddingHorizontal: 8, + borderRadius: 20, + overflow: 'hidden', + borderWidth: 1, + }, +}) diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index f575fff370..400494f082 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -5,7 +5,6 @@ import { StyleSheet, NativeSyntheticEvent, TextInputKeyPressEventData, - Pressable, } from 'react-native' import { FontAwesomeIcon, @@ -13,8 +12,8 @@ import { } from '@fortawesome/react-native-fontawesome' import {isWeb} from 'platform/detection' -import {Text} from 'view/com/util/text/Text' import {usePalette} from 'lib/hooks/usePalette' +import {EditableTag} from 'view/com/Tag' function uniq(tags: string[]) { return Array.from(new Set(tags)) @@ -34,29 +33,6 @@ function sanitize(tagString: string) { return tagString.trim().replace(/^#/, '') } -function Tag({ - tag, - removeTag, -}: { - tag: string - removeTag: (tag: string) => void -}) { - const pal = usePalette('default') - return ( - removeTag(tag)} - style={state => ({ - opacity: state.hovered || state.pressed || state.focused ? 0.8 : 1, - outline: 0, - })}> - - #{tag} - - - ) -} - export function TagInput({ max = 8, onChangeTags, @@ -130,7 +106,7 @@ export function TagInput({ /> )} {tags.map(tag => ( - + ))} {tags.length >= max ? null : ( Date: Thu, 28 Sep 2023 11:42:23 -0500 Subject: [PATCH 25/66] exploration of tag styles --- src/view/com/Tag.tsx | 61 ++++++++++++++++++--- src/view/com/post-thread/PostThreadItem.tsx | 14 +---- src/view/com/util/text/RichText.tsx | 12 +--- web/index.html | 5 +- 4 files changed, 61 insertions(+), 31 deletions(-) diff --git a/src/view/com/Tag.tsx b/src/view/com/Tag.tsx index cd71a73ac0..8dbceb5357 100644 --- a/src/view/com/Tag.tsx +++ b/src/view/com/Tag.tsx @@ -6,15 +6,61 @@ import { } from '@fortawesome/react-native-fontawesome' import {usePalette} from 'lib/hooks/usePalette' -import {Text} from 'view/com/util/text/Text' +import {Text, CustomTextProps} from 'view/com/util/text/Text' +import {Link} from 'view/com/util/Link' -export function Tag({value}: {value: string}) { +export function Tag({ + value, + textSize, +}: { + value: string + textSize?: CustomTextProps['type'] +}) { const pal = usePalette('default') + const type = textSize || 'xs-medium' return ( - - #{value} - + + + #{value} + + + ) +} + +export function InlineTag({ + value, + textSize, +}: { + value: string + textSize?: CustomTextProps['type'] +}) { + const pal = usePalette('default') + const type = textSize || 'xs-medium' + + return ( + + + #{value} + + ) } @@ -74,8 +120,9 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: 4, - paddingVertical: 3, - paddingHorizontal: 8, + paddingTop: 1, + paddingBottom: 2, + paddingHorizontal: 6, borderRadius: 20, overflow: 'hidden', borderWidth: 1, diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index bc51a06d6d..16833363a8 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -36,6 +36,7 @@ import {TimeElapsed} from 'view/com/util/TimeElapsed' import {makeProfileLink} from 'lib/routes/links' import {isDesktopWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Tag} from 'view/com/Tag' export const PostThreadItem = observer(function PostThreadItem({ item, @@ -339,18 +340,7 @@ export const PostThreadItem = observer(function PostThreadItem({ paddingBottom: 12, }}> {item.post.record.tags.map(tag => ( - - - #{tag} - - + ))} ) : null} diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index b17cf1eb94..e8812cce10 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -7,6 +7,7 @@ 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 {InlineTag} from 'view/com/Tag' const WORD_WRAP = {wordWrap: 1} @@ -93,16 +94,7 @@ export function RichText({ />, ) } else if (tag && AppBskyRichtextFacet.validateTag(tag).success) { - els.push( - , - ) + els.push() } else { els.push(segment.text) } diff --git a/web/index.html b/web/index.html index 05eafeb53e..7634a4f5e8 100644 --- a/web/index.html +++ b/web/index.html @@ -116,6 +116,7 @@ } .ProseMirror p { margin: 0; + line-height: 1.5; } .ProseMirror p.is-editor-empty:first-child::before { color: #8d8e96; @@ -155,8 +156,8 @@ .ProseMirror .mention::after { content: ''; position: absolute; - top: -1px; - bottom: -3px; + top: 0; + bottom: -2px; left: -8px; right: -8px; z-index: -1; From 7bef0eca6a0317349ac7eb4167d9467741ba1ce9 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 9 Oct 2023 13:36:01 -0500 Subject: [PATCH 26/66] add tags autocomplete --- .../composer/text-input/web/Tags/index.tsx | 2 + .../composer/text-input/web/Tags/plugin.tsx | 235 ++++++++++++++++++ .../com/composer/text-input/web/Tags/view.tsx | 214 ++++++++++++++++ 3 files changed, 451 insertions(+) create mode 100644 src/view/com/composer/text-input/web/Tags/index.tsx create mode 100644 src/view/com/composer/text-input/web/Tags/plugin.tsx create mode 100644 src/view/com/composer/text-input/web/Tags/view.tsx diff --git a/src/view/com/composer/text-input/web/Tags/index.tsx b/src/view/com/composer/text-input/web/Tags/index.tsx new file mode 100644 index 0000000000..8df8f65d0b --- /dev/null +++ b/src/view/com/composer/text-input/web/Tags/index.tsx @@ -0,0 +1,2 @@ +export {Tags} from './plugin' +export {createTagsAutocomplete} from './view' diff --git a/src/view/com/composer/text-input/web/Tags/plugin.tsx b/src/view/com/composer/text-input/web/Tags/plugin.tsx new file mode 100644 index 0000000000..7bb8f76829 --- /dev/null +++ b/src/view/com/composer/text-input/web/Tags/plugin.tsx @@ -0,0 +1,235 @@ +/** @see https://github.com/ueberdosis/tiptap/blob/main/packages/extension-mention/src/mention.ts */ + +import {mergeAttributes, Node} from '@tiptap/core' +import {Node as ProseMirrorNode} from '@tiptap/pm/model' +import {PluginKey} from '@tiptap/pm/state' +import Suggestion, {SuggestionOptions} from '@tiptap/suggestion' + +export type TagOptions = { + HTMLAttributes: Record + renderLabel: (props: {options: TagOptions; node: ProseMirrorNode}) => string + suggestion: Omit +} + +export const TagsPluginKey = new PluginKey('tags') + +export const Tags = Node.create({ + name: 'tag', + + addOptions() { + return { + HTMLAttributes: {}, + renderLabel({options, node}) { + return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + }, + suggestion: { + char: '#', + allowSpaces: true, + pluginKey: TagsPluginKey, + command: ({editor, range, props}) => { + // increase range.to by one when the next node is of type "text" + // and starts with a space character + const nodeAfter = editor.view.state.selection.$to.nodeAfter + const overrideSpace = nodeAfter?.text?.startsWith(' ') + + if (overrideSpace) { + range.to += 1 + } + + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: this.name, + attrs: props, + }, + { + type: 'text', + text: ' ', + }, + ]) + .run() + + window.getSelection()?.collapseToEnd() + }, + allow: ({state, range}) => { + const $from = state.doc.resolve(range.from) + const type = state.schema.nodes[this.name] + const allow = !!$from.parent.type.contentMatch.matchType(type) + + return allow + }, + findSuggestionMatch({$position}) { + const text = $position.nodeBefore?.isText && $position.nodeBefore.text + + if (!text) { + return null + } + + const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g + const puncRegex = /\p{P}+$/gu + const match = Array.from(text.matchAll(regex)).pop() + + if ( + !match || + match.input === undefined || + match.index === undefined + ) { + return null + } + + const cursorPosition = $position.pos + const startIndex = cursorPosition - text.length + let [matchedString, tag] = match + + const tagWithoutPunctuation = tag.replace(puncRegex, '') + // allow for multiple ending punctuation marks + const punctuationIndexOffset = + tag.length - tagWithoutPunctuation.length + + if (tagWithoutPunctuation.length > 66) return null + + const from = startIndex + match.index + matchedString.indexOf(tag) + // `to` should not include ending punctuation + const to = from + tagWithoutPunctuation.length + + if ( + from < cursorPosition && + to >= cursorPosition - punctuationIndexOffset + ) { + return { + range: { + from, + to, + }, + // should not include ending punctuation + query: tagWithoutPunctuation.replace(/^#/, ''), + // raw text string + text: matchedString, + } + } + + return null + }, + }, + } + }, + + group: 'inline', + + inline: true, + + atom: true, + + selectable: true, + + addAttributes() { + return { + id: { + default: null, + parseHTML: element => element.getAttribute('data-id'), + renderHTML: attributes => { + if (!attributes.id) { + return {} + } + + return { + 'data-id': attributes.id, + } + }, + }, + + label: { + default: null, + parseHTML: element => element.getAttribute('data-label'), + renderHTML: attributes => { + if (!attributes.label) { + return {} + } + + return { + 'data-label': attributes.label, + } + }, + }, + } + }, + + parseHTML() { + return [ + { + tag: `span[data-type="${this.name}"]`, + }, + ] + }, + + renderHTML({node, HTMLAttributes}) { + console.log( + 'renderText', + node, + this.options.renderLabel({ + options: this.options, + node, + }), + ) + return [ + 'span', + mergeAttributes( + {'data-type': this.name}, + this.options.HTMLAttributes, + HTMLAttributes, + ), + this.options.renderLabel({ + options: this.options, + node, + }), + ] + }, + + renderText({node}) { + return this.options.renderLabel({ + options: this.options, + node, + }) + }, + + addKeyboardShortcuts() { + return { + Backspace: () => + this.editor.commands.command(({tr, state}) => { + let isTag = false + const {selection} = state + const {empty, anchor} = selection + + if (!empty) { + return false + } + + state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => { + if (node.type.name === this.name) { + isTag = true + tr.insertText( + this.options.suggestion.char || '', + pos, + pos + node.nodeSize, + ) + + return false + } + }) + + return isTag + }), + } + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ] + }, +}) diff --git a/src/view/com/composer/text-input/web/Tags/view.tsx b/src/view/com/composer/text-input/web/Tags/view.tsx new file mode 100644 index 0000000000..4a07093ec9 --- /dev/null +++ b/src/view/com/composer/text-input/web/Tags/view.tsx @@ -0,0 +1,214 @@ +import React, {forwardRef, useImperativeHandle, useState} from 'react' +import {Pressable, StyleSheet, View} from 'react-native' +import {ReactRenderer} from '@tiptap/react' +import tippy, {Instance as TippyInstance} from 'tippy.js' +import { + SuggestionOptions, + SuggestionProps, + SuggestionKeyDownProps, +} from '@tiptap/suggestion' + +import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' +import {usePalette} from 'lib/hooks/usePalette' +import {Text} from 'view/com/util/text/Text' + +type AutocompleteResult = string +type ListProps = SuggestionProps & { + autocompleteModel: TagsAutocompleteModel +} +type AutocompleteRef = { + onKeyDown: (props: SuggestionKeyDownProps) => boolean +} + +export function createTagsAutocomplete({ + autocompleteModel, +}: { + autocompleteModel: TagsAutocompleteModel +}): Omit { + return { + async items({query}) { + autocompleteModel.setActive(true) + await autocompleteModel.search(query) + return autocompleteModel.suggestions.slice(0, 8) + }, + render() { + let component: ReactRenderer | undefined + let popup: TippyInstance[] | undefined + + return { + onStart: props => { + component = new ReactRenderer(Autocomplete, { + props: { + ...props, + autocompleteModel, + }, + editor: props.editor, + }) + + if (!props.clientRect) return + + // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }) + }, + + onUpdate(props) { + component?.updateProps(props) + + if (!props.clientRect) return + + popup?.[0]?.setProps({ + // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf + getReferenceClientRect: props.clientRect, + }) + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.[0]?.hide() + + return true + } + + return component?.ref?.onKeyDown(props) || false + }, + onExit() { + popup?.[0]?.destroy() + component?.destroy() + }, + } + }, + } +} + +const Autocomplete = forwardRef( + function AutocompleteImpl(props, ref) { + const {items, command, autocompleteModel} = props + const pal = usePalette('default') + const [selectedIndex, setSelectedIndex] = useState(0) + + const commit = React.useCallback( + (tag: string) => { + // @ts-ignore we're dealing with strings here not mentions + command({id: tag}) + autocompleteModel.commitRecentTag(tag) + }, + [command, autocompleteModel], + ) + + const selectItem = React.useCallback( + (index: number) => { + const item = items[index] + if (item) commit(item) + }, + [items, commit], + ) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({event}) => { + if (event.key === 'ArrowUp') { + setSelectedIndex( + (selectedIndex + props.items.length - 1) % props.items.length, + ) + return true + } + + if (event.key === 'ArrowDown') { + setSelectedIndex((selectedIndex + 1) % props.items.length) + return true + } + + if (event.key === 'Enter') { + if (!props.items.length) { + // no items, use whatever the user typed + commit(props.autocompleteModel.query) + } else { + selectItem(selectedIndex) + } + return true + } + + if (event.key === ' ') { + commit(props.autocompleteModel.query) + return true + } + + return false + }, + })) + + // hide entirely if no suggestions + if (!items.length) return null + + return ( +
+ + {items.map((tag, index) => { + const isSelected = selectedIndex === index + const isFirst = index === 0 + const isLast = index === items.length - 1 + + return ( + [ + styles.resultContainer, + { + backgroundColor: state.hovered + ? pal.viewLight.backgroundColor + : undefined, + }, + isSelected ? pal.viewLight : undefined, + isFirst + ? styles.firstResult + : isLast + ? styles.lastResult + : undefined, + ]} + onPress={() => selectItem(index)} + accessibilityRole="button"> + + #{tag} + + + ) + })} + +
+ ) + }, +) + +const styles = StyleSheet.create({ + container: { + width: 500, + borderRadius: 6, + borderWidth: 1, + borderStyle: 'solid', + padding: 4, + }, + resultContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexDirection: 'row', + paddingHorizontal: 12, + paddingVertical: 8, + gap: 4, + }, + firstResult: { + borderTopLeftRadius: 2, + borderTopRightRadius: 2, + }, + lastResult: { + borderBottomLeftRadius: 2, + borderBottomRightRadius: 2, + }, +}) From 48f15b73613669f4889ba9a74cd861af060092f1 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 9 Oct 2023 14:58:34 -0500 Subject: [PATCH 27/66] install fork of suggestion plugin --- package.json | 1 + src/view/com/composer/text-input/web/Tags/plugin.tsx | 2 +- src/view/com/composer/text-input/web/Tags/view.tsx | 2 +- yarn.lock | 5 +++++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 84c5d7c6d3..90192f74e8 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@emoji-mart/react": "^1.1.1", + "@estrattonbailey/tiptap-suggestion": "^2.2.0-rc.3", "@expo/html-elements": "^0.4.2", "@expo/webpack-config": "^19.0.0", "@fortawesome/fontawesome-svg-core": "^6.1.1", diff --git a/src/view/com/composer/text-input/web/Tags/plugin.tsx b/src/view/com/composer/text-input/web/Tags/plugin.tsx index 7bb8f76829..93d365ff15 100644 --- a/src/view/com/composer/text-input/web/Tags/plugin.tsx +++ b/src/view/com/composer/text-input/web/Tags/plugin.tsx @@ -3,7 +3,7 @@ import {mergeAttributes, Node} from '@tiptap/core' import {Node as ProseMirrorNode} from '@tiptap/pm/model' import {PluginKey} from '@tiptap/pm/state' -import Suggestion, {SuggestionOptions} from '@tiptap/suggestion' +import Suggestion, {SuggestionOptions} from '@estrattonbailey/tiptap-suggestion' export type TagOptions = { HTMLAttributes: Record diff --git a/src/view/com/composer/text-input/web/Tags/view.tsx b/src/view/com/composer/text-input/web/Tags/view.tsx index 4a07093ec9..0f538b4a43 100644 --- a/src/view/com/composer/text-input/web/Tags/view.tsx +++ b/src/view/com/composer/text-input/web/Tags/view.tsx @@ -6,7 +6,7 @@ import { SuggestionOptions, SuggestionProps, SuggestionKeyDownProps, -} from '@tiptap/suggestion' +} from '@estrattonbailey/tiptap-suggestion' import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {usePalette} from 'lib/hooks/usePalette' diff --git a/yarn.lock b/yarn.lock index 9a6fc04a73..6007b44131 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1812,6 +1812,11 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.47.0.tgz#5478fdf443ff8158f9de171c704ae45308696c7d" integrity sha512-P6omY1zv5MItm93kLM8s2vr1HICJH8v0dvddDhysbIuZ+vcjOHg5Zbkf1mTkcmi2JA9oBG2anOkRnW8WJTS8Og== +"@estrattonbailey/tiptap-suggestion@^2.2.0-rc.3": + version "2.2.0-rc.3" + resolved "https://registry.yarnpkg.com/@estrattonbailey/tiptap-suggestion/-/tiptap-suggestion-2.2.0-rc.3.tgz#190edb6b9bb620977ef5f1b8c9467bce8900cd21" + integrity sha512-RY16dkvtoCXLSgwW8E5j40LI+v2SUXzfbN63OPPDO7RrI/cXGyjpTz4pAGTOa2q4aU/2QTR/xPjBIpPHhxikLw== + "@expo/bunyan@4.0.0", "@expo/bunyan@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@expo/bunyan/-/bunyan-4.0.0.tgz#be0c1de943c7987a9fbd309ea0b1acd605890c7b" From c3ec1e66132cb2fd66d66b9fe1bbe065cdcd7fb1 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 9 Oct 2023 14:58:57 -0500 Subject: [PATCH 28/66] tag style updates on web --- web/index.html | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/web/index.html b/web/index.html index 7634a4f5e8..0b86ede66f 100644 --- a/web/index.html +++ b/web/index.html @@ -150,34 +150,34 @@ .ProseMirror .inline-tag, .ProseMirror .mention { position: relative; - margin: 0 6px; } - .ProseMirror .inline-tag::after, - .ProseMirror .mention::after { - content: ''; - position: absolute; - top: 0; - bottom: -2px; - left: -8px; - right: -8px; - z-index: -1; - opacity: 0.3; - border-radius: 30px; - border-width: 1px; - border-style: solid; + .ProseMirror .inline-tag, + .ProseMirror .mention { + border-radius: 4px; + background-color: var(--backgroundLight); + padding: 0 2px 1px; + + /* This approach works, but we can't do rounded corners + background-color: var(--backgroundLight); + box-shadow: -3px 0 var(--backgroundLight), -3px 2px var(--backgroundLight), 2px 0 var(--backgroundLight), 2px 2px var(--backgroundLight); + */ + + /* + This sorta works but not enough padding aroundd the next, looks bad. + + border-radius: 4px; + background-color: red; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + */ } .ProseMirror .inline-tag { color: var(--text); } - .ProseMirror .inline-tag::after { - background-color: var(--background); - border-color: var(--border): - } .ProseMirror .mention { - color: var(--yellow); - } - .ProseMirror .mention::after { + color: var(--text); background-color: var(--yellow); + box-shadow: -3px 0 var(--yellow), -3px 2px var(--yellow), 2px 0 var(--yellow), 2px 2px var(--yellow); } /* Tooltips */ From 36376767783aaa13df749f4688f6cc7c9ae084cc Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 9 Oct 2023 15:11:43 -0500 Subject: [PATCH 29/66] use consistent styling in the composer --- src/view/com/Tag.tsx | 23 ++++++++++++----------- src/view/com/composer/TagInput.tsx | 12 ++++-------- web/index.html | 2 +- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/view/com/Tag.tsx b/src/view/com/Tag.tsx index 8dbceb5357..3c0cd180f2 100644 --- a/src/view/com/Tag.tsx +++ b/src/view/com/Tag.tsx @@ -89,16 +89,16 @@ export function EditableTag({ onPointerEnter={hoverIn} onPointerLeave={hoverOut} style={state => [ - pal.border, + pal.viewLight, styles.tag, { - opacity: hovered || state.pressed || state.focused ? 0.8 : 1, + opacity: state.pressed || state.focused ? 0.8 : 1, outline: 0, paddingRight: 6, }, ]}> #{value} @@ -106,10 +106,12 @@ export function EditableTag({ icon="x" style={ { - color: hovered ? pal.textLight.color : pal.border.borderColor, + opacity: hovered ? 1 : 0.5, + color: pal.textLight.color, + marginTop: -1, } as FontAwesomeIconStyle } - size={8} + size={11} /> ) @@ -119,12 +121,11 @@ const styles = StyleSheet.create({ tag: { flexDirection: 'row', alignItems: 'center', - gap: 4, - paddingTop: 1, - paddingBottom: 2, - paddingHorizontal: 6, - borderRadius: 20, + gap: 6, + paddingTop: 4, + paddingBottom: 3, + paddingHorizontal: 8, + borderRadius: 4, overflow: 'hidden', - borderWidth: 1, }, }) diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 400494f082..6a2f86d5ea 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -140,13 +140,9 @@ const styles = StyleSheet.create({ input: { flexGrow: 1, minWidth: 100, - fontSize: 13, - paddingVertical: 4, - }, - tag: { - paddingVertical: 4, - paddingHorizontal: 8, - borderRadius: 4, - overflow: 'hidden', + fontSize: 14, + lineHeight: 14, + paddingTop: 4, + paddingBottom: 6, }, }) diff --git a/web/index.html b/web/index.html index 0b86ede66f..fabbe35eff 100644 --- a/web/index.html +++ b/web/index.html @@ -155,7 +155,7 @@ .ProseMirror .mention { border-radius: 4px; background-color: var(--backgroundLight); - padding: 0 2px 1px; + padding: 0 3px 2px; /* This approach works, but we can't do rounded corners background-color: var(--backgroundLight); From 1161050e1c5947659372621d821ec694e99e3122 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 9 Oct 2023 15:27:15 -0500 Subject: [PATCH 30/66] oops --- web/index.html | 1 - 1 file changed, 1 deletion(-) diff --git a/web/index.html b/web/index.html index fabbe35eff..c2cd6a56ba 100644 --- a/web/index.html +++ b/web/index.html @@ -177,7 +177,6 @@ .ProseMirror .mention { color: var(--text); background-color: var(--yellow); - box-shadow: -3px 0 var(--yellow), -3px 2px var(--yellow), 2px 0 var(--yellow), 2px 2px var(--yellow); } /* Tooltips */ From d9fc2775c435392e0e0cc4a61ba36e3d0d913bef Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 9 Oct 2023 15:38:40 -0500 Subject: [PATCH 31/66] pretty good spot with styles --- src/view/com/Tag.tsx | 16 +++++++--------- src/view/com/composer/TagInput.tsx | 6 +++--- src/view/com/post-thread/PostThreadItem.tsx | 2 +- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/view/com/Tag.tsx b/src/view/com/Tag.tsx index 3c0cd180f2..b38373ba48 100644 --- a/src/view/com/Tag.tsx +++ b/src/view/com/Tag.tsx @@ -25,7 +25,7 @@ export function Tag({ accessible anchorNoUnderline href={`/search?q=${value}`} - style={[pal.border, styles.tag]}> + style={[pal.viewLight, styles.tag]}> #{value} @@ -50,7 +50,7 @@ export function InlineTag({ anchorNoUnderline href={`/search?q=${value}`} style={[ - pal.border, + pal.viewLight, styles.tag, { paddingTop: 0, @@ -97,9 +97,7 @@ export function EditableTag({ paddingRight: 6, }, ]}> - + #{value} ) @@ -122,8 +120,8 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: 6, - paddingTop: 4, - paddingBottom: 3, + paddingTop: 3, + paddingBottom: 4, paddingHorizontal: 8, borderRadius: 4, overflow: 'hidden', diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 6a2f86d5ea..3ee1bff854 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -140,9 +140,9 @@ const styles = StyleSheet.create({ input: { flexGrow: 1, minWidth: 100, - fontSize: 14, - lineHeight: 14, - paddingTop: 4, + fontSize: 15, + lineHeight: 15, + paddingTop: 5, paddingBottom: 6, }, }) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 265c113bb7..959cfc5a50 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -340,7 +340,7 @@ export const PostThreadItem = observer(function PostThreadItem({ paddingBottom: 12, }}> {item.post.record.tags.map(tag => ( - + ))} ) : null} From 50335206bf3c882024b92e6cd9318233ab8d273a Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 9 Oct 2023 17:53:23 -0500 Subject: [PATCH 32/66] generally consistent across web/ios --- src/view/com/Tag.tsx | 39 +++++++++------------ src/view/com/composer/TagInput.tsx | 10 ++++-- src/view/com/post-thread/PostThreadItem.tsx | 21 ++++++----- 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/src/view/com/Tag.tsx b/src/view/com/Tag.tsx index b38373ba48..8292f353b8 100644 --- a/src/view/com/Tag.tsx +++ b/src/view/com/Tag.tsx @@ -7,7 +7,7 @@ import { import {usePalette} from 'lib/hooks/usePalette' import {Text, CustomTextProps} from 'view/com/util/text/Text' -import {Link} from 'view/com/util/Link' +import {TextLink} from 'view/com/util/Link' export function Tag({ value, @@ -20,16 +20,13 @@ export function Tag({ const type = textSize || 'xs-medium' return ( - - - #{value} - - + style={[pal.textLight]} + /> ) } @@ -44,23 +41,19 @@ export function InlineTag({ const type = textSize || 'xs-medium' return ( - - - #{value} - - + ]} + /> ) } @@ -90,14 +83,14 @@ export function EditableTag({ onPointerLeave={hoverOut} style={state => [ pal.viewLight, - styles.tag, + styles.editableTag, { opacity: state.pressed || state.focused ? 0.8 : 1, outline: 0, paddingRight: 6, }, ]}> - + #{value} + style={[ + pal.border, + { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + paddingBottom: 12, + marginBottom: 12, + borderBottomWidth: 1, + }, + ]}> {item.post.record.tags.map(tag => ( ))} @@ -387,7 +392,7 @@ export const PostThreadItem = observer(function PostThreadItem({ ) : ( <> )} - + { }, expandedInfo: { flexDirection: 'row', - padding: 10, + paddingVertical: 10, borderTopWidth: 1, borderBottomWidth: 1, marginTop: 5, From f97f1302cf7a0b6628172a2535ce24037c533475 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 10 Oct 2023 12:44:22 -0500 Subject: [PATCH 33/66] fix added space on commit --- .../composer/text-input/web/Tags/plugin.tsx | 20 ++++++++++++++----- .../com/composer/text-input/web/Tags/view.tsx | 19 +++++++++++++++--- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/view/com/composer/text-input/web/Tags/plugin.tsx b/src/view/com/composer/text-input/web/Tags/plugin.tsx index 93d365ff15..ba2bebea5b 100644 --- a/src/view/com/composer/text-input/web/Tags/plugin.tsx +++ b/src/view/com/composer/text-input/web/Tags/plugin.tsx @@ -27,6 +27,8 @@ export const Tags = Node.create({ allowSpaces: true, pluginKey: TagsPluginKey, command: ({editor, range, props}) => { + const {tag, punctuation} = props + // increase range.to by one when the next node is of type "text" // and starts with a space character const nodeAfter = editor.view.state.selection.$to.nodeAfter @@ -42,11 +44,11 @@ export const Tags = Node.create({ .insertContentAt(range, [ { type: this.name, - attrs: props, + attrs: {id: tag}, }, { type: 'text', - text: ' ', + text: `${punctuation || ''} `, }, ]) .run() @@ -92,8 +94,12 @@ export const Tags = Node.create({ const from = startIndex + match.index + matchedString.indexOf(tag) // `to` should not include ending punctuation - const to = from + tagWithoutPunctuation.length + const to = from + tag.length + console.log({ + from, + to, + }) if ( from < cursorPosition && to >= cursorPosition - punctuationIndexOffset @@ -103,8 +109,12 @@ export const Tags = Node.create({ from, to, }, - // should not include ending punctuation - query: tagWithoutPunctuation.replace(/^#/, ''), + /** + * This is passed to the `items({ query })` method configured in `createTagsAutocomplete`. + * + * TODO This value should follow the rules of our hashtags spec. + */ + query: tag.replace(/^#/, ''), // raw text string text: matchedString, } diff --git a/src/view/com/composer/text-input/web/Tags/view.tsx b/src/view/com/composer/text-input/web/Tags/view.tsx index 0f538b4a43..3d8f511420 100644 --- a/src/view/com/composer/text-input/web/Tags/view.tsx +++ b/src/view/com/composer/text-input/web/Tags/view.tsx @@ -26,6 +26,9 @@ export function createTagsAutocomplete({ autocompleteModel: TagsAutocompleteModel }): Omit { return { + /** + * This `query` param comes from the result of `findSuggestionMatch` + */ async items({query}) { autocompleteModel.setActive(true) await autocompleteModel.search(query) @@ -95,9 +98,19 @@ const Autocomplete = forwardRef( const [selectedIndex, setSelectedIndex] = useState(0) const commit = React.useCallback( - (tag: string) => { - // @ts-ignore we're dealing with strings here not mentions - command({id: tag}) + (query: string) => { + const tag = query.replace(/(\p{P}+)$/gu, '') + const punctuation = query.match(/(\p{P}+)$/gu)?.[0] || '' + /* + * This values here are passed directly to the `command` method + * configured in the `Tags` plugin. + * + * The type error is ignored because we parse the tag and punctuation + * separately above. We could do this in `command` definition, but we + * only want to `commitRecentTag` with the sanitized tag. + */ + // @ts-ignore + command({tag, punctuation}) autocompleteModel.commitRecentTag(tag) }, [command, autocompleteModel], From 41c4b4af7b55a4df413f4f3cb6de595fcba9debc Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 10 Oct 2023 15:19:39 -0500 Subject: [PATCH 34/66] enforce 64 characters --- .../composer/text-input/web/Tags/plugin.tsx | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/view/com/composer/text-input/web/Tags/plugin.tsx b/src/view/com/composer/text-input/web/Tags/plugin.tsx index ba2bebea5b..52ed5a95b8 100644 --- a/src/view/com/composer/text-input/web/Tags/plugin.tsx +++ b/src/view/com/composer/text-input/web/Tags/plugin.tsx @@ -85,34 +85,26 @@ export const Tags = Node.create({ const startIndex = cursorPosition - text.length let [matchedString, tag] = match - const tagWithoutPunctuation = tag.replace(puncRegex, '') - // allow for multiple ending punctuation marks - const punctuationIndexOffset = - tag.length - tagWithoutPunctuation.length + const sanitized = tag.replace(puncRegex, '').replace(/^#/, '') - if (tagWithoutPunctuation.length > 66) return null + // one of our hashtag spec rules + if (sanitized.length > 64) return null const from = startIndex + match.index + matchedString.indexOf(tag) - // `to` should not include ending punctuation const to = from + tag.length - console.log({ - from, - to, - }) - if ( - from < cursorPosition && - to >= cursorPosition - punctuationIndexOffset - ) { + if (from < cursorPosition && to >= cursorPosition) { return { range: { from, to, }, /** - * This is passed to the `items({ query })` method configured in `createTagsAutocomplete`. + * This is passed to the `items({ query })` method configured in + * `createTagsAutocomplete`. * - * TODO This value should follow the rules of our hashtags spec. + * We parse out the punctuation later, but we don't want to pass + * the # to the search query. */ query: tag.replace(/^#/, ''), // raw text string From b3bae78e301ece0de45ccb05ecf8cead30102ddd Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 10 Oct 2023 15:47:38 -0500 Subject: [PATCH 35/66] enforce length in TagInput --- src/view/com/composer/TagInput.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 19918e2303..d098be0af2 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -57,7 +57,8 @@ export function TagInput({ const onSubmitEditing = React.useCallback(() => { const tag = sanitize(value) - if (tag.length > 0) { + // enforce max hashtag length + if (tag.length > 0 && tag.length <= 64) { handleChangeTags(uniq([...tags, tag]).slice(0, max)) } From 1dc38a8fd9280de696d20930e35283c61ddf04ae Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 10 Oct 2023 18:43:16 -0500 Subject: [PATCH 36/66] add a test --- .../web/Tags/__tests__/utils.test.ts | 80 +++++++++++++++++++ .../composer/text-input/web/Tags/plugin.tsx | 48 +---------- .../com/composer/text-input/web/Tags/utils.ts | 55 +++++++++++++ .../com/composer/text-input/web/Tags/view.tsx | 5 +- 4 files changed, 142 insertions(+), 46 deletions(-) create mode 100644 src/view/com/composer/text-input/web/Tags/__tests__/utils.test.ts create mode 100644 src/view/com/composer/text-input/web/Tags/utils.ts diff --git a/src/view/com/composer/text-input/web/Tags/__tests__/utils.test.ts b/src/view/com/composer/text-input/web/Tags/__tests__/utils.test.ts new file mode 100644 index 0000000000..fa955f6b78 --- /dev/null +++ b/src/view/com/composer/text-input/web/Tags/__tests__/utils.test.ts @@ -0,0 +1,80 @@ +import {describe, it, expect} from '@jest/globals' + +import {findSuggestionMatch, parsePunctuationFromTag} from '../utils' + +describe('findSuggestionMatch', () => { + it(`finds tag`, () => { + const match = findSuggestionMatch({ + text: 'a #tag', + cursorPosition: 6, + }) + + expect(match).toEqual({ + range: { + from: 2, + to: 6, + }, + query: 'tag', + text: ' #tag', + }) + }) + + it(`validates tag length`, () => { + expect( + findSuggestionMatch({ + text: '#xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + cursorPosition: 65, + }), + ).toEqual({ + range: { + from: 0, + to: 65, + }, + query: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + text: '#xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + }) + + expect( + findSuggestionMatch({ + text: '#xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxo', + cursorPosition: 66, + }), + ).toEqual(null) + }) + + it(`reports tag with trailing punctuation`, () => { + const match = findSuggestionMatch({ + text: '#tag!!!', + cursorPosition: 7, + }) + + expect(match).toEqual({ + range: { + from: 0, + to: 7, + }, + query: 'tag!!!', + text: '#tag!!!', + }) + }) +}) + +describe('parsePunctuationFromTag', () => { + it(`parses with punctuation`, () => { + expect(parsePunctuationFromTag('tag!')).toEqual({ + tag: 'tag', + punctuation: '!', + }) + expect(parsePunctuationFromTag('tag!!!')).toEqual({ + tag: 'tag', + punctuation: '!!!', + }) + }) + + it(`parses without punctuation`, () => { + expect(parsePunctuationFromTag('tag')).toEqual({ + tag: 'tag', + punctuation: '', + }) + }) +}) diff --git a/src/view/com/composer/text-input/web/Tags/plugin.tsx b/src/view/com/composer/text-input/web/Tags/plugin.tsx index 52ed5a95b8..e2e0728dbe 100644 --- a/src/view/com/composer/text-input/web/Tags/plugin.tsx +++ b/src/view/com/composer/text-input/web/Tags/plugin.tsx @@ -5,6 +5,8 @@ import {Node as ProseMirrorNode} from '@tiptap/pm/model' import {PluginKey} from '@tiptap/pm/state' import Suggestion, {SuggestionOptions} from '@estrattonbailey/tiptap-suggestion' +import {findSuggestionMatch} from './utils' + export type TagOptions = { HTMLAttributes: Record renderLabel: (props: {options: TagOptions; node: ProseMirrorNode}) => string @@ -64,55 +66,13 @@ export const Tags = Node.create({ }, findSuggestionMatch({$position}) { const text = $position.nodeBefore?.isText && $position.nodeBefore.text + const cursorPosition = $position.pos if (!text) { return null } - const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g - const puncRegex = /\p{P}+$/gu - const match = Array.from(text.matchAll(regex)).pop() - - if ( - !match || - match.input === undefined || - match.index === undefined - ) { - return null - } - - const cursorPosition = $position.pos - const startIndex = cursorPosition - text.length - let [matchedString, tag] = match - - const sanitized = tag.replace(puncRegex, '').replace(/^#/, '') - - // one of our hashtag spec rules - if (sanitized.length > 64) return null - - const from = startIndex + match.index + matchedString.indexOf(tag) - const to = from + tag.length - - if (from < cursorPosition && to >= cursorPosition) { - return { - range: { - from, - to, - }, - /** - * This is passed to the `items({ query })` method configured in - * `createTagsAutocomplete`. - * - * We parse out the punctuation later, but we don't want to pass - * the # to the search query. - */ - query: tag.replace(/^#/, ''), - // raw text string - text: matchedString, - } - } - - return null + return findSuggestionMatch({text, cursorPosition}) }, }, } diff --git a/src/view/com/composer/text-input/web/Tags/utils.ts b/src/view/com/composer/text-input/web/Tags/utils.ts new file mode 100644 index 0000000000..ec2a6b2047 --- /dev/null +++ b/src/view/com/composer/text-input/web/Tags/utils.ts @@ -0,0 +1,55 @@ +export function parsePunctuationFromTag(value: string) { + const reg = /(\p{P}+)$/gu + const tag = value.replace(reg, '') + const punctuation = value.match(reg)?.[0] || '' + + return {tag, punctuation} +} + +export function findSuggestionMatch({ + text, + cursorPosition, +}: { + text: string + cursorPosition: number +}) { + const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g + const puncRegex = /\p{P}+$/gu + const match = Array.from(text.matchAll(regex)).pop() + + if (!match || match.input === undefined || match.index === undefined) { + return null + } + + const startIndex = cursorPosition - text.length + let [matchedString, tag] = match + + const sanitized = tag.replace(puncRegex, '').replace(/^#/, '') + + // one of our hashtag spec rules + if (sanitized.length > 64) return null + + const from = startIndex + match.index + matchedString.indexOf(tag) + const to = from + tag.length + + if (from < cursorPosition && to >= cursorPosition) { + return { + range: { + from, + to, + }, + /** + * This is passed to the `items({ query })` method configured in + * `createTagsAutocomplete`. + * + * We parse out the punctuation later, but we don't want to pass + * the # to the search query. + */ + query: tag.replace(/^#/, ''), + // raw text string + text: matchedString, + } + } + + return null +} diff --git a/src/view/com/composer/text-input/web/Tags/view.tsx b/src/view/com/composer/text-input/web/Tags/view.tsx index 3d8f511420..2845c0bdc9 100644 --- a/src/view/com/composer/text-input/web/Tags/view.tsx +++ b/src/view/com/composer/text-input/web/Tags/view.tsx @@ -12,6 +12,8 @@ import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' +import {parsePunctuationFromTag} from './utils' + type AutocompleteResult = string type ListProps = SuggestionProps & { autocompleteModel: TagsAutocompleteModel @@ -99,8 +101,7 @@ const Autocomplete = forwardRef( const commit = React.useCallback( (query: string) => { - const tag = query.replace(/(\p{P}+)$/gu, '') - const punctuation = query.match(/(\p{P}+)$/gu)?.[0] || '' + const {tag, punctuation} = parsePunctuationFromTag(query) /* * This values here are passed directly to the `command` method * configured in the `Tags` plugin. From b33f70fcaca694290aee3bef87164eff766c7c20 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 10 Oct 2023 18:59:16 -0500 Subject: [PATCH 37/66] remove unused TagDecorator --- .../composer/text-input/web/TagDecorator.ts | 81 ------------------- 1 file changed, 81 deletions(-) delete mode 100644 src/view/com/composer/text-input/web/TagDecorator.ts diff --git a/src/view/com/composer/text-input/web/TagDecorator.ts b/src/view/com/composer/text-input/web/TagDecorator.ts deleted file mode 100644 index 022e0be4ca..0000000000 --- a/src/view/com/composer/text-input/web/TagDecorator.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * 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 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 from = match.index + matchedString.indexOf(tag) - const to = from + tag.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] - }, -}) From 3bae37460d580b43e6ac463dc038c6b9c7f12944 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 10 Oct 2023 19:04:32 -0500 Subject: [PATCH 38/66] some comments --- src/state/models/ui/tags-autocomplete.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/state/models/ui/tags-autocomplete.ts b/src/state/models/ui/tags-autocomplete.ts index 86150eda37..ed9f3c7156 100644 --- a/src/state/models/ui/tags-autocomplete.ts +++ b/src/state/models/ui/tags-autocomplete.ts @@ -4,6 +4,9 @@ import {RootStoreModel} from '../root-store' import Fuse from 'fuse.js' import {isObj, hasProp, isStrArray} from 'lib/type-guards' +/** + * Used only to persist recent tags across app restarts. + */ export class RecentTagsModel { _tags: string[] = [] @@ -65,18 +68,25 @@ export class TagsAutocompleteModel { } const items = Array.from( + // de-duplicates via Set new Set([ + // sample up to 3 recent tags ...this.rootStore.recentTags.tags.slice(0, 3), + // sample up to 3 of your profile tags ...this.profileTags.slice(0, 3), + // and all searched tags ...this.searchedTags, ]), ) + // no query, return default suggestions if (!this.query) { return items.slice(0, 9) } + // Fuse allows weighting values too, if we ever need it const fuse = new Fuse(items) + // search amongst mixed set of tags const results = fuse.search(this.query) return results.slice(0, 9).map(r => r.item) @@ -96,20 +106,10 @@ export class TagsAutocompleteModel { } } + // TODO hook up to search type-ahead async _search() { runInAction(() => { - this.searchedTags = [ - 'code', - 'dev', - 'javascript', - 'react', - 'typescript', - 'mobx', - 'mobx-state-tree', - 'mobx-react', - 'mobx-react-lite', - 'mobx-react-form', - ] + this.searchedTags = [] }) } } From 4e26c9759499ead8acc5a55b1ac792e5e4d1143b Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 10 Oct 2023 19:12:17 -0500 Subject: [PATCH 39/66] consolidate tag components --- src/view/com/Tag.tsx | 27 --------------------------- src/view/com/util/text/RichText.tsx | 4 ++-- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/src/view/com/Tag.tsx b/src/view/com/Tag.tsx index 8292f353b8..9620f4be16 100644 --- a/src/view/com/Tag.tsx +++ b/src/view/com/Tag.tsx @@ -30,33 +30,6 @@ export function Tag({ ) } -export function InlineTag({ - value, - textSize, -}: { - value: string - textSize?: CustomTextProps['type'] -}) { - const pal = usePalette('default') - const type = textSize || 'xs-medium' - - return ( - - ) -} - export function EditableTag({ value, onRemove, diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index 6693d890c6..18a040f14c 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -7,7 +7,7 @@ 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 {InlineTag} from 'view/com/Tag' +import {Tag} from 'view/com/Tag' const WORD_WRAP = {wordWrap: 1} @@ -95,7 +95,7 @@ export function RichText({ />, ) } else if (tag && AppBskyRichtextFacet.validateTag(tag).success) { - els.push() + els.push() } else { els.push(segment.text) } From 078ba4cd358d2d8c12f70b188e49b74cebee23fd Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 10 Oct 2023 19:59:16 -0500 Subject: [PATCH 40/66] add tag highlighting back --- src/view/com/composer/text-input/TextInput.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 286e2762da..047907f13a 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -220,12 +220,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 ( {segment.text} From bfdf41a4c33cf74d6c9895e40b988348338f49aa Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 10 Oct 2023 20:08:37 -0500 Subject: [PATCH 41/66] cleaning --- src/view/com/composer/text-input/TextInput.web.tsx | 1 - .../text-input/mobile/TagsAutocomplete.tsx | 6 +++++- .../com/composer/text-input/web/Tags/plugin.tsx | 14 +++++--------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index c6b9e3c409..7459c43fe1 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -61,7 +61,6 @@ export const TextInput = React.forwardRef(function TextInputImpl( () => [ Document, LinkDecorator, - // TagDecorator, Tags.configure({ HTMLAttributes: { class: 'inline-tag', diff --git a/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx b/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx index 8d03662f7b..e7512796d9 100644 --- a/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx @@ -23,6 +23,10 @@ export function getHashtagAt(text: string, position: number) { } } + /* + * show autocomplete after a single # is typed + * AND the cursor is next to the # + */ const hashRegex = /#/g let hashMatch while ((hashMatch = hashRegex.exec(text))) { @@ -38,7 +42,7 @@ export function insertTagAt(text: string, position: number, tag: string) { const target = getHashtagAt(text, position) if (target) { return `${text.slice(0, target.index)}#${tag} ${text.slice( - target.index + target.value.length + 1, // add 1 to include the "@" + target.index + target.value.length + 1, // add 1 to include the "#" )}` } return text diff --git a/src/view/com/composer/text-input/web/Tags/plugin.tsx b/src/view/com/composer/text-input/web/Tags/plugin.tsx index e2e0728dbe..5db789b4cc 100644 --- a/src/view/com/composer/text-input/web/Tags/plugin.tsx +++ b/src/view/com/composer/text-input/web/Tags/plugin.tsx @@ -1,4 +1,8 @@ -/** @see https://github.com/ueberdosis/tiptap/blob/main/packages/extension-mention/src/mention.ts */ +/** + * This is basically a fork of the Mention plugin from Tiptap. + * + * @see https://github.com/ueberdosis/tiptap/blob/025dfff1d9e4796edf3a451f7f53d06a07b95d69/packages/extension-mention/src/mention.ts + */ import {mergeAttributes, Node} from '@tiptap/core' import {Node as ProseMirrorNode} from '@tiptap/pm/model' @@ -127,14 +131,6 @@ export const Tags = Node.create({ }, renderHTML({node, HTMLAttributes}) { - console.log( - 'renderText', - node, - this.options.renderLabel({ - options: this.options, - node, - }), - ) return [ 'span', mergeAttributes( From 9f9f877ce166b191f6573d3b61947ff4a4b44802 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 11 Oct 2023 15:09:37 -0500 Subject: [PATCH 42/66] add desktop TagInput autocomplete --- package.json | 1 + src/state/models/ui/tags-autocomplete.ts | 10 +- src/view/com/composer/Composer.tsx | 5 +- src/view/com/composer/TagInput.tsx | 2 + src/view/com/composer/TagInput.web.tsx | 311 +++++++++++++++++++++++ yarn.lock | 12 + 6 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 src/view/com/composer/TagInput.web.tsx diff --git a/package.json b/package.json index 6974482844..de2e861da5 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "mobx-utils": "^6.0.6", "normalize-url": "^8.0.0", "patch-package": "^6.5.1", + "pind": "^0.5.0", "postinstall-postinstall": "^2.1.0", "psl": "^1.9.0", "react": "18.2.0", diff --git a/src/state/models/ui/tags-autocomplete.ts b/src/state/models/ui/tags-autocomplete.ts index ed9f3c7156..ef7a051d99 100644 --- a/src/state/models/ui/tags-autocomplete.ts +++ b/src/state/models/ui/tags-autocomplete.ts @@ -109,7 +109,15 @@ export class TagsAutocompleteModel { // TODO hook up to search type-ahead async _search() { runInAction(() => { - this.searchedTags = [] + this.searchedTags = [ + 'bluesky', + 'code', + 'coding', + 'dev', + 'developer', + 'development', + 'devlife', + ] }) } } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 44504d7e5f..14b2d94db9 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -454,7 +454,10 @@ export const ComposePost = observer(function ComposePost({ paddingHorizontal: 15, }, ]}> - + diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index d098be0af2..4678d4730e 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -12,6 +12,7 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {isWeb} from 'platform/detection' import {usePalette} from 'lib/hooks/usePalette' import {EditableTag} from 'view/com/Tag' @@ -40,6 +41,7 @@ export function TagInput({ }: { max?: number onChangeTags: (tags: string[]) => void + tagsAutocompleteModel: TagsAutocompleteModel }) { const pal = usePalette('default') const input = React.useRef(null) diff --git a/src/view/com/composer/TagInput.web.tsx b/src/view/com/composer/TagInput.web.tsx new file mode 100644 index 0000000000..6e384bedeb --- /dev/null +++ b/src/view/com/composer/TagInput.web.tsx @@ -0,0 +1,311 @@ +import React from 'react' +import { + TextInput, + View, + StyleSheet, + NativeSyntheticEvent, + TextInputKeyPressEventData, + Platform, + Pressable, +} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Pin} from 'pind' + +import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' +import {usePalette} from 'lib/hooks/usePalette' +import {EditableTag} from 'view/com/Tag' +import {Text} from 'view/com/util/text/Text' + +function uniq(tags: string[]) { + return Array.from(new Set(tags)) +} + +function sanitize(tagString: string) { + return tagString.trim().replace(/^#/, '') +} + +export function TagInput({ + max = 8, + onChangeTags, + tagsAutocompleteModel: model, +}: { + max?: number + onChangeTags: (tags: string[]) => void + tagsAutocompleteModel: TagsAutocompleteModel +}) { + const pal = usePalette('default') + const dropdown = React.useRef(null) + const input = React.useRef(null) + const inputWidth = input.current + ? input.current.getBoundingClientRect().width + : 200 + + const [value, setValue] = React.useState('') + const [tags, setTags] = React.useState([]) + const [dropdownIsOpen, setDropdownIsOpen] = React.useState(false) + const [dropdownItems, setDropdownItems] = React.useState< + {value: string; label: string}[] + >([]) + const [selectedItemIndex, setSelectedItemIndex] = React.useState(0) + + const close = React.useCallback(() => { + setDropdownIsOpen(false) + model.setActive(false) + setSelectedItemIndex(0) + setDropdownItems([]) + }, [model, setDropdownIsOpen, setSelectedItemIndex, setDropdownItems]) + + const addTags = React.useCallback( + (_tags: string[]) => { + setTags(_tags) + onChangeTags(_tags) + }, + [onChangeTags, setTags], + ) + + const removeTag = React.useCallback( + (tag: string) => { + addTags(tags.filter(t => t !== tag)) + }, + [tags, addTags], + ) + + const addTagAndReset = React.useCallback( + (value: string) => { + const tag = sanitize(value) + + // enforce max hashtag length + if (tag.length > 0 && tag.length <= 64) { + addTags(uniq([...tags, tag]).slice(0, max)) + } + + setValue('') + input.current?.focus() + close() + }, + [max, tags, close, setValue, addTags], + ) + + const onSubmitEditing = React.useCallback(() => { + const item = dropdownItems[selectedItemIndex] + addTagAndReset(item?.value || value) + }, [value, dropdownItems, selectedItemIndex, addTagAndReset]) + + const onKeyPress = React.useCallback( + (e: NativeSyntheticEvent) => { + const {key} = e.nativeEvent + + if (key === 'Backspace' && value === '') { + addTags(tags.slice(0, -1)) + } else if (key === ' ') { + e.preventDefault() // prevents an additional space on web + addTagAndReset(value) + } + + if (dropdownIsOpen) { + if (key === 'Escape') { + close() + } else if (key === 'ArrowUp') { + e.preventDefault() + setSelectedItemIndex( + (selectedItemIndex + dropdownItems.length - 1) % + dropdownItems.length, + ) + } else if (key === 'ArrowDown') { + e.preventDefault() + setSelectedItemIndex((selectedItemIndex + 1) % dropdownItems.length) + } + } + }, + [ + value, + tags, + dropdownIsOpen, + selectedItemIndex, + dropdownItems.length, + close, + setSelectedItemIndex, + addTags, + addTagAndReset, + ], + ) + + const onChangeText = React.useCallback( + async (v: string) => { + setValue(v) + + if (v.length > 0) { + model.setActive(true) + await model.search(v) + + setDropdownItems( + model.suggestions.map(item => ({ + value: item, + label: item, + })), + ) + + setDropdownIsOpen(true) + } else { + close() + } + }, + [model, setValue, setDropdownIsOpen, close], + ) + + React.useEffect(() => { + // outside click + function onClick(e: MouseEvent) { + const drop = dropdown.current + const control = input.current + + if ( + !drop || + !control || + e.target === drop || + e.target === control || + drop.contains(e.target as Node) || + control.contains(e.target as Node) + ) + return + + close() + } + + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + } + }, [close]) + + return ( + + {!tags.length && ( + + )} + + {tags.map(tag => ( + + ))} + + {tags.length >= max ? null : ( + + )} + + + + {dropdownItems.map((item, index) => { + const isFirst = index === 0 + const isLast = index === dropdownItems.length - 1 + return ( + addTagAndReset(item.value)} + style={state => [ + pal.border, + styles.dropdownItem, + { + backgroundColor: state.hovered + ? pal.viewLight.backgroundColor + : undefined, + }, + selectedItemIndex === index ? pal.viewLight : undefined, + isFirst + ? styles.firstResult + : isLast + ? styles.lastResult + : undefined, + ]}> + + {item.label} + + + ) + })} + + + + ) +} + +const styles = StyleSheet.create({ + outer: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + }, + input: { + flexGrow: 1, + minWidth: 100, + fontSize: 15, + lineHeight: Platform.select({ + web: 20, + native: 18, + }), + paddingTop: 4, + paddingBottom: 4, + }, + dropdown: { + width: '100%', + borderRadius: 6, + borderWidth: 1, + borderStyle: 'solid', + padding: 4, + }, + dropdownItem: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexDirection: 'row', + paddingHorizontal: 12, + paddingVertical: 8, + gap: 4, + }, + firstResult: { + borderTopLeftRadius: 2, + borderTopRightRadius: 2, + }, + lastResult: { + borderBottomLeftRadius: 2, + borderBottomRightRadius: 2, + }, +}) diff --git a/yarn.lock b/yarn.lock index 58b1d2f3db..1685200e13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14421,6 +14421,13 @@ pify@^4.0.1: resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== +pind@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/pind/-/pind-0.5.0.tgz#9f04be810b0c91aac0bb0976a57ba1d76126d0c8" + integrity sha512-xK7GlJl2AeQQaYbtxoGOsGVa0Ms8M4B3CPIIohC2gmeDBRrxtBSWyzPc6cgdG4AEEvQETt//bIBETPy8VFO+BA== + dependencies: + tackjs "^3.0.1" + pinkie-promise@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" @@ -17517,6 +17524,11 @@ symbol-tree@^3.2.4: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +tackjs@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/tackjs/-/tackjs-3.0.1.tgz#d69af48df5921320953d28131a8ae110ae11ea06" + integrity sha512-a/0vc4RWqG/9mYeldQ9uhGGTicc4LCr0EPpjV0IIKB21QkmFfkN559wu/Z4rprOzWDnEjUzdwsqIIrQC1H+rpg== + tailwindcss@^3.0.2: version "3.3.3" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf" From d7f7cb312816947e210132f2afd89efbeccdd8cf Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 12 Oct 2023 15:04:22 -0500 Subject: [PATCH 43/66] mobile tags input and autocomplete --- src/state/models/ui/tags-autocomplete.ts | 19 +- src/view/com/Tag.tsx | 53 ++++ src/view/com/composer/Composer.tsx | 7 +- src/view/com/composer/TagInput.tsx | 307 +++++++++++++++++------ src/view/com/sheets/Base.tsx | 48 ++++ src/view/com/util/Portal.tsx | 56 +++++ src/view/shell/index.tsx | 18 +- 7 files changed, 418 insertions(+), 90 deletions(-) create mode 100644 src/view/com/sheets/Base.tsx create mode 100644 src/view/com/util/Portal.tsx diff --git a/src/state/models/ui/tags-autocomplete.ts b/src/state/models/ui/tags-autocomplete.ts index ef7a051d99..9b1d8b43c5 100644 --- a/src/state/models/ui/tags-autocomplete.ts +++ b/src/state/models/ui/tags-autocomplete.ts @@ -4,8 +4,14 @@ import {RootStoreModel} from '../root-store' import Fuse from 'fuse.js' import {isObj, hasProp, isStrArray} from 'lib/type-guards' +function uniq(arr: string[]) { + return Array.from(new Set(arr)) +} + /** * Used only to persist recent tags across app restarts. + * + * TODO may want an LRU? */ export class RecentTagsModel { _tags: string[] = [] @@ -62,6 +68,11 @@ export class TagsAutocompleteModel { this.rootStore.recentTags.add(tag) } + clear() { + this.query = '' + this.searchedTags = [] + } + get suggestions() { if (!this.isActive) { return [] @@ -87,9 +98,9 @@ export class TagsAutocompleteModel { // Fuse allows weighting values too, if we ever need it const fuse = new Fuse(items) // search amongst mixed set of tags - const results = fuse.search(this.query) - - return results.slice(0, 9).map(r => r.item) + const results = fuse.search(this.query).map(r => r.item) + // backfill again in case search has no results + return uniq([...results, ...items]).slice(0, 9) } async search(query: string) { @@ -98,8 +109,6 @@ export class TagsAutocompleteModel { await this.lock.acquireAsync() try { - // another query was set before we got our chance - if (this.query !== this.query) return await this._search() } finally { this.lock.release() diff --git a/src/view/com/Tag.tsx b/src/view/com/Tag.tsx index 9620f4be16..f187c8a745 100644 --- a/src/view/com/Tag.tsx +++ b/src/view/com/Tag.tsx @@ -81,6 +81,59 @@ export function EditableTag({ ) } +export function TagButton({ + value, + icon = 'x', + onClick, +}: { + value: string + icon?: React.ComponentProps['icon'] + onClick?: (tag: string) => void +}) { + const pal = usePalette('default') + const [hovered, setHovered] = React.useState(false) + + const hoverIn = React.useCallback(() => { + setHovered(true) + }, [setHovered]) + + const hoverOut = React.useCallback(() => { + setHovered(false) + }, [setHovered]) + + return ( + onClick?.(value)} + onPointerEnter={hoverIn} + onPointerLeave={hoverOut} + style={state => [ + pal.viewLight, + styles.editableTag, + { + opacity: state.pressed || state.focused ? 0.8 : 1, + outline: 0, + paddingRight: 6, + }, + ]}> + + #{value} + + + + ) +} + const styles = StyleSheet.create({ editableTag: { flexDirection: 'row', diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 14b2d94db9..8436a6a2c5 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -446,18 +446,13 @@ export const ComposePost = observer(function ComposePost({ - + diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 4678d4730e..65411584d1 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -1,36 +1,35 @@ import React from 'react' import { - TextInput, View, StyleSheet, NativeSyntheticEvent, TextInputKeyPressEventData, Platform, + Pressable, + ScrollView, } from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import BottomSheet, { + BottomSheetBackdrop, + BottomSheetTextInput, +} from '@gorhom/bottom-sheet' +import {Portal} from 'view/com/util/Portal' import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' -import {isWeb} from 'platform/detection' import {usePalette} from 'lib/hooks/usePalette' -import {EditableTag} from 'view/com/Tag' +import {TagButton} from 'view/com/Tag' +import {Text} from 'view/com/util/text/Text' +import * as Sheet from 'view/com/sheets/Base' +import {useStores} from 'state/index' +import {ActivityIndicator} from 'react-native' function uniq(tags: string[]) { return Array.from(new Set(tags)) } -// function sanitize(tagString: string, { max }: { max: number }) { -// const sanitized = tagString.replace(/^#/, '') -// .split(/\s/) -// .map(t => t.trim()) -// .map(t => t.replace(/^#/, '')) - -// return uniq(sanitized) -// .slice(0, max) -// } - function sanitize(tagString: string) { return tagString.trim().replace(/^#/, '') } @@ -41,14 +40,28 @@ export function TagInput({ }: { max?: number onChangeTags: (tags: string[]) => void - tagsAutocompleteModel: TagsAutocompleteModel }) { + const store = useStores() + const model = React.useMemo(() => new TagsAutocompleteModel(store), [store]) + const sheet = React.useRef(null) const pal = usePalette('default') - const input = React.useRef(null) + const input = React.useRef(null) + const [value, setValue] = React.useState('') const [tags, setTags] = React.useState([]) + const [selectedItemIndex, setSelectedItemIndex] = React.useState(0) + const [suggestions, setSuggestions] = React.useState([]) + const [isInitialLoad, setIsInitialLoad] = React.useState(true) - const handleChangeTags = React.useCallback( + const reset = React.useCallback(() => { + setValue('') + model.setActive(false) + model.clear() + setSelectedItemIndex(0) + setSuggestions([]) + }, [model, setValue, setSelectedItemIndex, setSuggestions]) + + const addTags = React.useCallback( (_tags: string[]) => { setTags(_tags) onChangeTags(_tags) @@ -56,90 +69,222 @@ export function TagInput({ [onChangeTags, setTags], ) - const onSubmitEditing = React.useCallback(() => { - const tag = sanitize(value) + const removeTag = React.useCallback( + (tag: string) => { + addTags(tags.filter(t => t !== tag)) + }, + [tags, addTags], + ) - // enforce max hashtag length - if (tag.length > 0 && tag.length <= 64) { - handleChangeTags(uniq([...tags, tag]).slice(0, max)) - } + const addTagAndReset = React.useCallback( + (value: string) => { + const tag = sanitize(value) + + // enforce max hashtag length + if (tag.length > 0 && tag.length <= 64) { + addTags(uniq([...tags, tag]).slice(0, max)) + } - if (isWeb) { setValue('') input.current?.focus() - } else { - // This is a hack to get the input to clear on iOS/Android, and only - // positive values work here - setTimeout(() => { - setValue('') - input.current?.focus() - }, 1) - } - }, [max, value, tags, setValue, handleChangeTags]) + }, + [max, tags, setValue, addTags], + ) + + const onSubmitEditing = React.useCallback(() => { + const item = suggestions[selectedItemIndex] + addTagAndReset(item || value) + }, [value, suggestions, selectedItemIndex, addTagAndReset]) const onKeyPress = React.useCallback( (e: NativeSyntheticEvent) => { - if (e.nativeEvent.key === 'Backspace' && value === '') { - handleChangeTags(tags.slice(0, -1)) - } else if (e.nativeEvent.key === ' ') { + const {key} = e.nativeEvent + + if (key === 'Backspace' && value === '') { + addTags(tags.slice(0, -1)) + } else if (key === ' ') { e.preventDefault() // prevents an additional space on web - onSubmitEditing() + addTagAndReset(value) + } + + if (key === 'Escape') { + reset() + } else if (key === 'ArrowUp') { + e.preventDefault() + setSelectedItemIndex( + (selectedItemIndex + suggestions.length - 1) % suggestions.length, + ) + } else if (key === 'ArrowDown') { + e.preventDefault() + setSelectedItemIndex((selectedItemIndex + 1) % suggestions.length) } }, - [value, tags, handleChangeTags, onSubmitEditing], + [ + value, + tags, + selectedItemIndex, + suggestions.length, + reset, + setSelectedItemIndex, + addTags, + addTagAndReset, + ], ) - const onChangeText = React.useCallback((v: string) => { - setValue(v) - }, []) + const onChangeText = React.useCallback( + async (v: string) => { + setValue(v) - const removeTag = React.useCallback( - (tag: string) => { - handleChangeTags(tags.filter(t => t !== tag)) + if (v.length > 0) { + model.setActive(true) + await model.search(v) + + setSuggestions(model.suggestions) + } else { + model.clear() + + setSuggestions(model.suggestions) + } + }, + [model, setValue], + ) + + const onCloseSheet = React.useCallback(() => { + reset() + setIsInitialLoad(true) + }, [reset, setIsInitialLoad]) + + const onSheetChange = React.useCallback( + async (index: number) => { + if (index > -1) { + model.setActive(true) + await model.search('') // get default results + setSuggestions(model.suggestions) + setIsInitialLoad(false) + } }, - [tags, handleChangeTags], + [model, setIsInitialLoad, setSuggestions], ) return ( - - {!tags.length && ( - - )} - {tags.map(tag => ( - - ))} - {tags.length >= max ? null : ( - - )} + + { + sheet.current?.snapToIndex(0) + }}> + + {tags.length ? ( + + Add + + + ) : ( + <> + + + Click to add tags to your post + + + )} + + + {tags.map(tag => ( + + + #{tag} + + + ))} + + + + ( + + )} + handleIndicatorStyle={{backgroundColor: pal.text.color}} + handleStyle={{display: 'none'}} + onChange={onSheetChange} + onClose={onCloseSheet}> + + + + + + + {tags.map(tag => ( + + ))} + + + + + + + + {isInitialLoad && } + + {suggestions + .filter(s => !tags.find(t => t === s)) + .map(suggestion => { + return ( + + ) + })} + + + + + + ) } const styles = StyleSheet.create({ + selectedTags: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + }, outer: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 8, + marginBottom: 20, }, input: { flexGrow: 1, @@ -152,4 +297,20 @@ const styles = StyleSheet.create({ paddingTop: 4, paddingBottom: 4, }, + suggestions: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingLeft: 20, + paddingVertical: 8, + }, + button: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + flexShrink: 1, + paddingVertical: 6, + paddingHorizontal: 12, + borderRadius: 20, + }, }) diff --git a/src/view/com/sheets/Base.tsx b/src/view/com/sheets/Base.tsx new file mode 100644 index 0000000000..75dd8f62ba --- /dev/null +++ b/src/view/com/sheets/Base.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import {View, StyleSheet, Dimensions} from 'react-native' + +import {usePalette} from 'lib/hooks/usePalette' + +export function Outer(props: React.PropsWithChildren<{}>) { + const pal = usePalette('default') + + return ( + <> + + + {props.children} + + ) +} + +export function Handle() { + const pal = usePalette('default') + return ( + + ) +} + +const styles = StyleSheet.create({ + background: { + ...StyleSheet.absoluteFillObject, + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + height: Dimensions.get('window').height * 2, + zIndex: -1, + }, + content: { + paddingVertical: 40, + paddingHorizontal: 20, + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + overflow: 'hidden', + }, + handle: { + position: 'absolute', + top: 12, + alignSelf: 'center', + width: 80, + height: 6, + borderRadius: 10, + }, +}) diff --git a/src/view/com/util/Portal.tsx b/src/view/com/util/Portal.tsx new file mode 100644 index 0000000000..1813d9e05e --- /dev/null +++ b/src/view/com/util/Portal.tsx @@ -0,0 +1,56 @@ +import React from 'react' + +type Component = React.ReactElement + +type ContextType = { + outlet: Component | null + append(id: string, component: Component): void + remove(id: string): void +} + +type ComponentMap = { + [id: string]: Component +} + +export const Context = React.createContext({ + outlet: null, + append: () => {}, + remove: () => {}, +}) + +export function Provider(props: React.PropsWithChildren<{}>) { + const map = React.useRef({}) + const [outlet, setOutlet] = React.useState(null) + + const append = React.useCallback((id, component) => { + if (map.current[id]) return + map.current[id] = {component} + setOutlet(<>{Object.values(map.current)}) + }, []) + + const remove = React.useCallback(id => { + delete map.current[id] + setOutlet(<>{Object.values(map.current)}) + }, []) + + return ( + + {props.children} + + ) +} + +export function Outlet() { + const ctx = React.useContext(Context) + return ctx.outlet +} + +export function Portal({children}: React.PropsWithChildren<{}>) { + const {append, remove} = React.useContext(Context) + const id = React.useId() + React.useEffect(() => { + append(id, children as Component) + return () => remove(id) + }, [id, children, append, remove]) + return null +} diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 3119715e94..a4f1cab0c8 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -10,6 +10,7 @@ import { import {useSafeAreaInsets} from 'react-native-safe-area-context' import {Drawer} from 'react-native-drawer-layout' import {useNavigationState} from '@react-navigation/native' +import {Provider, Outlet} from 'view/com/util/Portal' import {useStores} from 'state/index' import {ModalsContainer} from 'view/com/modals/Modal' import {Lightbox} from 'view/com/lightbox/Lightbox' @@ -79,6 +80,7 @@ const ShellInner = observer(function ShellInnerImpl() { /> + ) }) @@ -88,12 +90,16 @@ export const Shell: React.FC = observer(function ShellImpl() { const theme = useTheme() return ( - - - - - - + + + + + + + + ) }) From 8aeb36401493c8cdc88bdc9b2d08af823c40ea29 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 12 Oct 2023 15:36:07 -0500 Subject: [PATCH 44/66] remove selected item code --- src/view/com/composer/TagInput.tsx | 52 +++++++++++------------------- 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 65411584d1..12b68bddc0 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -49,7 +49,6 @@ export function TagInput({ const [value, setValue] = React.useState('') const [tags, setTags] = React.useState([]) - const [selectedItemIndex, setSelectedItemIndex] = React.useState(0) const [suggestions, setSuggestions] = React.useState([]) const [isInitialLoad, setIsInitialLoad] = React.useState(true) @@ -57,9 +56,8 @@ export function TagInput({ setValue('') model.setActive(false) model.clear() - setSelectedItemIndex(0) setSuggestions([]) - }, [model, setValue, setSelectedItemIndex, setSuggestions]) + }, [model, setValue, setSuggestions]) const addTags = React.useCallback( (_tags: string[]) => { @@ -85,16 +83,17 @@ export function TagInput({ addTags(uniq([...tags, tag]).slice(0, max)) } - setValue('') - input.current?.focus() + setTimeout(() => { + setValue('') + input.current?.focus() + }, 1) }, [max, tags, setValue, addTags], ) const onSubmitEditing = React.useCallback(() => { - const item = suggestions[selectedItemIndex] - addTagAndReset(item || value) - }, [value, suggestions, selectedItemIndex, addTagAndReset]) + addTagAndReset(value) + }, [value, addTagAndReset]) const onKeyPress = React.useCallback( (e: NativeSyntheticEvent) => { @@ -106,29 +105,8 @@ export function TagInput({ e.preventDefault() // prevents an additional space on web addTagAndReset(value) } - - if (key === 'Escape') { - reset() - } else if (key === 'ArrowUp') { - e.preventDefault() - setSelectedItemIndex( - (selectedItemIndex + suggestions.length - 1) % suggestions.length, - ) - } else if (key === 'ArrowDown') { - e.preventDefault() - setSelectedItemIndex((selectedItemIndex + 1) % suggestions.length) - } }, - [ - value, - tags, - selectedItemIndex, - suggestions.length, - reset, - setSelectedItemIndex, - addTags, - addTagAndReset, - ], + [value, tags, addTags, addTagAndReset], ) const onChangeText = React.useCallback( @@ -206,10 +184,11 @@ export function TagInput({ ( Date: Thu, 12 Oct 2023 17:39:36 -0500 Subject: [PATCH 45/66] let's roll with custom bottom sheet for now --- package.json | 1 + src/view/com/composer/TagInput.tsx | 174 ++++++------- src/view/com/sheets/Base.tsx | 10 +- src/view/com/util/BottomSheet.tsx | 394 +++++++++++++++++++++++++++++ yarn.lock | 5 + 5 files changed, 489 insertions(+), 95 deletions(-) create mode 100644 src/view/com/util/BottomSheet.tsx diff --git a/package.json b/package.json index de2e861da5..bd3d7b3e36 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "react-responsive": "^9.0.2", "rn-fetch-blob": "^0.12.0", "sentry-expo": "~7.0.0", + "smitter": "^1.1.1", "tippy.js": "^6.3.7", "tlds": "^1.234.0", "zeego": "^1.6.2", diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 12b68bddc0..32552e60c4 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -7,16 +7,18 @@ import { Platform, Pressable, ScrollView, + TextInput, } from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import BottomSheet, { - BottomSheetBackdrop, - BottomSheetTextInput, -} from '@gorhom/bottom-sheet' +import { + useSheet, + Sheet as BottomSheet, + Backdrop as BottomSheetBackdrop, +} from 'view/com/util/BottomSheet' import {Portal} from 'view/com/util/Portal' import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {usePalette} from 'lib/hooks/usePalette' @@ -43,15 +45,31 @@ export function TagInput({ }) { const store = useStores() const model = React.useMemo(() => new TagsAutocompleteModel(store), [store]) - const sheet = React.useRef(null) const pal = usePalette('default') - const input = React.useRef(null) + const input = React.useRef(null) const [value, setValue] = React.useState('') const [tags, setTags] = React.useState([]) const [suggestions, setSuggestions] = React.useState([]) const [isInitialLoad, setIsInitialLoad] = React.useState(true) + const sheet = useSheet({ + index: 0, + snaps: [0, '90%'], + async onStateChange(state) { + if (state.index > 0) { + model.setActive(true) + await model.search('') // get default results + setSuggestions(model.suggestions) + setIsInitialLoad(false) + } else { + reset() + setIsInitialLoad(true) + input.current?.blur() + } + }, + }) + const reset = React.useCallback(() => { setValue('') model.setActive(false) @@ -83,10 +101,7 @@ export function TagInput({ addTags(uniq([...tags, tag]).slice(0, max)) } - setTimeout(() => { - setValue('') - input.current?.focus() - }, 1) + setValue('') }, [max, tags, setValue, addTags], ) @@ -102,8 +117,10 @@ export function TagInput({ if (key === 'Backspace' && value === '') { addTags(tags.slice(0, -1)) } else if (key === ' ') { - e.preventDefault() // prevents an additional space on web addTagAndReset(value) + setTimeout(() => { + setValue('') + }, 1) } }, [value, tags, addTags, addTagAndReset], @@ -127,31 +144,17 @@ export function TagInput({ [model, setValue], ) - const onCloseSheet = React.useCallback(() => { - reset() - setIsInitialLoad(true) - }, [reset, setIsInitialLoad]) - - const onSheetChange = React.useCallback( - async (index: number) => { - if (index > -1) { - model.setActive(true) - await model.search('') // get default results - setSuggestions(model.suggestions) - setIsInitialLoad(false) - } - }, - [model, setIsInitialLoad, setSuggestions], - ) + const openSheet = React.useCallback(() => { + sheet.index = 1 + input.current?.focus() + }, [sheet]) return ( { - sheet.current?.snapToIndex(0) - }}> + onPress={openSheet}> {tags.length ? ( @@ -181,76 +184,61 @@ export function TagInput({ - ( - - )} - handleIndicatorStyle={{backgroundColor: pal.text.color}} - handleStyle={{display: 'none'}} - onChange={onSheetChange} - onClose={onCloseSheet}> + + + - - + + + - {tags.map(tag => ( - - ))} + {tags.map(tag => ( + + ))} - - + + - - - - {isInitialLoad && } + + + + {isInitialLoad && } - {suggestions - .filter(s => !tags.find(t => t === s)) - .map(suggestion => { - return ( - - ) - })} - - - + {suggestions + .filter(s => !tags.find(t => t === s)) + .map(suggestion => { + return ( + + ) + })} + + + + diff --git a/src/view/com/sheets/Base.tsx b/src/view/com/sheets/Base.tsx index 75dd8f62ba..d1a381d274 100644 --- a/src/view/com/sheets/Base.tsx +++ b/src/view/com/sheets/Base.tsx @@ -10,11 +10,15 @@ export function Outer(props: React.PropsWithChildren<{}>) { <> - {props.children} + {props.children} ) } +export function Content(props: React.PropsWithChildren<{}>) { + return {props.children} +} + export function Handle() { const pal = usePalette('default') return ( @@ -28,7 +32,7 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 40, borderTopRightRadius: 40, height: Dimensions.get('window').height * 2, - zIndex: -1, + zIndex: 1, }, content: { paddingVertical: 40, @@ -36,6 +40,7 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 40, borderTopRightRadius: 40, overflow: 'hidden', + zIndex: 2, }, handle: { position: 'absolute', @@ -44,5 +49,6 @@ const styles = StyleSheet.create({ width: 80, height: 6, borderRadius: 10, + zIndex: 2, }, }) diff --git a/src/view/com/util/BottomSheet.tsx b/src/view/com/util/BottomSheet.tsx new file mode 100644 index 0000000000..af159a9820 --- /dev/null +++ b/src/view/com/util/BottomSheet.tsx @@ -0,0 +1,394 @@ +import React from 'react' +import {View, Dimensions, Pressable} from 'react-native' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + Easing, + runOnJS, +} from 'react-native-reanimated' +import {smitter} from 'smitter' + +type BottomSheetState = { + index: number + minIndex: number + maxIndex: number + position: number + pinned: boolean + offset: number + snaps: number[] +} + +type BottomSheetProps = { + sheet: ReturnType +} + +type InternalEvents = { + syncState: BottomSheetState +} + +export function useSheet({ + index: initialIndex = 0, + minIndex: initialMinIndex = 0, + snaps, + onStateChange, +}: { + index?: number + minIndex?: number + maxIndex?: number + snaps: (number | string)[] + onStateChange?: (state: BottomSheetState) => void +}) { + const internal = React.useMemo(() => smitter(), []) + const dimensions = React.useMemo(() => Dimensions.get('window'), []) // TODO needs change? + const snapPoints = React.useMemo(() => { + return snaps.map(p => { + const px = + typeof p === 'number' ? p : (parseInt(p) / 100) * dimensions.height + return px + }) + }, [snaps, dimensions.height]) + + const index = React.useRef(initialIndex) + const minIndex = React.useRef(Math.max(initialMinIndex, 0)) + const maxIndex = React.useRef(snaps.length - 1) + const position = React.useRef( + index.current > -1 ? snapPoints[index.current] : 0, + ) + const pinned = React.useRef(false) + const offset = React.useRef(0) + + const getState = React.useCallback(() => { + return { + index: index.current, + minIndex: minIndex.current, + maxIndex: maxIndex.current, + position: position.current, + pinned: pinned.current, + offset: offset.current, + snaps: snapPoints, + } + }, [snapPoints]) + + const syncState = React.useCallback( + (state: Partial) => { + if (state.minIndex !== undefined) { + minIndex.current = Math.max(state.minIndex, 0) + } + if (state.maxIndex !== undefined) { + maxIndex.current = Math.min(state.maxIndex, snapPoints.length - 1) + } + if (state.index !== undefined) { + index.current = Math.max( + Math.min(state.index, maxIndex.current), + minIndex.current, + ) + position.current = snapPoints[index.current] + } + if (state.position !== undefined) { + position.current = Math.max( + Math.min(state.position, snapPoints[maxIndex.current]), + snapPoints[minIndex.current], + ) + } + if (state.pinned !== undefined) { + pinned.current = state.pinned + } + if (state.offset !== undefined) { + offset.current = state.offset + } + + onStateChange?.(getState()) + }, + [getState, onStateChange, snapPoints], + ) + + const setState = React.useCallback( + (state: Partial) => { + syncState(state) + internal.emit('syncState', getState()) + }, + [syncState, getState, internal], + ) + + return { + get state() { + return getState() + }, + open() { + setState({ + index: 1, + }) + }, + close() { + setState({ + index: 0, + minIndex: 0, + }) + }, + set index(value: number) { + setState({index: value}) + }, + get index() { + return index.current + }, + set position(value: number | string) { + const position = + typeof value === 'number' + ? value + : (parseInt(value) / 100) * dimensions.height + setState({position}) + }, + get position() { + return position.current + }, + set minIndex(index: number) { + setState({minIndex: index}) + }, + get minIndex() { + return minIndex.current + }, + set maxIndex(index: number) { + setState({maxIndex: index}) + }, + get maxIndex() { + return maxIndex.current + }, + set pinned(value: boolean) { + setState({pinned: value}) + }, + get pinned() { + return pinned.current + }, + set offset(offset: number) { + setState({offset}) + }, + get offset() { + return offset.current + }, + events: { + internal, + }, + _syncState: syncState, + } +} + +export function Sheet({ + children, + sheet, +}: React.PropsWithChildren) { + const state = useSharedValue(sheet.state) + state.value = sheet.state + + const {index, snaps} = state.value + + const dimensions = React.useMemo(() => Dimensions.get('window'), []) + + const top = useSharedValue(index > -1 ? snaps[index] : 0) + const animatedSheetStyles = useAnimatedStyle(() => ({ + transform: [{translateY: -top.value}], + })) + + const offset = useSharedValue(dimensions.height) + const animatedOuterStyles = useAnimatedStyle(() => ({ + transform: [{translateY: offset.value}], + })) + + React.useEffect(() => { + function goToPosition(pos: number) { + top.value = withTiming(pos, { + duration: 500, + easing: Easing.out(Easing.exp), + }) + } + + sheet.events.internal.on('syncState', s => { + if (state.value.index != s.index) { + const pos = index > -1 ? snaps[s.index] : 0 + goToPosition(pos) + } + if (state.value.position != s.position) { + goToPosition(s.position) + } + if (state.value.offset != s.offset) { + offset.value = withTiming(dimensions.height - s.offset, { + duration: 500, + easing: Easing.out(Easing.exp), + }) + } + + state.value = s + }) + }, [ + snaps, + dimensions.height, + index, + offset, + sheet.events.internal, + state, + top, + ]) + + const pan = Gesture.Pan() + .onChange(e => { + top.value = top.value - e.changeY + }) + .onFinalize(e => { + // ignore taps + if (Math.abs(e.translationY) < 5) return + + let y = top.value // from the bottom + + const dir = e.velocityY > 0 ? 1 : -1 + let v = Math.abs(e.velocityY) / 100 + + let decayDistance = 0 + while (v > 0.1) { + v *= 1 - 0.15 + decayDistance += v + } + + decayDistance = decayDistance * dir + y = y - decayDistance + + let {index, minIndex, maxIndex, position, pinned} = state.value + let nextPosition = position + + if (!pinned) { + for (let i = index; i < snaps.length; i++) { + const lower = snaps[i - 1] || snaps[0] + const curr = snaps[i] + const upper = snaps[i + 1] || snaps[snaps.length - 1] + + const lowerThreshold = (curr - lower) / 2 + lower + const upperThreshold = (upper - curr) / 2 + curr + + if (y < curr && y < lowerThreshold) { + index = Math.max(i - 1, minIndex) + break + } else if ( + (y <= curr && // less than current snap point + y > lowerThreshold) || // more than half way to current snap point + (y >= curr && // more than current snap point + y < upperThreshold) // less than half way to upper snap point + ) { + index = i + break + } else if ( + y > upper && // less than upper snap point + y > upperThreshold // more than current snap point + ) { + index = Math.min(i + 1, maxIndex) + break + } + } + + nextPosition = index > 0 ? snaps[index] : 0 + } + + top.value = withTiming(nextPosition, { + duration: 500, + easing: Easing.out(Easing.exp), + }) + + // update UI thread state + state.value = { + ...state.value, + index, + position: nextPosition, + } + + // update JS thread state without cyclical emit + runOnJS(sheet._syncState)({index, position: nextPosition}) + }) + + return ( + + + + + {children} + + + + + + + ) +} + +export function Backdrop({sheet}: {sheet: ReturnType}) { + const active = sheet.position > 0 + const opacity = useSharedValue(0) + const style = useAnimatedStyle(() => ({ + position: 'absolute', + top: '-200%', + bottom: '-200%', + left: 0, + right: 0, + backgroundColor: '#000', + zIndex: 0, + opacity: opacity.value, + display: opacity.value > 0 ? 'flex' : 'none', + })) + + React.useEffect(() => { + opacity.value = withTiming(active ? 0.5 : 0, { + duration: 500, + easing: Easing.out(Easing.exp), + }) + }, [active, opacity]) + + return ( + + sheet.close()} + style={{ + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + }} + /> + + ) +} diff --git a/yarn.lock b/yarn.lock index 1685200e13..c8dfc2c133 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16985,6 +16985,11 @@ slugify@^1.3.4: resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b" integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw== +smitter@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/smitter/-/smitter-1.1.1.tgz#cade535ccd3b2cc8ad274a9fe9b02937f50a316f" + integrity sha512-6AwxCy1VfHVBpCljZb/QCGUcRmZKL6s3o5NRjJfJKAQxtiC8GCJUpy1OFs3RcJinykoj/p7jIkPrM3Z3bYmgZg== + sockjs@^0.3.24: version "0.3.24" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" From 0dbf8f1b1801ded121ecc59ab112ea3bb7f25114 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 12 Oct 2023 17:52:17 -0500 Subject: [PATCH 46/66] use gorhom bottom sheet --- package.json | 1 - src/view/com/composer/TagInput.tsx | 65 +++-- src/view/com/util/BottomSheet.tsx | 394 ----------------------------- yarn.lock | 5 - 4 files changed, 40 insertions(+), 425 deletions(-) delete mode 100644 src/view/com/util/BottomSheet.tsx diff --git a/package.json b/package.json index bd3d7b3e36..de2e861da5 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,6 @@ "react-responsive": "^9.0.2", "rn-fetch-blob": "^0.12.0", "sentry-expo": "~7.0.0", - "smitter": "^1.1.1", "tippy.js": "^6.3.7", "tlds": "^1.234.0", "zeego": "^1.6.2", diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 32552e60c4..540b5c0771 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -13,12 +13,8 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import BottomSheet, {BottomSheetBackdrop} from '@gorhom/bottom-sheet' -import { - useSheet, - Sheet as BottomSheet, - Backdrop as BottomSheetBackdrop, -} from 'view/com/util/BottomSheet' import {Portal} from 'view/com/util/Portal' import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {usePalette} from 'lib/hooks/usePalette' @@ -53,22 +49,7 @@ export function TagInput({ const [suggestions, setSuggestions] = React.useState([]) const [isInitialLoad, setIsInitialLoad] = React.useState(true) - const sheet = useSheet({ - index: 0, - snaps: [0, '90%'], - async onStateChange(state) { - if (state.index > 0) { - model.setActive(true) - await model.search('') // get default results - setSuggestions(model.suggestions) - setIsInitialLoad(false) - } else { - reset() - setIsInitialLoad(true) - input.current?.blur() - } - }, - }) + const sheet = React.useRef(null) const reset = React.useCallback(() => { setValue('') @@ -145,10 +126,28 @@ export function TagInput({ ) const openSheet = React.useCallback(() => { - sheet.index = 1 + sheet.current?.snapToIndex(0) input.current?.focus() }, [sheet]) + const onSheetChange = React.useCallback( + async (index: number) => { + if (index > -1) { + model.setActive(true) + await model.search('') // get default results + setSuggestions(model.suggestions) + setIsInitialLoad(false) + } + }, + [model, setSuggestions, setIsInitialLoad], + ) + + const onCloseSheet = React.useCallback(() => { + reset() + setIsInitialLoad(true) + input.current?.blur() + }, [reset, setIsInitialLoad]) + return ( - - - + ( + + )} + handleIndicatorStyle={{backgroundColor: pal.text.color}} + handleStyle={{display: 'none'}} + onChange={onSheetChange} + onClose={onCloseSheet}> diff --git a/src/view/com/util/BottomSheet.tsx b/src/view/com/util/BottomSheet.tsx deleted file mode 100644 index af159a9820..0000000000 --- a/src/view/com/util/BottomSheet.tsx +++ /dev/null @@ -1,394 +0,0 @@ -import React from 'react' -import {View, Dimensions, Pressable} from 'react-native' -import {Gesture, GestureDetector} from 'react-native-gesture-handler' -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - Easing, - runOnJS, -} from 'react-native-reanimated' -import {smitter} from 'smitter' - -type BottomSheetState = { - index: number - minIndex: number - maxIndex: number - position: number - pinned: boolean - offset: number - snaps: number[] -} - -type BottomSheetProps = { - sheet: ReturnType -} - -type InternalEvents = { - syncState: BottomSheetState -} - -export function useSheet({ - index: initialIndex = 0, - minIndex: initialMinIndex = 0, - snaps, - onStateChange, -}: { - index?: number - minIndex?: number - maxIndex?: number - snaps: (number | string)[] - onStateChange?: (state: BottomSheetState) => void -}) { - const internal = React.useMemo(() => smitter(), []) - const dimensions = React.useMemo(() => Dimensions.get('window'), []) // TODO needs change? - const snapPoints = React.useMemo(() => { - return snaps.map(p => { - const px = - typeof p === 'number' ? p : (parseInt(p) / 100) * dimensions.height - return px - }) - }, [snaps, dimensions.height]) - - const index = React.useRef(initialIndex) - const minIndex = React.useRef(Math.max(initialMinIndex, 0)) - const maxIndex = React.useRef(snaps.length - 1) - const position = React.useRef( - index.current > -1 ? snapPoints[index.current] : 0, - ) - const pinned = React.useRef(false) - const offset = React.useRef(0) - - const getState = React.useCallback(() => { - return { - index: index.current, - minIndex: minIndex.current, - maxIndex: maxIndex.current, - position: position.current, - pinned: pinned.current, - offset: offset.current, - snaps: snapPoints, - } - }, [snapPoints]) - - const syncState = React.useCallback( - (state: Partial) => { - if (state.minIndex !== undefined) { - minIndex.current = Math.max(state.minIndex, 0) - } - if (state.maxIndex !== undefined) { - maxIndex.current = Math.min(state.maxIndex, snapPoints.length - 1) - } - if (state.index !== undefined) { - index.current = Math.max( - Math.min(state.index, maxIndex.current), - minIndex.current, - ) - position.current = snapPoints[index.current] - } - if (state.position !== undefined) { - position.current = Math.max( - Math.min(state.position, snapPoints[maxIndex.current]), - snapPoints[minIndex.current], - ) - } - if (state.pinned !== undefined) { - pinned.current = state.pinned - } - if (state.offset !== undefined) { - offset.current = state.offset - } - - onStateChange?.(getState()) - }, - [getState, onStateChange, snapPoints], - ) - - const setState = React.useCallback( - (state: Partial) => { - syncState(state) - internal.emit('syncState', getState()) - }, - [syncState, getState, internal], - ) - - return { - get state() { - return getState() - }, - open() { - setState({ - index: 1, - }) - }, - close() { - setState({ - index: 0, - minIndex: 0, - }) - }, - set index(value: number) { - setState({index: value}) - }, - get index() { - return index.current - }, - set position(value: number | string) { - const position = - typeof value === 'number' - ? value - : (parseInt(value) / 100) * dimensions.height - setState({position}) - }, - get position() { - return position.current - }, - set minIndex(index: number) { - setState({minIndex: index}) - }, - get minIndex() { - return minIndex.current - }, - set maxIndex(index: number) { - setState({maxIndex: index}) - }, - get maxIndex() { - return maxIndex.current - }, - set pinned(value: boolean) { - setState({pinned: value}) - }, - get pinned() { - return pinned.current - }, - set offset(offset: number) { - setState({offset}) - }, - get offset() { - return offset.current - }, - events: { - internal, - }, - _syncState: syncState, - } -} - -export function Sheet({ - children, - sheet, -}: React.PropsWithChildren) { - const state = useSharedValue(sheet.state) - state.value = sheet.state - - const {index, snaps} = state.value - - const dimensions = React.useMemo(() => Dimensions.get('window'), []) - - const top = useSharedValue(index > -1 ? snaps[index] : 0) - const animatedSheetStyles = useAnimatedStyle(() => ({ - transform: [{translateY: -top.value}], - })) - - const offset = useSharedValue(dimensions.height) - const animatedOuterStyles = useAnimatedStyle(() => ({ - transform: [{translateY: offset.value}], - })) - - React.useEffect(() => { - function goToPosition(pos: number) { - top.value = withTiming(pos, { - duration: 500, - easing: Easing.out(Easing.exp), - }) - } - - sheet.events.internal.on('syncState', s => { - if (state.value.index != s.index) { - const pos = index > -1 ? snaps[s.index] : 0 - goToPosition(pos) - } - if (state.value.position != s.position) { - goToPosition(s.position) - } - if (state.value.offset != s.offset) { - offset.value = withTiming(dimensions.height - s.offset, { - duration: 500, - easing: Easing.out(Easing.exp), - }) - } - - state.value = s - }) - }, [ - snaps, - dimensions.height, - index, - offset, - sheet.events.internal, - state, - top, - ]) - - const pan = Gesture.Pan() - .onChange(e => { - top.value = top.value - e.changeY - }) - .onFinalize(e => { - // ignore taps - if (Math.abs(e.translationY) < 5) return - - let y = top.value // from the bottom - - const dir = e.velocityY > 0 ? 1 : -1 - let v = Math.abs(e.velocityY) / 100 - - let decayDistance = 0 - while (v > 0.1) { - v *= 1 - 0.15 - decayDistance += v - } - - decayDistance = decayDistance * dir - y = y - decayDistance - - let {index, minIndex, maxIndex, position, pinned} = state.value - let nextPosition = position - - if (!pinned) { - for (let i = index; i < snaps.length; i++) { - const lower = snaps[i - 1] || snaps[0] - const curr = snaps[i] - const upper = snaps[i + 1] || snaps[snaps.length - 1] - - const lowerThreshold = (curr - lower) / 2 + lower - const upperThreshold = (upper - curr) / 2 + curr - - if (y < curr && y < lowerThreshold) { - index = Math.max(i - 1, minIndex) - break - } else if ( - (y <= curr && // less than current snap point - y > lowerThreshold) || // more than half way to current snap point - (y >= curr && // more than current snap point - y < upperThreshold) // less than half way to upper snap point - ) { - index = i - break - } else if ( - y > upper && // less than upper snap point - y > upperThreshold // more than current snap point - ) { - index = Math.min(i + 1, maxIndex) - break - } - } - - nextPosition = index > 0 ? snaps[index] : 0 - } - - top.value = withTiming(nextPosition, { - duration: 500, - easing: Easing.out(Easing.exp), - }) - - // update UI thread state - state.value = { - ...state.value, - index, - position: nextPosition, - } - - // update JS thread state without cyclical emit - runOnJS(sheet._syncState)({index, position: nextPosition}) - }) - - return ( - - - - - {children} - - - - - - - ) -} - -export function Backdrop({sheet}: {sheet: ReturnType}) { - const active = sheet.position > 0 - const opacity = useSharedValue(0) - const style = useAnimatedStyle(() => ({ - position: 'absolute', - top: '-200%', - bottom: '-200%', - left: 0, - right: 0, - backgroundColor: '#000', - zIndex: 0, - opacity: opacity.value, - display: opacity.value > 0 ? 'flex' : 'none', - })) - - React.useEffect(() => { - opacity.value = withTiming(active ? 0.5 : 0, { - duration: 500, - easing: Easing.out(Easing.exp), - }) - }, [active, opacity]) - - return ( - - sheet.close()} - style={{ - position: 'absolute', - top: 0, - bottom: 0, - left: 0, - right: 0, - }} - /> - - ) -} diff --git a/yarn.lock b/yarn.lock index c8dfc2c133..1685200e13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16985,11 +16985,6 @@ slugify@^1.3.4: resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b" integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw== -smitter@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/smitter/-/smitter-1.1.1.tgz#cade535ccd3b2cc8ad274a9fe9b02937f50a316f" - integrity sha512-6AwxCy1VfHVBpCljZb/QCGUcRmZKL6s3o5NRjJfJKAQxtiC8GCJUpy1OFs3RcJinykoj/p7jIkPrM3Z3bYmgZg== - sockjs@^0.3.24: version "0.3.24" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" From a5f37eb37fc6edd992c70bdf42e52184b34d696c Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 13 Oct 2023 14:38:48 -0500 Subject: [PATCH 47/66] move TagInput into dir, use button on desktop --- src/state/models/ui/tags-autocomplete.ts | 2 +- src/view/com/Tag.tsx | 16 +- src/view/com/composer/TagInput.web.tsx | 311 --------------- .../composer/TagInput/TagInputEntryButton.tsx | 76 ++++ .../{TagInput.tsx => TagInput/index.tsx} | 0 src/view/com/composer/TagInput/index.web.tsx | 366 ++++++++++++++++++ 6 files changed, 456 insertions(+), 315 deletions(-) delete mode 100644 src/view/com/composer/TagInput.web.tsx create mode 100644 src/view/com/composer/TagInput/TagInputEntryButton.tsx rename src/view/com/composer/{TagInput.tsx => TagInput/index.tsx} (100%) create mode 100644 src/view/com/composer/TagInput/index.web.tsx diff --git a/src/state/models/ui/tags-autocomplete.ts b/src/state/models/ui/tags-autocomplete.ts index 9b1d8b43c5..1f13004d47 100644 --- a/src/state/models/ui/tags-autocomplete.ts +++ b/src/state/models/ui/tags-autocomplete.ts @@ -48,7 +48,7 @@ export class TagsAutocompleteModel { isActive = false query = '' searchedTags: string[] = [] - profileTags: string[] = [] + profileTags: string[] = ['biology'] constructor(public rootStore: RootStoreModel) { makeAutoObservable( diff --git a/src/view/com/Tag.tsx b/src/view/com/Tag.tsx index f187c8a745..09149c8103 100644 --- a/src/view/com/Tag.tsx +++ b/src/view/com/Tag.tsx @@ -109,11 +109,11 @@ export function TagButton({ onPointerLeave={hoverOut} style={state => [ pal.viewLight, - styles.editableTag, + styles.tagButton, { - opacity: state.pressed || state.focused ? 0.8 : 1, outline: 0, - paddingRight: 6, + opacity: state.pressed || state.focused ? 0.6 : 1, + paddingRight: 10, }, ]}> @@ -145,4 +145,14 @@ const styles = StyleSheet.create({ borderRadius: 4, overflow: 'hidden', }, + tagButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + flexShrink: 1, + paddingVertical: 6, + paddingTop: 5, + paddingHorizontal: 12, + borderRadius: 20, + }, }) diff --git a/src/view/com/composer/TagInput.web.tsx b/src/view/com/composer/TagInput.web.tsx deleted file mode 100644 index 6e384bedeb..0000000000 --- a/src/view/com/composer/TagInput.web.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import React from 'react' -import { - TextInput, - View, - StyleSheet, - NativeSyntheticEvent, - TextInputKeyPressEventData, - Platform, - Pressable, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {Pin} from 'pind' - -import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' -import {usePalette} from 'lib/hooks/usePalette' -import {EditableTag} from 'view/com/Tag' -import {Text} from 'view/com/util/text/Text' - -function uniq(tags: string[]) { - return Array.from(new Set(tags)) -} - -function sanitize(tagString: string) { - return tagString.trim().replace(/^#/, '') -} - -export function TagInput({ - max = 8, - onChangeTags, - tagsAutocompleteModel: model, -}: { - max?: number - onChangeTags: (tags: string[]) => void - tagsAutocompleteModel: TagsAutocompleteModel -}) { - const pal = usePalette('default') - const dropdown = React.useRef(null) - const input = React.useRef(null) - const inputWidth = input.current - ? input.current.getBoundingClientRect().width - : 200 - - const [value, setValue] = React.useState('') - const [tags, setTags] = React.useState([]) - const [dropdownIsOpen, setDropdownIsOpen] = React.useState(false) - const [dropdownItems, setDropdownItems] = React.useState< - {value: string; label: string}[] - >([]) - const [selectedItemIndex, setSelectedItemIndex] = React.useState(0) - - const close = React.useCallback(() => { - setDropdownIsOpen(false) - model.setActive(false) - setSelectedItemIndex(0) - setDropdownItems([]) - }, [model, setDropdownIsOpen, setSelectedItemIndex, setDropdownItems]) - - const addTags = React.useCallback( - (_tags: string[]) => { - setTags(_tags) - onChangeTags(_tags) - }, - [onChangeTags, setTags], - ) - - const removeTag = React.useCallback( - (tag: string) => { - addTags(tags.filter(t => t !== tag)) - }, - [tags, addTags], - ) - - const addTagAndReset = React.useCallback( - (value: string) => { - const tag = sanitize(value) - - // enforce max hashtag length - if (tag.length > 0 && tag.length <= 64) { - addTags(uniq([...tags, tag]).slice(0, max)) - } - - setValue('') - input.current?.focus() - close() - }, - [max, tags, close, setValue, addTags], - ) - - const onSubmitEditing = React.useCallback(() => { - const item = dropdownItems[selectedItemIndex] - addTagAndReset(item?.value || value) - }, [value, dropdownItems, selectedItemIndex, addTagAndReset]) - - const onKeyPress = React.useCallback( - (e: NativeSyntheticEvent) => { - const {key} = e.nativeEvent - - if (key === 'Backspace' && value === '') { - addTags(tags.slice(0, -1)) - } else if (key === ' ') { - e.preventDefault() // prevents an additional space on web - addTagAndReset(value) - } - - if (dropdownIsOpen) { - if (key === 'Escape') { - close() - } else if (key === 'ArrowUp') { - e.preventDefault() - setSelectedItemIndex( - (selectedItemIndex + dropdownItems.length - 1) % - dropdownItems.length, - ) - } else if (key === 'ArrowDown') { - e.preventDefault() - setSelectedItemIndex((selectedItemIndex + 1) % dropdownItems.length) - } - } - }, - [ - value, - tags, - dropdownIsOpen, - selectedItemIndex, - dropdownItems.length, - close, - setSelectedItemIndex, - addTags, - addTagAndReset, - ], - ) - - const onChangeText = React.useCallback( - async (v: string) => { - setValue(v) - - if (v.length > 0) { - model.setActive(true) - await model.search(v) - - setDropdownItems( - model.suggestions.map(item => ({ - value: item, - label: item, - })), - ) - - setDropdownIsOpen(true) - } else { - close() - } - }, - [model, setValue, setDropdownIsOpen, close], - ) - - React.useEffect(() => { - // outside click - function onClick(e: MouseEvent) { - const drop = dropdown.current - const control = input.current - - if ( - !drop || - !control || - e.target === drop || - e.target === control || - drop.contains(e.target as Node) || - control.contains(e.target as Node) - ) - return - - close() - } - - document.addEventListener('click', onClick) - - return () => { - document.removeEventListener('click', onClick) - } - }, [close]) - - return ( - - {!tags.length && ( - - )} - - {tags.map(tag => ( - - ))} - - {tags.length >= max ? null : ( - - )} - - - - {dropdownItems.map((item, index) => { - const isFirst = index === 0 - const isLast = index === dropdownItems.length - 1 - return ( - addTagAndReset(item.value)} - style={state => [ - pal.border, - styles.dropdownItem, - { - backgroundColor: state.hovered - ? pal.viewLight.backgroundColor - : undefined, - }, - selectedItemIndex === index ? pal.viewLight : undefined, - isFirst - ? styles.firstResult - : isLast - ? styles.lastResult - : undefined, - ]}> - - {item.label} - - - ) - })} - - - - ) -} - -const styles = StyleSheet.create({ - outer: { - flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'center', - gap: 8, - }, - input: { - flexGrow: 1, - minWidth: 100, - fontSize: 15, - lineHeight: Platform.select({ - web: 20, - native: 18, - }), - paddingTop: 4, - paddingBottom: 4, - }, - dropdown: { - width: '100%', - borderRadius: 6, - borderWidth: 1, - borderStyle: 'solid', - padding: 4, - }, - dropdownItem: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - flexDirection: 'row', - paddingHorizontal: 12, - paddingVertical: 8, - gap: 4, - }, - firstResult: { - borderTopLeftRadius: 2, - borderTopRightRadius: 2, - }, - lastResult: { - borderBottomLeftRadius: 2, - borderBottomRightRadius: 2, - }, -}) diff --git a/src/view/com/composer/TagInput/TagInputEntryButton.tsx b/src/view/com/composer/TagInput/TagInputEntryButton.tsx new file mode 100644 index 0000000000..ee7c2ff6e1 --- /dev/null +++ b/src/view/com/composer/TagInput/TagInputEntryButton.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import {View, StyleSheet, Pressable} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' + +import {usePalette} from 'lib/hooks/usePalette' +import {Text} from 'view/com/util/text/Text' + +export function TagInputEntryButton({ + tags, + onRequestOpen, +}: { + tags: string[] + onRequestOpen: () => void +}) { + const pal = usePalette('default') + return ( + [ + styles.selectedTags, + { + outline: 0, + opacity: state.pressed || state.focused ? 0.6 : 1, + }, + ]} + onPress={onRequestOpen}> + + {tags.length ? ( + + Add + + + ) : ( + <> + + + Click to add tags to your post + + + )} + + + {tags.map(tag => ( + + + #{tag} + + + ))} + + ) +} + +const styles = StyleSheet.create({ + selectedTags: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + }, + button: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + flexShrink: 1, + paddingVertical: 6, + paddingHorizontal: 12, + borderRadius: 20, + }, +}) diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput/index.tsx similarity index 100% rename from src/view/com/composer/TagInput.tsx rename to src/view/com/composer/TagInput/index.tsx diff --git a/src/view/com/composer/TagInput/index.web.tsx b/src/view/com/composer/TagInput/index.web.tsx new file mode 100644 index 0000000000..44f5cdfd85 --- /dev/null +++ b/src/view/com/composer/TagInput/index.web.tsx @@ -0,0 +1,366 @@ +import React from 'react' +import { + TextInput, + View, + StyleSheet, + NativeSyntheticEvent, + TextInputKeyPressEventData, + Platform, + Pressable, +} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Pin} from 'pind' + +import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' +import {usePalette} from 'lib/hooks/usePalette' +import {TagButton} from 'view/com/Tag' +import {Text} from 'view/com/util/text/Text' +import {useStores} from 'state/index' +import {TagInputEntryButton} from './TagInputEntryButton' +import {TextInputFocusEventData} from 'react-native' + +function uniq(tags: string[]) { + return Array.from(new Set(tags)) +} + +function sanitize(tagString: string) { + return tagString.trim().replace(/^#/, '') +} + +export function TagInput({ + max = 8, + onChangeTags, +}: { + max?: number + onChangeTags: (tags: string[]) => void +}) { + const store = useStores() + const pal = usePalette('default') + const dropdown = React.useRef(null) + const input = React.useRef(null) + const inputWidth = input.current + ? input.current.getBoundingClientRect().width + : 200 + const model = React.useMemo(() => new TagsAutocompleteModel(store), [store]) + const containerRef = React.useRef(null) + + const [value, setValue] = React.useState('') + const [tags, setTags] = React.useState([]) + const [dropdownIsOpen, setDropdownIsOpen] = React.useState(false) + const [dropdownItems, setDropdownItems] = React.useState< + {value: string; label: string}[] + >([]) + const [selectedItemIndex, setSelectedItemIndex] = React.useState(0) + const [open, setOpen] = React.useState(false) + + const openTagInput = React.useCallback(async () => { + setOpen(true) + }, [setOpen]) + + const closeDropdownAndReset = React.useCallback(() => { + setDropdownIsOpen(false) + model.setActive(false) + setSelectedItemIndex(0) + setDropdownItems([]) + }, [model, setDropdownIsOpen, setSelectedItemIndex, setDropdownItems]) + + const addTags = React.useCallback( + (_tags: string[]) => { + setTags(_tags) + onChangeTags(_tags) + + if (!_tags.length && document.activeElement !== input.current) + setOpen(false) + }, + [onChangeTags, setTags, setOpen], + ) + + const removeTag = React.useCallback( + (tag: string) => { + addTags(tags.filter(t => t !== tag)) + }, + [tags, addTags], + ) + + const addTagAndReset = React.useCallback( + (value: string) => { + const tag = sanitize(value) + + // enforce max hashtag length + if (tag.length > 0 && tag.length <= 64) { + addTags(uniq([...tags, tag]).slice(0, max)) + } + + setValue('') + input.current?.focus() + closeDropdownAndReset() + }, + [max, tags, closeDropdownAndReset, setValue, addTags], + ) + + const onSubmitEditing = React.useCallback(() => { + const item = dropdownItems[selectedItemIndex] + addTagAndReset(item?.value || value) + }, [value, dropdownItems, selectedItemIndex, addTagAndReset]) + + const onKeyPress = React.useCallback( + (e: NativeSyntheticEvent) => { + const {key} = e.nativeEvent + + if (key === 'Backspace' && value === '') { + addTags(tags.slice(0, -1)) + } else if (key === ' ') { + e.preventDefault() // prevents an additional space on web + addTagAndReset(value) + } + + if (dropdownIsOpen) { + if (key === 'Escape') { + closeDropdownAndReset() + } else if (key === 'ArrowUp') { + e.preventDefault() + setSelectedItemIndex( + (selectedItemIndex + dropdownItems.length - 1) % + dropdownItems.length, + ) + } else if (key === 'ArrowDown') { + e.preventDefault() + setSelectedItemIndex((selectedItemIndex + 1) % dropdownItems.length) + } + } + }, + [ + value, + tags, + dropdownIsOpen, + selectedItemIndex, + dropdownItems.length, + closeDropdownAndReset, + setSelectedItemIndex, + addTags, + addTagAndReset, + ], + ) + + const onChangeText = React.useCallback( + async (v: string) => { + setValue(v) + + if (v.length > 0) { + model.setActive(true) + await model.search(v) + + setDropdownItems( + model.suggestions.map(item => ({ + value: item, + label: item, + })), + ) + + setDropdownIsOpen(true) + } else { + closeDropdownAndReset() + } + }, + [model, setValue, setDropdownIsOpen, closeDropdownAndReset], + ) + + const onFocus = React.useCallback(async () => { + model.setActive(true) + await model.search('') + + setDropdownItems( + model.suggestions.map(item => ({ + value: item, + label: item, + })), + ) + + setDropdownIsOpen(true) + }, [model, setDropdownIsOpen]) + + const onBlur = React.useCallback( + (e: NativeSyntheticEvent) => { + // @ts-ignore + const target = e.nativeEvent.relatedTarget as HTMLElement | undefined + + if ( + !tags.length && + (!target || !target.id.includes('tag_autocomplete_option')) + ) { + setOpen(false) + } + }, + [tags, setOpen], + ) + + React.useEffect(() => { + // outside click + function onClick(e: MouseEvent) { + const drop = dropdown.current + const control = input.current + + if ( + !drop || + !control || + e.target === drop || + e.target === control || + drop.contains(e.target as Node) || + control.contains(e.target as Node) + ) + return + + closeDropdownAndReset() + } + + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + } + }, [closeDropdownAndReset]) + + return ( + + {!open && ( + + )} + + {open && ( + + + {!tags.length && ( + + )} + + {tags.map(tag => ( + + ))} + + {tags.length >= max ? null : ( + + )} + + + + {dropdownItems.map((item, index) => { + const isFirst = index === 0 + const isLast = index === dropdownItems.length - 1 + return ( + addTagAndReset(item.value)} + style={state => [ + pal.border, + styles.dropdownItem, + { + backgroundColor: state.hovered + ? pal.viewLight.backgroundColor + : undefined, + }, + selectedItemIndex === index ? pal.viewLight : undefined, + isFirst + ? styles.firstResult + : isLast + ? styles.lastResult + : undefined, + ]}> + + {item.label} + + + ) + })} + + + + + )} + + ) +} + +const styles = StyleSheet.create({ + outer: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + }, + input: { + flexGrow: 1, + minWidth: 100, + fontSize: 15, + lineHeight: Platform.select({ + web: 20, + native: 18, + }), + paddingTop: 5, + paddingBottom: 5, + }, + dropdown: { + width: '100%', + borderRadius: 6, + borderWidth: 1, + borderStyle: 'solid', + padding: 4, + }, + dropdownItem: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexDirection: 'row', + paddingHorizontal: 12, + paddingVertical: 8, + gap: 4, + }, + firstResult: { + borderTopLeftRadius: 2, + borderTopRightRadius: 2, + }, + lastResult: { + borderBottomLeftRadius: 2, + borderBottomRightRadius: 2, + }, +}) From 7ee34b6a12607d508a03175da3a3bcd2a59cb110 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 13 Oct 2023 14:44:41 -0500 Subject: [PATCH 48/66] swap in new button --- src/view/com/composer/TagInput/index.tsx | 35 ++---------------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/src/view/com/composer/TagInput/index.tsx b/src/view/com/composer/TagInput/index.tsx index 540b5c0771..64565c2530 100644 --- a/src/view/com/composer/TagInput/index.tsx +++ b/src/view/com/composer/TagInput/index.tsx @@ -5,7 +5,6 @@ import { NativeSyntheticEvent, TextInputKeyPressEventData, Platform, - Pressable, ScrollView, TextInput, } from 'react-native' @@ -19,10 +18,10 @@ import {Portal} from 'view/com/util/Portal' import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {usePalette} from 'lib/hooks/usePalette' import {TagButton} from 'view/com/Tag' -import {Text} from 'view/com/util/text/Text' import * as Sheet from 'view/com/sheets/Base' import {useStores} from 'state/index' import {ActivityIndicator} from 'react-native' +import {TagInputEntryButton} from './TagInputEntryButton' function uniq(tags: string[]) { return Array.from(new Set(tags)) @@ -150,37 +149,7 @@ export function TagInput({ return ( - - - {tags.length ? ( - - Add + - - ) : ( - <> - - - Click to add tags to your post - - - )} - - - {tags.map(tag => ( - - - #{tag} - - - ))} - + Date: Fri, 13 Oct 2023 14:52:58 -0500 Subject: [PATCH 49/66] use separate models, commit once --- src/view/com/composer/Composer.tsx | 5 ++++- src/view/com/composer/text-input/TextInput.tsx | 9 ++++++--- src/view/com/composer/text-input/TextInput.web.tsx | 8 ++++++-- src/view/com/composer/text-input/web/Tags/view.tsx | 7 +++---- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 8436a6a2c5..eb0c9a806d 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -244,6 +244,10 @@ export const ComposePost = observer(function ComposePost({ }) if (replyTo && replyTo.uri) track('Post:Reply') + // save outline tags + tags.forEach(tag => tagsAutocompleteModel.commitRecentTag(tag)) + + // save inline tags for (const facet of richtext.facets || []) { for (const feature of facet.features) { if (AppBskyRichtextFacet.isTag(feature)) { @@ -396,7 +400,6 @@ export const ComposePost = observer(function ComposePost({ placeholder={selectTextInputPlaceholder} suggestedLinks={suggestedLinks} autocompleteView={autocompleteView} - tagsAutocompleteModel={tagsAutocompleteModel} autoFocus={true} setRichText={setRichText} onPhotoPasted={onPhotoPasted} diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 047907f13a..f853c6e0df 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -34,6 +34,7 @@ import {useTheme} from 'lib/ThemeContext' import {isUriImage} from 'lib/media/util' import {downloadAndResize} from 'lib/media/manip' import {POST_IMG_MAX} from 'lib/constants' +import {useStores} from 'state/index' export interface TextInputRef { focus: () => void @@ -45,7 +46,6 @@ interface TextInputProps extends ComponentProps { placeholder: string suggestedLinks: Set autocompleteView: UserAutocompleteModel - tagsAutocompleteModel: TagsAutocompleteModel setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise @@ -64,7 +64,6 @@ export const TextInput = forwardRef(function TextInputImpl( placeholder, suggestedLinks, autocompleteView, - tagsAutocompleteModel, setRichText, onPhotoPasted, onSuggestedLinksChanged, @@ -73,10 +72,15 @@ export const TextInput = forwardRef(function TextInputImpl( }: TextInputProps, ref, ) { + const store = useStores() const pal = usePalette('default') const textInput = useRef(null) const textInputSelection = useRef({start: 0, end: 0}) const theme = useTheme() + const tagsAutocompleteModel = React.useMemo( + () => new TagsAutocompleteModel(store), + [store], + ) React.useImperativeHandle(ref, () => ({ focus: () => textInput.current?.focus(), @@ -210,7 +214,6 @@ export const TextInput = forwardRef(function TextInputImpl( onChangeText( insertTagAt(richtext.text, textInputSelection.current?.start || 0, tag), ) - tagsAutocompleteModel.commitRecentTag(tag) tagsAutocompleteModel.setActive(false) }, [onChangeText, richtext, tagsAutocompleteModel], diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 7459c43fe1..3698317c1e 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -20,6 +20,7 @@ import {Emoji} from './web/EmojiPicker.web' import {LinkDecorator} from './web/LinkDecorator' import {generateJSON} from '@tiptap/html' import {Tags, createTagsAutocomplete} from './web/Tags' +import {useStores} from 'state/index' export interface TextInputRef { focus: () => void @@ -31,7 +32,6 @@ interface TextInputProps { placeholder: string suggestedLinks: Set autocompleteView: UserAutocompleteModel - tagsAutocompleteModel: TagsAutocompleteModel setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise @@ -47,7 +47,6 @@ export const TextInput = React.forwardRef(function TextInputImpl( placeholder, suggestedLinks, autocompleteView, - tagsAutocompleteModel, setRichText, onPhotoPasted, onPressPublish, @@ -56,6 +55,11 @@ export const TextInput = React.forwardRef(function TextInputImpl( TextInputProps, ref, ) { + const store = useStores() + const tagsAutocompleteModel = React.useMemo( + () => new TagsAutocompleteModel(store), + [store], + ) const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') const extensions = React.useMemo( () => [ diff --git a/src/view/com/composer/text-input/web/Tags/view.tsx b/src/view/com/composer/text-input/web/Tags/view.tsx index 2845c0bdc9..bc40250975 100644 --- a/src/view/com/composer/text-input/web/Tags/view.tsx +++ b/src/view/com/composer/text-input/web/Tags/view.tsx @@ -112,9 +112,8 @@ const Autocomplete = forwardRef( */ // @ts-ignore command({tag, punctuation}) - autocompleteModel.commitRecentTag(tag) }, - [command, autocompleteModel], + [command], ) const selectItem = React.useCallback( @@ -142,7 +141,7 @@ const Autocomplete = forwardRef( if (event.key === 'Enter') { if (!props.items.length) { // no items, use whatever the user typed - commit(props.autocompleteModel.query) + commit(autocompleteModel.query) } else { selectItem(selectedIndex) } @@ -150,7 +149,7 @@ const Autocomplete = forwardRef( } if (event.key === ' ') { - commit(props.autocompleteModel.query) + commit(autocompleteModel.query) return true } From 911a5a5638644bf99e8ade3421e07d85c8351a25 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 19 Oct 2023 14:04:02 -0400 Subject: [PATCH 50/66] strip trailing punctuation --- src/view/com/composer/TagInput/index.tsx | 5 ++++- src/view/com/composer/TagInput/index.web.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/view/com/composer/TagInput/index.tsx b/src/view/com/composer/TagInput/index.tsx index 64565c2530..afd6569cb0 100644 --- a/src/view/com/composer/TagInput/index.tsx +++ b/src/view/com/composer/TagInput/index.tsx @@ -28,7 +28,10 @@ function uniq(tags: string[]) { } function sanitize(tagString: string) { - return tagString.trim().replace(/^#/, '') + return tagString + .trim() + .replace(/^#/, '') + .replace(/\p{P}+$/gu, '') } export function TagInput({ diff --git a/src/view/com/composer/TagInput/index.web.tsx b/src/view/com/composer/TagInput/index.web.tsx index 44f5cdfd85..76406945c9 100644 --- a/src/view/com/composer/TagInput/index.web.tsx +++ b/src/view/com/composer/TagInput/index.web.tsx @@ -27,7 +27,10 @@ function uniq(tags: string[]) { } function sanitize(tagString: string) { - return tagString.trim().replace(/^#/, '') + return tagString + .trim() + .replace(/^#/, '') + .replace(/\p{P}+$/gu, '') } export function TagInput({ From 72bb3ccfacdb7d982fd375857c5f817c53c675eb Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 19 Oct 2023 16:16:03 -0400 Subject: [PATCH 51/66] handle focus on tag buttons --- src/view/com/Tag.tsx | 24 ++++++++++++++++++++ src/view/com/composer/TagInput/index.web.tsx | 17 +++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/view/com/Tag.tsx b/src/view/com/Tag.tsx index 09149c8103..476647ec31 100644 --- a/src/view/com/Tag.tsx +++ b/src/view/com/Tag.tsx @@ -5,6 +5,7 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {isWeb} from 'platform/detection' import {usePalette} from 'lib/hooks/usePalette' import {Text, CustomTextProps} from 'view/com/util/text/Text' import {TextLink} from 'view/com/util/Link' @@ -85,13 +86,16 @@ export function TagButton({ value, icon = 'x', onClick, + removeTag, }: { value: string icon?: React.ComponentProps['icon'] onClick?: (tag: string) => void + removeTag?: (tag: string) => void }) { const pal = usePalette('default') const [hovered, setHovered] = React.useState(false) + const [focused, setFocused] = React.useState(false) const hoverIn = React.useCallback(() => { setHovered(true) @@ -101,12 +105,32 @@ export function TagButton({ setHovered(false) }, [setHovered]) + React.useEffect(() => { + if (!isWeb) return + + function listener(e: KeyboardEvent) { + if (e.key === 'Backspace') { + if (focused) { + removeTag?.(value) + } + } + } + + document.addEventListener('keydown', listener) + + return () => { + document.removeEventListener('keydown', listener) + } + }, [value, focused, removeTag]) + return ( onClick?.(value)} onPointerEnter={hoverIn} onPointerLeave={hoverOut} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} style={state => [ pal.viewLight, styles.tagButton, diff --git a/src/view/com/composer/TagInput/index.web.tsx b/src/view/com/composer/TagInput/index.web.tsx index 76406945c9..ffaf04a057 100644 --- a/src/view/com/composer/TagInput/index.web.tsx +++ b/src/view/com/composer/TagInput/index.web.tsx @@ -14,6 +14,7 @@ import { } from '@fortawesome/react-native-fontawesome' import {Pin} from 'pind' +import {isWeb} from 'platform/detection' import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {usePalette} from 'lib/hooks/usePalette' import {TagButton} from 'view/com/Tag' @@ -132,6 +133,14 @@ export function TagInput({ } else if (key === 'ArrowDown') { e.preventDefault() setSelectedItemIndex((selectedItemIndex + 1) % dropdownItems.length) + } else if ( + isWeb && + key === 'Tab' && + // @ts-ignore web only + !e.nativeEvent.shiftKey + ) { + e.preventDefault() + onSubmitEditing() } } }, @@ -145,6 +154,7 @@ export function TagInput({ setSelectedItemIndex, addTags, addTagAndReset, + onSubmitEditing, ], ) @@ -244,7 +254,12 @@ export function TagInput({ )} {tags.map(tag => ( - + ))} {tags.length >= max ? null : ( From 2d62a4942a336bab80068b8cd65155b134cd312f Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 19 Oct 2023 16:28:12 -0400 Subject: [PATCH 52/66] centralize regex --- src/lib/strings/hashtags.ts | 3 +++ src/view/com/composer/TagInput/index.tsx | 8 ++++++-- src/view/com/composer/TagInput/index.web.tsx | 8 ++++++-- .../text-input/mobile/TagsAutocomplete.tsx | 7 +++---- .../com/composer/text-input/web/Tags/utils.ts | 18 ++++++++++++------ 5 files changed, 30 insertions(+), 14 deletions(-) create mode 100644 src/lib/strings/hashtags.ts diff --git a/src/lib/strings/hashtags.ts b/src/lib/strings/hashtags.ts new file mode 100644 index 0000000000..eb510f4d3b --- /dev/null +++ b/src/lib/strings/hashtags.ts @@ -0,0 +1,3 @@ +export const TAG_REGEX = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/gi +export const ENDING_PUNCTUATION_REGEX = /\p{P}+$/gu +export const LEADING_HASH_REGEX = /^#/ diff --git a/src/view/com/composer/TagInput/index.tsx b/src/view/com/composer/TagInput/index.tsx index afd6569cb0..586d6b1f8c 100644 --- a/src/view/com/composer/TagInput/index.tsx +++ b/src/view/com/composer/TagInput/index.tsx @@ -22,6 +22,10 @@ import * as Sheet from 'view/com/sheets/Base' import {useStores} from 'state/index' import {ActivityIndicator} from 'react-native' import {TagInputEntryButton} from './TagInputEntryButton' +import { + ENDING_PUNCTUATION_REGEX, + LEADING_HASH_REGEX, +} from 'lib/strings/hashtags' function uniq(tags: string[]) { return Array.from(new Set(tags)) @@ -30,8 +34,8 @@ function uniq(tags: string[]) { function sanitize(tagString: string) { return tagString .trim() - .replace(/^#/, '') - .replace(/\p{P}+$/gu, '') + .replace(LEADING_HASH_REGEX, '') + .replace(ENDING_PUNCTUATION_REGEX, '') } export function TagInput({ diff --git a/src/view/com/composer/TagInput/index.web.tsx b/src/view/com/composer/TagInput/index.web.tsx index ffaf04a057..0b5c332bb4 100644 --- a/src/view/com/composer/TagInput/index.web.tsx +++ b/src/view/com/composer/TagInput/index.web.tsx @@ -14,6 +14,10 @@ import { } from '@fortawesome/react-native-fontawesome' import {Pin} from 'pind' +import { + ENDING_PUNCTUATION_REGEX, + LEADING_HASH_REGEX, +} from 'lib/strings/hashtags' import {isWeb} from 'platform/detection' import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {usePalette} from 'lib/hooks/usePalette' @@ -30,8 +34,8 @@ function uniq(tags: string[]) { function sanitize(tagString: string) { return tagString .trim() - .replace(/^#/, '') - .replace(/\p{P}+$/gu, '') + .replace(LEADING_HASH_REGEX, '') + .replace(ENDING_PUNCTUATION_REGEX, '') } export function TagInput({ diff --git a/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx b/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx index e7512796d9..da6ca83477 100644 --- a/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx @@ -5,12 +5,11 @@ import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' +import {LEADING_HASH_REGEX, TAG_REGEX} from 'lib/strings/hashtags' export function getHashtagAt(text: string, position: number) { - const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/gi - let match - while ((match = regex.exec(text))) { + while ((match = TAG_REGEX.exec(text))) { const [matchedString, tag] = match if (tag.length > 66) continue @@ -27,7 +26,7 @@ export function getHashtagAt(text: string, position: number) { * show autocomplete after a single # is typed * AND the cursor is next to the # */ - const hashRegex = /#/g + const hashRegex = LEADING_HASH_REGEX let hashMatch while ((hashMatch = hashRegex.exec(text))) { if (position >= hashMatch.index && position <= hashMatch.index + 1) { diff --git a/src/view/com/composer/text-input/web/Tags/utils.ts b/src/view/com/composer/text-input/web/Tags/utils.ts index ec2a6b2047..81e6ee4b6b 100644 --- a/src/view/com/composer/text-input/web/Tags/utils.ts +++ b/src/view/com/composer/text-input/web/Tags/utils.ts @@ -1,5 +1,11 @@ +import { + TAG_REGEX, + ENDING_PUNCTUATION_REGEX, + LEADING_HASH_REGEX, +} from 'lib/strings/hashtags' + export function parsePunctuationFromTag(value: string) { - const reg = /(\p{P}+)$/gu + const reg = ENDING_PUNCTUATION_REGEX const tag = value.replace(reg, '') const punctuation = value.match(reg)?.[0] || '' @@ -13,9 +19,7 @@ export function findSuggestionMatch({ text: string cursorPosition: number }) { - const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g - const puncRegex = /\p{P}+$/gu - const match = Array.from(text.matchAll(regex)).pop() + const match = Array.from(text.matchAll(TAG_REGEX)).pop() if (!match || match.input === undefined || match.index === undefined) { return null @@ -24,7 +28,9 @@ export function findSuggestionMatch({ const startIndex = cursorPosition - text.length let [matchedString, tag] = match - const sanitized = tag.replace(puncRegex, '').replace(/^#/, '') + const sanitized = tag + .replace(ENDING_PUNCTUATION_REGEX, '') + .replace(LEADING_HASH_REGEX, '') // one of our hashtag spec rules if (sanitized.length > 64) return null @@ -45,7 +51,7 @@ export function findSuggestionMatch({ * We parse out the punctuation later, but we don't want to pass * the # to the search query. */ - query: tag.replace(/^#/, ''), + query: tag.replace(LEADING_HASH_REGEX, ''), // raw text string text: matchedString, } From d93587aeeffda0ef616ee03a7e2ff7379eb5c8a5 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 19 Oct 2023 16:45:22 -0400 Subject: [PATCH 53/66] don't backfill tags, close dropdown on tab --- src/state/models/ui/tags-autocomplete.ts | 6 +----- src/view/com/composer/TagInput/index.web.tsx | 1 + 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/state/models/ui/tags-autocomplete.ts b/src/state/models/ui/tags-autocomplete.ts index 1f13004d47..ef5c6002d8 100644 --- a/src/state/models/ui/tags-autocomplete.ts +++ b/src/state/models/ui/tags-autocomplete.ts @@ -4,10 +4,6 @@ import {RootStoreModel} from '../root-store' import Fuse from 'fuse.js' import {isObj, hasProp, isStrArray} from 'lib/type-guards' -function uniq(arr: string[]) { - return Array.from(new Set(arr)) -} - /** * Used only to persist recent tags across app restarts. * @@ -100,7 +96,7 @@ export class TagsAutocompleteModel { // search amongst mixed set of tags const results = fuse.search(this.query).map(r => r.item) // backfill again in case search has no results - return uniq([...results, ...items]).slice(0, 9) + return results.slice(0, 9) } async search(query: string) { diff --git a/src/view/com/composer/TagInput/index.web.tsx b/src/view/com/composer/TagInput/index.web.tsx index 0b5c332bb4..7867430e04 100644 --- a/src/view/com/composer/TagInput/index.web.tsx +++ b/src/view/com/composer/TagInput/index.web.tsx @@ -120,6 +120,7 @@ export function TagInput({ if (key === 'Backspace' && value === '') { addTags(tags.slice(0, -1)) + closeDropdownAndReset() } else if (key === ' ') { e.preventDefault() // prevents an additional space on web addTagAndReset(value) From c3b500db9cd89fd36519dafe1d6e8954249a72b4 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 19 Oct 2023 16:46:07 -0400 Subject: [PATCH 54/66] remove comment --- src/state/models/ui/tags-autocomplete.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/state/models/ui/tags-autocomplete.ts b/src/state/models/ui/tags-autocomplete.ts index ef5c6002d8..441f04036a 100644 --- a/src/state/models/ui/tags-autocomplete.ts +++ b/src/state/models/ui/tags-autocomplete.ts @@ -95,7 +95,6 @@ export class TagsAutocompleteModel { const fuse = new Fuse(items) // search amongst mixed set of tags const results = fuse.search(this.query).map(r => r.item) - // backfill again in case search has no results return results.slice(0, 9) } From 4505c2bb1e612220416e80f034443c0ca2d1c084 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 19 Oct 2023 16:47:02 -0400 Subject: [PATCH 55/66] remove unused EditableTag --- src/view/com/Tag.tsx | 51 -------------------------------------------- 1 file changed, 51 deletions(-) diff --git a/src/view/com/Tag.tsx b/src/view/com/Tag.tsx index 476647ec31..9e1c2c6b12 100644 --- a/src/view/com/Tag.tsx +++ b/src/view/com/Tag.tsx @@ -31,57 +31,6 @@ export function Tag({ ) } -export function EditableTag({ - value, - onRemove, -}: { - value: string - onRemove: (tag: string) => void -}) { - const pal = usePalette('default') - const [hovered, setHovered] = React.useState(false) - - const hoverIn = React.useCallback(() => { - setHovered(true) - }, [setHovered]) - - const hoverOut = React.useCallback(() => { - setHovered(false) - }, [setHovered]) - - return ( - onRemove(value)} - onPointerEnter={hoverIn} - onPointerLeave={hoverOut} - style={state => [ - pal.viewLight, - styles.editableTag, - { - opacity: state.pressed || state.focused ? 0.8 : 1, - outline: 0, - paddingRight: 6, - }, - ]}> - - #{value} - - - - ) -} - export function TagButton({ value, icon = 'x', From a5d4cfda9b5c86684beed19440018f11418245e7 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 19 Oct 2023 16:51:29 -0400 Subject: [PATCH 56/66] consolidate defs --- src/lib/strings/hashtags.ts | 7 +++++++ src/lib/strings/helpers.ts | 4 ++++ src/view/com/composer/TagInput/index.tsx | 17 ++--------------- src/view/com/composer/TagInput/index.web.tsx | 17 ++--------------- 4 files changed, 15 insertions(+), 30 deletions(-) diff --git a/src/lib/strings/hashtags.ts b/src/lib/strings/hashtags.ts index eb510f4d3b..3e057f6927 100644 --- a/src/lib/strings/hashtags.ts +++ b/src/lib/strings/hashtags.ts @@ -1,3 +1,10 @@ export const TAG_REGEX = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/gi export const ENDING_PUNCTUATION_REGEX = /\p{P}+$/gu export const LEADING_HASH_REGEX = /^#/ + +export function sanitize(tagString: string) { + return tagString + .trim() + .replace(LEADING_HASH_REGEX, '') + .replace(ENDING_PUNCTUATION_REGEX, '') +} diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts index ef93a366f9..ff91cbb226 100644 --- a/src/lib/strings/helpers.ts +++ b/src/lib/strings/helpers.ts @@ -32,3 +32,7 @@ export function toHashCode(str: string, seed = 0): number { return 4294967296 * (2097151 & h2) + (h1 >>> 0) } + +export function uniq(tags: string[]) { + return Array.from(new Set(tags)) +} diff --git a/src/view/com/composer/TagInput/index.tsx b/src/view/com/composer/TagInput/index.tsx index 586d6b1f8c..2afb2821a1 100644 --- a/src/view/com/composer/TagInput/index.tsx +++ b/src/view/com/composer/TagInput/index.tsx @@ -22,21 +22,8 @@ import * as Sheet from 'view/com/sheets/Base' import {useStores} from 'state/index' import {ActivityIndicator} from 'react-native' import {TagInputEntryButton} from './TagInputEntryButton' -import { - ENDING_PUNCTUATION_REGEX, - LEADING_HASH_REGEX, -} from 'lib/strings/hashtags' - -function uniq(tags: string[]) { - return Array.from(new Set(tags)) -} - -function sanitize(tagString: string) { - return tagString - .trim() - .replace(LEADING_HASH_REGEX, '') - .replace(ENDING_PUNCTUATION_REGEX, '') -} +import {sanitize} from 'lib/strings/hashtags' +import {uniq} from 'lib/strings/helpers' export function TagInput({ max = 8, diff --git a/src/view/com/composer/TagInput/index.web.tsx b/src/view/com/composer/TagInput/index.web.tsx index 7867430e04..8365150b44 100644 --- a/src/view/com/composer/TagInput/index.web.tsx +++ b/src/view/com/composer/TagInput/index.web.tsx @@ -14,10 +14,6 @@ import { } from '@fortawesome/react-native-fontawesome' import {Pin} from 'pind' -import { - ENDING_PUNCTUATION_REGEX, - LEADING_HASH_REGEX, -} from 'lib/strings/hashtags' import {isWeb} from 'platform/detection' import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {usePalette} from 'lib/hooks/usePalette' @@ -26,17 +22,8 @@ import {Text} from 'view/com/util/text/Text' import {useStores} from 'state/index' import {TagInputEntryButton} from './TagInputEntryButton' import {TextInputFocusEventData} from 'react-native' - -function uniq(tags: string[]) { - return Array.from(new Set(tags)) -} - -function sanitize(tagString: string) { - return tagString - .trim() - .replace(LEADING_HASH_REGEX, '') - .replace(ENDING_PUNCTUATION_REGEX, '') -} +import {sanitize} from 'lib/strings/hashtags' +import {uniq} from 'lib/strings/helpers' export function TagInput({ max = 8, From 61f88ae2b08c15fb2b2b91c0094e3e1adcd8d8e4 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Sat, 21 Oct 2023 12:14:37 -0400 Subject: [PATCH 57/66] inherit outline tags of parent post --- src/state/models/ui/shell.ts | 1 + src/view/com/composer/Composer.tsx | 5 +++-- src/view/com/composer/TagInput/index.tsx | 4 +++- src/view/com/composer/TagInput/index.web.tsx | 4 +++- src/view/com/post-thread/PostThreadItem.tsx | 6 +++++- src/view/com/post/Post.tsx | 4 +++- src/view/com/posts/FeedItem.tsx | 5 ++++- src/view/com/util/post-ctrls/PostCtrls.tsx | 3 +++ src/view/screens/PostThread.tsx | 1 + src/view/shell/Composer.tsx | 3 +++ src/view/shell/Composer.web.tsx | 3 +++ src/view/shell/index.tsx | 1 + src/view/shell/index.web.tsx | 1 + 13 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index a8937b84ca..4d6c476c33 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -251,6 +251,7 @@ export interface ComposerOpts { onPost?: () => void quote?: ComposerOptsQuote mention?: string // handle of user to mention + outlineTags?: string[] } export class ShellUiModel { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index eb0c9a806d..79ad416287 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -58,6 +58,7 @@ export const ComposePost = observer(function ComposePost({ onPost, quote: initQuote, mention: initMention, + outlineTags, }: Props) { const {track} = useAnalytics() const pal = usePalette('default') @@ -89,7 +90,7 @@ export const ComposePost = observer(function ComposePost({ const [labels, setLabels] = useState([]) const [suggestedLinks, setSuggestedLinks] = useState>(new Set()) const gallery = useMemo(() => new GalleryModel(store), [store]) - const [tags, setTags] = useState([]) + const [tags, setTags] = useState(outlineTags || []) const onClose = useCallback(() => { store.shell.closeComposer() }, [store]) @@ -455,7 +456,7 @@ export const ComposePost = observer(function ComposePost({ paddingHorizontal: 15, }, ]}> - + diff --git a/src/view/com/composer/TagInput/index.tsx b/src/view/com/composer/TagInput/index.tsx index 2afb2821a1..de637ce61a 100644 --- a/src/view/com/composer/TagInput/index.tsx +++ b/src/view/com/composer/TagInput/index.tsx @@ -27,9 +27,11 @@ import {uniq} from 'lib/strings/helpers' export function TagInput({ max = 8, + initialTags = [], onChangeTags, }: { max?: number + initialTags?: string[] onChangeTags: (tags: string[]) => void }) { const store = useStores() @@ -38,7 +40,7 @@ export function TagInput({ const input = React.useRef(null) const [value, setValue] = React.useState('') - const [tags, setTags] = React.useState([]) + const [tags, setTags] = React.useState(initialTags) const [suggestions, setSuggestions] = React.useState([]) const [isInitialLoad, setIsInitialLoad] = React.useState(true) diff --git a/src/view/com/composer/TagInput/index.web.tsx b/src/view/com/composer/TagInput/index.web.tsx index 8365150b44..5180ad8d1f 100644 --- a/src/view/com/composer/TagInput/index.web.tsx +++ b/src/view/com/composer/TagInput/index.web.tsx @@ -27,9 +27,11 @@ import {uniq} from 'lib/strings/helpers' export function TagInput({ max = 8, + initialTags = [], onChangeTags, }: { max?: number + initialTags?: string[] onChangeTags: (tags: string[]) => void }) { const store = useStores() @@ -43,7 +45,7 @@ export function TagInput({ const containerRef = React.useRef(null) const [value, setValue] = React.useState('') - const [tags, setTags] = React.useState([]) + const [tags, setTags] = React.useState(initialTags) const [dropdownIsOpen, setDropdownIsOpen] = React.useState(false) const [dropdownItems, setDropdownItems] = React.useState< {value: string; label: string}[] diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 4a70e3b921..0f9964f09a 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -57,6 +57,7 @@ export const PostThreadItem = observer(function PostThreadItem({ const itemUri = item.post.uri const itemCid = item.post.cid + const outlineTags = item.postRecord?.tags const itemHref = React.useMemo(() => { const urip = new AtUri(item.post.uri) return makeProfileLink(item.post.author, 'post', urip.rkey) @@ -91,6 +92,7 @@ export const PostThreadItem = observer(function PostThreadItem({ const onPressReply = React.useCallback(() => { store.shell.openComposer({ + outlineTags, replyTo: { uri: item.post.uri, cid: item.post.cid, @@ -103,7 +105,7 @@ export const PostThreadItem = observer(function PostThreadItem({ }, onPost: onPostReply, }) - }, [store, item, record, onPostReply]) + }, [store, item, record, onPostReply, outlineTags]) const onPressToggleRepost = React.useCallback(() => { return item @@ -399,6 +401,7 @@ export const PostThreadItem = observer(function PostThreadItem({ itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} + itemOutlineTags={outlineTags} author={item.post.author} text={item.richText?.text || record.text} indexedAt={item.post.indexedAt} @@ -530,6 +533,7 @@ export const PostThreadItem = observer(function PostThreadItem({ itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} + itemOutlineTags={outlineTags} author={item.post.author} text={item.richText?.text || record.text} indexedAt={item.post.indexedAt} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index d5191cf4e0..7cda28c619 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -106,6 +106,7 @@ const PostLoaded = observer(function PostLoadedImpl({ const itemUri = item.post.uri const itemCid = item.post.cid + const outlineTags = item.postRecord?.tags const itemUrip = new AtUri(item.post.uri) const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey) const itemTitle = `Post by ${item.post.author.handle}` @@ -122,6 +123,7 @@ const PostLoaded = observer(function PostLoadedImpl({ const onPressReply = React.useCallback(() => { store.shell.openComposer({ + outlineTags, replyTo: { uri: item.post.uri, cid: item.post.cid, @@ -133,7 +135,7 @@ const PostLoaded = observer(function PostLoadedImpl({ }, }, }) - }, [store, item, record]) + }, [store, item, record, outlineTags]) const onPressToggleRepost = React.useCallback(() => { return item diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 1ceae80ae7..ba016e46ae 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -52,6 +52,7 @@ export const FeedItem = observer(function FeedItemImpl({ const record = item.postRecord const itemUri = item.post.uri const itemCid = item.post.cid + const outlineTags = item.postRecord?.tags const itemHref = useMemo(() => { const urip = new AtUri(item.post.uri) return makeProfileLink(item.post.author, 'post', urip.rkey) @@ -72,6 +73,7 @@ export const FeedItem = observer(function FeedItemImpl({ const onPressReply = React.useCallback(() => { track('FeedItem:PostReply') store.shell.openComposer({ + outlineTags, replyTo: { uri: item.post.uri, cid: item.post.cid, @@ -83,7 +85,7 @@ export const FeedItem = observer(function FeedItemImpl({ }, }, }) - }, [item, track, record, store]) + }, [item, track, record, store, outlineTags]) const onPressToggleRepost = React.useCallback(() => { track('FeedItem:PostRepost') @@ -332,6 +334,7 @@ export const FeedItem = observer(function FeedItemImpl({ itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} + itemOutlineTags={outlineTags} author={item.post.author} text={item.richText?.text || record.text} indexedAt={item.post.indexedAt} diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 5769a478b7..6341589698 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -22,6 +22,7 @@ interface PostCtrlsOpts { itemCid: string itemHref: string itemTitle: string + itemOutlineTags?: string[] isAuthor: boolean author: { did: string @@ -70,6 +71,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { const onQuote = useCallback(() => { store.shell.closeModal() store.shell.openComposer({ + outlineTags: opts.itemOutlineTags, quote: { uri: opts.itemUri, cid: opts.itemCid, @@ -86,6 +88,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { opts.itemUri, opts.text, store.shell, + opts.itemOutlineTags, ]) const onPressToggleLikeWrapper = async () => { diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index d4447f1392..5af68c6223 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -54,6 +54,7 @@ export const PostThreadScreen = withAuthRequired( return } store.shell.openComposer({ + outlineTags: view.thread.postRecord?.tags, replyTo: { uri: view.thread.post.uri, cid: view.thread.post.cid, diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx index 219a594edb..30d8814edc 100644 --- a/src/view/shell/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -13,6 +13,7 @@ export const Composer = observer(function ComposerImpl({ onPost, quote, mention, + outlineTags, }: { active: boolean winHeight: number @@ -20,6 +21,7 @@ export const Composer = observer(function ComposerImpl({ onPost?: ComposerOpts['onPost'] quote?: ComposerOpts['quote'] mention?: ComposerOpts['mention'] + outlineTags?: ComposerOpts['outlineTags'] }) { const pal = usePalette('default') const initInterp = useAnimatedValue(0) @@ -64,6 +66,7 @@ export const Composer = observer(function ComposerImpl({ onPost={onPost} quote={quote} mention={mention} + outlineTags={outlineTags} /> ) diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index c3ec37e57f..022ce7db7e 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -14,6 +14,7 @@ export const Composer = observer(function ComposerImpl({ quote, onPost, mention, + outlineTags, }: { active: boolean winHeight: number @@ -21,6 +22,7 @@ export const Composer = observer(function ComposerImpl({ quote: ComposerOpts['quote'] onPost?: ComposerOpts['onPost'] mention?: ComposerOpts['mention'] + outlineTags?: ComposerOpts['outlineTags'] }) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() @@ -46,6 +48,7 @@ export const Composer = observer(function ComposerImpl({ quote={quote} onPost={onPost} mention={mention} + outlineTags={outlineTags} /> diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index a4f1cab0c8..66fdf4fdf5 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -77,6 +77,7 @@ const ShellInner = observer(function ShellInnerImpl() { onPost={store.shell.composerOpts?.onPost} quote={store.shell.composerOpts?.quote} mention={store.shell.composerOpts?.mention} + outlineTags={store.shell.composerOpts?.outlineTags} /> diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 3f2fed69b9..327355fd3a 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -53,6 +53,7 @@ const ShellInner = observer(function ShellInnerImpl() { quote={store.shell.composerOpts?.quote} onPost={store.shell.composerOpts?.onPost} mention={store.shell.composerOpts?.mention} + outlineTags={store.shell.composerOpts?.outlineTags} /> {showBottomBar && } From ee3d1ff40d62956226d34f719d4af2efaf64d02a Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 23 Oct 2023 15:04:46 -0500 Subject: [PATCH 58/66] Use new more restrictive regex --- src/lib/strings/hashtags.ts | 7 ++++-- .../com/composer/text-input/TextInput.tsx | 2 ++ .../text-input/mobile/TagsAutocomplete.tsx | 21 +++++++++------- .../composer/text-input/web/Tags/plugin.tsx | 10 +++++--- .../com/composer/text-input/web/Tags/utils.ts | 25 ++++++++++++------- 5 files changed, 42 insertions(+), 23 deletions(-) diff --git a/src/lib/strings/hashtags.ts b/src/lib/strings/hashtags.ts index 3e057f6927..1969718b88 100644 --- a/src/lib/strings/hashtags.ts +++ b/src/lib/strings/hashtags.ts @@ -1,6 +1,9 @@ -export const TAG_REGEX = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/gi +export const TAG_REGEX = + /(?:^|\s)(#[\p{L}\p{Emoji_Presentation}\p{Emoji_Modifier_Base}\p{Extended_Pictographic}]{1}[\p{L}\p{Emoji_Presentation}\p{Emoji_Modifier_Base}\p{Extended_Pictographic}\d_-]*)/giu +export const LOOSE_TAG_REGEX = + /(?:^|\s)(#[\p{L}\p{Emoji_Presentation}\p{Emoji_Modifier_Base}\p{Extended_Pictographic}]{1}[\p{L}\p{Emoji_Presentation}\p{Emoji_Modifier_Base}\p{Extended_Pictographic}\d_-]*\S*)/giu export const ENDING_PUNCTUATION_REGEX = /\p{P}+$/gu -export const LEADING_HASH_REGEX = /^#/ +export const LEADING_HASH_REGEX = /^#/g export function sanitize(tagString: string) { return tagString diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index f853c6e0df..09582aa4ea 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -236,6 +236,8 @@ export const TextInput = forwardRef(function TextInputImpl( }) }, [richtext, pal.link, pal.text]) + console.log('render') + return ( 66) continue + if (tag.length > 66 || index === undefined) continue - const from = match.index + matchedString.indexOf(tag) + const from = index + matchedString.indexOf(tag) const to = from + tag.length if (position >= from && position <= to) { @@ -26,11 +29,11 @@ export function getHashtagAt(text: string, position: number) { * show autocomplete after a single # is typed * AND the cursor is next to the # */ - const hashRegex = LEADING_HASH_REGEX - let hashMatch - while ((hashMatch = hashRegex.exec(text))) { - if (position >= hashMatch.index && position <= hashMatch.index + 1) { - return {value: '', index: hashMatch.index} + for (const match of Array.from(text.matchAll(LEADING_HASH_REGEX))) { + const {index} = match + if (index === undefined) continue + if (position >= index && position <= index + 1) { + return {value: '', index} } } diff --git a/src/view/com/composer/text-input/web/Tags/plugin.tsx b/src/view/com/composer/text-input/web/Tags/plugin.tsx index 5db789b4cc..5724b7e409 100644 --- a/src/view/com/composer/text-input/web/Tags/plugin.tsx +++ b/src/view/com/composer/text-input/web/Tags/plugin.tsx @@ -25,8 +25,8 @@ export const Tags = Node.create({ addOptions() { return { HTMLAttributes: {}, - renderLabel({options, node}) { - return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + renderLabel({node}) { + return `#${node.attrs.id}` }, suggestion: { char: '#', @@ -61,11 +61,15 @@ export const Tags = Node.create({ window.getSelection()?.collapseToEnd() }, + /** + * This method and `findSuggestionMatch` below both have to return a + * truthy value, otherwise the suggestiond plugin will call `onExit` + * and we lose the ability to add a tag + */ allow: ({state, range}) => { const $from = state.doc.resolve(range.from) const type = state.schema.nodes[this.name] const allow = !!$from.parent.type.contentMatch.matchType(type) - return allow }, findSuggestionMatch({$position}) { diff --git a/src/view/com/composer/text-input/web/Tags/utils.ts b/src/view/com/composer/text-input/web/Tags/utils.ts index 81e6ee4b6b..3e46957170 100644 --- a/src/view/com/composer/text-input/web/Tags/utils.ts +++ b/src/view/com/composer/text-input/web/Tags/utils.ts @@ -1,5 +1,5 @@ import { - TAG_REGEX, + LOOSE_TAG_REGEX, ENDING_PUNCTUATION_REGEX, LEADING_HASH_REGEX, } from 'lib/strings/hashtags' @@ -12,6 +12,13 @@ export function parsePunctuationFromTag(value: string) { return {tag, punctuation} } +/** + * A result must be returned from this method in order for the suggestion + * plugin to remain active and allow for the user to select a suggestion. + * + * That's why we use the loose regex form that includes trialing punctuation. + * We strip that our later. + */ export function findSuggestionMatch({ text, cursorPosition, @@ -19,24 +26,25 @@ export function findSuggestionMatch({ text: string cursorPosition: number }) { - const match = Array.from(text.matchAll(TAG_REGEX)).pop() + const match = Array.from(text.matchAll(LOOSE_TAG_REGEX)).pop() if (!match || match.input === undefined || match.index === undefined) { return null } const startIndex = cursorPosition - text.length - let [matchedString, tag] = match + let [matchedString, looselyMatchedTag] = match - const sanitized = tag + const sanitized = looselyMatchedTag .replace(ENDING_PUNCTUATION_REGEX, '') .replace(LEADING_HASH_REGEX, '') // one of our hashtag spec rules if (sanitized.length > 64) return null - const from = startIndex + match.index + matchedString.indexOf(tag) - const to = from + tag.length + const from = + startIndex + match.index + matchedString.indexOf(looselyMatchedTag) + const to = from + looselyMatchedTag.length if (from < cursorPosition && to >= cursorPosition) { return { @@ -48,10 +56,9 @@ export function findSuggestionMatch({ * This is passed to the `items({ query })` method configured in * `createTagsAutocomplete`. * - * We parse out the punctuation later, but we don't want to pass - * the # to the search query. + * We parse out the punctuation later. */ - query: tag.replace(LEADING_HASH_REGEX, ''), + query: looselyMatchedTag.replace(LEADING_HASH_REGEX, ''), // raw text string text: matchedString, } From c5005958f0323d86ebbf30343e10ef9f50eba50f Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 24 Oct 2023 09:49:13 -0500 Subject: [PATCH 59/66] Use api package regexes --- src/lib/strings/hashtags.ts | 7 ----- src/state/models/ui/tags-autocomplete.ts | 12 ++------ src/view/com/composer/TagInput/index.tsx | 4 +-- src/view/com/composer/TagInput/index.web.tsx | 4 +-- src/view/com/composer/TagInput/util.ts | 8 ++++++ .../text-input/mobile/TagsAutocomplete.tsx | 4 +-- .../com/composer/text-input/web/Tags/utils.ts | 28 +++++++++++-------- 7 files changed, 33 insertions(+), 34 deletions(-) create mode 100644 src/view/com/composer/TagInput/util.ts diff --git a/src/lib/strings/hashtags.ts b/src/lib/strings/hashtags.ts index 1969718b88..3613256b13 100644 --- a/src/lib/strings/hashtags.ts +++ b/src/lib/strings/hashtags.ts @@ -1,10 +1,3 @@ -export const TAG_REGEX = - /(?:^|\s)(#[\p{L}\p{Emoji_Presentation}\p{Emoji_Modifier_Base}\p{Extended_Pictographic}]{1}[\p{L}\p{Emoji_Presentation}\p{Emoji_Modifier_Base}\p{Extended_Pictographic}\d_-]*)/giu -export const LOOSE_TAG_REGEX = - /(?:^|\s)(#[\p{L}\p{Emoji_Presentation}\p{Emoji_Modifier_Base}\p{Extended_Pictographic}]{1}[\p{L}\p{Emoji_Presentation}\p{Emoji_Modifier_Base}\p{Extended_Pictographic}\d_-]*\S*)/giu -export const ENDING_PUNCTUATION_REGEX = /\p{P}+$/gu -export const LEADING_HASH_REGEX = /^#/g - export function sanitize(tagString: string) { return tagString .trim() diff --git a/src/state/models/ui/tags-autocomplete.ts b/src/state/models/ui/tags-autocomplete.ts index 441f04036a..e6f2a27ead 100644 --- a/src/state/models/ui/tags-autocomplete.ts +++ b/src/state/models/ui/tags-autocomplete.ts @@ -44,7 +44,7 @@ export class TagsAutocompleteModel { isActive = false query = '' searchedTags: string[] = [] - profileTags: string[] = ['biology'] + profileTags: string[] = [] constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -113,15 +113,7 @@ export class TagsAutocompleteModel { // TODO hook up to search type-ahead async _search() { runInAction(() => { - this.searchedTags = [ - 'bluesky', - 'code', - 'coding', - 'dev', - 'developer', - 'development', - 'devlife', - ] + this.searchedTags = [] }) } } diff --git a/src/view/com/composer/TagInput/index.tsx b/src/view/com/composer/TagInput/index.tsx index de637ce61a..a01b94152c 100644 --- a/src/view/com/composer/TagInput/index.tsx +++ b/src/view/com/composer/TagInput/index.tsx @@ -22,8 +22,8 @@ import * as Sheet from 'view/com/sheets/Base' import {useStores} from 'state/index' import {ActivityIndicator} from 'react-native' import {TagInputEntryButton} from './TagInputEntryButton' -import {sanitize} from 'lib/strings/hashtags' import {uniq} from 'lib/strings/helpers' +import {sanitizeHashtag} from './util' export function TagInput({ max = 8, @@ -70,7 +70,7 @@ export function TagInput({ const addTagAndReset = React.useCallback( (value: string) => { - const tag = sanitize(value) + const tag = sanitizeHashtag(value) // enforce max hashtag length if (tag.length > 0 && tag.length <= 64) { diff --git a/src/view/com/composer/TagInput/index.web.tsx b/src/view/com/composer/TagInput/index.web.tsx index 5180ad8d1f..d0ac8d2c75 100644 --- a/src/view/com/composer/TagInput/index.web.tsx +++ b/src/view/com/composer/TagInput/index.web.tsx @@ -22,8 +22,8 @@ import {Text} from 'view/com/util/text/Text' import {useStores} from 'state/index' import {TagInputEntryButton} from './TagInputEntryButton' import {TextInputFocusEventData} from 'react-native' -import {sanitize} from 'lib/strings/hashtags' import {uniq} from 'lib/strings/helpers' +import {sanitizeHashtag} from './util' export function TagInput({ max = 8, @@ -84,7 +84,7 @@ export function TagInput({ const addTagAndReset = React.useCallback( (value: string) => { - const tag = sanitize(value) + const tag = sanitizeHashtag(value) // enforce max hashtag length if (tag.length > 0 && tag.length <= 64) { diff --git a/src/view/com/composer/TagInput/util.ts b/src/view/com/composer/TagInput/util.ts new file mode 100644 index 0000000000..b085054853 --- /dev/null +++ b/src/view/com/composer/TagInput/util.ts @@ -0,0 +1,8 @@ +import {TRAILING_PUNCTUATION_REGEX, LEADING_HASH_REGEX} from '@atproto/api' + +export function sanitizeHashtag(tagString: string) { + return tagString + .trim() + .replace(LEADING_HASH_REGEX, '') + .replace(TRAILING_PUNCTUATION_REGEX, '') +} diff --git a/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx b/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx index d5d9218192..816e9509ea 100644 --- a/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx @@ -5,13 +5,13 @@ import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' -import {LEADING_HASH_REGEX, TAG_REGEX} from 'lib/strings/hashtags' +import {LEADING_HASH_REGEX, HASHTAG_REGEX} from '@atproto/api' /** * Loops over matches in the text to find the hashtag under the cursor. */ export function getHashtagAt(text: string, position: number) { - for (const match of Array.from(text.matchAll(TAG_REGEX))) { + for (const match of Array.from(text.matchAll(HASHTAG_REGEX))) { const {index} = match const [matchedString, tag] = match diff --git a/src/view/com/composer/text-input/web/Tags/utils.ts b/src/view/com/composer/text-input/web/Tags/utils.ts index 3e46957170..654552e07c 100644 --- a/src/view/com/composer/text-input/web/Tags/utils.ts +++ b/src/view/com/composer/text-input/web/Tags/utils.ts @@ -1,11 +1,15 @@ import { - LOOSE_TAG_REGEX, - ENDING_PUNCTUATION_REGEX, + HASHTAG_REGEX_WITH_TRAILING_PUNCTUATION, + TRAILING_PUNCTUATION_REGEX, LEADING_HASH_REGEX, -} from 'lib/strings/hashtags' +} from '@atproto/api' +/** + * This method eventually receives the `query` property from the result of + * `findSuggestionMatch` below. + */ export function parsePunctuationFromTag(value: string) { - const reg = ENDING_PUNCTUATION_REGEX + const reg = TRAILING_PUNCTUATION_REGEX const tag = value.replace(reg, '') const punctuation = value.match(reg)?.[0] || '' @@ -26,25 +30,27 @@ export function findSuggestionMatch({ text: string cursorPosition: number }) { - const match = Array.from(text.matchAll(LOOSE_TAG_REGEX)).pop() + const match = Array.from( + text.matchAll(HASHTAG_REGEX_WITH_TRAILING_PUNCTUATION), + ).pop() if (!match || match.input === undefined || match.index === undefined) { return null } const startIndex = cursorPosition - text.length - let [matchedString, looselyMatchedTag] = match + let [matchedString, tagWithTrailingPunctuation] = match - const sanitized = looselyMatchedTag - .replace(ENDING_PUNCTUATION_REGEX, '') + const sanitized = tagWithTrailingPunctuation + .replace(TRAILING_PUNCTUATION_REGEX, '') .replace(LEADING_HASH_REGEX, '') // one of our hashtag spec rules if (sanitized.length > 64) return null const from = - startIndex + match.index + matchedString.indexOf(looselyMatchedTag) - const to = from + looselyMatchedTag.length + startIndex + match.index + matchedString.indexOf(tagWithTrailingPunctuation) + const to = from + tagWithTrailingPunctuation.length if (from < cursorPosition && to >= cursorPosition) { return { @@ -58,7 +64,7 @@ export function findSuggestionMatch({ * * We parse out the punctuation later. */ - query: looselyMatchedTag.replace(LEADING_HASH_REGEX, ''), + query: tagWithTrailingPunctuation.replace(LEADING_HASH_REGEX, ''), // raw text string text: matchedString, } From ebd39c7a107326247c4621e999cee24a3e491174 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 24 Oct 2023 11:33:22 -0500 Subject: [PATCH 60/66] clean up sanitization --- src/view/com/composer/TagInput/index.tsx | 12 ++++---- src/view/com/composer/TagInput/index.web.tsx | 15 ++++++---- src/view/com/composer/TagInput/util.ts | 30 +++++++++++++++---- .../text-input/mobile/TagsAutocomplete.tsx | 4 ++- 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/view/com/composer/TagInput/index.tsx b/src/view/com/composer/TagInput/index.tsx index a01b94152c..d3d1f50826 100644 --- a/src/view/com/composer/TagInput/index.tsx +++ b/src/view/com/composer/TagInput/index.tsx @@ -23,7 +23,7 @@ import {useStores} from 'state/index' import {ActivityIndicator} from 'react-native' import {TagInputEntryButton} from './TagInputEntryButton' import {uniq} from 'lib/strings/helpers' -import {sanitizeHashtag} from './util' +import {sanitizeHashtag, sanitizeHashtagOnChange} from './util' export function TagInput({ max = 8, @@ -103,12 +103,14 @@ export function TagInput({ ) const onChangeText = React.useCallback( - async (v: string) => { - setValue(v) + async (value: string) => { + const tag = sanitizeHashtagOnChange(value) - if (v.length > 0) { + setValue(tag) + + if (tag.length > 0) { model.setActive(true) - await model.search(v) + await model.search(tag) setSuggestions(model.suggestions) } else { diff --git a/src/view/com/composer/TagInput/index.web.tsx b/src/view/com/composer/TagInput/index.web.tsx index d0ac8d2c75..2a9a95f1f7 100644 --- a/src/view/com/composer/TagInput/index.web.tsx +++ b/src/view/com/composer/TagInput/index.web.tsx @@ -23,7 +23,7 @@ import {useStores} from 'state/index' import {TagInputEntryButton} from './TagInputEntryButton' import {TextInputFocusEventData} from 'react-native' import {uniq} from 'lib/strings/helpers' -import {sanitizeHashtag} from './util' +import {sanitizeHashtag, sanitizeHashtagOnChange} from './util' export function TagInput({ max = 8, @@ -153,12 +153,14 @@ export function TagInput({ ) const onChangeText = React.useCallback( - async (v: string) => { - setValue(v) + async (value: string) => { + const tag = sanitizeHashtagOnChange(value) - if (v.length > 0) { + setValue(tag) + + if (tag.length > 0) { model.setActive(true) - await model.search(v) + await model.search(tag) setDropdownItems( model.suggestions.map(item => ({ @@ -199,9 +201,10 @@ export function TagInput({ (!target || !target.id.includes('tag_autocomplete_option')) ) { setOpen(false) + setValue('') } }, - [tags, setOpen], + [tags, setOpen, setValue], ) React.useEffect(() => { diff --git a/src/view/com/composer/TagInput/util.ts b/src/view/com/composer/TagInput/util.ts index b085054853..7abab04f0e 100644 --- a/src/view/com/composer/TagInput/util.ts +++ b/src/view/com/composer/TagInput/util.ts @@ -1,8 +1,28 @@ -import {TRAILING_PUNCTUATION_REGEX, LEADING_HASH_REGEX} from '@atproto/api' +import { + HASHTAG_INVALID_CHARACTER_REGEX, + TRAILING_PUNCTUATION_REGEX, + LEADING_PUNCTUATION_REGEX, + LEADING_NUMBER_REGEX, +} from '@atproto/api' -export function sanitizeHashtag(tagString: string) { - return tagString - .trim() - .replace(LEADING_HASH_REGEX, '') +/** + * Trims leading numbers, all invalid characters, and any trailing punctuation. + */ +export function sanitizeHashtag(hashtag: string) { + return hashtag + .replace(LEADING_PUNCTUATION_REGEX, '') + .replace(LEADING_NUMBER_REGEX, '') + .replace(HASHTAG_INVALID_CHARACTER_REGEX, '') .replace(TRAILING_PUNCTUATION_REGEX, '') } + +/** + * Trims leading numbers and all invalid charactes, but ignores trailing + * punctuation in case the user intends to use `_` or `-`. + */ +export function sanitizeHashtagOnChange(hashtag: string) { + return hashtag + .replace(LEADING_PUNCTUATION_REGEX, '') + .replace(LEADING_NUMBER_REGEX, '') + .replace(HASHTAG_INVALID_CHARACTER_REGEX, '') +} diff --git a/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx b/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx index 816e9509ea..a5d6756da5 100644 --- a/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx @@ -29,7 +29,9 @@ export function getHashtagAt(text: string, position: number) { * show autocomplete after a single # is typed * AND the cursor is next to the # */ - for (const match of Array.from(text.matchAll(LEADING_HASH_REGEX))) { + for (const match of Array.from( + text.matchAll(new RegExp(LEADING_HASH_REGEX, 'g')), + )) { const {index} = match if (index === undefined) continue if (position >= index && position <= index + 1) { From 94e2d0be435624ace93122993c0e3f597c06cafe Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 24 Oct 2023 11:40:20 -0500 Subject: [PATCH 61/66] Small tweaks --- src/view/com/composer/TagInput/index.tsx | 16 +++++++++------- src/view/com/composer/text-input/TextInput.tsx | 2 -- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/view/com/composer/TagInput/index.tsx b/src/view/com/composer/TagInput/index.tsx index d3d1f50826..98cb37e4b9 100644 --- a/src/view/com/composer/TagInput/index.tsx +++ b/src/view/com/composer/TagInput/index.tsx @@ -174,11 +174,13 @@ export function TagInput({ - + {tags.length < 1 && ( + + )} {tags.map(tag => ( @@ -250,8 +252,8 @@ const styles = StyleSheet.create({ web: 20, native: 18, }), - paddingTop: 4, - paddingBottom: 4, + paddingTop: 6, + paddingBottom: 6, }, suggestions: { flexDirection: 'row', diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 09582aa4ea..f853c6e0df 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -236,8 +236,6 @@ export const TextInput = forwardRef(function TextInputImpl( }) }, [richtext, pal.link, pal.text]) - console.log('render') - return ( Date: Tue, 24 Oct 2023 12:59:59 -0500 Subject: [PATCH 62/66] remove unused file --- src/lib/strings/hashtags.ts | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 src/lib/strings/hashtags.ts diff --git a/src/lib/strings/hashtags.ts b/src/lib/strings/hashtags.ts deleted file mode 100644 index 3613256b13..0000000000 --- a/src/lib/strings/hashtags.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function sanitize(tagString: string) { - return tagString - .trim() - .replace(LEADING_HASH_REGEX, '') - .replace(ENDING_PUNCTUATION_REGEX, '') -} From b78ec89338c362a5ebccadfa176b12607dbdd26f Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 25 Oct 2023 11:57:47 -0500 Subject: [PATCH 63/66] Use new api pkg APIs --- src/view/com/composer/TagInput/index.tsx | 3 ++- src/view/com/composer/TagInput/index.web.tsx | 3 ++- src/view/com/composer/TagInput/util.ts | 19 +++++-------------- .../com/composer/text-input/web/Tags/utils.ts | 4 ++-- 4 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/view/com/composer/TagInput/index.tsx b/src/view/com/composer/TagInput/index.tsx index 98cb37e4b9..23040224dd 100644 --- a/src/view/com/composer/TagInput/index.tsx +++ b/src/view/com/composer/TagInput/index.tsx @@ -13,6 +13,7 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import BottomSheet, {BottomSheetBackdrop} from '@gorhom/bottom-sheet' +import {sanitizeHashtag} from '@atproto/api' import {Portal} from 'view/com/util/Portal' import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' @@ -23,7 +24,7 @@ import {useStores} from 'state/index' import {ActivityIndicator} from 'react-native' import {TagInputEntryButton} from './TagInputEntryButton' import {uniq} from 'lib/strings/helpers' -import {sanitizeHashtag, sanitizeHashtagOnChange} from './util' +import {sanitizeHashtagOnChange} from './util' export function TagInput({ max = 8, diff --git a/src/view/com/composer/TagInput/index.web.tsx b/src/view/com/composer/TagInput/index.web.tsx index 2a9a95f1f7..ce0ee12926 100644 --- a/src/view/com/composer/TagInput/index.web.tsx +++ b/src/view/com/composer/TagInput/index.web.tsx @@ -13,6 +13,7 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {Pin} from 'pind' +import {sanitizeHashtag} from '@atproto/api' import {isWeb} from 'platform/detection' import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' @@ -23,7 +24,7 @@ import {useStores} from 'state/index' import {TagInputEntryButton} from './TagInputEntryButton' import {TextInputFocusEventData} from 'react-native' import {uniq} from 'lib/strings/helpers' -import {sanitizeHashtag, sanitizeHashtagOnChange} from './util' +import {sanitizeHashtagOnChange} from './util' export function TagInput({ max = 8, diff --git a/src/view/com/composer/TagInput/util.ts b/src/view/com/composer/TagInput/util.ts index 7abab04f0e..f178a2f3d2 100644 --- a/src/view/com/composer/TagInput/util.ts +++ b/src/view/com/composer/TagInput/util.ts @@ -1,28 +1,19 @@ import { HASHTAG_INVALID_CHARACTER_REGEX, - TRAILING_PUNCTUATION_REGEX, LEADING_PUNCTUATION_REGEX, LEADING_NUMBER_REGEX, + LEADING_HASH_REGEX, } from '@atproto/api' /** - * Trims leading numbers, all invalid characters, and any trailing punctuation. - */ -export function sanitizeHashtag(hashtag: string) { - return hashtag - .replace(LEADING_PUNCTUATION_REGEX, '') - .replace(LEADING_NUMBER_REGEX, '') - .replace(HASHTAG_INVALID_CHARACTER_REGEX, '') - .replace(TRAILING_PUNCTUATION_REGEX, '') -} - -/** - * Trims leading numbers and all invalid charactes, but ignores trailing + * Basically `sanitizeHashtag` from `@atproto/api`, but ignores trailing * punctuation in case the user intends to use `_` or `-`. */ export function sanitizeHashtagOnChange(hashtag: string) { return hashtag - .replace(LEADING_PUNCTUATION_REGEX, '') + .replace(LEADING_HASH_REGEX, '') .replace(LEADING_NUMBER_REGEX, '') + .replace(LEADING_PUNCTUATION_REGEX, '') .replace(HASHTAG_INVALID_CHARACTER_REGEX, '') + .slice(0, 64) } diff --git a/src/view/com/composer/text-input/web/Tags/utils.ts b/src/view/com/composer/text-input/web/Tags/utils.ts index 654552e07c..6894d2ba04 100644 --- a/src/view/com/composer/text-input/web/Tags/utils.ts +++ b/src/view/com/composer/text-input/web/Tags/utils.ts @@ -1,5 +1,5 @@ import { - HASHTAG_REGEX_WITH_TRAILING_PUNCTUATION, + HASHTAG_WITH_TRAILING_PUNCTUATION_REGEX, TRAILING_PUNCTUATION_REGEX, LEADING_HASH_REGEX, } from '@atproto/api' @@ -31,7 +31,7 @@ export function findSuggestionMatch({ cursorPosition: number }) { const match = Array.from( - text.matchAll(HASHTAG_REGEX_WITH_TRAILING_PUNCTUATION), + text.matchAll(HASHTAG_WITH_TRAILING_PUNCTUATION_REGEX), ).pop() if (!match || match.input === undefined || match.index === undefined) { From 8314f90a5e9eeba82bb47a548e5f50ecd3ea21ed Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 25 Oct 2023 12:07:12 -0500 Subject: [PATCH 64/66] Improve autocomplete model logic --- src/state/models/ui/tags-autocomplete.ts | 31 +++++++++++++++--------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/state/models/ui/tags-autocomplete.ts b/src/state/models/ui/tags-autocomplete.ts index e6f2a27ead..e8ddf9e6d9 100644 --- a/src/state/models/ui/tags-autocomplete.ts +++ b/src/state/models/ui/tags-autocomplete.ts @@ -6,8 +6,6 @@ import {isObj, hasProp, isStrArray} from 'lib/type-guards' /** * Used only to persist recent tags across app restarts. - * - * TODO may want an LRU? */ export class RecentTagsModel { _tags: string[] = [] @@ -21,7 +19,7 @@ export class RecentTagsModel { } add(tag: string) { - this._tags = Array.from(new Set([tag, ...this._tags])) + this._tags = Array.from(new Set([tag, ...this._tags])).slice(0, 100) // save up to 100 recent tags } remove(tag: string) { @@ -74,23 +72,32 @@ export class TagsAutocompleteModel { return [] } + // no query, return default suggestions + if (!this.query) { + return Array.from( + // de-duplicates via Set + new Set([ + // sample 6 recent tags + ...this.rootStore.recentTags.tags.slice(0, 6), + // sample 3 of your profile tags + ...this.profileTags.slice(0, 3), + ]), + ) + } + + // we're going to search this list const items = Array.from( // de-duplicates via Set new Set([ - // sample up to 3 recent tags - ...this.rootStore.recentTags.tags.slice(0, 3), - // sample up to 3 of your profile tags - ...this.profileTags.slice(0, 3), + // all recent tags + ...this.rootStore.recentTags.tags, + // all profile tags + ...this.profileTags, // and all searched tags ...this.searchedTags, ]), ) - // no query, return default suggestions - if (!this.query) { - return items.slice(0, 9) - } - // Fuse allows weighting values too, if we ever need it const fuse = new Fuse(items) // search amongst mixed set of tags From 0ec579b9bba2cb29099e5dbc38f2ee7a76681ac8 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 26 Oct 2023 16:18:39 -0700 Subject: [PATCH 65/66] Tweak rendering of hashtags and expanded posts for clarity and information density --- src/view/com/Tag.tsx | 26 +++- src/view/com/post-thread/PostThreadItem.tsx | 127 +++++++++----------- src/view/com/util/Link.tsx | 29 +++-- src/view/com/util/text/RichText.tsx | 10 +- 4 files changed, 104 insertions(+), 88 deletions(-) diff --git a/src/view/com/Tag.tsx b/src/view/com/Tag.tsx index 9e1c2c6b12..eeba4c8675 100644 --- a/src/view/com/Tag.tsx +++ b/src/view/com/Tag.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {StyleSheet, Pressable} from 'react-native' +import {StyleSheet, Pressable, StyleProp, TextStyle} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -7,18 +7,25 @@ import { import {isWeb} from 'platform/detection' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' import {Text, CustomTextProps} from 'view/com/util/text/Text' import {TextLink} from 'view/com/util/Link' export function Tag({ value, textSize, + smallSigil, + style, }: { value: string textSize?: CustomTextProps['type'] + smallSigil?: boolean + style?: StyleProp }) { - const pal = usePalette('default') + const theme = useTheme() const type = textSize || 'xs-medium' + const typeFontSize = theme.typography[type].fontSize || 16 + const hashtagFontSize = typeFontSize * (smallSigil ? 0.8 : 1) return ( + style={style}> + + # + + {value} + ) } diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 0f9964f09a..e5a5c56297 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -53,7 +53,6 @@ export const PostThreadItem = observer(function PostThreadItem({ const [deleted, setDeleted] = React.useState(false) const styles = useStyles() const record = item.postRecord - const hasEngagement = item.post.likeCount || item.post.repostCount const itemUri = item.post.uri const itemCid = item.post.cid @@ -332,25 +331,29 @@ export const PostThreadItem = observer(function PostThreadItem({ )} - {AppBskyFeedPost.isRecord(item.post.record) && - item.post.record.tags?.length ? ( - - {item.post.record.tags.map(tag => ( - - ))} - - ) : null} + + {item.post.repostCount ? ( + + + + {formatCount(item.post.repostCount)} + {' '} + {pluralize(item.post.repostCount, 'repost')} + + + ) : null} + {item.post.likeCount ? ( + + + + {formatCount(item.post.likeCount)} + {' '} + {pluralize(item.post.likeCount, 'like')} + + + ) : null} + {niceDate(item.post.indexedAt)} + - {hasEngagement ? ( - - {item.post.repostCount ? ( - - - - {formatCount(item.post.repostCount)} - {' '} - {pluralize(item.post.repostCount, 'repost')} - - - ) : ( - <> - )} - {item.post.likeCount ? ( - - - - {formatCount(item.post.likeCount)} - {' '} - {pluralize(item.post.likeCount, 'like')} - - - ) : ( - <> - )} - - ) : ( - <> - )} - + - {niceDate(post.indexedAt)} + {needsTranslation && ( - <> - - - Translate - - + + Translate + )} + {hasTags && AppBskyFeedPost.isRecord(post.record) + ? post.record.tags!.map(tag => ( + + )) + : null} ) } @@ -714,7 +701,7 @@ const useStyles = () => { }, postTextLargeContainer: { paddingHorizontal: 0, - paddingBottom: 10, + // paddingBottom: 10, }, translateLink: { marginBottom: 6, @@ -727,14 +714,14 @@ const useStyles = () => { }, expandedInfo: { flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'baseline', + gap: 12, paddingVertical: 10, + paddingHorizontal: 2, borderTopWidth: 1, borderBottomWidth: 1, marginTop: 5, - marginBottom: 15, - }, - expandedInfoItem: { - marginRight: 10, }, loadMore: { flexDirection: 'row', diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 6915d3e083..3476629a9b 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -148,19 +148,22 @@ export const TextLink = observer(function TextLink({ title, onPress, warnOnMismatchingLabel, + children, ...orgProps -}: { - testID?: string - type?: TypographyVariant - style?: StyleProp - href: string - text: string | JSX.Element | React.ReactNode - numberOfLines?: number - lineHeight?: number - dataSet?: any - title?: string - warnOnMismatchingLabel?: boolean -} & TextProps) { +}: React.PropsWithChildren< + { + testID?: string + type?: TypographyVariant + style?: StyleProp + href: string + text: string | JSX.Element | React.ReactNode + numberOfLines?: number + lineHeight?: number + dataSet?: any + title?: string + warnOnMismatchingLabel?: boolean + } & TextProps +>) { const {...props} = useLinkProps({to: sanitizeUrl(href)}) const store = useStores() const navigation = useNavigation() @@ -215,7 +218,7 @@ export const TextLink = observer(function TextLink({ hrefAttrs={hrefAttrs} // hack to get open in new tab to work on safari. without this, safari will open in a new window {...props} {...orgProps}> - {text} + {children || text} ) }) diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index 18a040f14c..f41a6f3dd0 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -95,7 +95,15 @@ export function RichText({ />, ) } else if (tag && AppBskyRichtextFacet.validateTag(tag).success) { - els.push() + els.push( + , + ) } else { els.push(segment.text) } From 38fd222069ec969f8f242fa9ab3e19c8d28289fd Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 26 Oct 2023 17:38:16 -0700 Subject: [PATCH 66/66] Improve overflow spacing --- src/view/com/post-thread/PostThreadItem.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index e5a5c56297..470ca2158d 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -716,7 +716,8 @@ const useStyles = () => { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'baseline', - gap: 12, + rowGap: 8, + columnGap: 12, paddingVertical: 10, paddingHorizontal: 2, borderTopWidth: 1,