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 = '' +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, - }), - ] - }, -})