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:
Samuel Newman
2026-04-15 12:15:45 -07:00
committed by GitHub
parent 75c9e2c181
commit d3f5093817
31 changed files with 2826 additions and 635 deletions
+45 -14
View File
@@ -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
+261 -116
View File
@@ -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} &middot;{' '}
{description}
{item.retry && (
<InlineLinkText
to="#"
label={help}
onPress={e => {
e.preventDefault()
item.retry?.()
return false
}}>
{cta}
</InlineLinkText>
<>
&middot;{' '}
<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(