Close web mention suggestions popup on Escape (#8605)
* alf web typeahead * fix type error * fix escape behaviour * change selection on hover * rm React. * undo random change
This commit is contained in:
@@ -114,10 +114,7 @@ import {SelectPostLanguagesBtn} from '#/view/com/composer/select-language/Select
|
|||||||
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
|
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
|
||||||
// TODO: Prevent naming components that coincide with RN primitives
|
// TODO: Prevent naming components that coincide with RN primitives
|
||||||
// due to linting false positives
|
// due to linting false positives
|
||||||
import {
|
import {TextInput} from '#/view/com/composer/text-input/TextInput'
|
||||||
TextInput,
|
|
||||||
type TextInputRef,
|
|
||||||
} from '#/view/com/composer/text-input/TextInput'
|
|
||||||
import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
|
import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
|
||||||
import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
|
import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
|
||||||
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
|
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
|
||||||
@@ -155,6 +152,7 @@ import {
|
|||||||
processVideo,
|
processVideo,
|
||||||
type VideoState,
|
type VideoState,
|
||||||
} from './state/video'
|
} from './state/video'
|
||||||
|
import {type TextInputRef} from './text-input/TextInput.types'
|
||||||
import {getVideoMetadata} from './videos/pickVideo'
|
import {getVideoMetadata} from './videos/pickVideo'
|
||||||
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
||||||
|
|
||||||
@@ -306,7 +304,9 @@ export const ComposePost = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onPressCancel = useCallback(() => {
|
const onPressCancel = useCallback(() => {
|
||||||
if (
|
if (textInput.current?.maybeClosePopup()) {
|
||||||
|
return
|
||||||
|
} else if (
|
||||||
thread.posts.some(
|
thread.posts.some(
|
||||||
post =>
|
post =>
|
||||||
post.shortenedGraphemeLength > 0 ||
|
post.shortenedGraphemeLength > 0 ||
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, {
|
import {
|
||||||
type ComponentProps,
|
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -9,7 +8,6 @@ import React, {
|
|||||||
import {
|
import {
|
||||||
type NativeSyntheticEvent,
|
type NativeSyntheticEvent,
|
||||||
Text as RNText,
|
Text as RNText,
|
||||||
type TextInput as RNTextInput,
|
|
||||||
type TextInputSelectionChangeEventData,
|
type TextInputSelectionChangeEventData,
|
||||||
View,
|
View,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
@@ -33,33 +31,15 @@ import {
|
|||||||
import {atoms as a, useAlf} from '#/alf'
|
import {atoms as a, useAlf} from '#/alf'
|
||||||
import {normalizeTextStyles} from '#/alf/typography'
|
import {normalizeTextStyles} from '#/alf/typography'
|
||||||
import {Autocomplete} from './mobile/Autocomplete'
|
import {Autocomplete} from './mobile/Autocomplete'
|
||||||
|
import {type TextInputProps} from './TextInput.types'
|
||||||
export interface TextInputRef {
|
|
||||||
focus: () => void
|
|
||||||
blur: () => void
|
|
||||||
getCursorPosition: () => DOMRect | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TextInputProps extends ComponentProps<typeof RNTextInput> {
|
|
||||||
richtext: RichText
|
|
||||||
placeholder: string
|
|
||||||
webForceMinHeight: boolean
|
|
||||||
hasRightPadding: boolean
|
|
||||||
isActive: boolean
|
|
||||||
setRichText: (v: RichText) => void
|
|
||||||
onPhotoPasted: (uri: string) => void
|
|
||||||
onPressPublish: (richtext: RichText) => void
|
|
||||||
onNewLink: (uri: string) => void
|
|
||||||
onError: (err: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Selection {
|
interface Selection {
|
||||||
start: number
|
start: number
|
||||||
end: number
|
end: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextInput = forwardRef(function TextInputImpl(
|
export function TextInput({
|
||||||
{
|
ref,
|
||||||
richtext,
|
richtext,
|
||||||
placeholder,
|
placeholder,
|
||||||
hasRightPadding,
|
hasRightPadding,
|
||||||
@@ -68,22 +48,21 @@ export const TextInput = forwardRef(function TextInputImpl(
|
|||||||
onNewLink,
|
onNewLink,
|
||||||
onError,
|
onError,
|
||||||
...props
|
...props
|
||||||
}: TextInputProps,
|
}: TextInputProps) {
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const {theme: t, fonts} = useAlf()
|
const {theme: t, fonts} = useAlf()
|
||||||
const textInput = useRef<PasteInputRef>(null)
|
const textInput = useRef<PasteInputRef>(null)
|
||||||
const textInputSelection = useRef<Selection>({start: 0, end: 0})
|
const textInputSelection = useRef<Selection>({start: 0, end: 0})
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const [autocompletePrefix, setAutocompletePrefix] = useState('')
|
const [autocompletePrefix, setAutocompletePrefix] = useState('')
|
||||||
const prevLength = React.useRef(richtext.length)
|
const prevLength = useRef(richtext.length)
|
||||||
|
|
||||||
React.useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => textInput.current?.focus(),
|
focus: () => textInput.current?.focus(),
|
||||||
blur: () => {
|
blur: () => {
|
||||||
textInput.current?.blur()
|
textInput.current?.blur()
|
||||||
},
|
},
|
||||||
getCursorPosition: () => undefined, // Not implemented on native
|
getCursorPosition: () => undefined, // Not implemented on native
|
||||||
|
maybeClosePopup: () => false, // Not needed on native
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const pastSuggestedUris = useRef(new Set<string>())
|
const pastSuggestedUris = useRef(new Set<string>())
|
||||||
@@ -185,7 +164,7 @@ export const TextInput = forwardRef(function TextInputImpl(
|
|||||||
[onChangeText, richtext, setAutocompletePrefix],
|
[onChangeText, richtext, setAutocompletePrefix],
|
||||||
)
|
)
|
||||||
|
|
||||||
const inputTextStyle = React.useMemo(() => {
|
const inputTextStyle = useMemo(() => {
|
||||||
const style = normalizeTextStyles(
|
const style = normalizeTextStyles(
|
||||||
[a.text_lg, a.leading_snug, t.atoms.text],
|
[a.text_lg, a.leading_snug, t.atoms.text],
|
||||||
{
|
{
|
||||||
@@ -277,4 +256,4 @@ export const TextInput = forwardRef(function TextInputImpl(
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {type TextInput} from 'react-native'
|
||||||
|
import {type RichText} from '@atproto/api'
|
||||||
|
|
||||||
|
export type TextInputRef = {
|
||||||
|
focus: () => void
|
||||||
|
blur: () => void
|
||||||
|
/**
|
||||||
|
* @platform web
|
||||||
|
*/
|
||||||
|
getCursorPosition: () =>
|
||||||
|
| {left: number; right: number; top: number; bottom: number}
|
||||||
|
| undefined
|
||||||
|
/**
|
||||||
|
* Closes the autocomplete popup if it is open.
|
||||||
|
* Returns `true` if the popup was closed, `false` otherwise.
|
||||||
|
*
|
||||||
|
* @platform web
|
||||||
|
*/
|
||||||
|
maybeClosePopup: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TextInputProps = {
|
||||||
|
ref: React.Ref<TextInputRef>
|
||||||
|
richtext: RichText
|
||||||
|
webForceMinHeight: boolean
|
||||||
|
hasRightPadding: boolean
|
||||||
|
isActive: boolean
|
||||||
|
setRichText: (v: RichText) => void
|
||||||
|
onPhotoPasted: (uri: string) => void
|
||||||
|
onPressPublish: (richtext: RichText) => void
|
||||||
|
onNewLink: (uri: string) => void
|
||||||
|
onError: (err: string) => void
|
||||||
|
onFocus: () => void
|
||||||
|
} & Pick<
|
||||||
|
React.ComponentProps<typeof TextInput>,
|
||||||
|
| 'placeholder'
|
||||||
|
| 'autoFocus'
|
||||||
|
| 'style'
|
||||||
|
| 'accessible'
|
||||||
|
| 'accessibilityLabel'
|
||||||
|
| 'accessibilityHint'
|
||||||
|
>
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
import React, {useRef} from 'react'
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import {StyleSheet, View} from 'react-native'
|
import {StyleSheet, View} from 'react-native'
|
||||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||||
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
|
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||||
@@ -16,7 +23,6 @@ import {EditorContent, type JSONContent, useEditor} from '@tiptap/react'
|
|||||||
import Graphemer from 'graphemer'
|
import Graphemer from 'graphemer'
|
||||||
|
|
||||||
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
|
||||||
import {blobToDataUri, isUriImage} from '#/lib/media/util'
|
import {blobToDataUri, isUriImage} from '#/lib/media/util'
|
||||||
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
||||||
import {
|
import {
|
||||||
@@ -27,35 +33,15 @@ import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEm
|
|||||||
import {atoms as a, useAlf} from '#/alf'
|
import {atoms as a, useAlf} from '#/alf'
|
||||||
import {normalizeTextStyles} from '#/alf/typography'
|
import {normalizeTextStyles} from '#/alf/typography'
|
||||||
import {Portal} from '#/components/Portal'
|
import {Portal} from '#/components/Portal'
|
||||||
import {Text} from '../../util/text/Text'
|
import {Text} from '#/components/Typography'
|
||||||
import {createSuggestion} from './web/Autocomplete'
|
import {type TextInputProps} from './TextInput.types'
|
||||||
|
import {type AutocompleteRef, createSuggestion} from './web/Autocomplete'
|
||||||
import {type Emoji} from './web/EmojiPicker'
|
import {type Emoji} from './web/EmojiPicker'
|
||||||
import {LinkDecorator} from './web/LinkDecorator'
|
import {LinkDecorator} from './web/LinkDecorator'
|
||||||
import {TagDecorator} from './web/TagDecorator'
|
import {TagDecorator} from './web/TagDecorator'
|
||||||
|
|
||||||
export interface TextInputRef {
|
export function TextInput({
|
||||||
focus: () => void
|
ref,
|
||||||
blur: () => void
|
|
||||||
getCursorPosition: () => DOMRect | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TextInputProps {
|
|
||||||
richtext: RichText
|
|
||||||
placeholder: string
|
|
||||||
suggestedLinks: Set<string>
|
|
||||||
webForceMinHeight: boolean
|
|
||||||
hasRightPadding: boolean
|
|
||||||
isActive: boolean
|
|
||||||
setRichText: (v: RichText | ((v: RichText) => RichText)) => void
|
|
||||||
onPhotoPasted: (uri: string) => void
|
|
||||||
onPressPublish: (richtext: RichText) => void
|
|
||||||
onNewLink: (uri: string) => void
|
|
||||||
onError: (err: string) => void
|
|
||||||
onFocus: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TextInput = React.forwardRef(function TextInputImpl(
|
|
||||||
{
|
|
||||||
richtext,
|
richtext,
|
||||||
placeholder,
|
placeholder,
|
||||||
webForceMinHeight,
|
webForceMinHeight,
|
||||||
@@ -66,18 +52,15 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
onPressPublish,
|
onPressPublish,
|
||||||
onNewLink,
|
onNewLink,
|
||||||
onFocus,
|
onFocus,
|
||||||
}: // onError, TODO
|
}: TextInputProps) {
|
||||||
TextInputProps,
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const {theme: t, fonts} = useAlf()
|
const {theme: t, fonts} = useAlf()
|
||||||
const autocomplete = useActorAutocompleteFn()
|
const autocomplete = useActorAutocompleteFn()
|
||||||
const pal = usePalette('default')
|
|
||||||
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
|
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
|
||||||
|
|
||||||
const [isDropping, setIsDropping] = React.useState(false)
|
const [isDropping, setIsDropping] = useState(false)
|
||||||
|
const autocompleteRef = useRef<AutocompleteRef>(null)
|
||||||
|
|
||||||
const extensions = React.useMemo(
|
const extensions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
Document,
|
Document,
|
||||||
LinkDecorator,
|
LinkDecorator,
|
||||||
@@ -86,7 +69,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: 'mention',
|
class: 'mention',
|
||||||
},
|
},
|
||||||
suggestion: createSuggestion({autocomplete}),
|
suggestion: createSuggestion({autocomplete, autocompleteRef}),
|
||||||
}),
|
}),
|
||||||
Paragraph,
|
Paragraph,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
@@ -99,7 +82,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
[autocomplete, placeholder],
|
[autocomplete, placeholder],
|
||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -109,7 +92,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
}
|
}
|
||||||
}, [onPressPublish, isActive])
|
}, [onPressPublish, isActive])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -119,7 +102,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
}
|
}
|
||||||
}, [isActive, onPhotoPasted])
|
}, [isActive, onPhotoPasted])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -296,13 +279,13 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
[modeClass],
|
[modeClass],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onEmojiInserted = React.useCallback(
|
const onEmojiInserted = useCallback(
|
||||||
(emoji: Emoji) => {
|
(emoji: Emoji) => {
|
||||||
editor?.chain().focus().insertContent(emoji.native).run()
|
editor?.chain().focus().insertContent(emoji.native).run()
|
||||||
},
|
},
|
||||||
[editor],
|
[editor],
|
||||||
)
|
)
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -312,7 +295,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
}
|
}
|
||||||
}, [onEmojiInserted, isActive])
|
}, [onEmojiInserted, isActive])
|
||||||
|
|
||||||
React.useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
editor?.chain().focus()
|
editor?.chain().focus()
|
||||||
},
|
},
|
||||||
@@ -323,9 +306,10 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
const pos = editor?.state.selection.$anchor.pos
|
const pos = editor?.state.selection.$anchor.pos
|
||||||
return pos ? editor?.view.coordsAtPos(pos) : undefined
|
return pos ? editor?.view.coordsAtPos(pos) : undefined
|
||||||
},
|
},
|
||||||
|
maybeClosePopup: () => autocompleteRef.current?.maybeClose() ?? false,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const inputStyle = React.useMemo(() => {
|
const inputStyle = useMemo(() => {
|
||||||
const style = normalizeTextStyles(
|
const style = normalizeTextStyles(
|
||||||
[a.text_lg, a.leading_snug, t.atoms.text],
|
[a.text_lg, a.leading_snug, t.atoms.text],
|
||||||
{
|
{
|
||||||
@@ -360,10 +344,20 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
style={styles.dropContainer}
|
style={styles.dropContainer}
|
||||||
entering={FadeIn.duration(80)}
|
entering={FadeIn.duration(80)}
|
||||||
exiting={FadeOut.duration(80)}>
|
exiting={FadeOut.duration(80)}>
|
||||||
<View style={[pal.view, pal.border, styles.dropModal]}>
|
<View
|
||||||
|
style={[
|
||||||
|
t.atoms.bg,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
styles.dropModal,
|
||||||
|
]}>
|
||||||
<Text
|
<Text
|
||||||
type="lg"
|
style={[
|
||||||
style={[pal.text, pal.borderDark, styles.dropText]}>
|
a.text_lg,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
t.atoms.border_contrast_high,
|
||||||
|
styles.dropText,
|
||||||
|
]}>
|
||||||
<Trans>Drop to add images</Trans>
|
<Trans>Drop to add images</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -372,7 +366,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
function editorJsonToText(
|
function editorJsonToText(
|
||||||
json: JSONContent,
|
json: JSONContent,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'
|
import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'
|
||||||
import {Pressable, StyleSheet, View} from 'react-native'
|
import {Pressable, View} from 'react-native'
|
||||||
import {type AppBskyActorDefs} from '@atproto/api'
|
import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api'
|
||||||
import {Trans} from '@lingui/macro'
|
import {Trans} from '@lingui/macro'
|
||||||
import {ReactRenderer} from '@tiptap/react'
|
import {ReactRenderer} from '@tiptap/react'
|
||||||
import {
|
import {
|
||||||
@@ -10,25 +10,26 @@ import {
|
|||||||
} from '@tiptap/suggestion'
|
} from '@tiptap/suggestion'
|
||||||
import tippy, {type Instance as TippyInstance} from 'tippy.js'
|
import tippy, {type Instance as TippyInstance} from 'tippy.js'
|
||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
|
||||||
import {type ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
import {type ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
||||||
import {Text} from '#/view/com/util/text/Text'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
import {atoms as a} from '#/alf'
|
import {Text} from '#/components/Typography'
|
||||||
import {useSimpleVerificationState} from '#/components/verification'
|
|
||||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
|
||||||
import {useGrapheme} from '../hooks/useGrapheme'
|
|
||||||
|
|
||||||
interface MentionListRef {
|
interface MentionListRef {
|
||||||
onKeyDown: (props: SuggestionKeyDownProps) => boolean
|
onKeyDown: (props: SuggestionKeyDownProps) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AutocompleteRef {
|
||||||
|
maybeClose: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function createSuggestion({
|
export function createSuggestion({
|
||||||
autocomplete,
|
autocomplete,
|
||||||
|
autocompleteRef,
|
||||||
}: {
|
}: {
|
||||||
autocomplete: ActorAutocompleteFn
|
autocomplete: ActorAutocompleteFn
|
||||||
|
autocompleteRef: React.Ref<AutocompleteRef>
|
||||||
}): Omit<SuggestionOptions, 'editor'> {
|
}): Omit<SuggestionOptions, 'editor'> {
|
||||||
return {
|
return {
|
||||||
async items({query}) {
|
async items({query}) {
|
||||||
@@ -40,10 +41,15 @@ export function createSuggestion({
|
|||||||
let component: ReactRenderer<MentionListRef> | undefined
|
let component: ReactRenderer<MentionListRef> | undefined
|
||||||
let popup: TippyInstance[] | undefined
|
let popup: TippyInstance[] | undefined
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
popup?.[0]?.destroy()
|
||||||
|
component?.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart: props => {
|
onStart: props => {
|
||||||
component = new ReactRenderer(MentionList, {
|
component = new ReactRenderer(MentionList, {
|
||||||
props,
|
props: {...props, autocompleteRef, hide},
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -78,51 +84,59 @@ export function createSuggestion({
|
|||||||
|
|
||||||
onKeyDown(props) {
|
onKeyDown(props) {
|
||||||
if (props.event.key === 'Escape') {
|
if (props.event.key === 'Escape') {
|
||||||
popup?.[0]?.hide()
|
return false
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return component?.ref?.onKeyDown(props) || false
|
return component?.ref?.onKeyDown(props) || false
|
||||||
},
|
},
|
||||||
|
|
||||||
onExit() {
|
onExit() {
|
||||||
popup?.[0]?.destroy()
|
hide()
|
||||||
component?.destroy()
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MentionList = forwardRef<MentionListRef, SuggestionProps>(
|
const MentionList = forwardRef<
|
||||||
function MentionListImpl(props: SuggestionProps, ref) {
|
MentionListRef,
|
||||||
|
SuggestionProps & {
|
||||||
|
autocompleteRef: React.Ref<AutocompleteRef>
|
||||||
|
hide: () => void
|
||||||
|
}
|
||||||
|
>(function MentionListImpl({items, command, hide, autocompleteRef}, ref) {
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
const pal = usePalette('default')
|
const t = useTheme()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
const selectItem = (index: number) => {
|
const selectItem = (index: number) => {
|
||||||
const item = props.items[index]
|
const item = items[index]
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
props.command({id: item.handle})
|
command({id: item.handle})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const upHandler = () => {
|
const upHandler = () => {
|
||||||
setSelectedIndex(
|
setSelectedIndex((selectedIndex + items.length - 1) % items.length)
|
||||||
(selectedIndex + props.items.length - 1) % props.items.length,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const downHandler = () => {
|
const downHandler = () => {
|
||||||
setSelectedIndex((selectedIndex + 1) % props.items.length)
|
setSelectedIndex((selectedIndex + 1) % items.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
const enterHandler = () => {
|
const enterHandler = () => {
|
||||||
selectItem(selectedIndex)
|
selectItem(selectedIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => setSelectedIndex(0), [props.items])
|
useEffect(() => setSelectedIndex(0), [items])
|
||||||
|
|
||||||
|
useImperativeHandle(autocompleteRef, () => ({
|
||||||
|
maybeClose: () => {
|
||||||
|
hide()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
onKeyDown: ({event}) => {
|
onKeyDown: ({event}) => {
|
||||||
@@ -145,11 +159,19 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const {items} = props
|
if (!moderationOpts) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="items">
|
<div className="items">
|
||||||
<View style={[pal.borderDark, pal.view, styles.container]}>
|
<View
|
||||||
|
style={[
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.border,
|
||||||
|
a.p_xs,
|
||||||
|
{width: 300},
|
||||||
|
]}>
|
||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
items.map((item, index) => {
|
items.map((item, index) => {
|
||||||
const isSelected = selectedIndex === index
|
const isSelected = selectedIndex === index
|
||||||
@@ -159,123 +181,66 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
|
|||||||
key={item.handle}
|
key={item.handle}
|
||||||
profile={item}
|
profile={item}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
itemIndex={index}
|
onPress={() => selectItem(index)}
|
||||||
totalItems={items.length}
|
onHover={() => setSelectedIndex(index)}
|
||||||
onPress={() => {
|
moderationOpts={moderationOpts}
|
||||||
selectItem(index)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<Text type="sm" style={[pal.text, styles.noResult]}>
|
<Text style={[a.text_sm, a.px_md, a.py_md]}>
|
||||||
<Trans>No result</Trans>
|
<Trans>No result</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
function AutocompleteProfileCard({
|
function AutocompleteProfileCard({
|
||||||
profile,
|
profile,
|
||||||
isSelected,
|
isSelected,
|
||||||
itemIndex,
|
|
||||||
totalItems,
|
|
||||||
onPress,
|
onPress,
|
||||||
|
onHover,
|
||||||
|
moderationOpts,
|
||||||
}: {
|
}: {
|
||||||
profile: AppBskyActorDefs.ProfileViewBasic
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
itemIndex: number
|
|
||||||
totalItems: number
|
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
|
onHover: () => void
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const t = useTheme()
|
||||||
const {getGraphemeString} = useGrapheme()
|
|
||||||
const {name: displayName} = getGraphemeString(
|
|
||||||
sanitizeDisplayName(profile.displayName || sanitizeHandle(profile.handle)),
|
|
||||||
30, // Heuristic value; can be modified
|
|
||||||
)
|
|
||||||
const state = useSimpleVerificationState({
|
|
||||||
profile,
|
|
||||||
})
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[
|
style={[
|
||||||
isSelected ? pal.viewLight : undefined,
|
isSelected && t.atoms.bg_contrast_25,
|
||||||
pal.borderDark,
|
a.align_center,
|
||||||
styles.mentionContainer,
|
a.justify_between,
|
||||||
itemIndex === 0
|
a.flex_row,
|
||||||
? styles.firstMention
|
a.px_md,
|
||||||
: itemIndex === totalItems - 1
|
a.py_sm,
|
||||||
? styles.lastMention
|
a.gap_2xl,
|
||||||
: undefined,
|
a.rounded_xs,
|
||||||
|
a.transition_color,
|
||||||
]}
|
]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
|
onPointerEnter={onHover}
|
||||||
accessibilityRole="button">
|
accessibilityRole="button">
|
||||||
<View style={[styles.avatarAndDisplayName, a.flex_1]}>
|
<View style={[a.flex_1]}>
|
||||||
<UserAvatar
|
<ProfileCard.Header>
|
||||||
avatar={profile.avatar ?? null}
|
<ProfileCard.Avatar
|
||||||
size={26}
|
profile={profile}
|
||||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
moderationOpts={moderationOpts}
|
||||||
|
disabledPreview
|
||||||
/>
|
/>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
|
<ProfileCard.NameAndHandle
|
||||||
<Text emoji style={[pal.text]} numberOfLines={1}>
|
profile={profile}
|
||||||
{displayName}
|
moderationOpts={moderationOpts}
|
||||||
</Text>
|
|
||||||
{state.isVerified && (
|
|
||||||
<View>
|
|
||||||
<VerificationCheck
|
|
||||||
width={12}
|
|
||||||
verifier={state.role === 'verifier'}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</ProfileCard.Header>
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text type="xs" style={pal.textLight} numberOfLines={1}>
|
|
||||||
{sanitizeHandle(profile.handle, '@')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
width: 500,
|
|
||||||
borderRadius: 6,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: 'solid',
|
|
||||||
padding: 4,
|
|
||||||
},
|
|
||||||
mentionContainer: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
flexDirection: 'row',
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 8,
|
|
||||||
gap: 16,
|
|
||||||
},
|
|
||||||
firstMention: {
|
|
||||||
borderTopLeftRadius: 2,
|
|
||||||
borderTopRightRadius: 2,
|
|
||||||
},
|
|
||||||
lastMention: {
|
|
||||||
borderBottomLeftRadius: 2,
|
|
||||||
borderBottomRightRadius: 2,
|
|
||||||
},
|
|
||||||
avatarAndDisplayName: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
noResult: {
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 8,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
Reference in New Issue
Block a user