diff --git a/package.json b/package.json index 720af2e367..0143c1d78c 100644 --- a/package.json +++ b/package.json @@ -88,18 +88,18 @@ "@tanstack/query-async-storage-persister": "^5.25.0", "@tanstack/react-query": "^5.8.1", "@tanstack/react-query-persist-client": "^5.25.0", - "@tiptap/core": "^2.0.0-beta.220", - "@tiptap/extension-document": "^2.0.0-beta.220", - "@tiptap/extension-hard-break": "^2.0.3", - "@tiptap/extension-history": "^2.0.3", - "@tiptap/extension-mention": "^2.0.0-beta.220", - "@tiptap/extension-paragraph": "^2.0.0-beta.220", - "@tiptap/extension-placeholder": "^2.0.0-beta.220", - "@tiptap/extension-text": "^2.0.0-beta.220", - "@tiptap/html": "^2.1.11", - "@tiptap/pm": "^2.0.0-beta.220", - "@tiptap/react": "^2.0.0-beta.220", - "@tiptap/suggestion": "^2.0.0-beta.220", + "@tiptap/core": "^2.6.6", + "@tiptap/extension-document": "^2.6.6", + "@tiptap/extension-hard-break": "^2.6.6", + "@tiptap/extension-history": "^2.6.6", + "@tiptap/extension-mention": "^2.6.6", + "@tiptap/extension-paragraph": "^2.6.6", + "@tiptap/extension-placeholder": "^2.6.6", + "@tiptap/extension-text": "^2.6.6", + "@tiptap/html": "^2.6.6", + "@tiptap/pm": "^2.6.6", + "@tiptap/react": "^2.6.6", + "@tiptap/suggestion": "^2.6.6", "@types/invariant": "^2.2.37", "@types/lodash.throttle": "^4.1.9", "@types/node": "^18.16.2", @@ -143,6 +143,7 @@ "expo-updates": "~0.25.14", "expo-web-browser": "~13.0.3", "fast-text-encoding": "^1.0.6", + "fuse.js": "^7.0.0", "history": "^5.3.0", "hls.js": "^1.5.11", "js-sha256": "^0.9.0", @@ -161,6 +162,7 @@ "nanoid": "^5.0.5", "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/components/Composer/OutlineTags/index.tsx b/src/components/Composer/OutlineTags/index.tsx new file mode 100644 index 0000000000..a4d18c61f5 --- /dev/null +++ b/src/components/Composer/OutlineTags/index.tsx @@ -0,0 +1,7 @@ +export function OutlineTags(_props: { + max?: number + initialTags?: string[] + onChangeTags: (tags: string[]) => void +}) { + return null +} diff --git a/src/components/Composer/OutlineTags/index.web.tsx b/src/components/Composer/OutlineTags/index.web.tsx new file mode 100644 index 0000000000..29f8f58c45 --- /dev/null +++ b/src/components/Composer/OutlineTags/index.web.tsx @@ -0,0 +1,350 @@ +import React from 'react' +import { + NativeSyntheticEvent, + Platform, + Pressable, + StyleSheet, + TextInput, + TextInputKeyPressEventData, + View, +} from 'react-native' +import {TextInputFocusEventData} from 'react-native' +import {Pin} from 'pind' + +import {isWeb} from '#/platform/detection' +import {useTagAutocomplete} from '#/view/com/composer/text-input/tagsAutocompleteState' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Text} from '#/components/Typography' + +/** + * 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(/^\d+/g, '').slice(0, 64) +} + +function TagButton({ + children, + onPress, +}: { + children: string + onPress: ButtonProps['onPress'] +}) { + return ( + + ) +} + +export function OutlineTags({ + max = 8, + initialTags = [], + onChangeTags, +}: { + max?: number + initialTags?: string[] + onChangeTags: (tags: string[]) => void +}) { + const t = useTheme() + const dropdown = React.useRef(null) + const input = React.useRef(null) + const inputWidth = input.current + ? input.current.getBoundingClientRect().width + : 200 + const {query, suggestions, setQuery, saveRecentTag} = useTagAutocomplete() + const containerRef = React.useRef(null) + + const [tags, setTags] = React.useState(initialTags) + const [selectedItemIndex, setSelectedItemIndex] = React.useState(0) + + const dropdownIsActive = Boolean(query.length) + + const closeDropdownAndReset = React.useCallback(() => { + setQuery('') + setSelectedItemIndex(0) + }, [setQuery, setSelectedItemIndex]) + + const addTags = React.useCallback( + (_tags: string[]) => { + const _t = _tags.slice(0, max) + setTags(_t) + onChangeTags(_t) + }, + [onChangeTags, setTags, max], + ) + + const removeTag = React.useCallback( + (tag: string) => { + addTags(tags.filter(t => t !== tag)) + }, + [tags, addTags], + ) + + const addTagAndReset = React.useCallback( + (value: string) => { + const tag = sanitizeHashtagOnChange(value).replace(/^#{1}/, '') + + // enforce max hashtag length + if (tag.length > 0 && tag.length <= 64) { + addTags(Array.from(new Set([...tags, tag])).slice(0, max)) + } + + saveRecentTag(tag) + setQuery('') + input.current?.focus() + closeDropdownAndReset() + }, + [max, tags, closeDropdownAndReset, setQuery, addTags, saveRecentTag], + ) + + const onSubmitEditing = React.useCallback(() => { + const item = suggestions[selectedItemIndex] + addTagAndReset(item?.value || query) + }, [query, suggestions, selectedItemIndex, addTagAndReset]) + + const onKeyPress = React.useCallback( + (e: NativeSyntheticEvent) => { + const {key} = e.nativeEvent + + if (key === 'Backspace' && query === '') { + addTags(tags.slice(0, -1)) + closeDropdownAndReset() + } else if (key === ' ') { + e.preventDefault() // prevents an additional space on web + addTagAndReset(query) + } + + if (dropdownIsActive) { + if (key === 'Escape') { + closeDropdownAndReset() + } else if (key === 'ArrowUp') { + e.preventDefault() + setSelectedItemIndex( + (selectedItemIndex + suggestions.length - 1) % suggestions.length, + ) + } else if (key === 'ArrowDown') { + e.preventDefault() + setSelectedItemIndex((selectedItemIndex + 1) % suggestions.length) + } else if ( + isWeb && + key === 'Tab' && + // @ts-ignore web only + !e.nativeEvent.shiftKey + ) { + e.preventDefault() + onSubmitEditing() + } + } + }, + [ + query, + tags, + dropdownIsActive, + selectedItemIndex, + suggestions.length, + closeDropdownAndReset, + setSelectedItemIndex, + addTags, + addTagAndReset, + onSubmitEditing, + ], + ) + + const onChangeText = React.useCallback( + async (value: string) => { + const tag = sanitizeHashtagOnChange(value) + + setQuery(tag) + + if (tag.length) { + setQuery(tag) + } else { + setSelectedItemIndex(0) + } + }, + [setSelectedItemIndex, setQuery], + ) + + 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')) + ) { + setQuery('') + } + }, + [tags, setQuery], + ) + + 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 ( + + + {tags.map((tag, i) => ( + removeTag(tag)}> + {tag} + + ))} + + {tags.length >= max ? null : ( + + )} + + + + + {suggestions.map((item, index) => { + const isFirst = index === 0 + const isLast = index === suggestions.length - 1 + return ( + addTagAndReset(item.value)} + style={state => [ + t.atoms.border_contrast_low, + styles.dropdownItem, + { + backgroundColor: state.hovered + ? t.atoms.bg_contrast_25.backgroundColor + : undefined, + }, + selectedItemIndex === index + ? t.atoms.bg_contrast_25 + : undefined, + isFirst + ? styles.firstResult + : isLast + ? styles.lastResult + : undefined, + ]}> + {item.value} + + ) + })} + + + + ) +} + +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, + }, +}) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index db5c9c2478..c36b1d49ba 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -111,6 +111,7 @@ export async function post( embed, langs, labels, + tags: draft.tags, }) } catch (e: any) { logger.error(`Failed to create post`, { diff --git a/src/storage/index.ts b/src/storage/index.ts index 7ef226d3aa..426ed56e70 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -1,7 +1,7 @@ import {MMKV} from 'react-native-mmkv' import {IS_DEV} from '#/env' -import {Device} from '#/storage/schema' +import {Account, Device} from '#/storage/schema' export * from '#/storage/schema' @@ -74,9 +74,17 @@ export class Storage { */ export const device = new Storage<[], Device>({id: 'bsky_device'}) +/** + * Account data that's specific to the account + * + * `account.set([did, key], true)` + */ +export const account = new Storage<[string], Account>({id: 'bsky_account'}) + if (IS_DEV && typeof window !== 'undefined') { // @ts-ignore window.bsky_storage = { device, + account, } } diff --git a/src/storage/schema.ts b/src/storage/schema.ts index cf410c77de..69a379ae17 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -1,3 +1,5 @@ +import {Result} from '#/view/com/composer/text-input/tagsAutocompleteState' + /** * Device data that's specific to the device and does not vary based account */ @@ -9,3 +11,10 @@ export type Device = { countryCode: string | undefined } } + +/** + * Account data that's specific to the current account + */ +export type Account = { + recentTags: Result[] | undefined +} diff --git a/src/style.css b/src/style.css index ef22a44571..779422442c 100644 --- a/src/style.css +++ b/src/style.css @@ -15,6 +15,11 @@ --text: black; --background: white; --backgroundLight: hsl(211, 20%, 95%); + + --tagFg: black; + --tagBg: #c1ccd7; + --mentionFg: white; + --mentionBg: #14a571; } @media (prefers-color-scheme: dark) { :root { @@ -22,6 +27,11 @@ --text: white; --background: black; --backgroundLight: hsl(211, 20%, 20%); + + --tagFg: black; + --tagBg: #c1ccd7; + --mentionFg: white; + --mentionBg: #14a571; } } @@ -30,6 +40,11 @@ html.theme--light { --background: white; --backgroundLight: hsl(211, 20%, 95%); background-color: white; + + --tagFg: black; + --tagBg: #c1ccd7; + --mentionFg: white; + --mentionBg: #14a571; } html.theme--dark { color-scheme: dark; @@ -37,6 +52,11 @@ html.theme--dark { --text: white; --background: black; --backgroundLight: hsl(211, 20%, 20%); + + --tagFg: white; + --tagBg: #637f9c; + --mentionFg: white; + --mentionBg: #14a571; } html.theme--dim { color-scheme: dark; @@ -44,6 +64,9 @@ html.theme--dim { --text: white; --background: hsl(211, 20%, 4%); --backgroundLight: hsl(211, 20%, 10%); + + --tagFg: white; + --tagBg: #637f9c; } /* Buttons and inputs have a font set by UA, so we'll have to reset that */ @@ -120,6 +143,26 @@ a[role='link'][data-no-underline='1']:hover { .ProseMirror-focused { outline: 0; } +/* Hashtags in ProseMirror */ +.ProseMirror .inline-tag, +.ProseMirror .mention { + position: relative; +} +/* Neatoâ„¢ */ +.ProseMirror .inline-tag { + color: var(--tagFg); + background-color: var(--tagBg); +} +.ProseMirror .mention { + color: var(--mentionFg); + background-color: var(--mentionBg); +} +.ProseMirror .inline-tag, +.ProseMirror .mention { + border-radius: 4px; + padding: 0 3px 2px; +} + textarea:focus, input:focus { outline: 0; diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 126addd1c8..fa61623cc6 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -105,6 +105,7 @@ import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {OutlineTags} from '#/components/Composer/OutlineTags' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' @@ -164,7 +165,13 @@ export const ComposePost = ({ const [draft, dispatch] = useReducer( composerReducer, - {initImageUris, initQuoteUri: initQuote?.uri, initText, initMention}, + { + initImageUris, + initQuoteUri: initQuote?.uri, + initText, + initMention, + initOutlineTags: [], + }, createComposerState, ) const richtext = draft.richtext @@ -508,6 +515,13 @@ export const ComposePost = ({ dispatch({type: 'embed_update_gif', alt: altText}) }, []) + const onChangeOutlineTags = useCallback( + (tags: string[]) => { + dispatch({type: 'tags_update', tags}) + }, + [dispatch], + ) + const { scrollHandler, onScrollViewContentSizeChange, @@ -744,6 +758,8 @@ export const ComposePost = ({ + + void @@ -71,7 +71,13 @@ export const TextInput = React.forwardRef(function TextInputImpl( () => [ Document, LinkDecorator, - TagDecorator, + // TagDecorator, + Tags.configure({ + HTMLAttributes: { + class: 'inline-tag', + }, + suggestion: createTagsAutocomplete(), + }), Mention.configure({ HTMLAttributes: { class: 'mention', @@ -327,6 +333,8 @@ function editorJsonToText( text += json.text || '' } else if (json.type === 'mention') { text += `@${json.attrs?.id || ''}` + } else if (json.type === 'tag') { + text += `#${json.attrs?.id || ''}` } return text } diff --git a/src/view/com/composer/text-input/tagsAutocompleteState.ts b/src/view/com/composer/text-input/tagsAutocompleteState.ts new file mode 100644 index 0000000000..2aeabecb02 --- /dev/null +++ b/src/view/com/composer/text-input/tagsAutocompleteState.ts @@ -0,0 +1,74 @@ +import React from 'react' +import Fuse from 'fuse.js' + +import {useSession} from '#/state/session' +import {account} from '#/storage' + +export type Result = { + value: string +} + +export type Model = { + readonly suggestions: Result[] + setQuery(query: string): void + save(tag: string): void +} + +export function useTagAutocomplete() { + const {currentAccount} = useSession() + const [query, setQuery] = React.useState('') + const [searchSuggestions, setSearchSuggestions] = React.useState([]) + + const search = React.useCallback( + async (_query: string) => { + // TODO actually search + // TODO debounce/abort controller + setSearchSuggestions([]) + }, + [setSearchSuggestions], + ) + + const onSetQuery = React.useCallback( + (query: string) => { + setQuery(query) + search(query) + }, + [setQuery, search], + ) + + const saveRecentTag = React.useCallback( + (tag: string) => { + if (!currentAccount) { + throw new Error('No current account') + } + const recentTags = account.get([currentAccount.did, 'recentTags']) || [] + account.set( + [currentAccount.did, 'recentTags'], + [{value: tag}, ...recentTags.filter(t => t.value !== tag)].slice(0, 40), + ) + }, + [currentAccount], + ) + + const suggestions: Result[] = React.useMemo(() => { + if (!currentAccount) { + throw new Error('No current account') + } + const recentTags = account.get([currentAccount.did, 'recentTags']) || [] + const items = [ + ...recentTags.map(t => t.value), + ...searchSuggestions.map(s => s.value), + ] + const fuse = new Fuse(items) + // search amongst mixed set of tags + const results = fuse.search(query).map(r => r.item) + return results.map(value => ({value})) + }, [currentAccount, query, searchSuggestions]) + + return { + query, + suggestions, + setQuery: onSetQuery, + saveRecentTag, + } +} 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..e11071f026 --- /dev/null +++ b/src/view/com/composer/text-input/web/Tags/plugin.tsx @@ -0,0 +1,308 @@ +/** + * This is basically a fork of the Mention plugin from Tiptap. + * + * @see https://github.com/ueberdosis/tiptap/blob/ec6121da1c3f808987d32de7a8c56b52520bfea8/packages/extension-mention/src/mention.ts + */ + +import {mergeAttributes, Node} from '@tiptap/core' +import {DOMOutputSpec, Node as ProseMirrorNode} from '@tiptap/pm/model' +import {PluginKey} from '@tiptap/pm/state' +import Suggestion, {SuggestionOptions} from '@tiptap/suggestion' + +import {findSuggestionMatch} from './utils' + +// See `addAttributes` below +export interface MentionNodeAttrs { + /** + * The identifier for the selected item that was mentioned, stored as a `data-id` + * attribute. + */ + id: string | null + /** + * The label to be rendered by the editor as the displayed text for this mentioned + * item, if provided. Stored as a `data-label` attribute. See `renderLabel`. + */ + label?: string | null +} + +export type MentionOptions< + SuggestionItem = any, + Attrs extends Record = MentionNodeAttrs, +> = { + /** + * The HTML attributes for a mention node. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record + + /** + * A function to render the label of a mention. + * @deprecated use renderText and renderHTML instead + * @param props The render props + * @returns The label + * @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + */ + renderLabel?: (props: { + options: MentionOptions + node: ProseMirrorNode + }) => string + + /** + * A function to render the text of a mention. + * @param props The render props + * @returns The text + * @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + */ + renderText: (props: { + options: MentionOptions + node: ProseMirrorNode + }) => string + + /** + * A function to render the HTML of a mention. + * @param props The render props + * @returns The HTML as a ProseMirror DOM Output Spec + * @example ({ options, node }) => ['span', { 'data-type': 'mention' }, `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`] + */ + renderHTML: (props: { + options: MentionOptions + node: ProseMirrorNode + }) => DOMOutputSpec + + /** + * Whether to delete the trigger character with backspace. + * @default false + */ + deleteTriggerWithBackspace: boolean + + /** + * The suggestion options. + * @default {} + * @example { char: '@', pluginKey: MentionPluginKey, command: ({ editor, range, props }) => { ... } } + */ + suggestion: Omit, 'editor'> +} + +/** + * The plugin key for the mention plugin. + * @default 'mention' + */ +export const TagsPluginKey = new PluginKey('tag') + +/** + * This extension allows you to insert mentions into the editor. + * @see https://www.tiptap.dev/api/extensions/mention + */ +export const Tags = Node.create< + MentionOptions +>({ + name: 'tag', + + addOptions() { + return { + HTMLAttributes: {}, + renderText({options, node}) { + return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + }, + deleteTriggerWithBackspace: false, + renderHTML({options, node}) { + return [ + 'span', + mergeAttributes(this.HTMLAttributes, options.HTMLAttributes), + `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`, + ] + }, + suggestion: { + char: '#', + 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 + const overrideSpace = nodeAfter?.text?.startsWith(' ') + + if (overrideSpace) { + range.to += 1 + } + + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: this.name, + attrs: {id: tag}, + }, + { + type: 'text', + text: `${punctuation || ''} `, + }, + ]) + .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: findSuggestionMatch, + }, + } + }, + + group: 'inline', + + inline: true, + + selectable: false, + + 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}) { + if (this.options.renderLabel !== undefined) { + console.warn( + 'renderLabel is deprecated use renderText and renderHTML instead', + ) + return [ + 'span', + mergeAttributes( + {'data-type': this.name}, + this.options.HTMLAttributes, + HTMLAttributes, + ), + this.options.renderLabel({ + options: this.options, + node, + }), + ] + } + const mergedOptions = {...this.options} + + mergedOptions.HTMLAttributes = mergeAttributes( + {'data-type': this.name}, + this.options.HTMLAttributes, + HTMLAttributes, + ) + const html = this.options.renderHTML({ + options: mergedOptions, + node, + }) + + if (typeof html === 'string') { + return [ + 'span', + mergeAttributes( + {'data-type': this.name}, + this.options.HTMLAttributes, + HTMLAttributes, + ), + html, + ] + } + return html + }, + + renderText({node}) { + if (this.options.renderLabel !== undefined) { + console.warn( + 'renderLabel is deprecated use renderText and renderHTML instead', + ) + return this.options.renderLabel({ + options: this.options, + node, + }) + } + return this.options.renderText({ + options: this.options, + node, + }) + }, + + addKeyboardShortcuts() { + return { + Backspace: () => + this.editor.commands.command(({tr, state}) => { + let isMention = 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) { + isMention = true + tr.insertText( + this.options.deleteTriggerWithBackspace + ? '' + : this.options.suggestion.char || '', + pos, + pos + node.nodeSize, + ) + + return false + } + }) + + return isMention + }), + } + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ] + }, +}) 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..87d695ae03 --- /dev/null +++ b/src/view/com/composer/text-input/web/Tags/utils.ts @@ -0,0 +1,69 @@ +import {TAG_REGEX, TRAILING_PUNCTUATION_REGEX} from '@atproto/api' +import {findSuggestionMatch as defaultFindSuggestionMatch} from '@tiptap/suggestion' + +/** + * This method eventually receives the `query` property from the result of + * `findSuggestionMatch` below. + */ +export function parsePunctuationFromTag(value: string) { + const reg = TRAILING_PUNCTUATION_REGEX + const tag = value.replace(reg, '') + const punctuation = value.match(reg)?.[0] || '' + + 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. + * + * @see https://github.com/ueberdosis/tiptap/blob/cf2067906f506486c6613f872be8b1fd318526c9/packages/suggestion/src/findSuggestionMatch.ts + */ +export function findSuggestionMatch({ + $position, +}: Parameters[0]) { + const text = $position.nodeBefore?.isText && $position.nodeBefore.text + + if (!text) { + return null + } + + const textFrom = $position.pos - text.length + const match = Array.from(text.matchAll(TAG_REGEX)).pop() + + if (!match || match.input === undefined || match.index === undefined) { + return null + } + + const [fullMatch, , tag] = match + + if (!tag || tag.length === 0 || tag.length > 64) return null + + const leadingSpaceOffset = fullMatch.startsWith(' ') ? 1 : 0 + const hashtagOffset = 1 + + // The absolute position of the match in the document + const from = textFrom + match.index + leadingSpaceOffset + const to = from + tag.length + hashtagOffset + + // If the $position is located within the matched substring, return that range + if (from < $position.pos && to >= $position.pos) { + return { + range: { + from, + to, + }, + /** + * TODO + * We parse out the punctuation later. + */ + query: tag, + text: fullMatch.replace(/^\s{1}/, ''), + } + } + + 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 new file mode 100644 index 0000000000..b627e9de6d --- /dev/null +++ b/src/view/com/composer/text-input/web/Tags/view.tsx @@ -0,0 +1,213 @@ +import React, {forwardRef, useImperativeHandle, useState} from 'react' +import {Pressable, StyleSheet, View} from 'react-native' +import {ReactRenderer} from '@tiptap/react' +import { + SuggestionKeyDownProps, + SuggestionOptions, + SuggestionProps, +} from '@tiptap/suggestion' +import tippy, {Instance as TippyInstance} from 'tippy.js' + +import {usePalette} from '#/lib/hooks/usePalette' +import {useTagAutocomplete} from '#/view/com/composer/text-input/tagsAutocompleteState' +import {Text} from '#/view/com/util/text/Text' +import {parsePunctuationFromTag} from './utils' + +type AutocompleteRef = { + onKeyDown: (props: SuggestionKeyDownProps) => boolean +} + +export function createTagsAutocomplete(): Omit { + return { + render() { + let component: ReactRenderer | undefined + let popup: TippyInstance[] | undefined + + return { + onStart: props => { + component = new ReactRenderer(Autocomplete, { + props, + 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 {command, query} = props + const {suggestions, setQuery, saveRecentTag} = useTagAutocomplete() + const pal = usePalette('default') + const [selectedIndex, setSelectedIndex] = useState(0) + + React.useEffect(() => { + setQuery(query) + }, [query, setQuery]) + + const commit = React.useCallback( + (query: string) => { + const {tag, punctuation} = parsePunctuationFromTag(query) + /* + * 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. + */ + command({tag, punctuation}) + saveRecentTag(tag) + }, + [command, saveRecentTag], + ) + + const selectItem = React.useCallback( + (index: number) => { + const item = suggestions[index] + if (item) commit(item.value) + }, + [suggestions, commit], + ) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({event}) => { + if (event.key === 'ArrowUp') { + setSelectedIndex( + (selectedIndex + suggestions.length - 1) % suggestions.length, + ) + return true + } + + if (event.key === 'ArrowDown') { + setSelectedIndex((selectedIndex + 1) % suggestions.length) + return true + } + + if (event.key === 'Enter') { + if (!suggestions.length) { + // no suggestions, use whatever the user typed + commit(props.query) + } else { + selectItem(selectedIndex) + } + return true + } + + if (event.key === ' ') { + commit(props.query) + return true + } + + return false + }, + })) + + // hide entirely if no suggestions + if (!suggestions.length) return null + + return ( +
+ + {suggestions.map(({value}, index) => { + const {tag} = parsePunctuationFromTag(value) + const isSelected = selectedIndex === index + const isFirst = index === 0 + const isLast = index === suggestions.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, + }, +}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 99950495f9..91452ff63c 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -29,6 +29,7 @@ import {useComposerControls} from '#/state/shell/composer' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' import {AppModerationCause} from '#/components/Pills' import {RichText} from '#/components/RichText' import {Text as NewText} from '#/components/Typography' @@ -367,6 +368,28 @@ let PostThreadItemLoaded = ({ /> )} + + {AppBskyFeedPost.isRecord(post.record) && post.record.tags ? ( + + {post.record.tags.map((tag, i) => ( + + ))} + + ) : null}