move files around (#5576)
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import React, {useCallback, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ComAtprotoModerationDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ChatDisabled() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.p_md]}>
|
||||
<View
|
||||
style={[a.align_start, a.p_xl, a.rounded_md, t.atoms.bg_contrast_25]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
||||
<Trans>Your chats have been disabled</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Our moderators have reviewed reports and decided to disable your
|
||||
access to chats on Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
<AppealDialog />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function AppealDialog() {
|
||||
const control = Dialog.useDialogControl()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
testID="appealDisabledChatBtn"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="small"
|
||||
onPress={control.open}
|
||||
label={_(msg`Appeal this decision`)}
|
||||
style={a.mt_sm}>
|
||||
<ButtonText>{_(msg`Appeal this decision`)}</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<DialogInner />
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogInner() {
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
const [details, setDetails] = useState('')
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const agent = useAgent()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const {mutate, isPending} = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!currentAccount)
|
||||
throw new Error('No current account, should be unreachable')
|
||||
await agent.createModerationReport({
|
||||
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
|
||||
subject: {
|
||||
$type: 'com.atproto.admin.defs#repoRef',
|
||||
did: currentAccount.did,
|
||||
},
|
||||
reason: details,
|
||||
})
|
||||
},
|
||||
onError: err => {
|
||||
logger.error('Failed to submit chat appeal', {message: err})
|
||||
Toast.show(_(msg`Failed to submit appeal, please try again.`), 'xmark')
|
||||
},
|
||||
onSuccess: () => {
|
||||
control.close()
|
||||
Toast.show(_(msg`Appeal submitted`))
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = useCallback(() => mutate(), [mutate])
|
||||
const onBack = useCallback(() => control.close(), [control])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Appeal this decision`)}>
|
||||
<Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
|
||||
<Trans>Appeal this decision</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>This appeal will be sent to Bluesky's moderation service.</Trans>
|
||||
</Text>
|
||||
<View style={[a.my_md]}>
|
||||
<Dialog.Input
|
||||
label={_(msg`Text input field`)}
|
||||
placeholder={_(
|
||||
msg`Please explain why you think your chats were incorrectly disabled`,
|
||||
)}
|
||||
value={details}
|
||||
onChangeText={setDetails}
|
||||
autoFocus={true}
|
||||
numberOfLines={3}
|
||||
multiline
|
||||
maxLength={300}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={
|
||||
gtMobile
|
||||
? [a.flex_row, a.justify_between]
|
||||
: [{flexDirection: 'column-reverse'}, a.gap_sm]
|
||||
}>
|
||||
<Button
|
||||
testID="backBtn"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
onPress={onBack}
|
||||
label={_(msg`Back`)}>
|
||||
<ButtonText>{_(msg`Back`)}</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
testID="submitBtn"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={onSubmit}
|
||||
label={_(msg`Submit`)}>
|
||||
<ButtonText>{_(msg`Submit`)}</ButtonText>
|
||||
{isPending && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import React, {useCallback, useState} from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyEmbedRecord,
|
||||
ChatBskyConvoDefs,
|
||||
moderateProfile,
|
||||
ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {decrementBadgeCount} from '#/lib/notifications/notifications'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {
|
||||
postUriToRelativePath,
|
||||
toBskyAppUrl,
|
||||
toShortUrl,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import * as tokens from '#/alf/tokens'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
|
||||
import {Link} from '#/components/Link'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export let ChatListItem = ({
|
||||
convo,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
}): React.ReactNode => {
|
||||
const {currentAccount} = useSession()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
const otherUser = convo.members.find(
|
||||
member => member.did !== currentAccount?.did,
|
||||
)
|
||||
|
||||
if (!otherUser || !moderationOpts) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatListItemReady
|
||||
convo={convo}
|
||||
profile={otherUser}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
ChatListItem = React.memo(ChatListItem)
|
||||
|
||||
function ChatListItemReady({
|
||||
convo,
|
||||
profile: profileUnshadowed,
|
||||
moderationOpts,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const menuControl = useMenuControl()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const moderation = React.useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
[profile, moderationOpts],
|
||||
)
|
||||
const playHaptic = useHaptics()
|
||||
|
||||
const blockInfo = React.useMemo(() => {
|
||||
const modui = moderation.ui('profileView')
|
||||
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
|
||||
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
|
||||
const userBlock = blocks.find(alert => alert.source.type === 'user')
|
||||
return {
|
||||
listBlocks,
|
||||
userBlock,
|
||||
}
|
||||
}, [moderation])
|
||||
|
||||
const isDeletedAccount = profile.handle === 'missing.invalid'
|
||||
const displayName = isDeletedAccount
|
||||
? 'Deleted Account'
|
||||
: sanitizeDisplayName(
|
||||
profile.displayName || profile.handle,
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
|
||||
const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount
|
||||
|
||||
const {lastMessage, lastMessageSentAt} = React.useMemo(() => {
|
||||
let lastMessage = _(msg`No messages yet`)
|
||||
let lastMessageSentAt: string | null = null
|
||||
|
||||
if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
|
||||
const isFromMe = convo.lastMessage.sender?.did === currentAccount?.did
|
||||
|
||||
if (convo.lastMessage.text) {
|
||||
if (isFromMe) {
|
||||
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
|
||||
} else {
|
||||
lastMessage = convo.lastMessage.text
|
||||
}
|
||||
} else if (convo.lastMessage.embed) {
|
||||
const defaultEmbeddedContentMessage = _(
|
||||
msg`(contains embedded content)`,
|
||||
)
|
||||
|
||||
if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) {
|
||||
const embed = convo.lastMessage.embed
|
||||
|
||||
if (AppBskyEmbedRecord.isViewRecord(embed.record)) {
|
||||
const record = embed.record
|
||||
const path = postUriToRelativePath(record.uri, {
|
||||
handle: record.author.handle,
|
||||
})
|
||||
const href = path ? toBskyAppUrl(path) : undefined
|
||||
const short = href
|
||||
? toShortUrl(href)
|
||||
: defaultEmbeddedContentMessage
|
||||
if (isFromMe) {
|
||||
lastMessage = _(msg`You: ${short}`)
|
||||
} else {
|
||||
lastMessage = short
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isFromMe) {
|
||||
lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`)
|
||||
} else {
|
||||
lastMessage = defaultEmbeddedContentMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastMessageSentAt = convo.lastMessage.sentAt
|
||||
}
|
||||
if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
|
||||
lastMessage = isDeletedAccount
|
||||
? _(msg`Conversation deleted`)
|
||||
: _(msg`Message deleted`)
|
||||
}
|
||||
|
||||
return {
|
||||
lastMessage,
|
||||
lastMessageSentAt,
|
||||
}
|
||||
}, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount])
|
||||
|
||||
const [showActions, setShowActions] = useState(false)
|
||||
|
||||
const onMouseEnter = useCallback(() => {
|
||||
setShowActions(true)
|
||||
}, [])
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
setShowActions(false)
|
||||
}, [])
|
||||
|
||||
const onFocus = useCallback<React.FocusEventHandler>(e => {
|
||||
if (e.nativeEvent.relatedTarget == null) return
|
||||
setShowActions(true)
|
||||
}, [])
|
||||
|
||||
const onPress = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
decrementBadgeCount(convo.unreadCount)
|
||||
if (isDeletedAccount) {
|
||||
e.preventDefault()
|
||||
menuControl.open()
|
||||
return false
|
||||
} else {
|
||||
logEvent('chat:open', {logContext: 'ChatsList'})
|
||||
}
|
||||
},
|
||||
[convo.unreadCount, isDeletedAccount, menuControl],
|
||||
)
|
||||
|
||||
const onLongPress = useCallback(() => {
|
||||
playHaptic()
|
||||
menuControl.open()
|
||||
}, [playHaptic, menuControl])
|
||||
|
||||
return (
|
||||
<View
|
||||
// @ts-expect-error web only
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onFocus={onFocus}
|
||||
onBlur={onMouseLeave}
|
||||
style={[a.relative]}>
|
||||
<View
|
||||
style={[
|
||||
a.z_10,
|
||||
a.absolute,
|
||||
{top: tokens.space.md, left: tokens.space.lg},
|
||||
]}>
|
||||
<PreviewableUserAvatar
|
||||
profile={profile}
|
||||
size={52}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Link
|
||||
to={`/messages/${convo.id}`}
|
||||
label={displayName}
|
||||
accessibilityHint={
|
||||
!isDeletedAccount
|
||||
? _(msg`Go to conversation with ${profile.handle}`)
|
||||
: _(
|
||||
msg`This conversation is with a deleted or a deactivated account. Press for options.`,
|
||||
)
|
||||
}
|
||||
accessibilityActions={
|
||||
isNative
|
||||
? [
|
||||
{name: 'magicTap', label: _(msg`Open conversation options`)},
|
||||
{name: 'longpress', label: _(msg`Open conversation options`)},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
onPress={onPress}
|
||||
onLongPress={isNative ? onLongPress : undefined}
|
||||
onAccessibilityAction={onLongPress}>
|
||||
{({hovered, pressed, focused}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
isDeletedAccount ? a.align_center : a.align_start,
|
||||
a.flex_1,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
a.gap_md,
|
||||
(hovered || pressed || focused) && t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{/* Avatar goes here */}
|
||||
<View style={{width: 52, height: 52}} />
|
||||
|
||||
<View style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}>
|
||||
<View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[{maxWidth: '85%'}, web([a.leading_normal])]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text,
|
||||
a.font_bold,
|
||||
{lineHeight: 21},
|
||||
isDimStyle && t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</Text>
|
||||
{lastMessageSentAt && (
|
||||
<TimeElapsed timestamp={lastMessageSentAt}>
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
{lineHeight: 21},
|
||||
t.atoms.text_contrast_medium,
|
||||
web({whiteSpace: 'preserve nowrap'}),
|
||||
]}>
|
||||
{' '}
|
||||
· {timeElapsed}
|
||||
</Text>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
)}
|
||||
{(convo.muted || moderation.blocked) && (
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
{lineHeight: 21},
|
||||
t.atoms.text_contrast_medium,
|
||||
web({whiteSpace: 'preserve nowrap'}),
|
||||
]}>
|
||||
{' '}
|
||||
·{' '}
|
||||
<BellStroke
|
||||
size="xs"
|
||||
style={[t.atoms.text_contrast_medium]}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!isDeletedAccount && (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}>
|
||||
@{profile.handle}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text
|
||||
emoji
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
convo.unreadCount > 0
|
||||
? a.font_bold
|
||||
: t.atoms.text_contrast_high,
|
||||
isDimStyle && t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{lastMessage}
|
||||
</Text>
|
||||
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentList')}
|
||||
size="lg"
|
||||
style={[a.pt_xs]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{convo.unreadCount > 0 && (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
{
|
||||
backgroundColor: isDimStyle
|
||||
? t.palette.contrast_200
|
||||
: t.palette.primary_500,
|
||||
height: 7,
|
||||
width: 7,
|
||||
top: 15,
|
||||
right: 12,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<ConvoMenu
|
||||
convo={convo}
|
||||
profile={profile}
|
||||
control={menuControl}
|
||||
currentScreen="list"
|
||||
showMarkAsRead={convo.unreadCount > 0}
|
||||
hideTrigger={isNative}
|
||||
blockInfo={blockInfo}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.h_full,
|
||||
a.self_end,
|
||||
a.justify_center,
|
||||
{
|
||||
right: tokens.space.lg,
|
||||
opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import React from 'react'
|
||||
import {Pressable, TextInput, useWindowDimensions, View} from 'react-native'
|
||||
import {
|
||||
useFocusedInputHandler,
|
||||
useReanimatedKeyboardAnimation,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
measure,
|
||||
useAnimatedProps,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import Graphemer from 'graphemer'
|
||||
|
||||
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {
|
||||
useMessageDraft,
|
||||
useSaveMessageDraft,
|
||||
} from '#/state/messages/message-drafts'
|
||||
import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
||||
|
||||
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
|
||||
|
||||
export function MessageInput({
|
||||
onSendMessage,
|
||||
hasEmbed,
|
||||
setEmbed,
|
||||
children,
|
||||
}: {
|
||||
onSendMessage: (message: string) => void
|
||||
hasEmbed: boolean
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
children?: React.ReactNode
|
||||
openEmojiPicker?: (pos: EmojiPickerPosition) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
const {getDraft, clearDraft} = useMessageDraft()
|
||||
|
||||
// Input layout
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
const {height: windowHeight} = useWindowDimensions()
|
||||
const {height: keyboardHeight} = useReanimatedKeyboardAnimation()
|
||||
const maxHeight = useSharedValue<undefined | number>(undefined)
|
||||
const isInputScrollable = useSharedValue(false)
|
||||
|
||||
const inputStyles = useSharedInputStyles()
|
||||
const [isFocused, setIsFocused] = React.useState(false)
|
||||
const [message, setMessage] = React.useState(getDraft)
|
||||
const inputRef = useAnimatedRef<TextInput>()
|
||||
|
||||
useSaveMessageDraft(message)
|
||||
useExtractEmbedFromFacets(message, setEmbed)
|
||||
|
||||
const onSubmit = React.useCallback(() => {
|
||||
if (!hasEmbed && message.trim() === '') {
|
||||
return
|
||||
}
|
||||
if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
||||
Toast.show(_(msg`Message is too long`), 'xmark')
|
||||
return
|
||||
}
|
||||
clearDraft()
|
||||
onSendMessage(message)
|
||||
playHaptic()
|
||||
setMessage('')
|
||||
setEmbed(undefined)
|
||||
|
||||
// Pressing the send button causes the text input to lose focus, so we need to
|
||||
// re-focus it after sending
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 100)
|
||||
}, [
|
||||
hasEmbed,
|
||||
message,
|
||||
clearDraft,
|
||||
onSendMessage,
|
||||
playHaptic,
|
||||
setEmbed,
|
||||
_,
|
||||
inputRef,
|
||||
])
|
||||
|
||||
useFocusedInputHandler(
|
||||
{
|
||||
onChangeText: () => {
|
||||
'worklet'
|
||||
const measurement = measure(inputRef)
|
||||
if (!measurement) return
|
||||
|
||||
const max = windowHeight - -keyboardHeight.value - topInset - 150
|
||||
const availableSpace = max - measurement.height
|
||||
|
||||
maxHeight.value = max
|
||||
isInputScrollable.value = availableSpace < 30
|
||||
},
|
||||
},
|
||||
[windowHeight, topInset],
|
||||
)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
maxHeight: maxHeight.value,
|
||||
}))
|
||||
|
||||
const animatedProps = useAnimatedProps(() => ({
|
||||
scrollEnabled: isInputScrollable.value,
|
||||
}))
|
||||
|
||||
return (
|
||||
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
|
||||
{children}
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
padding: a.p_sm.padding - 2,
|
||||
paddingLeft: a.p_md.padding - 2,
|
||||
borderWidth: 1,
|
||||
borderRadius: 23,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
isFocused && inputStyles.chromeFocus,
|
||||
]}>
|
||||
<AnimatedTextInput
|
||||
accessibilityLabel={_(msg`Message input field`)}
|
||||
accessibilityHint={_(msg`Type your message here`)}
|
||||
placeholder={_(msg`Write a message`)}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
value={message}
|
||||
multiline={true}
|
||||
onChangeText={setMessage}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.px_sm,
|
||||
t.atoms.text,
|
||||
{paddingBottom: isIOS ? 5 : 0},
|
||||
animatedStyle,
|
||||
]}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
blurOnSubmit={false}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
ref={inputRef}
|
||||
hitSlop={HITSLOP_10}
|
||||
animatedProps={animatedProps}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Send message`)}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
|
||||
]}
|
||||
onPress={onSubmit}>
|
||||
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import React from 'react'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import Graphemer from 'graphemer'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
|
||||
import {isSafari, isTouchDevice} from '#/lib/browser'
|
||||
import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {
|
||||
useMessageDraft,
|
||||
useSaveMessageDraft,
|
||||
} from '#/state/messages/message-drafts'
|
||||
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||
import {
|
||||
Emoji,
|
||||
EmojiPickerPosition,
|
||||
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
||||
|
||||
export function MessageInput({
|
||||
onSendMessage,
|
||||
hasEmbed,
|
||||
setEmbed,
|
||||
children,
|
||||
openEmojiPicker,
|
||||
}: {
|
||||
onSendMessage: (message: string) => void
|
||||
hasEmbed: boolean
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
children?: React.ReactNode
|
||||
openEmojiPicker?: (pos: EmojiPickerPosition) => void
|
||||
}) {
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {getDraft, clearDraft} = useMessageDraft()
|
||||
const [message, setMessage] = React.useState(getDraft)
|
||||
|
||||
const inputStyles = useSharedInputStyles()
|
||||
const isComposing = React.useRef(false)
|
||||
const [isFocused, setIsFocused] = React.useState(false)
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
|
||||
const textAreaRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const onSubmit = React.useCallback(() => {
|
||||
if (!hasEmbed && message.trim() === '') {
|
||||
return
|
||||
}
|
||||
if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
||||
Toast.show(_(msg`Message is too long`), 'xmark')
|
||||
return
|
||||
}
|
||||
clearDraft()
|
||||
onSendMessage(message)
|
||||
setMessage('')
|
||||
setEmbed(undefined)
|
||||
}, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed])
|
||||
|
||||
const onKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Don't submit the form when the Japanese or any other IME is composing
|
||||
if (isComposing.current) return
|
||||
|
||||
// see https://github.com/bluesky-social/social-app/issues/4178
|
||||
// see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/
|
||||
// see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html
|
||||
//
|
||||
// On Safari, the final keydown event to dismiss the IME - which is the enter key - is also "Enter" below.
|
||||
// Obviously, this causes problems because the final dismissal should _not_ submit the text, but should just
|
||||
// stop the IME editing. This is the behavior of Chrome and Firefox, but not Safari.
|
||||
//
|
||||
// Keycode is deprecated, however the alternative seems to only be to compare the timestamp from the
|
||||
// onCompositionEnd event to the timestamp of the keydown event, which is not reliable. For example, this hack
|
||||
// uses that method: https://github.com/ProseMirror/prosemirror-view/pull/44. However, from my 500ms resulted in
|
||||
// far too long of a delay, and a subsequent enter press would often just end up doing nothing. A shorter time
|
||||
// frame was also not great, since it was too short to be reliable (i.e. an older system might have a larger
|
||||
// time gap between the two events firing.
|
||||
if (isSafari && e.key === 'Enter' && e.keyCode === 229) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
if (e.shiftKey) return
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}
|
||||
},
|
||||
[onSubmit],
|
||||
)
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setMessage(e.target.value)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const onEmojiInserted = React.useCallback(
|
||||
(emoji: Emoji) => {
|
||||
const position = textAreaRef.current?.selectionStart ?? 0
|
||||
setMessage(
|
||||
message =>
|
||||
message.slice(0, position) + emoji.native + message.slice(position),
|
||||
)
|
||||
},
|
||||
[setMessage],
|
||||
)
|
||||
React.useEffect(() => {
|
||||
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
|
||||
return () => {
|
||||
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
|
||||
}
|
||||
}, [onEmojiInserted])
|
||||
|
||||
useSaveMessageDraft(message)
|
||||
useExtractEmbedFromFacets(message, setEmbed)
|
||||
|
||||
return (
|
||||
<View style={a.p_sm}>
|
||||
{children}
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
paddingRight: a.p_sm.padding - 2,
|
||||
paddingLeft: a.p_sm.padding - 2,
|
||||
borderWidth: 1,
|
||||
borderRadius: 23,
|
||||
borderColor: 'transparent',
|
||||
height: textAreaHeight + 23,
|
||||
},
|
||||
isHovered && inputStyles.chromeHover,
|
||||
isFocused && inputStyles.chromeFocus,
|
||||
]}
|
||||
// @ts-expect-error web only
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}>
|
||||
<Button
|
||||
onPress={e => {
|
||||
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
|
||||
openEmojiPicker?.({top: py, left: px, right: px, bottom: py})
|
||||
})
|
||||
}}
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
marginTop: 5,
|
||||
height: 30,
|
||||
width: 30,
|
||||
},
|
||||
]}
|
||||
label={_(msg`Open emoji picker`)}>
|
||||
{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>
|
||||
)}
|
||||
</Button>
|
||||
<TextareaAutosize
|
||||
ref={textAreaRef}
|
||||
style={StyleSheet.flatten([
|
||||
a.flex_1,
|
||||
a.px_sm,
|
||||
a.border_0,
|
||||
t.atoms.text,
|
||||
{
|
||||
paddingTop: 10,
|
||||
backgroundColor: 'transparent',
|
||||
resize: 'none',
|
||||
},
|
||||
])}
|
||||
maxRows={12}
|
||||
placeholder={_(msg`Write a message`)}
|
||||
defaultValue=""
|
||||
value={message}
|
||||
dirName="ltr"
|
||||
autoFocus={true}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onCompositionStart={() => {
|
||||
isComposing.current = true
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
isComposing.current = false
|
||||
}}
|
||||
onHeightChange={height => setTextAreaHeight(height)}
|
||||
onChange={onChange}
|
||||
// On mobile web phones, we want to keep the same behavior as the native app. Do not submit the message
|
||||
// in these cases.
|
||||
onKeyDown={isTouchDevice && isTabletOrDesktop ? undefined : onKeyDown}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Send message`)}
|
||||
accessibilityHint=""
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
height: 30,
|
||||
width: 30,
|
||||
marginTop: 5,
|
||||
backgroundColor: t.palette.primary_500,
|
||||
},
|
||||
]}
|
||||
onPress={onSubmit}>
|
||||
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {LayoutAnimation, View} from 'react-native'
|
||||
import {
|
||||
AppBskyFeedPost,
|
||||
AppBskyRichtextFacet,
|
||||
AtUri,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {RouteProp, useNavigation, useRoute} from '@react-navigation/native'
|
||||
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
isBskyPostUrl,
|
||||
makeRecordUri,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {usePostQuery} from '#/state/queries/post'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as MediaPreview from '#/components/MediaPreview'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function useMessageEmbed() {
|
||||
const route =
|
||||
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const embedFromParams = route.params.embed
|
||||
|
||||
const [embedUri, setEmbed] = useState(embedFromParams)
|
||||
|
||||
if (embedFromParams && embedUri !== embedFromParams) {
|
||||
setEmbed(embedFromParams)
|
||||
}
|
||||
|
||||
return {
|
||||
embedUri,
|
||||
setEmbed: useCallback(
|
||||
(embedUrl: string | undefined) => {
|
||||
if (!embedUrl) {
|
||||
navigation.setParams({embed: ''})
|
||||
setEmbed(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (embedFromParams) return
|
||||
|
||||
const url = convertBskyAppUrlIfNeeded(embedUrl)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
|
||||
setEmbed(uri)
|
||||
},
|
||||
[embedFromParams, navigation],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function useExtractEmbedFromFacets(
|
||||
message: string,
|
||||
setEmbed: (embedUrl: string | undefined) => void,
|
||||
) {
|
||||
const rt = new RichTextAPI({text: message})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
let uriFromFacet: string | undefined
|
||||
|
||||
for (const facet of rt.facets ?? []) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) {
|
||||
uriFromFacet = feature.uri
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (uriFromFacet) {
|
||||
setEmbed(uriFromFacet)
|
||||
}
|
||||
}, [uriFromFacet, setEmbed])
|
||||
}
|
||||
|
||||
export function MessageInputEmbed({
|
||||
embedUri,
|
||||
setEmbed,
|
||||
}: {
|
||||
embedUri: string | undefined
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const {data: post, status} = usePostQuery(embedUri)
|
||||
|
||||
const moderationOpts = useModerationOpts()
|
||||
const moderation = useMemo(
|
||||
() =>
|
||||
moderationOpts && post ? moderatePost(post, moderationOpts) : undefined,
|
||||
[moderationOpts, post],
|
||||
)
|
||||
|
||||
const {rt, record} = useMemo(() => {
|
||||
if (
|
||||
post &&
|
||||
AppBskyFeedPost.isRecord(post.record) &&
|
||||
AppBskyFeedPost.validateRecord(post.record).success
|
||||
) {
|
||||
return {
|
||||
rt: new RichTextAPI({
|
||||
text: post.record.text,
|
||||
facets: post.record.facets,
|
||||
}),
|
||||
record: post.record,
|
||||
}
|
||||
}
|
||||
|
||||
return {rt: undefined, record: undefined}
|
||||
}, [post])
|
||||
|
||||
if (!embedUri) {
|
||||
return null
|
||||
}
|
||||
|
||||
let content = null
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
content = (
|
||||
<View
|
||||
style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
|
||||
<Loader />
|
||||
</View>
|
||||
)
|
||||
break
|
||||
case 'error':
|
||||
content = (
|
||||
<View
|
||||
style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
|
||||
<Text style={a.text_center}>Could not fetch post</Text>
|
||||
</View>
|
||||
)
|
||||
break
|
||||
case 'success':
|
||||
const itemUrip = new AtUri(post.uri)
|
||||
const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
|
||||
|
||||
if (!post || !moderation || !rt || !record) {
|
||||
return null
|
||||
}
|
||||
|
||||
content = (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
a.p_sm,
|
||||
a.mb_sm,
|
||||
]}
|
||||
pointerEvents="none">
|
||||
<PostMeta
|
||||
showAvatar
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={itemHref}
|
||||
style={a.flex_0}
|
||||
/>
|
||||
<ContentHider modui={moderation.ui('contentView')}>
|
||||
<PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} />
|
||||
{rt.text && (
|
||||
<View style={a.mt_xs}>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
value={rt}
|
||||
style={[a.text_sm, t.atoms.text_contrast_high]}
|
||||
authorHandle={post.author.handle}
|
||||
numberOfLines={3}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<MediaPreview.Embed embed={post.embed} style={a.mt_sm} />
|
||||
</ContentHider>
|
||||
</View>
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
{content}
|
||||
<Button
|
||||
label={_(msg`Remove embed`)}
|
||||
onPress={() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setEmbed(undefined)
|
||||
}}
|
||||
size="tiny"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
shape="round">
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ConvoItem, ConvoItemError} from '#/state/messages/convo/types'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {description, help, cta} = React.useMemo(() => {
|
||||
return {
|
||||
[ConvoItemError.FirehoseFailed]: {
|
||||
description: _(msg`This chat was disconnected`),
|
||||
help: _(msg`Press to attempt reconnection`),
|
||||
cta: _(msg`Reconnect`),
|
||||
},
|
||||
[ConvoItemError.HistoryFailed]: {
|
||||
description: _(msg`Failed to load past messages`),
|
||||
help: _(msg`Press to retry`),
|
||||
cta: _(msg`Retry`),
|
||||
},
|
||||
}[item.code]
|
||||
}, [_, item.code])
|
||||
|
||||
return (
|
||||
<View style={[a.py_md, a.w_full, a.flex_row, a.justify_center]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.gap_sm,
|
||||
{maxWidth: 400},
|
||||
]}>
|
||||
<CircleInfo size="sm" fill={t.palette.negative_400} />
|
||||
|
||||
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
{description} ·{' '}
|
||||
{item.retry && (
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label={help}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
item.retry?.()
|
||||
return false
|
||||
}}>
|
||||
{cta}
|
||||
</InlineLinkText>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import React, {useCallback, useRef} from 'react'
|
||||
import {FlatList, LayoutChangeEvent, View} from 'react-native'
|
||||
import {
|
||||
KeyboardStickyView,
|
||||
useKeyboardHandler,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import {
|
||||
runOnJS,
|
||||
scrollTo,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
isBskyPostUrl,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {isConvoActive, useConvoActive} from '#/state/messages/convo'
|
||||
import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {
|
||||
EmojiPicker,
|
||||
EmojiPickerState,
|
||||
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
|
||||
import {MessageInput} from '#/screens/Messages/components/MessageInput'
|
||||
import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
||||
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
|
||||
import {MessageItem} from '#/components/dms/MessageItem'
|
||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
|
||||
|
||||
function MaybeLoader({isLoading}: {isLoading: boolean}) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: 50,
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{isLoading && <Loader size="xl" />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function renderItem({item}: {item: ConvoItem}) {
|
||||
if (item.type === 'message' || item.type === 'pending-message') {
|
||||
return <MessageItem item={item} />
|
||||
} else if (item.type === 'deleted-message') {
|
||||
return <Text>Deleted message</Text>
|
||||
} else if (item.type === 'error') {
|
||||
return <MessageListError item={item} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function keyExtractor(item: ConvoItem) {
|
||||
return item.key
|
||||
}
|
||||
|
||||
function onScrollToIndexFailed() {
|
||||
// Placeholder function. You have to give FlatList something or else it will error.
|
||||
}
|
||||
|
||||
export function MessagesList({
|
||||
hasScrolled,
|
||||
setHasScrolled,
|
||||
blocked,
|
||||
footer,
|
||||
}: {
|
||||
hasScrolled: boolean
|
||||
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
|
||||
blocked?: boolean
|
||||
footer?: React.ReactNode
|
||||
}) {
|
||||
const convoState = useConvoActive()
|
||||
const agent = useAgent()
|
||||
const getPost = useGetPost()
|
||||
const {embedUri, setEmbed} = useMessageEmbed()
|
||||
|
||||
const flatListRef = useAnimatedRef<FlatList>()
|
||||
|
||||
const [newMessagesPill, setNewMessagesPill] = React.useState({
|
||||
show: false,
|
||||
startContentOffset: 0,
|
||||
})
|
||||
|
||||
const [emojiPickerState, setEmojiPickerState] =
|
||||
React.useState<EmojiPickerState>({
|
||||
isOpen: false,
|
||||
pos: {top: 0, left: 0, right: 0, bottom: 0},
|
||||
})
|
||||
|
||||
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
||||
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
|
||||
// the bottom.
|
||||
const isAtBottom = useSharedValue(true)
|
||||
|
||||
// This will be used on web to assist in determining if we need to maintain the content offset
|
||||
const isAtTop = useSharedValue(true)
|
||||
|
||||
// Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing
|
||||
// onStartReached to fire.
|
||||
const prevContentHeight = useRef(0)
|
||||
const prevItemCount = useRef(0)
|
||||
|
||||
// -- Keep track of background state and positioning for new pill
|
||||
const layoutHeight = useSharedValue(0)
|
||||
const didBackground = React.useRef(false)
|
||||
React.useEffect(() => {
|
||||
if (convoState.status === ConvoStatus.Backgrounded) {
|
||||
didBackground.current = true
|
||||
}
|
||||
}, [convoState.status])
|
||||
|
||||
// -- Scroll handling
|
||||
|
||||
// Every time the content size changes, that means one of two things is happening:
|
||||
// 1. New messages are being added from the log or from a message you have sent
|
||||
// 2. Old messages are being prepended to the top
|
||||
//
|
||||
// The first time that the content size changes is when the initial items are rendered. Because we cannot rely on
|
||||
// `initialScrollIndex`, we need to immediately scroll to the bottom of the list. That scroll will not be animated.
|
||||
//
|
||||
// Subsequent resizes will only scroll to the bottom if the user is at the bottom of the list (within 100 pixels of
|
||||
// the bottom). Therefore, any new messages that come in or are sent will result in an animated scroll to end. However
|
||||
// we will not scroll whenever new items get prepended to the top.
|
||||
const onContentSizeChange = useCallback(
|
||||
(_: number, height: number) => {
|
||||
// Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the
|
||||
// previous off whenever we add new content to the previous offset whenever we add new content to the list.
|
||||
if (isWeb && isAtTop.value && hasScrolled) {
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: height - prevContentHeight.current,
|
||||
animated: false,
|
||||
})
|
||||
}
|
||||
|
||||
// This number _must_ be the height of the MaybeLoader component
|
||||
if (height > 50 && isAtBottom.value) {
|
||||
// If the size of the content is changing by more than the height of the screen, then we don't
|
||||
// want to scroll further than the start of all the new content. Since we are storing the previous offset,
|
||||
// we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill
|
||||
// that can be pressed to immediately scroll to the end.
|
||||
if (
|
||||
didBackground.current &&
|
||||
hasScrolled &&
|
||||
height - prevContentHeight.current > layoutHeight.value - 50 &&
|
||||
convoState.items.length - prevItemCount.current > 1
|
||||
) {
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: prevContentHeight.current - 65,
|
||||
animated: true,
|
||||
})
|
||||
setNewMessagesPill({
|
||||
show: true,
|
||||
startContentOffset: prevContentHeight.current - 65,
|
||||
})
|
||||
} else {
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: height,
|
||||
animated: hasScrolled && height > prevContentHeight.current,
|
||||
})
|
||||
|
||||
// HACK Unfortunately, we need to call `setHasScrolled` after a brief delay,
|
||||
// because otherwise there is too much of a delay between the time the content
|
||||
// scrolls and the time the screen appears, causing a flicker.
|
||||
// We cannot actually use a synchronous scroll here, because `onContentSizeChange`
|
||||
// is actually async itself - all the info has to come across the bridge first.
|
||||
if (!hasScrolled && !convoState.isFetchingHistory) {
|
||||
setTimeout(() => {
|
||||
setHasScrolled(true)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prevContentHeight.current = height
|
||||
prevItemCount.current = convoState.items.length
|
||||
didBackground.current = false
|
||||
},
|
||||
[
|
||||
hasScrolled,
|
||||
setHasScrolled,
|
||||
convoState.isFetchingHistory,
|
||||
convoState.items.length,
|
||||
// these are stable
|
||||
flatListRef,
|
||||
isAtTop.value,
|
||||
isAtBottom.value,
|
||||
layoutHeight.value,
|
||||
],
|
||||
)
|
||||
|
||||
const onStartReached = useCallback(() => {
|
||||
if (hasScrolled && prevContentHeight.current > layoutHeight.value) {
|
||||
convoState.fetchMessageHistory()
|
||||
}
|
||||
}, [convoState, hasScrolled, layoutHeight.value])
|
||||
|
||||
const onScroll = React.useCallback(
|
||||
(e: ReanimatedScrollEvent) => {
|
||||
'worklet'
|
||||
layoutHeight.value = e.layoutMeasurement.height
|
||||
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
|
||||
|
||||
// Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom
|
||||
// when a new message is added, hence the 100 pixel offset
|
||||
isAtBottom.value = e.contentSize.height - 100 < bottomOffset
|
||||
isAtTop.value = e.contentOffset.y <= 1
|
||||
|
||||
if (
|
||||
newMessagesPill.show &&
|
||||
(e.contentOffset.y > newMessagesPill.startContentOffset + 200 ||
|
||||
isAtBottom.value)
|
||||
) {
|
||||
runOnJS(setNewMessagesPill)({
|
||||
show: false,
|
||||
startContentOffset: 0,
|
||||
})
|
||||
}
|
||||
},
|
||||
[layoutHeight, newMessagesPill, isAtBottom, isAtTop],
|
||||
)
|
||||
|
||||
// -- Keyboard animation handling
|
||||
const {bottom: bottomInset} = useSafeAreaInsets()
|
||||
const bottomOffset = isWeb ? 0 : clamp(60 + bottomInset, 60, 75)
|
||||
|
||||
const keyboardHeight = useSharedValue(0)
|
||||
const keyboardIsOpening = useSharedValue(false)
|
||||
|
||||
// In some cases - like when the emoji piker opens - we don't want to animate the scroll in the list onLayout event.
|
||||
// We use this value to keep track of when we want to disable the animation.
|
||||
const layoutScrollWithoutAnimation = useSharedValue(false)
|
||||
|
||||
useKeyboardHandler({
|
||||
onStart: e => {
|
||||
'worklet'
|
||||
// Immediate updates - like opening the emoji picker - will have a duration of zero. In those cases, we should
|
||||
// just update the height here instead of having the `onMove` event do it (that event will not fire!)
|
||||
if (e.duration === 0) {
|
||||
layoutScrollWithoutAnimation.value = true
|
||||
keyboardHeight.value = e.height
|
||||
} else {
|
||||
keyboardIsOpening.value = true
|
||||
}
|
||||
},
|
||||
onMove: e => {
|
||||
'worklet'
|
||||
keyboardHeight.value = e.height
|
||||
if (e.height > bottomOffset) {
|
||||
scrollTo(flatListRef, 0, 1e7, false)
|
||||
}
|
||||
},
|
||||
onEnd: () => {
|
||||
'worklet'
|
||||
keyboardIsOpening.value = false
|
||||
},
|
||||
})
|
||||
|
||||
const animatedListStyle = useAnimatedStyle(() => ({
|
||||
marginBottom:
|
||||
keyboardHeight.value > bottomOffset ? keyboardHeight.value : bottomOffset,
|
||||
}))
|
||||
|
||||
// -- Message sending
|
||||
const onSendMessage = useCallback(
|
||||
async (text: string) => {
|
||||
let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true})
|
||||
|
||||
// detect facets without resolution first - this is used to see if there's
|
||||
// any post links in the text that we can embed. We do this first because
|
||||
// we want to remove the post link from the text, re-trim, then detect facets
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
let embed: AppBskyEmbedRecord.Main | undefined
|
||||
|
||||
if (embedUri) {
|
||||
try {
|
||||
const post = await getPost({uri: embedUri})
|
||||
if (post) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.record',
|
||||
record: {
|
||||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
},
|
||||
}
|
||||
|
||||
// look for the embed uri in the facets, so we can remove it from the text
|
||||
const postLinkFacet = rt.facets?.find(facet => {
|
||||
return facet.features.find(feature => {
|
||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||
if (isBskyPostUrl(feature.uri)) {
|
||||
const url = convertBskyAppUrlIfNeeded(feature.uri)
|
||||
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
|
||||
|
||||
// this might have a handle instead of a DID
|
||||
// so just compare the rkey - not particularly dangerous
|
||||
return post.uri.endsWith(rkey)
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
if (postLinkFacet) {
|
||||
const isAtStart = postLinkFacet.index.byteStart === 0
|
||||
const isAtEnd =
|
||||
postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength
|
||||
|
||||
// remove the post link from the text
|
||||
if (isAtStart || isAtEnd) {
|
||||
rt.delete(
|
||||
postLinkFacet.index.byteStart,
|
||||
postLinkFacet.index.byteEnd,
|
||||
)
|
||||
}
|
||||
|
||||
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get post as quote for DM', {error})
|
||||
}
|
||||
}
|
||||
|
||||
await rt.detectFacets(agent)
|
||||
|
||||
rt = shortenLinks(rt)
|
||||
rt = stripInvalidMentions(rt)
|
||||
|
||||
if (!hasScrolled) {
|
||||
setHasScrolled(true)
|
||||
}
|
||||
|
||||
convoState.sendMessage({
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
embed,
|
||||
})
|
||||
},
|
||||
[agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
|
||||
)
|
||||
|
||||
// -- List layout changes (opening emoji keyboard, etc.)
|
||||
const onListLayout = React.useCallback(
|
||||
(e: LayoutChangeEvent) => {
|
||||
layoutHeight.value = e.nativeEvent.layout.height
|
||||
|
||||
if (isWeb || !keyboardIsOpening.value) {
|
||||
flatListRef.current?.scrollToEnd({
|
||||
animated: !layoutScrollWithoutAnimation.value,
|
||||
})
|
||||
layoutScrollWithoutAnimation.value = false
|
||||
}
|
||||
},
|
||||
[
|
||||
flatListRef,
|
||||
keyboardIsOpening.value,
|
||||
layoutScrollWithoutAnimation,
|
||||
layoutHeight,
|
||||
],
|
||||
)
|
||||
|
||||
const scrollToEndOnPress = React.useCallback(() => {
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: prevContentHeight.current,
|
||||
animated: true,
|
||||
})
|
||||
}, [flatListRef])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
||||
<ScrollProvider onScroll={onScroll}>
|
||||
<List
|
||||
ref={flatListRef}
|
||||
data={convoState.items}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
disableFullWindowScroll={true}
|
||||
disableVirtualization={true}
|
||||
style={animatedListStyle}
|
||||
// The extra two items account for the header and the footer components
|
||||
initialNumToRender={isNative ? 32 : 62}
|
||||
maxToRenderPerBatch={isWeb ? 32 : 62}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
maintainVisibleContentPosition={{
|
||||
minIndexForVisible: 0,
|
||||
}}
|
||||
removeClippedSubviews={false}
|
||||
sideBorders={false}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
onLayout={onListLayout}
|
||||
onStartReached={onStartReached}
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
scrollEventThrottle={100}
|
||||
ListHeaderComponent={
|
||||
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
||||
}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
<KeyboardStickyView offset={{closed: -bottomOffset, opened: 0}}>
|
||||
{convoState.status === ConvoStatus.Disabled ? (
|
||||
<ChatDisabled />
|
||||
) : blocked ? (
|
||||
footer
|
||||
) : (
|
||||
<>
|
||||
{isConvoActive(convoState) &&
|
||||
!convoState.isFetchingHistory &&
|
||||
convoState.items.length === 0 && <ChatEmptyPill />}
|
||||
<MessageInput
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}
|
||||
openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}>
|
||||
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
||||
</MessageInput>
|
||||
</>
|
||||
)}
|
||||
</KeyboardStickyView>
|
||||
|
||||
{isWeb && (
|
||||
<EmojiPicker
|
||||
pinToTop
|
||||
state={emojiPickerState}
|
||||
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user