Merge branch 'tags/outline' into feeds-playground
* tags/outline: Revise autocomplete usage Basic presentation Create post with outline tags Basic outline tags on web Improve autocomplete Hook up autocomplete model WIP model Fix styles Ok working For sure matches Mention WIP, can't add spaces after tag Dump in files Upgrade all tiptap deps to latest
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
export function OutlineTags(_props: {
|
||||
max?: number
|
||||
initialTags?: string[]
|
||||
onChangeTags: (tags: string[]) => void
|
||||
}) {
|
||||
return null
|
||||
}
|
||||
@@ -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 (
|
||||
<Button
|
||||
label="Remove tag"
|
||||
size="tiny"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={onPress}>
|
||||
<ButtonText style={[a.text_sm]}>#{children}</ButtonText>
|
||||
<ButtonIcon icon={X} position="right" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function OutlineTags({
|
||||
max = 8,
|
||||
initialTags = [],
|
||||
onChangeTags,
|
||||
}: {
|
||||
max?: number
|
||||
initialTags?: string[]
|
||||
onChangeTags: (tags: string[]) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const dropdown = React.useRef<HTMLDivElement>(null)
|
||||
const input = React.useRef<HTMLInputElement>(null)
|
||||
const inputWidth = input.current
|
||||
? input.current.getBoundingClientRect().width
|
||||
: 200
|
||||
const {query, suggestions, setQuery, saveRecentTag} = useTagAutocomplete()
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const [tags, setTags] = React.useState<string[]>(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<TextInputKeyPressEventData>) => {
|
||||
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<TextInputFocusEventData>) => {
|
||||
// @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 (
|
||||
<View ref={containerRef as any} style={[a.px_sm]}>
|
||||
<View style={[a.flex_row, a.align_start, a.flex_wrap, a.gap_sm]}>
|
||||
{tags.map((tag, i) => (
|
||||
<TagButton key={tag + i} onPress={() => removeTag(tag)}>
|
||||
{tag}
|
||||
</TagButton>
|
||||
))}
|
||||
|
||||
{tags.length >= max ? null : (
|
||||
<TextInput
|
||||
ref={input as any}
|
||||
id="tags-autocomplete-input"
|
||||
role={'listbox' as any}
|
||||
aria-controls="tags-autocomplete-dropdown"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={dropdownIsActive}
|
||||
value={query}
|
||||
onBlur={onBlur}
|
||||
onKeyPress={onKeyPress}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
onChangeText={onChangeText}
|
||||
blurOnSubmit={false}
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_medium,
|
||||
{
|
||||
paddingVertical: 4,
|
||||
},
|
||||
]}
|
||||
placeholder="Add outline tags"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
accessible={true}
|
||||
accessibilityLabel="Add tags to your post"
|
||||
accessibilityHint={`Type a tag and press enter to add it. You can add up to ${max} tag.`}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Pin
|
||||
pinned={Boolean(query.length)}
|
||||
to={input}
|
||||
at="bottomLeft"
|
||||
from="topLeft"
|
||||
style={{width: inputWidth}}>
|
||||
<View
|
||||
ref={dropdown as any}
|
||||
style={[t.atoms.bg, t.atoms.border_contrast_low, styles.dropdown]}
|
||||
role={'listbox' as any}
|
||||
id="tags-autocomplete-dropdown">
|
||||
{suggestions.map((item, index) => {
|
||||
const isFirst = index === 0
|
||||
const isLast = index === suggestions.length - 1
|
||||
return (
|
||||
<Pressable
|
||||
id={`tag_autocomplete_option_${item.value}`}
|
||||
accessibilityRole="button"
|
||||
key={item.value}
|
||||
onPress={() => 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,
|
||||
]}>
|
||||
<Text numberOfLines={1}>{item.value}</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</Pin>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
@@ -111,6 +111,7 @@ export async function post(
|
||||
embed,
|
||||
langs,
|
||||
labels,
|
||||
tags: draft.tags,
|
||||
})
|
||||
} catch (e: any) {
|
||||
logger.error(`Failed to create post`, {
|
||||
|
||||
@@ -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<Scopes extends unknown[], Schema> {
|
||||
*/
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = ({
|
||||
</Animated.ScrollView>
|
||||
<SuggestedLanguage text={richtext.text} />
|
||||
|
||||
<OutlineTags onChangeTags={onChangeOutlineTags} />
|
||||
|
||||
<Animated.View
|
||||
style={[a.flex_row, a.p_sm, t.atoms.bg, bottomBarAnimatedStyle]}>
|
||||
<ScrollView
|
||||
|
||||
@@ -53,6 +53,7 @@ export type ComposerDraft = {
|
||||
postgate: AppBskyFeedPostgate.Record
|
||||
threadgate: ThreadgateAllowUISetting[]
|
||||
embed: EmbedDraft
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export type ComposerAction =
|
||||
@@ -76,6 +77,7 @@ export type ComposerAction =
|
||||
| {type: 'embed_add_gif'; gif: Gif}
|
||||
| {type: 'embed_update_gif'; alt: string}
|
||||
| {type: 'embed_remove_gif'}
|
||||
| {type: 'tags_update'; tags: string[]}
|
||||
|
||||
export const MAX_IMAGES = 4
|
||||
|
||||
@@ -324,6 +326,12 @@ export function composerReducer(
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'tags_update': {
|
||||
return {
|
||||
...state,
|
||||
tags: action.tags,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
@@ -334,11 +342,13 @@ export function createComposerState({
|
||||
initMention,
|
||||
initImageUris,
|
||||
initQuoteUri,
|
||||
initOutlineTags,
|
||||
}: {
|
||||
initText: string | undefined
|
||||
initMention: string | undefined
|
||||
initImageUris: ComposerOpts['imageUris']
|
||||
initQuoteUri: string | undefined
|
||||
initOutlineTags: string[]
|
||||
}): ComposerDraft {
|
||||
let media: ImagesMedia | undefined
|
||||
if (initImageUris?.length) {
|
||||
@@ -379,5 +389,6 @@ export function createComposerState({
|
||||
media,
|
||||
link: undefined,
|
||||
},
|
||||
tags: initOutlineTags,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,15 +13,15 @@ import {Text as TiptapText} from '@tiptap/extension-text'
|
||||
import {generateJSON} from '@tiptap/html'
|
||||
import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
|
||||
|
||||
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {blobToDataUri, isUriImage} from '#/lib/media/util'
|
||||
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {blobToDataUri, isUriImage} from 'lib/media/util'
|
||||
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||
import {
|
||||
LinkFacetMatch,
|
||||
suggestLinkCardUri,
|
||||
} from 'view/com/composer/text-input/text-input-util'
|
||||
} from '#/view/com/composer/text-input/text-input-util'
|
||||
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||
import {atoms as a, useAlf} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {normalizeTextStyles} from '#/components/Typography'
|
||||
@@ -29,7 +29,7 @@ import {Text} from '../../util/text/Text'
|
||||
import {createSuggestion} from './web/Autocomplete'
|
||||
import {Emoji} from './web/EmojiPicker.web'
|
||||
import {LinkDecorator} from './web/LinkDecorator'
|
||||
import {TagDecorator} from './web/TagDecorator'
|
||||
import {createTagsAutocomplete, Tags} from './web/Tags'
|
||||
|
||||
export interface TextInputRef {
|
||||
focus: () => 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
|
||||
}
|
||||
|
||||
@@ -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<Result[]>([])
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export {Tags} from './plugin'
|
||||
export {createTagsAutocomplete} from './view'
|
||||
@@ -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<string, any> = MentionNodeAttrs,
|
||||
> = {
|
||||
/**
|
||||
* The HTML attributes for a mention node.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>
|
||||
|
||||
/**
|
||||
* 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<SuggestionItem, Attrs>
|
||||
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<SuggestionItem, Attrs>
|
||||
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<SuggestionItem, Attrs>
|
||||
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<SuggestionOptions<SuggestionItem, Attrs>, '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<any, {tag: string; punctuation?: string}>
|
||||
>({
|
||||
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,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -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<typeof defaultFindSuggestionMatch>[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
|
||||
}
|
||||
@@ -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<SuggestionOptions, 'editor'> {
|
||||
return {
|
||||
render() {
|
||||
let component: ReactRenderer<AutocompleteRef> | 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<AutocompleteRef, SuggestionProps>(
|
||||
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 (
|
||||
<div className="items">
|
||||
<View style={[pal.borderDark, pal.view, styles.container]}>
|
||||
{suggestions.map(({value}, index) => {
|
||||
const {tag} = parsePunctuationFromTag(value)
|
||||
const isSelected = selectedIndex === index
|
||||
const isFirst = index === 0
|
||||
const isLast = index === suggestions.length - 1
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={tag}
|
||||
style={state => [
|
||||
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">
|
||||
<Text type="md" style={pal.textLight} numberOfLines={1}>
|
||||
#{tag}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
@@ -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 = ({
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{AppBskyFeedPost.isRecord(post.record) && post.record.tags ? (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.flex_wrap,
|
||||
a.align_start,
|
||||
a.gap_sm,
|
||||
a.pt_sm,
|
||||
]}>
|
||||
{post.record.tags.map((tag, i) => (
|
||||
<Button
|
||||
key={tag + i}
|
||||
label={tag}
|
||||
size="tiny"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
<ButtonText>#{tag}</ButtonText>
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</ContentHider>
|
||||
<ExpandedPostDetails
|
||||
post={post}
|
||||
|
||||
Reference in New Issue
Block a user