261 lines
7.7 KiB
TypeScript
261 lines
7.7 KiB
TypeScript
import {useCallback, useEffect, useState} from 'react'
|
|
import {Pressable, View} from 'react-native'
|
|
import {useLingui} from '@lingui/react/macro'
|
|
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
|
|
|
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
|
import {useHaptics} from '#/lib/haptics'
|
|
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
|
|
import {useEmail} from '#/state/email-verification'
|
|
import {
|
|
useMessageDraft,
|
|
useSaveMessageDraft,
|
|
} from '#/state/messages/message-drafts'
|
|
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
|
import {
|
|
type Emoji,
|
|
EmojiPicker,
|
|
type EmojiPickerState,
|
|
} from '#/view/com/composer/text-input/web/EmojiPicker'
|
|
import {atoms as a, useTheme} from '#/alf'
|
|
import * as Composer from '#/components/Composer'
|
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
|
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
|
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
|
import * as Toast from '#/components/Toast'
|
|
import {IS_WEB} from '#/env'
|
|
|
|
export function MessageComposer({
|
|
onSendMessage,
|
|
hasEmbed,
|
|
setEmbed,
|
|
children,
|
|
}: {
|
|
onSendMessage: (message: string) => void
|
|
hasEmbed: boolean
|
|
setEmbed: (embedUrl: string | undefined) => void
|
|
children?: React.ReactNode
|
|
}) {
|
|
const t = useTheme()
|
|
const {t: l} = useLingui()
|
|
const playHaptic = useHaptics()
|
|
const {needsEmailVerification} = useEmail()
|
|
const editable = !needsEmailVerification
|
|
const {getDraft, clearDraft} = useMessageDraft()
|
|
const [emojiPickerState, setEmojiPickerState] = useState<EmojiPickerState>({
|
|
isOpen: false,
|
|
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
|
|
})
|
|
const composerInternalApiRef = Composer.useComposerInternalApiRef()
|
|
|
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
|
const {
|
|
state: hovered,
|
|
onIn: onHoverIn,
|
|
onOut: onHoverOut,
|
|
} = useInteractionState()
|
|
|
|
const [text, setText] = useState(getDraft)
|
|
useSaveMessageDraft(text)
|
|
|
|
const openEmojiPicker = useCallback((pos: any) => {
|
|
setEmojiPickerState({isOpen: true, pos})
|
|
}, [])
|
|
|
|
const onSubmit = useCallback(() => {
|
|
if (!editable) return
|
|
if (!hasEmbed && text.trim() === '') return
|
|
if (countGraphemes(text) > MAX_DM_GRAPHEME_LENGTH) {
|
|
Toast.show(l`Message is too long`, {
|
|
type: 'error',
|
|
})
|
|
return
|
|
}
|
|
|
|
clearDraft()
|
|
onSendMessage(text)
|
|
playHaptic()
|
|
setEmbed(undefined)
|
|
composerInternalApiRef.current?.clear()
|
|
|
|
if (IS_WEB) {
|
|
composerInternalApiRef.current?.input?.focus()
|
|
}
|
|
}, [
|
|
l,
|
|
editable,
|
|
hasEmbed,
|
|
text,
|
|
clearDraft,
|
|
onSendMessage,
|
|
playHaptic,
|
|
setEmbed,
|
|
composerInternalApiRef,
|
|
])
|
|
|
|
useEffect(() => {
|
|
function onEmojiInserted(emoji: Emoji) {
|
|
composerInternalApiRef.current?.insert(emoji.native)
|
|
}
|
|
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
|
|
return () => {
|
|
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<>
|
|
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
|
|
{children}
|
|
<Composer.Root
|
|
internalApiRef={composerInternalApiRef}
|
|
initialText={text}
|
|
onChange={setText}
|
|
onFacetCommitted={facet => {
|
|
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
|
|
setEmbed(facet.value)
|
|
}
|
|
}}
|
|
onRequestSubmit={req => {
|
|
if (req.platform === 'web' && req.shiftKey) return
|
|
req.nativeEvent.preventDefault()
|
|
onSubmit()
|
|
}}>
|
|
<View
|
|
// @ts-expect-error web only
|
|
onMouseEnter={onHoverIn}
|
|
onMouseLeave={onHoverOut}>
|
|
<Composer.Input
|
|
editable={editable}
|
|
autoFocus={IS_WEB}
|
|
label={l`Message input field`}
|
|
placeholder={l`Write a message`}
|
|
maxNumberOfLines={12}
|
|
style={[
|
|
t.atoms.bg_contrast_25,
|
|
{
|
|
borderWidth: 1,
|
|
borderColor: 'transparent',
|
|
borderRadius: 25,
|
|
},
|
|
editable &&
|
|
hovered && {
|
|
borderColor: t.atoms.border_contrast_medium.borderColor,
|
|
},
|
|
editable &&
|
|
focused && {
|
|
borderColor: t.palette.primary_500,
|
|
},
|
|
]}
|
|
padding={[
|
|
a.p_md,
|
|
{
|
|
paddingRight: 35 + a.p_sm.padding,
|
|
},
|
|
IS_WEB
|
|
? {
|
|
paddingLeft: 30 + a.p_sm.padding,
|
|
}
|
|
: {},
|
|
]}
|
|
textStyle={[a.text_md, a.leading_snug]}
|
|
onFocus={onFocus}
|
|
onBlur={onBlur}
|
|
/>
|
|
|
|
{IS_WEB && (
|
|
<Pressable
|
|
onPress={e => {
|
|
e.currentTarget.measure(
|
|
(_fx, _fy, _width, _height, px, py) => {
|
|
openEmojiPicker?.({
|
|
top: py,
|
|
left: px,
|
|
right: px,
|
|
bottom: py,
|
|
nextFocusRef: {
|
|
current:
|
|
composerInternalApiRef.current?.input?.element,
|
|
},
|
|
})
|
|
},
|
|
)
|
|
}}
|
|
style={[
|
|
a.overflow_hidden,
|
|
a.absolute,
|
|
a.rounded_full,
|
|
a.align_center,
|
|
a.justify_center,
|
|
a.z_30,
|
|
{
|
|
height: 30,
|
|
width: 30,
|
|
top: 7,
|
|
left: 7,
|
|
},
|
|
]}
|
|
accessibilityLabel={l`Open emoji picker`}
|
|
accessibilityHint="">
|
|
{state => (
|
|
<View
|
|
style={[
|
|
a.absolute,
|
|
a.inset_0,
|
|
a.align_center,
|
|
a.justify_center,
|
|
{
|
|
backgroundColor:
|
|
state.hovered || state.focused || state.pressed
|
|
? t.atoms.bg.backgroundColor
|
|
: undefined,
|
|
},
|
|
]}>
|
|
<EmojiSmile size="lg" />
|
|
</View>
|
|
)}
|
|
</Pressable>
|
|
)}
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={l`Send message`}
|
|
accessibilityHint=""
|
|
hitSlop={HITSLOP_10}
|
|
style={[
|
|
a.absolute,
|
|
a.rounded_full,
|
|
a.align_center,
|
|
a.justify_center,
|
|
a.z_30,
|
|
{
|
|
height: 35,
|
|
width: 35,
|
|
backgroundColor: t.palette.primary_500,
|
|
top: 4,
|
|
right: 4,
|
|
},
|
|
]}
|
|
onPress={onSubmit}
|
|
disabled={!editable}>
|
|
<PaperPlane
|
|
fill={t.palette.white}
|
|
style={[a.relative, {left: 1}]}
|
|
/>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<Composer.Autocomplete />
|
|
</Composer.Root>
|
|
</View>
|
|
|
|
{IS_WEB && (
|
|
<EmojiPicker
|
|
pinToTop
|
|
state={emojiPickerState}
|
|
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|