Groupchats feature branch (#10181)
Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type LayoutChangeEvent, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
moderateProfile,
|
||||
type ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {ScrollEdgeEffectProvider} from '@bsky.app/expo-scroll-edge-effect'
|
||||
import {
|
||||
ScrollEdgeEffect,
|
||||
ScrollEdgeEffectProvider,
|
||||
} from '@bsky.app/expo-scroll-edge-effect'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
@@ -45,7 +49,7 @@ import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
|
||||
import {Error} from '#/components/Error'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
CommonNavigatorParams,
|
||||
@@ -83,7 +87,10 @@ export function MessagesConversationScreenInner({route}: Props) {
|
||||
)
|
||||
|
||||
return (
|
||||
<Layout.Screen testID="convoScreen" style={web([{minHeight: 0}, a.flex_1])}>
|
||||
<Layout.Screen
|
||||
testID="convoScreen"
|
||||
noInsetTop={IS_LIQUID_GLASS}
|
||||
style={web([{minHeight: 0}, a.flex_1])}>
|
||||
<ScrollEdgeEffectProvider>
|
||||
<ConvoProvider key={convoId} convoId={convoId}>
|
||||
<Inner />
|
||||
@@ -98,10 +105,11 @@ function Inner() {
|
||||
const convoState = useConvo()
|
||||
const {_} = useLingui()
|
||||
const isFocused = useIsFocused()
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {data: recipientUnshadowed} = useProfileQuery({
|
||||
did: convoState.recipients?.[0].did,
|
||||
did: convoState.getPrimaryMember?.()?.did,
|
||||
})
|
||||
const recipient = useMaybeProfileShadow(recipientUnshadowed)
|
||||
|
||||
@@ -133,9 +141,10 @@ function Inner() {
|
||||
if (convoState.status === ConvoStatus.Error) {
|
||||
return (
|
||||
<>
|
||||
<Layout.Center style={[a.flex_1]}>
|
||||
<Layout.Center
|
||||
style={[a.flex_1, IS_LIQUID_GLASS && {paddingTop: topInset}]}>
|
||||
{moderation ? (
|
||||
<MessagesListHeader moderation={moderation} profile={recipient} />
|
||||
<MessagesListHeader profile={recipient} moderation={moderation} />
|
||||
) : (
|
||||
<MessagesListHeader />
|
||||
)}
|
||||
@@ -154,12 +163,15 @@ function Inner() {
|
||||
<Layout.Center style={[a.flex_1]}>
|
||||
{/* MessagesList does not use the body scroll */}
|
||||
{isFocused && IS_WEB && <RemoveScrollBar />}
|
||||
{!readyToShow &&
|
||||
(moderation ? (
|
||||
<MessagesListHeader moderation={moderation} profile={recipient} />
|
||||
) : (
|
||||
<MessagesListHeader />
|
||||
))}
|
||||
{!readyToShow && (
|
||||
<View style={IS_LIQUID_GLASS && {paddingTop: topInset}}>
|
||||
{moderation ? (
|
||||
<MessagesListHeader profile={recipient} moderation={moderation} />
|
||||
) : (
|
||||
<MessagesListHeader />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<View style={[a.flex_1]}>
|
||||
{moderation && recipient ? (
|
||||
<InnerReady
|
||||
@@ -205,6 +217,11 @@ function InnerReady({
|
||||
}) {
|
||||
const convoState = useConvo()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
const [headerHeight, setHeaderHeight] = useState(0)
|
||||
const onHeaderLayout = (e: LayoutChangeEvent) => {
|
||||
setHeaderHeight(e.nativeEvent.layout.height)
|
||||
}
|
||||
const {params} =
|
||||
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
|
||||
const {needsEmailVerification} = useEmail()
|
||||
@@ -248,15 +265,29 @@ function InnerReady({
|
||||
maybeBlockForEmailVerification()
|
||||
}, [maybeBlockForEmailVerification])
|
||||
|
||||
const header = (
|
||||
<MessagesListHeader profile={recipient} moderation={moderation} />
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<MessagesListHeader profile={recipient} moderation={moderation} />
|
||||
{IS_LIQUID_GLASS ? (
|
||||
<ScrollEdgeEffect
|
||||
edge="top"
|
||||
style={[a.absolute, a.w_full, a.z_10, {paddingTop: topInset}]}
|
||||
onLayout={onHeaderLayout}>
|
||||
{header}
|
||||
</ScrollEdgeEffect>
|
||||
) : (
|
||||
header
|
||||
)}
|
||||
{isConvoActive(convoState) && (
|
||||
<MessagesList
|
||||
hasScrolled={hasScrolled}
|
||||
setHasScrolled={setHasScrolled}
|
||||
blocked={moderation?.blocked}
|
||||
hasAcceptOverride={!!params.accept}
|
||||
transparentHeaderHeight={IS_LIQUID_GLASS ? headerHeight : 0}
|
||||
footer={
|
||||
<MessagesListBlockedFooter
|
||||
recipient={recipient}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,40 @@
|
||||
import {memo, useCallback, useMemo, useState} from 'react'
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
ChatBskyActorDefs,
|
||||
ChatBskyConvoDefs,
|
||||
moderateProfile,
|
||||
type ModerationDecision,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {GestureActionView} from '#/lib/custom-animations/GestureActionView'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {decrementBadgeCount} from '#/lib/notifications/notifications'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {
|
||||
postUriToRelativePath,
|
||||
toBskyAppUrl,
|
||||
toShortUrl,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {
|
||||
precacheConvoQuery,
|
||||
useMarkAsReadMutation,
|
||||
} from '#/state/queries/messages/conversation'
|
||||
import {precacheProfile} from '#/state/queries/profile'
|
||||
import {unstableCacheProfileView} from '#/state/queries/profile'
|
||||
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 {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||
@@ -45,11 +49,17 @@ import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export const ChatListItemPortal = createPortalGroup()
|
||||
|
||||
export let ChatListItem = ({
|
||||
/**
|
||||
* IMPORTANT NOTE: THIS IS CURRENTLY JANKY AF AND PROBABLY BROKEN, JUST WANTED TO ADD GROUPCHAT SUPPPORT
|
||||
*
|
||||
* TAKE A SECOND PASS PLEASE -sfn
|
||||
*/
|
||||
|
||||
export function ChatListItem({
|
||||
convo,
|
||||
showMenu = true,
|
||||
children,
|
||||
@@ -57,32 +67,77 @@ export let ChatListItem = ({
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
showMenu?: boolean
|
||||
children?: React.ReactNode
|
||||
}): React.ReactNode => {
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
const otherUser = convo.members.find(
|
||||
member => member.did !== currentAccount?.did,
|
||||
)
|
||||
|
||||
if (!otherUser || !moderationOpts) {
|
||||
if (!moderationOpts) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatListItemReady
|
||||
convo={convo}
|
||||
profile={otherUser}
|
||||
moderationOpts={moderationOpts}
|
||||
showMenu={showMenu}>
|
||||
{children}
|
||||
</ChatListItemReady>
|
||||
)
|
||||
if (
|
||||
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
|
||||
convo.kind,
|
||||
ChatBskyConvoDefs.isGroupConvo,
|
||||
)
|
||||
) {
|
||||
const owner = convo.members.find(r => {
|
||||
if (
|
||||
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
|
||||
r.kind,
|
||||
ChatBskyActorDefs.isGroupConvoMember,
|
||||
)
|
||||
) {
|
||||
return r.kind.role === 'owner'
|
||||
} else {
|
||||
throw new Error(
|
||||
'Expected a GroupConvoMember, got an unknown kind of member',
|
||||
)
|
||||
}
|
||||
})
|
||||
if (!owner) {
|
||||
// TODO: Determine if this is the right thing to do here. Throwing here so that
|
||||
// if it turns out to be wrong it'll be very visible
|
||||
throw new Error('Could not find the group owner in the group members')
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupChatItem
|
||||
convo={convo}
|
||||
groupOwner={owner}
|
||||
groupInfo={convo.kind}
|
||||
moderationOpts={moderationOpts}
|
||||
showMenu={showMenu}
|
||||
/>
|
||||
)
|
||||
} else if (
|
||||
bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>(
|
||||
convo.kind,
|
||||
ChatBskyConvoDefs.isDirectConvo,
|
||||
)
|
||||
) {
|
||||
const otherMember = convo.members.find(
|
||||
member => member.did !== currentAccount?.did,
|
||||
)
|
||||
|
||||
if (!otherMember) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<DirectChatItem
|
||||
convo={convo}
|
||||
profile={otherMember}
|
||||
moderationOpts={moderationOpts}
|
||||
showMenu={showMenu}>
|
||||
{children}
|
||||
</DirectChatItem>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
ChatListItem = memo(ChatListItem)
|
||||
|
||||
function ChatListItemReady({
|
||||
function DirectChatItem({
|
||||
convo,
|
||||
profile: profileUnshadowed,
|
||||
moderationOpts,
|
||||
@@ -95,25 +150,140 @@ function ChatListItemReady({
|
||||
showMenu?: boolean
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const menuControl = useMenuControl()
|
||||
const leaveConvoControl = useDialogControl()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {t: l} = useLingui()
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const {mutate: markAsRead} = useMarkAsReadMutation()
|
||||
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
[profile, moderationOpts],
|
||||
)
|
||||
|
||||
const isDeletedAccount = profile.handle === 'missing.invalid'
|
||||
const displayName = isDeletedAccount
|
||||
? l`Deleted Account`
|
||||
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
|
||||
|
||||
return (
|
||||
<BaseChatItem
|
||||
convo={convo}
|
||||
avatar={
|
||||
<PreviewableUserAvatar
|
||||
profile={profile}
|
||||
size={52}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
}
|
||||
primaryProfile={profile}
|
||||
primaryProfileModeration={moderation}
|
||||
title={displayName}
|
||||
subtitle={isDeletedAccount ? undefined : sanitizeHandle(profile.handle)}
|
||||
accessibilityHint={
|
||||
!isDeletedAccount
|
||||
? l`Go to conversation with ${profile.handle}`
|
||||
: l`This conversation is with a deleted or a deactivated account. Press for options`
|
||||
}
|
||||
showMenu={showMenu}
|
||||
isDeletedAccount={isDeletedAccount}
|
||||
isBlockedAccount={moderation.blocked}
|
||||
showProfileBadges
|
||||
postAlerts={
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentList')}
|
||||
size="lg"
|
||||
style={[a.pt_xs]}
|
||||
/>
|
||||
}>
|
||||
{children}
|
||||
</BaseChatItem>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupChatItem({
|
||||
convo,
|
||||
groupOwner: groupOwnerUnshadowed,
|
||||
groupInfo,
|
||||
moderationOpts,
|
||||
showMenu,
|
||||
children,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
groupOwner: bsky.profile.AnyProfileView
|
||||
groupInfo: ChatBskyConvoDefs.GroupConvo
|
||||
moderationOpts: ModerationOpts
|
||||
showMenu?: boolean
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const groupOwner = useProfileShadow(groupOwnerUnshadowed)
|
||||
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(groupOwner, moderationOpts),
|
||||
[groupOwner, moderationOpts],
|
||||
)
|
||||
|
||||
const chatName = groupInfo.name ?? l`${groupOwner.handle}'s group chat`
|
||||
|
||||
return (
|
||||
<BaseChatItem
|
||||
convo={convo}
|
||||
avatar={<AvatarBubbles profiles={convo.members} size="medium" />}
|
||||
title={chatName}
|
||||
accessibilityHint={l`Go to the group chat named "${chatName}"`}
|
||||
primaryProfile={groupOwner}
|
||||
primaryProfileModeration={moderation}
|
||||
isBlockedAccount={false}
|
||||
isDeletedAccount={false}
|
||||
showProfileBadges={false}
|
||||
showMenu={showMenu}>
|
||||
{children}
|
||||
</BaseChatItem>
|
||||
)
|
||||
}
|
||||
|
||||
function BaseChatItem({
|
||||
convo,
|
||||
avatar,
|
||||
title,
|
||||
subtitle,
|
||||
accessibilityHint,
|
||||
isDeletedAccount,
|
||||
isBlockedAccount,
|
||||
primaryProfile,
|
||||
primaryProfileModeration,
|
||||
showMenu,
|
||||
showProfileBadges,
|
||||
postAlerts,
|
||||
children,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
avatar: React.ReactNode
|
||||
title: string
|
||||
subtitle?: string
|
||||
accessibilityHint: string
|
||||
isDeletedAccount: boolean
|
||||
isBlockedAccount: boolean
|
||||
primaryProfile: Shadow<bsky.profile.AnyProfileView>
|
||||
primaryProfileModeration: ModerationDecision
|
||||
showMenu?: boolean
|
||||
showProfileBadges: boolean
|
||||
postAlerts?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const menuControl = useMenuControl()
|
||||
const leaveConvoControl = useDialogControl()
|
||||
const {mutate: markAsRead} = useMarkAsReadMutation()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const playHaptic = useHaptics()
|
||||
const queryClient = useQueryClient()
|
||||
const isUnread = convo.unreadCount > 0
|
||||
|
||||
const blockInfo = useMemo(() => {
|
||||
const modui = moderation.ui('profileView')
|
||||
const modui = primaryProfileModeration.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')
|
||||
@@ -121,21 +291,13 @@ function ChatListItemReady({
|
||||
listBlocks,
|
||||
userBlock,
|
||||
}
|
||||
}, [moderation])
|
||||
}, [primaryProfileModeration])
|
||||
|
||||
const isDeletedAccount = profile.handle === 'missing.invalid'
|
||||
const displayName = isDeletedAccount
|
||||
? _(msg`Deleted Account`)
|
||||
: sanitizeDisplayName(
|
||||
profile.displayName || profile.handle,
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
|
||||
const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount
|
||||
const isDimStyle = convo.muted || isBlockedAccount || isDeletedAccount
|
||||
|
||||
const {lastMessage, lastMessageSentAt, latestReportableMessage} =
|
||||
useMemo(() => {
|
||||
let lastMessage = _(msg`No messages yet`)
|
||||
let lastMessage = l`No messages yet`
|
||||
|
||||
let lastMessageSentAt: string | null = null
|
||||
|
||||
@@ -150,14 +312,12 @@ function ChatListItemReady({
|
||||
|
||||
if (convo.lastMessage.text) {
|
||||
if (isFromMe) {
|
||||
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
|
||||
lastMessage = l`You: ${convo.lastMessage.text}`
|
||||
} else {
|
||||
lastMessage = convo.lastMessage.text
|
||||
}
|
||||
} else if (convo.lastMessage.embed) {
|
||||
const defaultEmbeddedContentMessage = _(
|
||||
msg`(contains embedded content)`,
|
||||
)
|
||||
const defaultEmbeddedContentMessage = l`(contains embedded content)`
|
||||
|
||||
if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) {
|
||||
const embed = convo.lastMessage.embed
|
||||
@@ -172,14 +332,14 @@ function ChatListItemReady({
|
||||
? toShortUrl(href)
|
||||
: defaultEmbeddedContentMessage
|
||||
if (isFromMe) {
|
||||
lastMessage = _(msg`You: ${short}`)
|
||||
lastMessage = l`You: ${short}`
|
||||
} else {
|
||||
lastMessage = short
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isFromMe) {
|
||||
lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`)
|
||||
lastMessage = l`You: ${defaultEmbeddedContentMessage}`
|
||||
} else {
|
||||
lastMessage = defaultEmbeddedContentMessage
|
||||
}
|
||||
@@ -192,8 +352,8 @@ function ChatListItemReady({
|
||||
lastMessageSentAt = convo.lastMessage.sentAt
|
||||
|
||||
lastMessage = isDeletedAccount
|
||||
? _(msg`Conversation deleted`)
|
||||
: _(msg`Message deleted`)
|
||||
? l`Conversation deleted`
|
||||
: l`Message deleted`
|
||||
}
|
||||
|
||||
if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) {
|
||||
@@ -205,44 +365,36 @@ function ChatListItemReady({
|
||||
const isFromMe =
|
||||
convo.lastReaction.reaction.sender.did === currentAccount?.did
|
||||
const lastMessageText = convo.lastReaction.message.text
|
||||
const fallbackMessage = _(
|
||||
msg({
|
||||
message: 'a message',
|
||||
comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`,
|
||||
}),
|
||||
)
|
||||
const fallbackMessage = l({
|
||||
message: 'a message',
|
||||
comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`,
|
||||
})
|
||||
|
||||
if (isFromMe) {
|
||||
lastMessage = _(
|
||||
msg`You reacted ${convo.lastReaction.reaction.value} to ${
|
||||
lastMessageText
|
||||
? `"${convo.lastReaction.message.text}"`
|
||||
: fallbackMessage
|
||||
}`,
|
||||
)
|
||||
lastMessage = l`You reacted ${convo.lastReaction.reaction.value} to ${
|
||||
lastMessageText
|
||||
? `"${convo.lastReaction.message.text}"`
|
||||
: fallbackMessage
|
||||
}`
|
||||
} else {
|
||||
const senderDid = convo.lastReaction.reaction.sender.did
|
||||
const sender = convo.members.find(
|
||||
member => member.did === senderDid,
|
||||
)
|
||||
if (sender) {
|
||||
lastMessage = _(
|
||||
msg`${sanitizeDisplayName(
|
||||
sender.displayName || sender.handle,
|
||||
)} reacted ${convo.lastReaction.reaction.value} to ${
|
||||
lastMessageText
|
||||
? `"${convo.lastReaction.message.text}"`
|
||||
: fallbackMessage
|
||||
}`,
|
||||
)
|
||||
lastMessage = l`${sanitizeDisplayName(
|
||||
sender.displayName || sender.handle,
|
||||
)} reacted ${convo.lastReaction.reaction.value} to ${
|
||||
lastMessageText
|
||||
? `"${convo.lastReaction.message.text}"`
|
||||
: fallbackMessage
|
||||
}`
|
||||
} else {
|
||||
lastMessage = _(
|
||||
msg`Someone reacted ${convo.lastReaction.reaction.value} to ${
|
||||
lastMessageText
|
||||
? `"${convo.lastReaction.message.text}"`
|
||||
: fallbackMessage
|
||||
}`,
|
||||
)
|
||||
lastMessage = l`Someone reacted ${convo.lastReaction.reaction.value} to ${
|
||||
lastMessageText
|
||||
? `"${convo.lastReaction.message.text}"`
|
||||
: fallbackMessage
|
||||
}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,7 +406,7 @@ function ChatListItemReady({
|
||||
latestReportableMessage,
|
||||
}
|
||||
}, [
|
||||
_,
|
||||
l,
|
||||
convo.lastMessage,
|
||||
convo.lastReaction,
|
||||
currentAccount?.did,
|
||||
@@ -279,9 +431,11 @@ function ChatListItemReady({
|
||||
|
||||
const onPress = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
precacheProfile(queryClient, profile)
|
||||
for (const member of convo.members) {
|
||||
unstableCacheProfileView(queryClient, member)
|
||||
}
|
||||
precacheConvoQuery(queryClient, convo)
|
||||
decrementBadgeCount(convo.unreadCount)
|
||||
void decrementBadgeCount(convo.unreadCount)
|
||||
if (isDeletedAccount) {
|
||||
e.preventDefault()
|
||||
menuControl.open()
|
||||
@@ -290,7 +444,7 @@ function ChatListItemReady({
|
||||
ax.metric('chat:open', {logContext: 'ChatsList'})
|
||||
}
|
||||
},
|
||||
[ax, isDeletedAccount, menuControl, queryClient, profile, convo],
|
||||
[ax, isDeletedAccount, menuControl, queryClient, convo],
|
||||
)
|
||||
|
||||
const onLongPress = useCallback(() => {
|
||||
@@ -345,33 +499,23 @@ function ChatListItemReady({
|
||||
a.absolute,
|
||||
{top: tokens.space.md, left: tokens.space.lg},
|
||||
]}>
|
||||
<PreviewableUserAvatar
|
||||
profile={profile}
|
||||
size={52}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
{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`,
|
||||
)
|
||||
}
|
||||
label={title}
|
||||
accessibilityHint={accessibilityHint}
|
||||
accessibilityActions={
|
||||
IS_NATIVE
|
||||
? [
|
||||
{
|
||||
name: 'magicTap',
|
||||
label: _(msg`Open conversation options`),
|
||||
label: l`Open conversation options`,
|
||||
},
|
||||
{
|
||||
name: 'longpress',
|
||||
label: _(msg`Open conversation options`),
|
||||
label: l`Open conversation options`,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
@@ -407,14 +551,18 @@ function ChatListItemReady({
|
||||
{lineHeight: 21},
|
||||
isDimStyle && t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{displayName}
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
<ProfileBadges
|
||||
profile={profile}
|
||||
size="md"
|
||||
style={[a.pl_xs, a.self_center]}
|
||||
/>
|
||||
|
||||
{showProfileBadges && (
|
||||
<ProfileBadges
|
||||
profile={primaryProfile}
|
||||
size="md"
|
||||
style={[a.pl_xs, a.self_center]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{lastMessageSentAt && (
|
||||
<View style={[a.pl_xs]}>
|
||||
<TimeElapsed timestamp={lastMessageSentAt}>
|
||||
@@ -432,7 +580,7 @@ function ChatListItemReady({
|
||||
</TimeElapsed>
|
||||
</View>
|
||||
)}
|
||||
{(convo.muted || moderation.blocked) && (
|
||||
{(convo.muted || isBlockedAccount) && (
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
@@ -450,7 +598,7 @@ function ChatListItemReady({
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!isDeletedAccount && (
|
||||
{subtitle && (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
@@ -458,7 +606,7 @@ function ChatListItemReady({
|
||||
t.atoms.text_contrast_medium,
|
||||
a.pb_xs,
|
||||
]}>
|
||||
@{profile.handle}
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -474,11 +622,7 @@ function ChatListItemReady({
|
||||
{lastMessage}
|
||||
</Text>
|
||||
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentList')}
|
||||
size="lg"
|
||||
style={[a.pt_xs]}
|
||||
/>
|
||||
{postAlerts}
|
||||
|
||||
{children}
|
||||
</View>
|
||||
@@ -509,7 +653,7 @@ function ChatListItemReady({
|
||||
{showMenu && (
|
||||
<ConvoMenu
|
||||
convo={convo}
|
||||
profile={profile}
|
||||
profile={primaryProfile}
|
||||
control={menuControl}
|
||||
currentScreen="list"
|
||||
showMarkAsRead={convo.unreadCount > 0}
|
||||
@@ -529,6 +673,7 @@ function ChatListItemReady({
|
||||
latestReportableMessage={latestReportableMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LeaveConvoPrompt
|
||||
control={leaveConvoControl}
|
||||
convoId={convo.id}
|
||||
|
||||
@@ -15,8 +15,7 @@ import Animated, {
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {GlassContainer} from 'expo-glass-effect'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||
|
||||
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||
@@ -47,13 +46,13 @@ export function MessageInput({
|
||||
children,
|
||||
}: {
|
||||
textInputId?: string
|
||||
onSendMessage: (message: string) => void
|
||||
onSendMessage: (message: string) => Promise<void> | void
|
||||
hasEmbed: boolean
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
children?: React.ReactNode
|
||||
openEmojiPicker?: (pos: EmojiPickerPosition) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
const {getDraft, clearDraft} = useMessageDraft()
|
||||
@@ -82,13 +81,13 @@ export function MessageInput({
|
||||
return
|
||||
}
|
||||
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
||||
Toast.show(_(msg`Message is too long`), {
|
||||
Toast.show(l`Message is too long`, {
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
clearDraft()
|
||||
onSendMessage(message)
|
||||
void onSendMessage(message)
|
||||
playHaptic()
|
||||
setEmbed(undefined)
|
||||
setMessage('')
|
||||
@@ -111,7 +110,7 @@ export function MessageInput({
|
||||
playHaptic,
|
||||
setEmbed,
|
||||
inputRef,
|
||||
_,
|
||||
l,
|
||||
])
|
||||
|
||||
useFocusedInputHandler(
|
||||
@@ -169,9 +168,9 @@ export function MessageInput({
|
||||
fallbackStyle={[t.atoms.bg_contrast_50]}>
|
||||
<AnimatedTextInput
|
||||
nativeID={textInputId}
|
||||
accessibilityLabel={_(msg`Message input field`)}
|
||||
accessibilityHint={_(msg`Type your message here`)}
|
||||
placeholder={_(msg`Message`)}
|
||||
accessibilityLabel={l`Message input field`}
|
||||
accessibilityHint={l`Type your message here`}
|
||||
placeholder={l`Message`}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
value={message}
|
||||
onChange={evt => {
|
||||
@@ -225,7 +224,7 @@ export function MessageInput({
|
||||
}}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Send message`)}
|
||||
accessibilityLabel={l`Send message`}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {flushSync} from 'react-dom'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||
@@ -40,7 +39,7 @@ export function MessageInput({
|
||||
openEmojiPicker?: (pos: EmojiPickerPosition) => void
|
||||
}) {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const {getDraft, clearDraft} = useMessageDraft()
|
||||
const [message, setMessage] = useState(getDraft)
|
||||
@@ -57,7 +56,7 @@ export function MessageInput({
|
||||
return
|
||||
}
|
||||
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
||||
Toast.show(_(msg`Message is too long`), {
|
||||
Toast.show(l`Message is too long`, {
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
@@ -66,7 +65,7 @@ export function MessageInput({
|
||||
onSendMessage(message)
|
||||
setMessage('')
|
||||
setEmbed(undefined)
|
||||
}, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed])
|
||||
}, [message, onSendMessage, l, clearDraft, hasEmbed, setEmbed])
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -177,7 +176,7 @@ export function MessageInput({
|
||||
width: 30,
|
||||
},
|
||||
]}
|
||||
label={_(msg`Open emoji picker`)}>
|
||||
label={l`Open emoji picker`}>
|
||||
{state => (
|
||||
<View
|
||||
style={[
|
||||
@@ -210,7 +209,7 @@ export function MessageInput({
|
||||
},
|
||||
])}
|
||||
maxRows={12}
|
||||
placeholder={_(msg`Write a message`)}
|
||||
placeholder={l`Message`}
|
||||
defaultValue=""
|
||||
value={message}
|
||||
dirName="ltr"
|
||||
@@ -231,7 +230,7 @@ export function MessageInput({
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Send message`)}
|
||||
accessibilityLabel={l`Send message`}
|
||||
accessibilityHint=""
|
||||
style={[
|
||||
a.rounded_full,
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
import {useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {type 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 {createStaticClick, InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {description, help, cta} = useMemo(() => {
|
||||
return {
|
||||
[ConvoItemError.FirehoseFailed]: {
|
||||
description: _(msg`This chat was disconnected`),
|
||||
help: _(msg`Press to attempt reconnection`),
|
||||
cta: _(msg`Reconnect`),
|
||||
description: l`This chat was disconnected`,
|
||||
help: l`Press to attempt reconnection`,
|
||||
cta: l`Reconnect`,
|
||||
},
|
||||
[ConvoItemError.HistoryFailed]: {
|
||||
description: _(msg`Failed to load past messages`),
|
||||
help: _(msg`Press to retry`),
|
||||
cta: _(msg`Retry`),
|
||||
description: l`Failed to load past messages`,
|
||||
help: l`Press to retry`,
|
||||
cta: l`Retry`,
|
||||
},
|
||||
}[item.code]
|
||||
}, [_, item.code])
|
||||
}, [l, item.code])
|
||||
|
||||
return (
|
||||
<View style={[a.py_md, a.w_full, a.flex_row, a.justify_center]}>
|
||||
<View style={[a.my_md, a.w_full, a.flex_row, a.justify_center]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
@@ -41,18 +40,18 @@ export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) {
|
||||
<CircleInfo size="sm" fill={t.palette.negative_400} />
|
||||
|
||||
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
{description} ·{' '}
|
||||
{description}
|
||||
{item.retry && (
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label={help}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
item.retry?.()
|
||||
return false
|
||||
}}>
|
||||
{cta}
|
||||
</InlineLinkText>
|
||||
<>
|
||||
·{' '}
|
||||
<InlineLinkText
|
||||
label={help}
|
||||
{...createStaticClick(() => {
|
||||
item.retry?.()
|
||||
})}>
|
||||
{cta}
|
||||
</InlineLinkText>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type KeyboardChatScrollViewProps,
|
||||
KeyboardGestureArea,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
import {
|
||||
runOnJS,
|
||||
type ScrollEvent,
|
||||
type SharedValue,
|
||||
@@ -77,18 +77,6 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) {
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -103,12 +91,14 @@ export function MessagesList({
|
||||
blocked,
|
||||
footer,
|
||||
hasAcceptOverride,
|
||||
transparentHeaderHeight,
|
||||
}: {
|
||||
hasScrolled: boolean
|
||||
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
|
||||
blocked?: boolean
|
||||
footer?: React.ReactNode
|
||||
hasAcceptOverride?: boolean
|
||||
transparentHeaderHeight?: number
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const convoState = useConvoActive()
|
||||
@@ -155,6 +145,16 @@ export function MessagesList({
|
||||
const prevContentHeight = useRef(0)
|
||||
const prevItemCount = useRef(0)
|
||||
|
||||
// Tracks whether the initial scroll-to-bottom has been triggered. Separated from isAtBottom so that contentInset
|
||||
// (which causes an early onScroll with negative offset) can't prevent the first scroll.
|
||||
// Reset when hasScrolled goes back to false (e.g. convo re-initialization after backgrounding).
|
||||
const hasInitiallyScrolled = useRef(false)
|
||||
const prevHasScrolled = useRef(hasScrolled)
|
||||
if (prevHasScrolled.current && !hasScrolled) {
|
||||
hasInitiallyScrolled.current = false
|
||||
}
|
||||
prevHasScrolled.current = hasScrolled
|
||||
|
||||
// -- Keep track of background state and positioning for new pill
|
||||
const layoutHeight = useSharedValue(0)
|
||||
const didBackground = useRef(false)
|
||||
@@ -187,8 +187,25 @@ export function MessagesList({
|
||||
})
|
||||
}
|
||||
|
||||
// This number _must_ be the height of the MaybeLoader component
|
||||
if (height > 50 && isAtBottom.get()) {
|
||||
// Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset
|
||||
// can cause an early onScroll with a negative offset that sets isAtBottom to false before we get here.
|
||||
if (!hasInitiallyScrolled.current && convoState.items.length > 0) {
|
||||
hasInitiallyScrolled.current = true
|
||||
flatListRef.current?.scrollToOffset({offset: height, animated: false})
|
||||
// If history is already done loading, mark ready after a frame for the scroll to settle.
|
||||
// Otherwise, the footer sentinel's onLayout will handle it when history finishes.
|
||||
if (!convoState.isFetchingHistory) {
|
||||
requestAnimationFrame(() => {
|
||||
setHasScrolled(true)
|
||||
})
|
||||
}
|
||||
prevContentHeight.current = height
|
||||
prevItemCount.current = convoState.items.length
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent: auto-scroll only if user is at the bottom
|
||||
if (isAtBottom.get()) {
|
||||
// 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
|
||||
@@ -212,17 +229,6 @@ export function MessagesList({
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +375,40 @@ export function MessagesList({
|
||||
setEmojiPickerState({isOpen: true, pos})
|
||||
}, [])
|
||||
|
||||
const renderItem = ({item}: {item: ConvoItem}) => {
|
||||
if (item.type === 'message' || item.type === 'pending-message') {
|
||||
return (
|
||||
<MessageItem
|
||||
item={item}
|
||||
profile={convoState.convo.members.find(
|
||||
member => member.did === item.message.sender.did,
|
||||
)}
|
||||
isGroupChat={convoState.getGroupInfo?.() != null}
|
||||
/>
|
||||
)
|
||||
} else if (item.type === 'deleted-message') {
|
||||
return <Text>Deleted message</Text>
|
||||
} else if (item.type === 'error') {
|
||||
return <MessageListError item={item} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Footer sentinel: when history is still loading during the initial scroll, the footer's onLayout fires each time
|
||||
// new items are prepended (shifting its position). Once history finishes, this triggers setHasScrolled.
|
||||
const onFooterLayout = useCallback(() => {
|
||||
if (
|
||||
hasInitiallyScrolled.current &&
|
||||
!hasScrolled &&
|
||||
!convoState.isFetchingHistory
|
||||
) {
|
||||
requestAnimationFrame(() => {
|
||||
setHasScrolled(true)
|
||||
})
|
||||
}
|
||||
}, [hasScrolled, setHasScrolled, convoState.isFetchingHistory])
|
||||
|
||||
const renderScrollComponent = useCallback(
|
||||
(props: ScrollViewProps) => (
|
||||
<ChatScrollComponent {...props} inputHeight={inputHeightUI} />
|
||||
@@ -382,7 +422,8 @@ export function MessagesList({
|
||||
interpolator="ios"
|
||||
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
||||
offset={Math.round(inputHeightJS)}
|
||||
textInputNativeID={textInputId}
|
||||
// slightly too buggy unfortunately, enable when possible
|
||||
// textInputNativeID={textInputId}
|
||||
style={[a.flex_1]}>
|
||||
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
||||
<ScrollProvider onScroll={onScroll}>
|
||||
@@ -411,15 +452,27 @@ export function MessagesList({
|
||||
}
|
||||
// native only (prop is not supported on web)
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
// pushes up the content under the input on web (renderScrollComponent handles it on native)
|
||||
ListFooterComponent={web(
|
||||
<WebInputSpacer inputHeight={inputHeightJS} />,
|
||||
)}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: platform({
|
||||
// ios is slightly larger as the input has no top padding
|
||||
ios: tokens.space.lg,
|
||||
android: tokens.space.md,
|
||||
web: 0, // web uses ListFooterComponent instead for scroll reasons
|
||||
}),
|
||||
}}
|
||||
ListFooterComponent={
|
||||
<View
|
||||
style={web({height: tokens.space.md + inputHeightJS})}
|
||||
onLayout={onFooterLayout}
|
||||
/>
|
||||
}
|
||||
style={web({
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||
scrollbarGutter: 'stable both-edges',
|
||||
})}
|
||||
contentInset={{top: transparentHeaderHeight}}
|
||||
scrollIndicatorInsets={{top: transparentHeaderHeight}}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
<KeyboardStickyView
|
||||
@@ -444,7 +497,9 @@ export function MessagesList({
|
||||
{ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
|
||||
<MessageComposer
|
||||
textInputId={textInputId}
|
||||
onSendMessage={onSendMessage}
|
||||
onSendMessage={(message: string) =>
|
||||
void onSendMessage(message)
|
||||
}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}>
|
||||
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
||||
@@ -518,12 +573,6 @@ function ChatScrollComponent({
|
||||
)
|
||||
}
|
||||
|
||||
function WebInputSpacer({inputHeight}: {inputHeight: number}) {
|
||||
if (!IS_WEB) return null
|
||||
|
||||
return <Animated.View style={{height: inputHeight}} />
|
||||
}
|
||||
|
||||
type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
|
||||
|
||||
function getFooterState(
|
||||
|
||||
Reference in New Issue
Block a user