clean up tags autocomplete desktop

This commit is contained in:
Eric Bailey
2023-09-27 19:57:20 -05:00
parent bd9c1626e6
commit 6f6a34cc99
6 changed files with 89 additions and 218 deletions
+6
View File
@@ -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)
}
}
}
+68 -41
View File
@@ -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',
]
})
}
+4 -4
View File
@@ -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<TagsAutocompleteView>(
() => new TagsAutocompleteView(store),
const tagsAutocompleteModel = useMemo<TagsAutocompleteModel>(
() => 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}
@@ -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<typeof RNTextInput> {
placeholder: string
suggestedLinks: Set<string>
autocompleteView: UserAutocompleteModel
tagsAutocompleteView: TagsAutocompleteView
tagsAutocompleteModel: TagsAutocompleteModel
setRichText: (v: RichText | ((v: RichText) => RichText)) => void
onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => Promise<void>
@@ -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<string>
autocompleteView: UserAutocompleteModel
tagsAutocompleteView: TagsAutocompleteView
tagsAutocompleteModel: TagsAutocompleteModel
setRichText: (v: RichText | ((v: RichText) => RichText)) => void
onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => Promise<void>
@@ -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(() => {
@@ -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<string, any>
renderLabel: (props: { options: TagOptions; node: ProseMirrorNode }) => string
suggestion: Omit<SuggestionOptions, 'editor'>
}
export const TagsPluginKey = new PluginKey('tags')
export const Tags = Node.create<TagOptions>({
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,
}),
]
},
})