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}