clean up tags autocomplete desktop
This commit is contained in:
@@ -22,6 +22,7 @@ import {resetToTab} from '../../Navigation'
|
|||||||
import {ImageSizesCache} from './cache/image-sizes'
|
import {ImageSizesCache} from './cache/image-sizes'
|
||||||
import {MutedThreads} from './muted-threads'
|
import {MutedThreads} from './muted-threads'
|
||||||
import {reset as resetNavigation} from '../../Navigation'
|
import {reset as resetNavigation} from '../../Navigation'
|
||||||
|
import {RecentTagsModel} from './ui/tags-autocomplete'
|
||||||
|
|
||||||
// TEMPORARY (APP-700)
|
// TEMPORARY (APP-700)
|
||||||
// remove after backend testing finishes
|
// remove after backend testing finishes
|
||||||
@@ -53,6 +54,7 @@ export class RootStoreModel {
|
|||||||
linkMetas = new LinkMetasCache(this)
|
linkMetas = new LinkMetasCache(this)
|
||||||
imageSizes = new ImageSizesCache()
|
imageSizes = new ImageSizesCache()
|
||||||
mutedThreads = new MutedThreads()
|
mutedThreads = new MutedThreads()
|
||||||
|
recentTags = new RecentTagsModel()
|
||||||
|
|
||||||
constructor(agent: BskyAgent) {
|
constructor(agent: BskyAgent) {
|
||||||
this.agent = agent
|
this.agent = agent
|
||||||
@@ -77,6 +79,7 @@ export class RootStoreModel {
|
|||||||
preferences: this.preferences.serialize(),
|
preferences: this.preferences.serialize(),
|
||||||
invitedUsers: this.invitedUsers.serialize(),
|
invitedUsers: this.invitedUsers.serialize(),
|
||||||
mutedThreads: this.mutedThreads.serialize(),
|
mutedThreads: this.mutedThreads.serialize(),
|
||||||
|
recentTags: this.recentTags.serialize(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +112,9 @@ export class RootStoreModel {
|
|||||||
if (hasProp(v, 'mutedThreads')) {
|
if (hasProp(v, 'mutedThreads')) {
|
||||||
this.mutedThreads.hydrate(v.mutedThreads)
|
this.mutedThreads.hydrate(v.mutedThreads)
|
||||||
}
|
}
|
||||||
|
if (hasProp(v, 'recentTags')) {
|
||||||
|
this.recentTags.hydrate(v.recentTags)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,25 +2,44 @@ import {makeAutoObservable, runInAction} from 'mobx'
|
|||||||
import AwaitLock from 'await-lock'
|
import AwaitLock from 'await-lock'
|
||||||
import {RootStoreModel} from '../root-store'
|
import {RootStoreModel} from '../root-store'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
|
import {isObj, hasProp, isStrArray} from 'lib/type-guards'
|
||||||
|
|
||||||
export class TagsAutocompleteView {
|
export class RecentTagsModel {
|
||||||
// state
|
_tags: string[] = []
|
||||||
isLoading = false
|
|
||||||
isActive = false
|
constructor() {
|
||||||
prefix = ''
|
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()
|
lock = new AwaitLock()
|
||||||
|
isActive = false
|
||||||
|
query = ''
|
||||||
searchedTags: string[] = []
|
searchedTags: string[] = []
|
||||||
recentTags: string[] = [
|
profileTags: string[] = []
|
||||||
'js',
|
|
||||||
'javascript',
|
|
||||||
'art',
|
|
||||||
'music',
|
|
||||||
]
|
|
||||||
profileTags: string[] = [
|
|
||||||
'bikes',
|
|
||||||
'beer',
|
|
||||||
]
|
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
makeAutoObservable(
|
makeAutoObservable(
|
||||||
@@ -32,56 +51,64 @@ export class TagsAutocompleteView {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setActive(isActive: boolean) {
|
||||||
|
this.isActive = isActive
|
||||||
|
}
|
||||||
|
|
||||||
|
commitRecentTag(tag: string) {
|
||||||
|
this.rootStore.recentTags.add(tag)
|
||||||
|
}
|
||||||
|
|
||||||
get suggestions() {
|
get suggestions() {
|
||||||
if (!this.isActive) {
|
if (!this.isActive) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = [
|
const items = Array.from(
|
||||||
...this.recentTags,
|
new Set([
|
||||||
...this.profileTags,
|
...this.rootStore.recentTags.tags.slice(0, 3),
|
||||||
...this.searchedTags,
|
...this.profileTags.slice(0, 3),
|
||||||
]
|
...this.searchedTags,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
if (!this.prefix) {
|
if (!this.query) {
|
||||||
return items.slice(0, 8)
|
return items.slice(0, 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fuse = new Fuse(items)
|
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) {
|
async search(query: string) {
|
||||||
this.isActive = v
|
this.query = query.trim()
|
||||||
}
|
|
||||||
|
|
||||||
async setPrefix(prefix: string) {
|
|
||||||
this.prefix = prefix.trim()
|
|
||||||
await this.lock.acquireAsync()
|
await this.lock.acquireAsync()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.prefix) {
|
// another query was set before we got our chance
|
||||||
if (this.prefix !== this.prefix) {
|
if (this.query !== this.query) return
|
||||||
return // another prefix was set before we got our chance
|
await this._search()
|
||||||
}
|
|
||||||
await this._search()
|
|
||||||
} else {
|
|
||||||
// this.searchRes = []
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
this.lock.release()
|
this.lock.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// internal
|
|
||||||
// =
|
|
||||||
|
|
||||||
async _search() {
|
async _search() {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.searchedTags = [
|
this.searchedTags = [
|
||||||
'code',
|
'code',
|
||||||
'dev',
|
'dev',
|
||||||
|
'javascript',
|
||||||
|
'react',
|
||||||
|
'typescript',
|
||||||
|
'mobx',
|
||||||
|
'mobx-state-tree',
|
||||||
|
'mobx-react',
|
||||||
|
'mobx-react-lite',
|
||||||
|
'mobx-react-form',
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|||||||
import {RichText} from '@atproto/api'
|
import {RichText} from '@atproto/api'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
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 {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
|
||||||
import {ExternalEmbed} from './ExternalEmbed'
|
import {ExternalEmbed} from './ExternalEmbed'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
@@ -98,8 +98,8 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
() => new UserAutocompleteModel(store),
|
() => new UserAutocompleteModel(store),
|
||||||
[store],
|
[store],
|
||||||
)
|
)
|
||||||
const tagAutoCompleteView = useMemo<TagsAutocompleteView>(
|
const tagsAutocompleteModel = useMemo<TagsAutocompleteModel>(
|
||||||
() => new TagsAutocompleteView(store),
|
() => new TagsAutocompleteModel(store),
|
||||||
[store],
|
[store],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -371,7 +371,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
placeholder={selectTextInputPlaceholder}
|
placeholder={selectTextInputPlaceholder}
|
||||||
suggestedLinks={suggestedLinks}
|
suggestedLinks={suggestedLinks}
|
||||||
autocompleteView={autocompleteView}
|
autocompleteView={autocompleteView}
|
||||||
tagsAutocompleteView={tagAutoCompleteView}
|
tagsAutocompleteModel={tagsAutocompleteModel}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
setRichText={setRichText}
|
setRichText={setRichText}
|
||||||
onPhotoPasted={onPhotoPasted}
|
onPhotoPasted={onPhotoPasted}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import PasteInput, {
|
|||||||
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
|
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||||
import isEqual from 'lodash.isequal'
|
import isEqual from 'lodash.isequal'
|
||||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
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 {Autocomplete} from './mobile/Autocomplete'
|
||||||
import {Text} from 'view/com/util/text/Text'
|
import {Text} from 'view/com/util/text/Text'
|
||||||
import {cleanError} from 'lib/strings/errors'
|
import {cleanError} from 'lib/strings/errors'
|
||||||
@@ -40,7 +40,7 @@ interface TextInputProps extends ComponentProps<typeof RNTextInput> {
|
|||||||
placeholder: string
|
placeholder: string
|
||||||
suggestedLinks: Set<string>
|
suggestedLinks: Set<string>
|
||||||
autocompleteView: UserAutocompleteModel
|
autocompleteView: UserAutocompleteModel
|
||||||
tagsAutocompleteView: TagsAutocompleteView
|
tagsAutocompleteModel: TagsAutocompleteModel
|
||||||
setRichText: (v: RichText | ((v: RichText) => RichText)) => void
|
setRichText: (v: RichText | ((v: RichText) => RichText)) => void
|
||||||
onPhotoPasted: (uri: string) => void
|
onPhotoPasted: (uri: string) => void
|
||||||
onPressPublish: (richtext: RichText) => Promise<void>
|
onPressPublish: (richtext: RichText) => Promise<void>
|
||||||
|
|||||||
@@ -12,15 +12,14 @@ import {Placeholder} from '@tiptap/extension-placeholder'
|
|||||||
import {Text} from '@tiptap/extension-text'
|
import {Text} from '@tiptap/extension-text'
|
||||||
import isEqual from 'lodash.isequal'
|
import isEqual from 'lodash.isequal'
|
||||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
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 {createSuggestion} from './web/Autocomplete'
|
||||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||||
import {isUriImage, blobToDataUri} from 'lib/media/util'
|
import {isUriImage, blobToDataUri} from 'lib/media/util'
|
||||||
import {Emoji} from './web/EmojiPicker.web'
|
import {Emoji} from './web/EmojiPicker.web'
|
||||||
import {LinkDecorator} from './web/LinkDecorator'
|
import {LinkDecorator} from './web/LinkDecorator'
|
||||||
import {generateJSON} from '@tiptap/html'
|
import {generateJSON} from '@tiptap/html'
|
||||||
import {TagDecorator} from './web/TagDecorator'
|
import {Tags, createTagsAutocomplete} from './web/Tags'
|
||||||
import {Tags, createTagsSuggestion} from './web/Tags'
|
|
||||||
|
|
||||||
export interface TextInputRef {
|
export interface TextInputRef {
|
||||||
focus: () => void
|
focus: () => void
|
||||||
@@ -32,7 +31,7 @@ interface TextInputProps {
|
|||||||
placeholder: string
|
placeholder: string
|
||||||
suggestedLinks: Set<string>
|
suggestedLinks: Set<string>
|
||||||
autocompleteView: UserAutocompleteModel
|
autocompleteView: UserAutocompleteModel
|
||||||
tagsAutocompleteView: TagsAutocompleteView
|
tagsAutocompleteModel: TagsAutocompleteModel
|
||||||
setRichText: (v: RichText | ((v: RichText) => RichText)) => void
|
setRichText: (v: RichText | ((v: RichText) => RichText)) => void
|
||||||
onPhotoPasted: (uri: string) => void
|
onPhotoPasted: (uri: string) => void
|
||||||
onPressPublish: (richtext: RichText) => Promise<void>
|
onPressPublish: (richtext: RichText) => Promise<void>
|
||||||
@@ -48,7 +47,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
placeholder,
|
placeholder,
|
||||||
suggestedLinks,
|
suggestedLinks,
|
||||||
autocompleteView,
|
autocompleteView,
|
||||||
tagsAutocompleteView,
|
tagsAutocompleteModel,
|
||||||
setRichText,
|
setRichText,
|
||||||
onPhotoPasted,
|
onPhotoPasted,
|
||||||
onPressPublish,
|
onPressPublish,
|
||||||
@@ -65,9 +64,11 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
// TagDecorator,
|
// TagDecorator,
|
||||||
Tags.configure({
|
Tags.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: 'autolink',
|
class: 'inline-tag',
|
||||||
},
|
},
|
||||||
suggestion: createTagsSuggestion({autocompleteView: tagsAutocompleteView}),
|
suggestion: createTagsAutocomplete({
|
||||||
|
autocompleteModel: tagsAutocompleteModel,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
Mention.configure({
|
Mention.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
@@ -83,7 +84,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
History,
|
History,
|
||||||
Hardbreak,
|
Hardbreak,
|
||||||
],
|
],
|
||||||
[autocompleteView, placeholder],
|
[autocompleteView, placeholder, tagsAutocompleteModel],
|
||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
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,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user