diff --git a/assets/icons/messagePlus_stroke2_corner0_rounded.svg b/assets/icons/messagePlus_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..bf9e277fb8
--- /dev/null
+++ b/assets/icons/messagePlus_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index 3eb3dbc4b8..1623c5c139 100644
--- a/package.json
+++ b/package.json
@@ -81,7 +81,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
- "@atproto/api": "^0.19.8",
+ "@atproto/api": "^0.19.9",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7",
diff --git a/patches/react-native-keyboard-controller+1.21.5.patch b/patches/react-native-keyboard-controller+1.21.5.patch
new file mode 100644
index 0000000000..d721bbe494
--- /dev/null
+++ b/patches/react-native-keyboard-controller+1.21.5.patch
@@ -0,0 +1,48 @@
+diff --git a/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
+index 24a25ae..2c5ff6d 100644
+--- a/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
++++ b/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
+@@ -1,8 +1,6 @@
+ import { useCallback } from "react";
+-import { Platform } from "react-native";
+ import { scrollTo, useAnimatedReaction } from "react-native-reanimated";
+
+-import { IS_FABRIC } from "../../../architecture";
+ import { isScrollAtEnd, shouldShiftContent } from "../useChatKeyboard/helpers";
+
+ import type { KeyboardLiftBehavior } from "../useChatKeyboard/types";
+@@ -52,7 +50,6 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
+ scroll,
+ layout,
+ size,
+- contentOffsetY,
+ inverted,
+ keyboardLiftBehavior,
+ freeze,
+@@ -62,20 +59,14 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
+ (target: number) => {
+ "worklet";
+
+- if (contentOffsetY && IS_FABRIC) {
+- // eslint-disable-next-line react-compiler/react-compiler
+- contentOffsetY.value = target;
+- } else if (Platform.OS === "android") {
+- // Defer scrollTo so the animatedProps inset commit lands first;
+- // otherwise the native ScrollView clamps to the old range.
+- requestAnimationFrame(() => {
+- scrollTo(scrollViewRef, 0, target, false);
+- });
+- } else {
++ // Always defer scrollTo so the animatedProps inset commit lands first;
++ // otherwise the native ScrollView clamps contentOffset to the old
++ // contentInset range (iOS Fabric) or the old contentInsetBottom (Android).
++ requestAnimationFrame(() => {
+ scrollTo(scrollViewRef, 0, target, false);
+- }
++ });
+ },
+- [scrollViewRef, contentOffsetY],
++ [scrollViewRef],
+ );
+
+ useAnimatedReaction(
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 7ef150e9e5..6f661e03e9 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -78,6 +78,7 @@ import HashtagScreen from '#/screens/Hashtag'
import {LogScreen} from '#/screens/Log'
import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
+import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
@@ -568,6 +569,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => MessagesConversationScreen}
options={{title: title(msg`Chat`), requireAuth: true}}
/>
+ MessagesConversationSettingsScreen}
+ options={{title: title(msg`Group chat settings`), requireAuth: true}}
+ />
MessagesSettingsScreen}
diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
index 1f6b25278b..ccb256e219 100644
--- a/src/analytics/metrics/types.ts
+++ b/src/analytics/metrics/types.ts
@@ -563,6 +563,9 @@ export type Events = {
| 'ChatsList'
| 'SendViaChatDialog'
}
+ 'groupchat:create': {
+ logContext: 'NewChatDialog'
+ }
'starterPack:addUser': {
starterPack?: string
}
diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx
new file mode 100644
index 0000000000..2dd2f3b203
--- /dev/null
+++ b/src/components/AvatarBubbles.tsx
@@ -0,0 +1,260 @@
+import {useCallback, useEffect} from 'react'
+import {type StyleProp, View, type ViewStyle} from 'react-native'
+import Animated, {
+ Easing,
+ interpolate,
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withTiming,
+} from 'react-native-reanimated'
+
+import {useSession} from '#/state/session'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, useTheme} from '#/alf'
+import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
+import type * as bsky from '#/types/bsky'
+
+type Props = {
+ animate?: boolean
+ profiles: bsky.profile.AnyProfileView[]
+ size?: 'small' | 'medium' | 'large'
+}
+
+export function AvatarBubbles({
+ animate = false,
+ profiles: allProfiles,
+ size = 'large',
+}: Props) {
+ const {currentAccount} = useSession()
+ const profiles = allProfiles.filter(p => p.did !== currentAccount?.did)
+ const containerSize = size === 'small' ? 40 : size === 'medium' ? 56 : 120
+ const scale = size === 'small' ? 40 / 120 : size === 'medium' ? 56 / 120 : 1
+ const marginOffset = size === 'small' || size === 'medium' ? -2 : 0
+
+ const initialValue = animate ? 0 : 1
+ const p0 = useSharedValue(initialValue)
+ const p1 = useSharedValue(initialValue)
+ const p2 = useSharedValue(initialValue)
+ const p3 = useSharedValue(initialValue)
+
+ const animateScale = (p: Animated.SharedValue, index: number) => {
+ p.set(0)
+ p.set(() =>
+ withDelay(
+ 500 + index * 100,
+ withTiming(1, {
+ duration: 250,
+ easing: Easing.out(Easing.back(1.75)),
+ }),
+ ),
+ )
+ }
+
+ const playScaleAnimation = useCallback(() => {
+ animateScale(p0, 0)
+ animateScale(p1, 1)
+ animateScale(p2, 2)
+ animateScale(p3, 3)
+ }, [p0, p1, p2, p3])
+
+ useEffect(() => {
+ if (!animate) return
+ playScaleAnimation()
+ }, [animate, playScaleAnimation])
+
+ let avatars = (
+ <>
+
+
+ >
+ )
+
+ if (profiles.length === 3) {
+ avatars = (
+ <>
+
+
+
+ >
+ )
+ }
+
+ if (profiles.length >= 4) {
+ avatars = (
+ <>
+
+
+
+
+ >
+ )
+ }
+
+ return (
+
+
+ {avatars}
+
+
+ )
+}
+
+function AvatarBubble({
+ profile,
+ scale,
+ size,
+ style,
+ x,
+ y,
+ includeProfileBorder,
+}: {
+ profile?: bsky.profile.AnyProfileView
+ scale: Animated.SharedValue
+ size: number
+ style?: StyleProp
+ x: number
+ y: number
+ includeProfileBorder?: boolean
+}) {
+ const t = useTheme()
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ {translateX: x},
+ {translateY: y},
+ {scale: interpolate(scale.get(), [0, 1], [0, 1])},
+ ],
+ }))
+
+ return (
+
+ {profile ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function Avatar({
+ profile,
+ size = 76,
+}: {
+ profile: bsky.profile.AnyProfileView
+ size?: number
+}) {
+ return (
+
+ )
+}
+
+function AvatarPlaceholder({size = 76}: {size?: number}) {
+ const t = useTheme()
+
+ return (
+
+
+
+ )
+}
diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx
index e94eaf7795..cce4332dc4 100644
--- a/src/components/ContextMenu/index.tsx
+++ b/src/components/ContextMenu/index.tsx
@@ -482,7 +482,11 @@ function TriggerClone({
)
}
-export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
+export function AuxiliaryView({
+ children,
+ align = 'left',
+ style,
+}: AuxiliaryViewProps) {
const context = useContextMenuContext()
const {width: screenWidth} = useWindowDimensions()
const {top: topInset} = useSafeAreaInsets()
@@ -556,6 +560,7 @@ export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
: {right: screenWidth - measurement.x - measurement.width},
animatedStyle,
a.z_20,
+ style,
]}>
{children}
diff --git a/src/components/ContextMenu/types.ts b/src/components/ContextMenu/types.ts
index 7d4f3019a8..260d95e85c 100644
--- a/src/components/ContextMenu/types.ts
+++ b/src/components/ContextMenu/types.ts
@@ -21,6 +21,7 @@ export type {
export type AuxiliaryViewProps = {
children?: React.ReactNode
align?: 'left' | 'right'
+ style?: StyleProp
}
export type ItemProps = Omit & {
diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx
index c1f54e2394..3ed704f99d 100644
--- a/src/components/dms/ActionsWrapper.tsx
+++ b/src/components/dms/ActionsWrapper.tsx
@@ -1,7 +1,6 @@
import {View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
-import {msg} from '@lingui/core/macro'
-import {useLingui} from '@lingui/react'
+import {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
@@ -15,7 +14,7 @@ export function ActionsWrapper({
isFromSelf: boolean
children: React.ReactNode
}) {
- const {_} = useLingui()
+ const {t: l} = useLingui()
return (
@@ -32,7 +31,7 @@ export function ActionsWrapper({
]}
accessible={true}
accessibilityActions={[
- {name: 'activate', label: _(msg`Open message options`)},
+ {name: 'activate', label: l`Open message options`},
]}
onAccessibilityAction={() => trigger.control.open('full')}>
{children}
diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx
index ae9a4a3c4a..587a25e95b 100644
--- a/src/components/dms/ConvoMenu.tsx
+++ b/src/components/dms/ConvoMenu.tsx
@@ -25,9 +25,9 @@ import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
-import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
-import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
-import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
+import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
+import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
+import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {
@@ -95,7 +95,7 @@ let ConvoMenu = ({
shape="round"
variant="ghost"
style={[a.bg_transparent]}>
-
+
)}
@@ -220,9 +220,9 @@ function MenuContent({
}
if (userBlock) {
- queueUnblock()
+ void queueUnblock()
} else {
- queueBlock()
+ void queueBlock()
}
}, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock])
@@ -233,7 +233,7 @@ function MenuContent({
Leave conversation
-
+
) : (
<>
@@ -245,7 +245,7 @@ function MenuContent({
Mark as read
-
+
)}
Leave conversation
-
+
>
diff --git a/src/components/dms/DateDivider.tsx b/src/components/dms/DateDivider.tsx
index dfc2d53da5..0a54de39fc 100644
--- a/src/components/dms/DateDivider.tsx
+++ b/src/components/dms/DateDivider.tsx
@@ -1,8 +1,6 @@
import {memo} from 'react'
import {View} from 'react-native'
-import {msg} from '@lingui/core/macro'
-import {useLingui} from '@lingui/react'
-import {Trans} from '@lingui/react/macro'
+import {Trans, useLingui} from '@lingui/react/macro'
import {subDays} from 'date-fns'
import {atoms as a, useTheme} from '#/alf'
@@ -29,7 +27,7 @@ const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, {
})
let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
- const {_} = useLingui()
+ const {t: l} = useLingui()
const t = useTheme()
let date: string
@@ -42,9 +40,9 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
const oneWeekAgo = subDays(today, 7)
if (localDateString(today) === localDateString(timestamp)) {
- date = _(msg`Today`)
+ date = l`Today`
} else if (localDateString(yesterday) === localDateString(timestamp)) {
- date = _(msg`Yesterday`)
+ date = l`Yesterday`
} else {
if (timestamp < oneWeekAgo) {
if (timestamp.getFullYear() === today.getFullYear()) {
@@ -58,7 +56,7 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
}
return (
-
+
{
a.px_md,
]}>
-
- {date}
- {' '}
- at {time}
+ {date} at {time}
diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx
index dda99c77e2..2460aa585d 100644
--- a/src/components/dms/MessageContextMenu.tsx
+++ b/src/components/dms/MessageContextMenu.tsx
@@ -2,8 +2,7 @@ import {memo, useCallback} from 'react'
import {LayoutAnimation, Platform} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {type ChatBskyConvoDefs, RichText} 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 {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
@@ -12,13 +11,14 @@ import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session'
+import {atoms as a} from '#/alf'
import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
-import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
+import {BubbleQuestion_Stroke2_Corner0_Rounded as TranslateIcon} from '#/components/icons/Bubble'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
-import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
-import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
+import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
@@ -35,7 +35,7 @@ export let MessageContextMenu = ({
message: ChatBskyConvoDefs.MessageView
children: TriggerProps['children']
}): React.ReactNode => {
- const {_} = useLingui()
+ const {t: l} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
@@ -47,6 +47,7 @@ export let MessageContextMenu = ({
const translate = useGoogleTranslate()
const isFromSelf = message.sender?.did === currentAccount?.did
+ const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
const onCopyMessage = useCallback(() => {
const str = richTextToString(
@@ -58,10 +59,10 @@ export let MessageContextMenu = ({
)
void Clipboard.setStringAsync(str)
- Toast.show(_(msg`Copied to clipboard`), {
+ Toast.show(l`Copied to clipboard`, {
type: 'success',
})
- }, [_, message.text, message.facets])
+ }, [l, message.text, message.facets])
const onPressTranslateMessage = useCallback(() => {
void translate(message.text, langPrefs.primaryLanguage)
@@ -79,11 +80,9 @@ export let MessageContextMenu = ({
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(message.id)
- .then(() =>
- Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))),
- )
- .catch(() => Toast.show(_(msg`Failed to delete message`)))
- }, [_, convo, message.id])
+ .then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
+ .catch(() => Toast.show(l`Failed to delete message`))
+ }, [l, convo, message.id])
const onEmojiSelect = useCallback(
(emoji: string) => {
@@ -96,17 +95,17 @@ export let MessageContextMenu = ({
) {
convo
.removeReaction(message.id, emoji)
- .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
+ .catch(() => Toast.show(l`Failed to remove emoji reaction`))
} else {
if (hasReachedReactionLimit(message, currentAccount?.did)) return
convo.addReaction(message.id, emoji).catch(() =>
- Toast.show(_(msg`Failed to add emoji reaction`), {
+ Toast.show(l`Failed to add emoji reaction`, {
type: 'error',
}),
)
}
},
- [_, convo, message, currentAccount?.did],
+ [l, convo, message, currentAccount?.did],
)
const sender = convo.convo.members.find(
@@ -117,7 +116,9 @@ export let MessageContextMenu = ({
<>
{IS_NATIVE && (
-
+
+ label={l`Message options`}
+ contentLabel={l`Message from @${
+ sender?.handle ?? 'unknown' // should always be defined
+ }: ${message.text}`}>
{children}
-
+
{message.text.length > 0 && (
<>
- {_(msg`Translate`)}
-
+ {l`Translate`}
+
- {_(msg`Copy message text`)}
+ {l`Copy message text`}
@@ -159,23 +160,22 @@ export let MessageContextMenu = ({
)}
deleteControl.open()}>
- {_(msg`Delete for me`)}
-
+ {l`Delete for me`}
+
{!isFromSelf && (
reportControl.open()}>
- {_(msg`Report`)}
-
+ {l`Report`}
+
)}
-
-
diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx
index 386b85d7f9..adfc3e67b1 100644
--- a/src/components/dms/MessageItem.tsx
+++ b/src/components/dms/MessageItem.tsx
@@ -1,13 +1,17 @@
-import {memo, useCallback, useMemo} from 'react'
+import {memo, useCallback, useMemo, useState} from 'react'
import {
type GestureResponderEvent,
+ Pressable,
type StyleProp,
type TextStyle,
View,
} from 'react-native'
import Animated, {
+ FadeIn,
+ FadeOut,
LayoutAnimationConfig,
LinearTransition,
+ useSharedValue,
ZoomIn,
ZoomOut,
} from 'react-native-reanimated'
@@ -16,217 +20,420 @@ import {
ChatBskyConvoDefs,
RichText as RichTextAPI,
} from '@atproto/api'
-import {type I18n} from '@lingui/core'
-import {msg} from '@lingui/core/macro'
-import {useLingui} from '@lingui/react'
+import {plural} from '@lingui/core/macro'
+import {Trans, useLingui} from '@lingui/react/macro'
+import {HITSLOP_10} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
import {useConvoActive} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
-import {TimeElapsed} from '#/view/com/util/TimeElapsed'
-import {atoms as a, native, useTheme} from '#/alf'
+import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, native, useTheme, web} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
+import * as Dialog from '#/components/Dialog'
+import {useDialogControl} from '#/components/Dialog'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link'
+import * as ProfileCard from '#/components/ProfileCard'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
-import {IS_NATIVE} from '#/env'
+import type * as bsky from '#/types/bsky'
import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed'
-import {localDateString} from './util'
+
+const AVATAR_SIZE = 28
+const CLUSTERED_MESSAGE_GAP = 2
+const BORDER_RADIUS = 18
+const SQUARED_BORDER_RADIUS = 4
+const DISPLAY_NAME_INSET = 22
+
+// 42px avatar + 2 * 8px my_sm margins
+const ROW_HEIGHT = 58
+
+const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
+const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
+
+type Reaction = {
+ key: string
+ value: string
+ senders: ChatBskyConvoDefs.ReactionViewSender[]
+ count: number
+}
+
+function isWithinCluster({
+ isPending,
+ adjacentMessage,
+ isFromSameSender,
+ currentSentAt,
+ direction,
+}: {
+ isPending: boolean
+ adjacentMessage:
+ | ChatBskyConvoDefs.MessageView
+ | ChatBskyConvoDefs.DeletedMessageView
+ | null
+ isFromSameSender: boolean
+ currentSentAt: string
+ direction: 'prev' | 'next'
+}): boolean {
+ if (!isFromSameSender) return true
+ if (isPending && adjacentMessage) return false
+ if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) {
+ const thisDate = new Date(currentSentAt)
+ const adjDate = new Date(adjacentMessage.sentAt)
+ const diff =
+ direction === 'next'
+ ? adjDate.getTime() - thisDate.getTime()
+ : thisDate.getTime() - adjDate.getTime()
+ return diff > CLUSTERED_MESSAGE_THRESHOLD_MS
+ }
+ return true
+}
let MessageItem = ({
item,
+ isGroupChat = false,
+ profile,
}: {
item: ConvoItem & {type: 'message' | 'pending-message'}
+ isGroupChat?: boolean
+ profile?: bsky.profile.AnyProfileView
}): React.ReactNode => {
const t = useTheme()
const {currentAccount} = useSession()
- const {_} = useLingui()
+ const {t: l} = useLingui()
const {convo} = useConvoActive()
+ const moderationOpts = useModerationOpts()
+
+ const reactionsControl = useDialogControl()
const {message, nextMessage, prevMessage} = item
const isPending = item.type === 'pending-message'
+ const displayName = sanitizeDisplayName(
+ profile?.displayName || sanitizeHandle(profile?.handle ?? ''),
+ )
+
const isFromSelf = message.sender?.did === currentAccount?.did
+ const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage)
const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
- const isNextFromSelf =
- nextIsMessage && nextMessage.sender?.did === currentAccount?.did
+ const isPrevFromSameSender =
+ prevIsMessage && prevMessage.sender?.did === message.sender?.did
+ const isNextFromSameSender =
+ nextIsMessage && nextMessage.sender?.did === message.sender?.did
- const isNextFromSameSender = isNextFromSelf === isFromSelf
+ const isFirstInCluster = useMemo(
+ () =>
+ isWithinCluster({
+ isPending,
+ adjacentMessage: prevMessage,
+ isFromSameSender: isPrevFromSameSender,
+ currentSentAt: message.sentAt,
+ direction: 'prev',
+ }),
+ [isPending, prevMessage, isPrevFromSameSender, message.sentAt],
+ )
- const isNewDay = useMemo(() => {
- if (!prevMessage) return true
+ const isLastInCluster = useMemo(
+ () =>
+ isWithinCluster({
+ isPending,
+ adjacentMessage: nextMessage,
+ isFromSameSender: isNextFromSameSender,
+ currentSentAt: message.sentAt,
+ direction: 'next',
+ }),
+ [isPending, nextMessage, isNextFromSameSender, message.sentAt],
+ )
- const thisDate = new Date(message.sentAt)
- const prevDate = new Date(prevMessage.sentAt)
+ const hasLargeGapFromPrev =
+ !ChatBskyConvoDefs.isMessageView(prevMessage) ||
+ new Date(message.sentAt).getTime() -
+ new Date(prevMessage.sentAt).getTime() >
+ MESSAGE_GAP_THRESHOLD_MS
- return localDateString(thisDate) !== localDateString(prevDate)
- }, [message, prevMessage])
+ const showDateDivider = hasLargeGapFromPrev
- const isLastMessageOfDay = useMemo(() => {
- if (!nextMessage || !nextIsMessage) return true
+ const isInCluster = !(isFirstInCluster && isLastInCluster)
+ const isInMiddleOfCluster =
+ isInCluster && !isFirstInCluster && !isLastInCluster
- const thisDate = new Date(message.sentAt)
- const prevDate = new Date(nextMessage.sentAt)
+ const hasReactions = message.reactions && message.reactions.length > 0
+ const squaredBottomCorner =
+ !hasReactions && isInCluster && (isInMiddleOfCluster || isFirstInCluster)
+ const squaredTopCorner =
+ isInCluster && (isInMiddleOfCluster || isLastInCluster)
- return localDateString(thisDate) !== localDateString(prevDate)
- }, [message.sentAt, nextIsMessage, nextMessage])
-
- const needsTail = isLastMessageOfDay || !isNextFromSameSender
-
- const isLastInGroup = useMemo(() => {
- // if this message is pending, it means the next message is pending too
- if (isPending && nextMessage) {
- return false
- }
-
- // or, if there's a 5 minute gap between this message and the next
- if (ChatBskyConvoDefs.isMessageView(nextMessage)) {
- const thisDate = new Date(message.sentAt)
- const nextDate = new Date(nextMessage.sentAt)
-
- const diff = nextDate.getTime() - thisDate.getTime()
-
- // 5 minutes
- return diff > 5 * 60 * 1000
- }
-
- return true
- }, [message, nextMessage, isPending])
-
- const pendingColor = t.palette.primary_200
+ const pendingColor = t.palette.primary_300
const rt = useMemo(() => {
return new RichTextAPI({text: message.text, facets: message.facets})
}, [message.text, message.facets])
+ const hasEmbedAndText =
+ AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0
+
+ const avatar = profile ? (
+
+ ) : (
+
+ )
+
+ const groupedReactions = useMemo(() => {
+ const reactions = message.reactions ?? []
+ const grouped = new Map<
+ string,
+ {
+ key: string
+ value: string
+ senders: ChatBskyConvoDefs.ReactionViewSender[]
+ count: number
+ }
+ >()
+ for (const reaction of reactions) {
+ if (!reaction) continue
+ const existing = grouped.get(reaction.value)
+ if (existing) {
+ existing.senders.push(reaction.sender)
+ existing.count++
+ } else {
+ grouped.set(reaction.value, {
+ key: reaction.value,
+ value: reaction.value,
+ senders: [reaction.sender],
+ count: 1,
+ })
+ }
+ }
+ return Array.from(grouped.values())
+ }, [message.reactions])
+
+ const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
+
+ const reactionsLabel = useMemo(() => {
+ if (reactions.length === 0) return ''
+ if (reactions.length === 1) {
+ const reaction = reactions[0]
+ const sender = reaction.sender
+ if (sender.did === currentAccount?.did) {
+ return l`You reacted ${reaction.value}`
+ } else {
+ const senderDid = reaction.sender.did
+ const sender = convo.members.find(member => member.did === senderDid)
+ if (sender) {
+ return l`${sanitizeDisplayName(
+ sender.displayName || sender.handle,
+ )} reacted ${reaction.value}`
+ }
+ return l`Someone reacted ${reaction.value}`
+ }
+ }
+ return l`${plural(reactions.length, {
+ one: '# person',
+ other: '# people',
+ })} reacted – ${groupedReactions.map(g => g.value).join(' ')}`
+ }, [reactions, groupedReactions, currentAccount?.did, convo.members, l])
+
const appliedReactions = (
- {message.reactions && message.reactions.length > 0 && (
-
+ {hasReactions ? (
+ <>
- {message.reactions.map((reaction, _i, reactions) => {
- let label
- if (reaction.sender.did === currentAccount?.did) {
- label = _(msg`You reacted ${reaction.value}`)
- } else {
- const senderDid = reaction.sender.did
- const sender = convo.members.find(
- member => member.did === senderDid,
- )
- if (sender) {
- label = _(
- msg`${sanitizeDisplayName(
- sender.displayName || sender.handle,
- )} reacted ${reaction.value}`,
- )
- } else {
- label = _(msg`Someone reacted ${reaction.value}`)
- }
+
+ isGroupChat ? reactionsControl.open() : undefined
+ }>
+ {groupedReactions.map(group => (
1 && native(ZoomOut.delay(200))}
+ exiting={
+ groupedReactions.length > 1 && native(ZoomOut.delay(200))
+ }
layout={native(LinearTransition.delay(300))}
- key={reaction.sender.did + reaction.value}
- style={[a.p_2xs]}
- accessible={true}
- accessibilityLabel={label}
- accessibilityHint={_(
- msg`Double tap or long press the message to add a reaction`,
- )}>
+ key={group.value}
+ style={[a.p_2xs]}>
- {reaction.value}
+ {group.value}
- )
- })}
+ ))}
+ {groupedReactions.length !== reactions.length &&
+ reactions.length > 1 ? (
+
+
+ {reactions.length}
+
+
+ ) : null}
+
-
- )}
+
+ >
+ ) : null}
)
return (
<>
- {isNewDay && }
+ {showDateDivider && (
+
+
+
+ )}
-
- {AppBskyEmbedRecord.isView(message.embed) && (
-
- )}
- {rt.text.length > 0 && (
-
-
+
+ {isGroupChat && !isFromSelf && isLastInCluster ? (
+
+ {avatar}
- )}
-
- {IS_NATIVE && appliedReactions}
-
-
- {!IS_NATIVE && appliedReactions}
-
- {isLastInGroup && (
+ ) : null}
+
+ {isGroupChat &&
+ !isFromSelf &&
+ isFirstInCluster &&
+ !isOnlyEmoji(message.text) ? (
+
+ {displayName}
+
+ ) : null}
+
+ {rt.text.length > 0 && (
+
+
+
+ )}
+ {AppBskyEmbedRecord.isView(message.embed) && (
+
+ )}
+ {appliedReactions}
+
+
+
+ {isLastInCluster && (
)}
@@ -244,8 +451,7 @@ let MessageItemMetadata = ({
style: StyleProp
}): React.ReactNode => {
const t = useTheme()
- const {_} = useLingui()
- const {message} = item
+ const {t: l} = useLingui()
const handleRetry = useCallback(
(e: GestureResponderEvent) => {
@@ -258,75 +464,251 @@ let MessageItemMetadata = ({
[item],
)
- const relativeTimestamp = useCallback(
- (i18n: I18n, timestamp: string) => {
- const date = new Date(timestamp)
- const now = new Date()
+ const errorColor = t.palette.negative_400
- const time = i18n.date(date, {
- hour: 'numeric',
- minute: 'numeric',
- })
-
- const diff = now.getTime() - date.getTime()
-
- // if under 30 seconds
- if (diff < 1000 * 30) {
- return _(msg`Now`)
- }
-
- return time
- },
- [_],
- )
-
- return (
-
-
- {({timeElapsed}) => (
-
- {timeElapsed}
-
- )}
-
-
- {item.type === 'pending-message' && item.failed && (
- <>
- {' '}
- ·{' '}
-
- {_(msg`Failed to send`)}
+ switch (item.type) {
+ case 'pending-message':
+ return item.failed ? (
+
+
+ Message failed to send.
{item.retry && (
<>
{' '}
- ·{' '}
- {_(msg`Retry`)}
+ style={[a.text_xs, {color: errorColor}]}>
+ Tap to retry
+ .
>
)}
- >
- )}
-
- )
+
+ ) : null
+ default:
+ return null
+ }
}
MessageItemMetadata = memo(MessageItemMetadata)
export {MessageItemMetadata}
+
+function ReactionsDialog({
+ control,
+ members,
+ reactions,
+ groupedReactions,
+}: {
+ control: Dialog.DialogControlProps
+ members: bsky.profile.AnyProfileView[]
+ reactions?: ChatBskyConvoDefs.ReactionView[]
+ groupedReactions?: Reaction[]
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const [selected, setSelected] = useState('all')
+
+ const handleFilter = (value: string) => {
+ setSelected(value)
+ }
+
+ const filteredMembers =
+ selected === 'all'
+ ? members
+ : members.filter(m =>
+ reactions?.some(r => r.sender.did === m.did && r.value === selected),
+ )
+
+ const minHeight = members.length * ROW_HEIGHT
+
+ return (
+ setSelected('all')}
+ nativeOptions={{preventExpansion: true, minHeight}}>
+
+
+
+ Reactions
+
+
+
+
+ {filteredMembers.map(profile => {
+ const displayName = sanitizeDisplayName(
+ profile?.displayName || sanitizeHandle(profile?.handle ?? ''),
+ )
+ const handle = sanitizeHandle(profile?.handle ?? '', '@')
+ const reaction = reactions?.find(
+ ({sender}) => sender.did === profile.did,
+ )
+ const rt = reaction
+ ? new RichTextAPI({text: reaction.value})
+ : undefined
+
+ return rt ? (
+
+
+
+
+
+ {displayName}
+
+
+ {handle}
+
+
+
+
+
+
+
+ ) : null
+ })}
+
+
+ )
+}
+
+function ReactionTabs({
+ groupedReactions,
+ selected,
+ totalReactions,
+ onFilter,
+}: {
+ groupedReactions?: Reaction[]
+ selected: string
+ totalReactions: number
+ onFilter: (value: string) => void
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const contentSize = useSharedValue(0)
+ const scrollX = useSharedValue(0)
+
+ const handlePress = (value: string) => {
+ onFilter(value)
+ }
+
+ const tabs = [
+ {
+ key: 'all',
+ value: l`All`,
+ senders: [],
+ count: totalReactions,
+ } as Reaction,
+ ...(groupedReactions ?? []),
+ ]
+
+ return (
+
+ {
+ scrollX.set(Math.round(e.nativeEvent.contentOffset.x))
+ }}>
+ {
+ contentSize.set(e.nativeEvent.layout.width)
+ }}>
+ {tabs?.map((reaction, index) => (
+
+ ))}
+
+
+
+ )
+}
+
+function ReactionTab({
+ index,
+ reaction,
+ selected,
+ total,
+ onPress,
+}: {
+ index: number
+ reaction: Reaction
+ selected: string
+ total: number
+ onPress: (value: string) => void
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ return (
+ onPress(reaction.key)}>
+
+ {l`${reaction.value} ${reaction.count}`}
+
+
+ )
+}
diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx
index 67f07dd4fc..ba48b6e123 100644
--- a/src/components/dms/MessageItemEmbed.tsx
+++ b/src/components/dms/MessageItemEmbed.tsx
@@ -2,14 +2,24 @@ import {memo} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api'
-import {atoms as a, native, tokens, useTheme, web} from '#/alf'
+import {atoms as a, native, useTheme, web} from '#/alf'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {MessageContextProvider} from './MessageContext'
+const CLUSTERED_MESSAGE_GAP = 2
+const BORDER_RADIUS = 20
+const SQUARED_BORDER_RADIUS = 4
+
let MessageItemEmbed = ({
embed,
+ isFromSelf,
+ squaredTopCorner,
+ squaredBottomCorner,
}: {
embed: $Typed
+ isFromSelf: boolean
+ squaredTopCorner: boolean
+ squaredBottomCorner: boolean
}): React.ReactNode => {
const t = useTheme()
const screen = useWindowDimensions()
@@ -18,7 +28,7 @@ let MessageItemEmbed = ({
-
+
diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx
index 3f7694a342..4d7c2d7e33 100644
--- a/src/components/dms/MessagesListHeader.tsx
+++ b/src/components/dms/MessagesListHeader.tsx
@@ -5,24 +5,29 @@ import {
type ModerationCause,
type ModerationDecision,
} from '@atproto/api'
-import {msg} from '@lingui/core/macro'
-import {useLingui} from '@lingui/react'
+import {useLingui} from '@lingui/react/macro'
+import {useNavigation} from '@react-navigation/native'
+import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links'
-import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {type NavigationProp} from '#/lib/routes/types'
+import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/profile-shadow'
import {isConvoActive, useConvo} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
+import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a, useTheme, web} from '#/alf'
+import {atoms as a, useTheme} from '#/alf'
+import {AvatarBubbles} from '#/components/AvatarBubbles'
+import {Button, ButtonIcon} from '#/components/Button'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
-import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
+import {Bell2Off_Filled_Corner0_Rounded as BellOffIcon} from '#/components/icons/Bell2'
+import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
-import {PostAlerts} from '#/components/moderation/PostAlerts'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
-import {IS_WEB} from '#/env'
+import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE
@@ -48,7 +53,7 @@ export function MessagesListHeader({
}, [moderation])
return (
-
+
@@ -72,19 +77,12 @@ export function MessagesListHeader({
-
@@ -108,22 +106,27 @@ function HeaderReady({
userBlock?: ModerationCause
}
}) {
- const {_} = useLingui()
+ const {t: l} = useLingui()
const t = useTheme()
const convoState = useConvo()
+ const {currentAccount} = useSession()
+
+ const navigation = useNavigation()
+
+ const groupInfo = convoState.getGroupInfo?.()
+ const isGroupChat = groupInfo != null
const isDeletedAccount = profile?.handle === 'missing.invalid'
- const displayName = isDeletedAccount
- ? _(msg`Deleted Account`)
- : sanitizeDisplayName(
- profile.displayName || profile.handle,
- moderation.ui('displayName'),
- )
+ const displayName = isGroupChat
+ ? (groupInfo.name ?? l`${profile.handle}'s group chat`)
+ : isDeletedAccount
+ ? l`Deleted Account`
+ : createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
- // @ts-ignore findLast is polyfilled - esb
const latestMessageFromOther = convoState.items.findLast(
(item: ConvoItem) =>
- item.type === 'message' && item.message.sender.did === profile.did,
+ item.type === 'message' &&
+ item.message.sender.did !== currentAccount?.did,
)
const latestReportableMessage =
@@ -131,85 +134,95 @@ function HeaderReady({
? latestMessageFromOther.message
: undefined
+ const handleNavigateToSettings = () => {
+ const convoId = convoState.convo?.id
+ if (convoId) {
+ navigation.navigate('MessagesConversationSettings', {
+ conversation: convoId,
+ })
+ } else {
+ logger.error(`handleNavigateToSettings: missing convo ID`)
+ }
+ }
+
return (
-
-
-
-
-
- {displayName}
-
-
-
- {!isDeletedAccount && (
-
- @{profile.handle}
+ {isGroupChat ? (
+
+
+
+ {displayName}
+
+
+ ) : (
+
+
+
+
+
+ {displayName}
+
+
{convoState.convo?.muted && (
<>
- {' '}
- ·{' '}
-
+ {' '}
+ ·{' '}
+
+
>
)}
-
- )}
-
-
+
+
+
+ )}
- {isConvoActive(convoState) && (
-
- )}
+ {isConvoActive(convoState) ? (
+ isGroupChat ? (
+
+ ) : (
+
+ )
+ ) : null}
-
-
-
-
)
}
diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx
index 72b417665c..f0861baf45 100644
--- a/src/components/dms/dialogs/NewChatDialog.tsx
+++ b/src/components/dms/dialogs/NewChatDialog.tsx
@@ -3,13 +3,14 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {logger} from '#/logger'
+import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {FAB} from '#/view/com/util/fab/FAB'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow'
-import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {MessagePlus_Stroke2_Corner0_Rounded as NewChatIcon} from '#/components/icons/Message'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
@@ -38,12 +39,28 @@ export function NewChat({
},
onError: error => {
logger.error('Failed to create chat', {safeMessage: error})
- Toast.show(l`An issue occurred starting the chat`, {
+ Toast.show(l`An issue occurred starting the chat, please try again`, {
type: 'error',
})
},
})
+ const {mutate: createGroupChat} = useCreateGroupChat({
+ onSuccess: data => {
+ onNewChat(data.convo.id)
+ ax.metric('groupchat:create', {logContext: 'NewChatDialog'})
+ },
+ onError: error => {
+ logger.error('Failed to create groupchat', {safeMessage: error})
+ Toast.show(
+ l`An issue occurred creating the group chat, please try again`,
+ {
+ type: 'error',
+ },
+ )
+ },
+ })
+
const onCreateChat = useCallback(
(did: string) => {
control.close(() => createChat([did]))
@@ -52,10 +69,12 @@ export function NewChat({
)
const onCreateGroupChat = useCallback(
- (_dids: string[], _groupName: string) => {
- control.close()
+ (members: string[], name: string) => {
+ control.close(() => {
+ createGroupChat({members, name})
+ })
},
- [control],
+ [control, createGroupChat],
)
const onPress = useCallback(() => {
@@ -74,7 +93,7 @@ export function NewChat({
}
+ icon={}
accessibilityRole="button"
accessibilityLabel={l`New chat`}
accessibilityHint=""
diff --git a/src/components/icons/Message.tsx b/src/components/icons/Message.tsx
index e3ca70f01b..35d6deb222 100644
--- a/src/components/icons/Message.tsx
+++ b/src/components/icons/Message.tsx
@@ -15,3 +15,7 @@ export const Message_Stroke2_Corner0_Rounded_Filled = createSinglePathSVG({
export const Message_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4 12a8 8 0 1 1 4.445 7.169 1 1 0 0 0-.629-.088l-3.537.662.7-3.415a1 1 0 0 0-.09-.66A7.961 7.961 0 0 1 4 12Zm8-10C6.477 2 2 6.477 2 12c0 1.523.341 2.968.951 4.262l-.93 4.537a1 1 0 0 0 1.163 1.184l4.68-.876A9.968 9.968 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2ZM7.5 13.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
})
+
+export const MessagePlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.136-.893l-4.68.876A1 1 0 0 1 2.02 20.8l.93-4.537A10 10 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 2a8 8 0 0 0-7.111 11.668 1 1 0 0 1 .09.66l-.7 3.415 3.537-.662c.214-.04.435-.009.63.088A8 8 0 1 0 12 4Zm0 4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1Z',
+})
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 5bb7265709..e87aad55c3 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -73,6 +73,7 @@ export type CommonNavigatorParams = {
Hashtag: {tag: string; author?: string}
Topic: {topic: string}
MessagesConversation: {conversation: string; embed?: string; accept?: true}
+ MessagesConversationSettings: {conversation: string}
MessagesSettings: undefined
MessagesInbox: undefined
NotificationsActivityList: {posts: string}
diff --git a/src/routes.ts b/src/routes.ts
index 75eda461c8..387b1ca410 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -85,6 +85,7 @@ export const router = new Router({
MessagesSettings: '/messages/settings',
MessagesInbox: '/messages/inbox',
MessagesConversation: '/messages/:conversation',
+ MessagesConversationSettings: '/messages/:conversation/settings',
// starter packs
Start: '/start/:name/:rkey',
StarterPackEdit: '/starter-pack/edit/:rkey',
diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx
index 509907b331..785ce04195 100644
--- a/src/screens/Messages/Conversation.tsx
+++ b/src/screens/Messages/Conversation.tsx
@@ -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 (
-
+
@@ -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 (
<>
-
+
{moderation ? (
-
+
) : (
)}
@@ -154,12 +163,15 @@ function Inner() {
{/* MessagesList does not use the body scroll */}
{isFocused && IS_WEB && }
- {!readyToShow &&
- (moderation ? (
-
- ) : (
-
- ))}
+ {!readyToShow && (
+
+ {moderation ? (
+
+ ) : (
+
+ )}
+
+ )}
{moderation && recipient ? (
()
+ const {top: topInset} = useSafeAreaInsets()
+ const [headerHeight, setHeaderHeight] = useState(0)
+ const onHeaderLayout = (e: LayoutChangeEvent) => {
+ setHeaderHeight(e.nativeEvent.layout.height)
+ }
const {params} =
useRoute>()
const {needsEmailVerification} = useEmail()
@@ -248,15 +265,29 @@ function InnerReady({
maybeBlockForEmailVerification()
}, [maybeBlockForEmailVerification])
+ const header = (
+
+ )
+
return (
<>
-
+ {IS_LIQUID_GLASS ? (
+
+ {header}
+
+ ) : (
+ header
+ )}
{isConvoActive(convoState) && (
+ status: 'owner' | 'member' | 'invited'
+ }
+
+type Props = NativeStackScreenProps<
+ CommonNavigatorParams,
+ 'MessagesConversationSettings'
+>
+
+/**
+ * TODO This is just layout for now.
+ */
+export function MessagesConversationSettingsScreen({route}: Props) {
+ const {gtTablet} = useBreakpoints()
+
+ const convoId = route.params.conversation
+
+ return (
+
+
+
+
+
+ Group chat settings
+
+
+
+
+
+
+
+
+ )
+}
+
+function keyExtractor(item: Item) {
+ return item.type === 'CHAT_MEMBER' ? item.profile.did : item.type
+}
+
+function SettingsInner() {
+ const {t: l} = useLingui()
+
+ const initialNumToRender = useInitialNumToRender({minItemHeight: 68})
+ const bottomBarOffset = useBottomBarOffset()
+
+ const convoState = useConvo()
+ const {currentAccount} = useSession()
+ const primaryMember = convoState?.getPrimaryMember?.()
+
+ const data: bsky.profile.AnyProfileView[] = convoState.convo?.members ?? []
+ const invites: string[] = []
+
+ const items = [
+ {
+ type: 'MEMBERS_AND_REQUESTS',
+ },
+ {
+ type: 'ADD_MEMBERS_LINK',
+ },
+ ...[...data]
+ .sort((a, b) => {
+ const aIsAdmin = a.did === primaryMember?.did
+ const bIsAdmin = b.did === primaryMember?.did
+ const aIsSelf = a.did === currentAccount?.did
+ const bIsSelf = b.did === currentAccount?.did
+ if (aIsAdmin !== bIsAdmin) return aIsAdmin ? -1 : 1
+ if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1
+ return 0
+ })
+ .map(profile => ({
+ type: 'CHAT_MEMBER',
+ profile,
+ status:
+ primaryMember?.did === profile.did
+ ? 'owner'
+ : invites.includes(profile.did)
+ ? 'invited'
+ : 'member',
+ })),
+ ]
+
+ function renderItem({item}: {item: Item}) {
+ switch (item.type) {
+ case 'MEMBERS_AND_REQUESTS':
+ return
+ case 'ADD_MEMBERS_LINK':
+ return
+ case 'CHAT_MEMBER':
+ return
+ default:
+ return null
+ }
+ }
+
+ if (convoState.status === ConvoStatus.Error) {
+ return (
+ <>
+ convoState.error.retry()}
+ sideBorders={false}
+ />
+ >
+ )
+ }
+
+ return (
+
+ ) : (
+
+ )
+ }
+ renderItem={renderItem}
+ sideBorders={false}
+ windowSize={11}
+ onEndReachedThreshold={IS_NATIVE ? 1.5 : 0}
+ />
+ )
+}
+
+function MembersAndRequests({
+ memberCount,
+ requestCount,
+}: {
+ memberCount: number
+ requestCount: number
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ return (
+
+
+
+ Members{' '}
+
+ {l`${memberCount}/${MEMBER_LIMIT}`}
+
+ {requestCount > 0 ? (
+
+ {l`${plural(requestCount, {
+ one: '# request',
+ other: '# requests',
+ })}`}
+
+ ) : null}
+
+ )
+}
+
+function AddMembersLink() {
+ const t = useTheme()
+
+ return (
+
+
+ [
+ a.flex_row,
+ a.align_center,
+ a.justify_between,
+ pressed && web({outline: 'none'}),
+ ]}>
+ {({pressed}) => (
+ <>
+
+
+
+
+
+
+ Add members
+
+
+
+
+ >
+ )}
+
+
+
+ )
+}
+
+function Member({
+ profile,
+ status,
+}: {
+ profile: Shadow
+ status: 'owner' | 'member' | 'invited'
+}) {
+ const navigation = useNavigation()
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const {currentAccount} = useSession()
+ const moderationOpts = useModerationOpts()
+ const moderation = useMemo(
+ () =>
+ moderationOpts ? moderateProfile(profile, moderationOpts) : undefined,
+ [profile, moderationOpts],
+ )
+
+ if (!moderation) return null
+
+ const isDeletedAccount = profile.handle === 'missing.invalid'
+ const displayName = isDeletedAccount
+ ? l`Deleted Account`
+ : sanitizeDisplayName(
+ profile.displayName || profile.handle,
+ moderation.ui('displayName'),
+ )
+
+ let statusBadge: React.ReactNode | null = null
+ if (currentAccount?.did === profile.did) {
+ switch (status) {
+ case 'owner':
+ statusBadge =
+ break
+ }
+ } else {
+ statusBadge =
+ }
+
+ return (
+
+ {
+ navigation.navigate('Profile', {name: profile.did})
+ }}>
+
+
+
+
+
+ {displayName}
+
+
+ {sanitizeHandle(profile.handle, '@')}
+
+
+
+ {statusBadge}
+
+
+
+ )
+}
+
+function StatusBadge({
+ label,
+ style,
+}: {
+ label: string
+ style?: StyleProp
+}) {
+ const t = useTheme()
+
+ return (
+
+
+ {label}
+
+
+ )
+}
+
+function StatusButton({
+ label,
+ style,
+ ...rest
+}: {
+ label: string
+ style?: StyleProp
+} & TriggerChildProps['props']) {
+ const t = useTheme()
+
+ return (
+
+
+ {label}
+
+
+ )
+}
+
+function MemberMenu({
+ profile,
+ type,
+}: {
+ profile: Shadow
+ type: 'owner' | 'member' | 'invited'
+}) {
+ const navigation = useNavigation()
+ const t = useTheme()
+ const {t: l} = useLingui()
+ const ax = useAnalytics()
+
+ const requireEmailVerification = useRequireEmailVerification()
+ const convoState = useConvo()
+ const {currentAccount} = useSession()
+
+ const blockMemberPrompt = Prompt.usePromptControl()
+
+ const isOwner =
+ currentAccount?.did == null
+ ? false
+ : convoState.getPrimaryMember?.()?.did === currentAccount.did
+
+ const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did)
+ const {mutate: initiateConvo} = useGetConvoForMembers({
+ onSuccess: ({convo}) => {
+ ax.metric('chat:open', {logContext: 'ProfileHeader'})
+ navigation.navigate('MessagesConversation', {conversation: convo.id})
+ },
+ onError: () => {
+ Toast.show(l`Failed to create conversation`)
+ },
+ })
+ const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
+
+ const messageMember = () => {
+ if (!convoAvailability?.canChat) {
+ return
+ }
+
+ if (convoAvailability.convo) {
+ ax.metric('chat:open', {logContext: 'ProfileHeader'})
+ navigation.navigate('MessagesConversation', {
+ conversation: convoAvailability.convo.id,
+ })
+ } else {
+ ax.metric('chat:create', {logContext: 'ProfileHeader'})
+ initiateConvo([profile.did])
+ }
+ }
+
+ const handleMessageMember = requireEmailVerification(messageMember, {
+ instructions: [
+
+ Before you can message another user, you must first verify your email.
+ ,
+ ],
+ })
+
+ const handleBlockMember = async () => {
+ if (profile.viewer?.blocking) {
+ try {
+ await queueUnblock()
+ Toast.show(l({message: 'Account unblocked', context: 'toast'}))
+ } catch (err) {
+ const e = err as Error
+ if (e?.name !== 'AbortError') {
+ ax.logger.error('Failed to unblock account', {message: e})
+ Toast.show(l`There was an issue! ${e.toString()}`, {
+ type: 'error',
+ })
+ }
+ }
+ } else {
+ try {
+ await queueBlock()
+ Toast.show(l({message: 'Account blocked', context: 'toast'}))
+ } catch (err) {
+ const e = err as Error
+ if (e?.name !== 'AbortError') {
+ ax.logger.error('Failed to block account', {message: e})
+ Toast.show(l`There was an issue! ${e.toString()}`, {
+ type: 'error',
+ })
+ }
+ }
+ }
+ }
+
+ const moderationOpts = useModerationOpts()
+ const moderation = useMemo(
+ () =>
+ moderationOpts ? moderateProfile(profile, moderationOpts) : undefined,
+ [profile, moderationOpts],
+ )
+
+ if (!moderation) return null
+
+ const isDeletedAccount = profile.handle === 'missing.invalid'
+ const displayName = isDeletedAccount
+ ? l`Deleted Account`
+ : sanitizeDisplayName(
+ profile.displayName || profile.handle,
+ moderation.ui('displayName'),
+ )
+
+ return (
+ <>
+
+
+ {({props, state, control: menuControl}) =>
+ type === 'owner' || type === 'invited' ? (
+
+ ) : (
+
+
+
+ )
+ }
+
+
+
+ {
+ navigation.navigate('Profile', {name: profile.did})
+ }}>
+
+ Go to profile
+
+
+
+
+
+ Message
+
+
+
+
+
+
+ {type === 'owner' || type === 'member' ? (
+ blockMemberPrompt.open()}>
+
+ Block
+
+
+
+ ) : null}
+ {isOwner ? (
+ {}}>
+
+ Remove from chat
+
+
+
+ ) : null}
+ {isOwner && type === 'invited' ? (
+ {}}>
+
+ Uninvite
+
+
+
+ ) : null}
+
+
+
+ void handleBlockMember()}
+ />
+ >
+ )
+}
+
+function SettingsHeader({
+ convo,
+ profiles,
+}: {
+ convo: ChatBskyConvoDefs.ConvoView
+ profiles: bsky.profile.AnyProfileView[]
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const convoState = useConvo()
+ const {currentAccount} = useSession()
+
+ const isOwner =
+ currentAccount?.did == null
+ ? false
+ : convoState.getPrimaryMember?.()?.did === currentAccount.did
+
+ const {mutate: muteConvo} = useMuteConvo(convo.id, {
+ onSuccess: data => {
+ if (data.convo.muted) {
+ Toast.show(l({message: 'Group chat muted', context: 'toast'}))
+ } else {
+ Toast.show(l({message: 'Group chat unmuted', context: 'toast'}))
+ }
+ },
+ onError: () => {
+ Toast.show(l`Could not mute group chat`, {
+ type: 'error',
+ })
+ },
+ })
+
+ const editNamePrompt = Prompt.usePromptControl()
+ const inviteLinkPrompt = Prompt.usePromptControl()
+ const lockChatPrompt = Prompt.usePromptControl()
+
+ const [groupName, setGroupName] = useState(
+ convoState.getGroupInfo?.()?.name ?? '',
+ )
+ const [newGroupName, setNewGroupName] = useState(groupName)
+
+ const [isLocked, setIsLocked] = useState(false)
+
+ const handleToggleMute = () => {
+ try {
+ muteConvo({mute: !convo?.muted})
+ } catch (err) {
+ const e = err as Error
+ logger.error('Failed to mute group chat', {message: e})
+ Toast.show(l`There was an issue! ${e.toString()}`, {type: 'error'})
+ }
+ }
+
+ const handlePromptName = () => {
+ editNamePrompt.open()
+ }
+
+ const handleEditName = () => {
+ setGroupName(newGroupName)
+ editNamePrompt.close()
+ }
+
+ const handlePromptInviteLink = () => {
+ inviteLinkPrompt.open()
+ }
+
+ const handleConfirmInviteLink = () => {
+ inviteLinkPrompt.close()
+ }
+
+ const handlePromptLock = () => {
+ lockChatPrompt.open()
+ }
+
+ const handleConfirmLock = () => {
+ setIsLocked(true)
+ }
+
+ const handleUnlock = () => {
+ setIsLocked(false)
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {groupName}
+
+
+ Created April 2, 2026
+
+
+
+ {isOwner ? (
+
+ ) : null}
+
+ {isOwner ? (
+
+ ) : null}
+ {isOwner ? null : (
+ {}}
+ />
+ )}
+ {isOwner ? null : (
+ {}}
+ />
+ )}
+
+
+
+
+
+ >
+ )
+}
+
+function SettingsHeaderPlaceholder() {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ return (
+
+
+
+
+
+ {l`…`}
+
+
+ …
+
+
+
+
+
+
+
+
+ )
+}
+
+function SettingsButton({
+ color = 'secondary',
+ icon,
+ label,
+ text,
+ onPress,
+}: {
+ color?: ButtonColor
+ icon: React.ComponentType
+ label: string
+ text: string
+ onPress: () => void
+}) {
+ const t = useTheme()
+
+ return (
+
+
+
+ {text}
+
+
+ )
+}
+
+function SettingsButtonPlaceholder() {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ return (
+
+
+
+ {l`…`}
+
+
+ )
+}
+
+function EditNamePrompt({
+ control,
+ value,
+ onChangeText,
+ onConfirm,
+}: {
+ control: Dialog.DialogOuterProps['control']
+ value: string
+ onChangeText: (value: string) => void
+ onConfirm: () => void
+}) {
+ const {t: l} = useLingui()
+
+ return (
+
+ <>
+
+
+ Edit group name
+
+
+
+
+
+
+
+
+
+
+
+ >
+
+ )
+}
+
+function InviteLinkPrompt({
+ control,
+ onConfirm,
+}: {
+ control: Dialog.DialogOuterProps['control']
+ onConfirm: () => void
+}) {
+ const {t: l} = useLingui()
+
+ return (
+
+ )
+}
+
+function LockChatPrompt({
+ control,
+ onConfirm,
+}: {
+ control: Dialog.DialogOuterProps['control']
+ onConfirm: () => void
+}) {
+ const {t: l} = useLingui()
+
+ return (
+
+ )
+}
+
+function BlockMemberPrompt({
+ control,
+ onConfirm,
+}: {
+ control: Dialog.DialogOuterProps['control']
+ onConfirm: () => void
+}) {
+ const {t: l} = useLingui()
+
+ return (
+
+ )
+}
+
+function SubtleHoverWrapper({children}: React.PropsWithChildren) {
+ const {
+ state: hover,
+ onIn: onHoverIn,
+ onOut: onHoverOut,
+ } = useInteractionState()
+
+ return (
+
+
+ {children}
+
+ )
+}
diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx
index a57450e637..68e29620da 100644
--- a/src/screens/Messages/components/ChatListItem.tsx
+++ b/src/screens/Messages/components/ChatListItem.tsx
@@ -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 (
-
- {children}
-
- )
+ if (
+ bsky.dangerousIsType(
+ convo.kind,
+ ChatBskyConvoDefs.isGroupConvo,
+ )
+ ) {
+ const owner = convo.members.find(r => {
+ if (
+ bsky.dangerousIsType(
+ 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 (
+
+ )
+ } else if (
+ bsky.dangerousIsType(
+ convo.kind,
+ ChatBskyConvoDefs.isDirectConvo,
+ )
+ ) {
+ const otherMember = convo.members.find(
+ member => member.did !== currentAccount?.did,
+ )
+
+ if (!otherMember) {
+ return null
+ }
+ return (
+
+ {children}
+
+ )
+ } 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 (
+
+ }
+ 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={
+
+ }>
+ {children}
+
+ )
+}
+
+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 (
+ }
+ title={chatName}
+ accessibilityHint={l`Go to the group chat named "${chatName}"`}
+ primaryProfile={groupOwner}
+ primaryProfileModeration={moderation}
+ isBlockedAccount={false}
+ isDeletedAccount={false}
+ showProfileBadges={false}
+ showMenu={showMenu}>
+ {children}
+
+ )
+}
+
+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
+ 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},
]}>
-
+ {avatar}
- {displayName}
+ {title}
-
+
+ {showProfileBadges && (
+
+ )}
+
{lastMessageSentAt && (
@@ -432,7 +580,7 @@ function ChatListItemReady({
)}
- {(convo.muted || moderation.blocked) && (
+ {(convo.muted || isBlockedAccount) && (
- {!isDeletedAccount && (
+ {subtitle && (
- @{profile.handle}
+ {subtitle}
)}
@@ -474,11 +622,7 @@ function ChatListItemReady({
{lastMessage}
-
+ {postAlerts}
{children}
@@ -509,7 +653,7 @@ function ChatListItemReady({
{showMenu && (
0}
@@ -529,6 +673,7 @@ function ChatListItemReady({
latestReportableMessage={latestReportableMessage}
/>
)}
+
void
+ onSendMessage: (message: string) => Promise | 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]}>
{
@@ -225,7 +224,7 @@ export function MessageInput({
}}>
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) => {
@@ -177,7 +176,7 @@ export function MessageInput({
width: 30,
},
]}
- label={_(msg`Open emoji picker`)}>
+ label={l`Open emoji picker`}>
{state => (
{
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 (
-
+
- {description} ·{' '}
+ {description}
{item.retry && (
- {
- e.preventDefault()
- item.retry?.()
- return false
- }}>
- {cta}
-
+ <>
+ ·{' '}
+ {
+ item.retry?.()
+ })}>
+ {cta}
+
+ >
)}
diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx
index eda3c593d9..d55a361def 100644
--- a/src/screens/Messages/components/MessagesList.tsx
+++ b/src/screens/Messages/components/MessagesList.tsx
@@ -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
- } else if (item.type === 'deleted-message') {
- return Deleted message
- } else if (item.type === 'error') {
- return
- }
-
- 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>
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 (
+ member.did === item.message.sender.did,
+ )}
+ isGroupChat={convoState.getGroupInfo?.() != null}
+ />
+ )
+ } else if (item.type === 'deleted-message') {
+ return Deleted message
+ } else if (item.type === 'error') {
+ return
+ }
+
+ 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) => (
@@ -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 */}
@@ -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(
- ,
- )}
+ 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={
+
+ }
style={web({
scrollbarWidth: 'thin',
scrollbarColor: `${t.palette.contrast_100} transparent`,
scrollbarGutter: 'stable both-edges',
})}
+ contentInset={{top: transparentHeaderHeight}}
+ scrollIndicatorInsets={{top: transparentHeaderHeight}}
/>
+ void onSendMessage(message)
+ }
hasEmbed={!!embedUri}
setEmbed={setEmbed}>
@@ -518,12 +573,6 @@ function ChatScrollComponent({
)
}
-function WebInputSpacer({inputHeight}: {inputHeight: number}) {
- if (!IS_WEB) return null
-
- return
-}
-
type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
function getFooterState(
diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts
index b6c8ee2f16..ef2b251cce 100644
--- a/src/state/messages/convo/agent.ts
+++ b/src/state/messages/convo/agent.ts
@@ -1,6 +1,6 @@
import {
type AtpAgent,
- type ChatBskyActorDefs,
+ ChatBskyActorDefs,
ChatBskyConvoDefs,
type ChatBskyConvoGetLog,
type ChatBskyConvoSendMessage,
@@ -37,6 +37,7 @@ import {
import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type MessagesEventBusError} from '#/state/messages/events/types'
import {IS_NATIVE} from '#/env'
+import * as bsky from '#/types/bsky'
const logger = Logger.create(Logger.Context.ConversationAgent)
@@ -112,6 +113,9 @@ export class Convo {
this.markConvoAccepted = this.markConvoAccepted.bind(this)
this.addReaction = this.addReaction.bind(this)
this.removeReaction = this.removeReaction.bind(this)
+ this.isGroup = this.isGroup.bind(this)
+ this.getGroupInfo = this.getGroupInfo.bind(this)
+ this.getPrimaryMember = this.getPrimaryMember.bind(this)
}
private commit() {
@@ -155,6 +159,9 @@ export class Convo {
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
+ isGroup: this.isGroup,
+ getGroupInfo: this.getGroupInfo,
+ getPrimaryMember: this.getPrimaryMember,
}
}
case ConvoStatus.Disabled:
@@ -175,6 +182,9 @@ export class Convo {
markConvoAccepted: this.markConvoAccepted,
addReaction: this.addReaction,
removeReaction: this.removeReaction,
+ isGroup: this.isGroup,
+ getGroupInfo: this.getGroupInfo,
+ getPrimaryMember: this.getPrimaryMember,
}
}
case ConvoStatus.Error: {
@@ -192,6 +202,9 @@ export class Convo {
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
+ isGroup: undefined,
+ getGroupInfo: undefined,
+ getPrimaryMember: undefined,
}
}
default: {
@@ -209,6 +222,9 @@ export class Convo {
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
+ isGroup: this.isGroup,
+ getGroupInfo: this.getGroupInfo,
+ getPrimaryMember: this.getPrimaryMember,
}
}
}
@@ -222,7 +238,7 @@ export class Convo {
switch (action.event) {
case ConvoDispatchEvent.Init: {
this.status = ConvoStatus.Initializing
- this.setup()
+ void this.setup()
this.setupFirehose()
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
break
@@ -234,12 +250,12 @@ export class Convo {
switch (action.event) {
case ConvoDispatchEvent.Ready: {
this.status = ConvoStatus.Ready
- this.fetchMessageHistory()
+ void this.fetchMessageHistory()
break
}
case ConvoDispatchEvent.Background: {
this.status = ConvoStatus.Backgrounded
- this.fetchMessageHistory()
+ void this.fetchMessageHistory()
this.requestPollInterval(BACKGROUND_POLL_INTERVAL)
break
}
@@ -258,7 +274,7 @@ export class Convo {
}
case ConvoDispatchEvent.Disable: {
this.status = ConvoStatus.Disabled
- this.fetchMessageHistory() // finish init
+ void this.fetchMessageHistory() // finish init
this.cleanupFirehoseConnection?.()
this.withdrawRequestedPollInterval()
break
@@ -269,7 +285,7 @@ export class Convo {
case ConvoStatus.Ready: {
switch (action.event) {
case ConvoDispatchEvent.Resume: {
- this.refreshConvo()
+ void this.refreshConvo()
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
break
}
@@ -308,11 +324,11 @@ export class Convo {
} else {
if (this.convo) {
this.status = ConvoStatus.Ready
- this.refreshConvo()
+ void this.refreshConvo()
this.maybeRecoverFromNetworkError()
} else {
this.status = ConvoStatus.Initializing
- this.setup()
+ void this.setup()
}
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
}
@@ -435,7 +451,7 @@ export class Convo {
this.firehoseError = undefined
this.commit()
} else {
- this.batchRetryPendingMessages()
+ void this.batchRetryPendingMessages()
}
if (this.fetchMessageHistoryError) {
@@ -487,7 +503,8 @@ export class Convo {
} else {
this.dispatch({event: ConvoDispatchEvent.Ready})
}
- } catch (e: any) {
+ } catch (err) {
+ const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error('setup failed', {
safeMessage: e.message,
@@ -557,11 +574,7 @@ export class Convo {
async fetchConvo() {
if (this.pendingFetchConvo) return this.pendingFetchConvo
- this.pendingFetchConvo = new Promise<{
- convo: ChatBskyConvoDefs.ConvoView
- sender: ChatBskyActorDefs.ProfileViewBasic | undefined
- recipients: ChatBskyActorDefs.ProfileViewBasic[]
- }>(async (resolve, reject) => {
+ this.pendingFetchConvo = (async () => {
try {
const response = await networkRetry(2, () => {
return this.agent.api.chat.bsky.convo.getConvo(
@@ -574,17 +587,15 @@ export class Convo {
const convo = response.data.convo
- resolve({
+ return {
convo,
sender: convo.members.find(m => m.did === this.senderUserDid),
recipients: convo.members.filter(m => m.did !== this.senderUserDid),
- })
- } catch (e) {
- reject(e)
+ }
} finally {
this.pendingFetchConvo = undefined
}
- })
+ })()
return this.pendingFetchConvo
}
@@ -596,7 +607,8 @@ export class Convo {
this.convo = convo || this.convo
this.sender = sender || this.sender
this.recipients = recipients || this.recipients
- } catch (e: any) {
+ } catch (err) {
+ const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error(`failed to refresh convo`, {
safeMessage: e.message,
@@ -664,7 +676,8 @@ export class Convo {
this.pastMessages.set(message.id, message)
}
}
- } catch (e: any) {
+ } catch (err) {
+ const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error('failed to fetch message history', {
safeMessage: e.message,
@@ -673,7 +686,7 @@ export class Convo {
this.fetchMessageHistoryError = {
retry: () => {
- this.fetchMessageHistory()
+ void this.fetchMessageHistory()
},
}
} finally {
@@ -716,7 +729,7 @@ export class Convo {
onFirehoseConnect() {
this.firehoseError = undefined
- this.batchRetryPendingMessages()
+ void this.batchRetryPendingMessages()
this.commit()
}
@@ -761,8 +774,8 @@ export class Convo {
/**
* If this message is already in new messages, it was added by our
* sending logic, and is based on client-ordering. When we receive
- * the "commited" event from the log, we should replace this
- * reference and re-insert in order to respect the order we receied
+ * the "committed" event from the log, we should replace this
+ * reference and re-insert in order to respect the order we received
* from the log.
*/
if (this.newMessages.has(ev.message.id)) {
@@ -836,7 +849,7 @@ export class Convo {
this.commit()
if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) {
- this.processPendingMessages()
+ void this.processPendingMessages()
}
}
@@ -912,7 +925,7 @@ export class Convo {
}
}
- private handleSendMessageFailure(e: any) {
+ private handleSendMessageFailure(e: Error | XRPCError) {
if (e instanceof XRPCError) {
if (NETWORK_FAILURE_STATUSES.includes(e.status)) {
this.pendingMessageFailure = 'recoverable'
@@ -1026,7 +1039,8 @@ export class Convo {
{encoding: 'application/json', headers: DM_SERVICE_HEADERS},
)
})
- } catch (e: any) {
+ } catch (err) {
+ const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error(`failed to delete message`, {
safeMessage: e.message,
@@ -1334,4 +1348,46 @@ export class Convo {
throw error
}
}
+
+ // Group utilities
+
+ isGroup(): boolean | undefined {
+ if (!this.convo) return undefined
+ const info = this.getGroupInfo()
+ return !!info
+ }
+
+ getGroupInfo(): ChatBskyConvoDefs.GroupConvo | undefined {
+ if (
+ this.convo &&
+ bsky.dangerousIsType(
+ this.convo.kind,
+ ChatBskyConvoDefs.isGroupConvo,
+ )
+ ) {
+ return this.convo.kind
+ }
+ return undefined
+ }
+
+ getPrimaryMember(): ChatBskyActorDefs.ProfileViewBasic | undefined {
+ if (this.isGroup()) {
+ return this.recipients?.find(r => {
+ if (
+ bsky.dangerousIsType(
+ r.kind,
+ ChatBskyActorDefs.isGroupConvoMember,
+ )
+ ) {
+ return r.kind.role === 'owner'
+ } else {
+ throw new Error(
+ 'Expected a GroupConvoMember, got an unknown kind of member',
+ )
+ }
+ })
+ } else {
+ return this.recipients?.find(r => r.did !== this.senderUserDid)
+ }
+ }
}
diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts
index 7053877935..d7adb51c6d 100644
--- a/src/state/messages/convo/types.ts
+++ b/src/state/messages/convo/types.ts
@@ -144,6 +144,9 @@ type FetchMessageHistory = () => Promise
type MarkConvoAccepted = () => void
type AddReaction = (messageId: string, reaction: string) => Promise
type RemoveReaction = (messageId: string, reaction: string) => Promise
+type IsGroup = () => boolean | undefined
+type GetGroupInfo = () => ChatBskyConvoDefs.GroupConvo | undefined
+type GetPrimaryMember = () => ChatBskyActorDefs.ProfileViewBasic | undefined
export type ConvoStateUninitialized = {
status: ConvoStatus.Uninitialized
@@ -159,6 +162,9 @@ export type ConvoStateUninitialized = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
+ isGroup: IsGroup
+ getGroupInfo: GetGroupInfo
+ getPrimaryMember: GetPrimaryMember
}
export type ConvoStateInitializing = {
status: ConvoStatus.Initializing
@@ -174,6 +180,9 @@ export type ConvoStateInitializing = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
+ isGroup: IsGroup
+ getGroupInfo: GetGroupInfo
+ getPrimaryMember: GetPrimaryMember
}
export type ConvoStateReady = {
status: ConvoStatus.Ready
@@ -189,6 +198,9 @@ export type ConvoStateReady = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
+ isGroup: IsGroup
+ getGroupInfo: GetGroupInfo
+ getPrimaryMember: GetPrimaryMember
}
export type ConvoStateBackgrounded = {
status: ConvoStatus.Backgrounded
@@ -204,6 +216,9 @@ export type ConvoStateBackgrounded = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
+ isGroup: IsGroup
+ getGroupInfo: GetGroupInfo
+ getPrimaryMember: GetPrimaryMember
}
export type ConvoStateSuspended = {
status: ConvoStatus.Suspended
@@ -219,6 +234,9 @@ export type ConvoStateSuspended = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
+ isGroup: IsGroup
+ getGroupInfo: GetGroupInfo
+ getPrimaryMember: GetPrimaryMember
}
export type ConvoStateError = {
status: ConvoStatus.Error
@@ -234,6 +252,9 @@ export type ConvoStateError = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
+ isGroup: undefined
+ getGroupInfo: undefined
+ getPrimaryMember: undefined
}
export type ConvoStateDisabled = {
status: ConvoStatus.Disabled
@@ -249,6 +270,9 @@ export type ConvoStateDisabled = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
+ isGroup: IsGroup
+ getGroupInfo: GetGroupInfo
+ getPrimaryMember: GetPrimaryMember
}
export type ConvoState =
| ConvoStateUninitialized
diff --git a/src/state/queries/messages/create-group-chat.ts b/src/state/queries/messages/create-group-chat.ts
new file mode 100644
index 0000000000..9f8aadc7d0
--- /dev/null
+++ b/src/state/queries/messages/create-group-chat.ts
@@ -0,0 +1,37 @@
+import {type ChatBskyGroupCreateGroup} from '@atproto/api'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+
+import {DM_SERVICE_HEADERS} from '#/lib/constants'
+import {logger} from '#/logger'
+import {useAgent} from '#/state/session'
+import {precacheConvoQuery} from './conversation'
+
+export function useCreateGroupChat({
+ onSuccess,
+ onError,
+}: {
+ onSuccess?: (data: ChatBskyGroupCreateGroup.OutputSchema) => void
+ onError?: (error: Error) => void
+}) {
+ const queryClient = useQueryClient()
+ const agent = useAgent()
+
+ return useMutation({
+ mutationFn: async ({name, members}: {name: string; members: string[]}) => {
+ const {data} = await agent.chat.bsky.group.createGroup(
+ {name, members},
+ {headers: DM_SERVICE_HEADERS},
+ )
+
+ return data
+ },
+ onSuccess: data => {
+ precacheConvoQuery(queryClient, data.convo)
+ onSuccess?.(data)
+ },
+ onError: error => {
+ logger.error(error)
+ onError?.(error)
+ },
+ })
+}
diff --git a/src/state/queries/messages/mute-conversation.ts b/src/state/queries/messages/mute-conversation.ts
index 08878d7fb5..d90ebb1b55 100644
--- a/src/state/queries/messages/mute-conversation.ts
+++ b/src/state/queries/messages/mute-conversation.ts
@@ -31,13 +31,13 @@ export function useMuteConvo(
mutationFn: async ({mute}: {mute: boolean}) => {
if (!convoId) throw new Error('No convoId provided')
if (mute) {
- const {data} = await agent.api.chat.bsky.convo.muteConvo(
+ const {data} = await agent.chat.bsky.convo.muteConvo(
{convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
} else {
- const {data} = await agent.api.chat.bsky.convo.unmuteConvo(
+ const {data} = await agent.chat.bsky.convo.unmuteConvo(
{convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
diff --git a/yarn.lock b/yarn.lock
index d3cb6da5eb..1a160ff2a2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -20,14 +20,14 @@
"@jridgewell/gen-mapping" "^0.3.0"
"@jridgewell/trace-mapping" "^0.3.9"
-"@atproto/api@^0.19.8":
- version "0.19.8"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.8.tgz#ae847abece43f0108535c6305780079e8782ab29"
- integrity sha512-b79kuI3AzEmpLLi9afRNq6T0KFEEVL4d+vHFAtWxeDwS7lfwUOIIngMjAVvwmwC5nJRZIrK8L9d4y7LD8zdvsg==
+"@atproto/api@^0.19.9":
+ version "0.19.9"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.9.tgz#f09ed8412159d6878eeaf25a0a8b4445c62fa9eb"
+ integrity sha512-+sUYNuiA1Rv8HemMCURHwRkMp2D7cq6nNquefjosu6UB54IzkD0MLK3YY383poLRShiApouOxRse2OKK25dbQw==
dependencies:
- "@atproto/common-web" "^0.4.20"
+ "@atproto/common-web" "^0.4.21"
"@atproto/lexicon" "^0.6.2"
- "@atproto/syntax" "^0.5.3"
+ "@atproto/syntax" "^0.5.4"
"@atproto/xrpc" "^0.7.7"
await-lock "^2.2.2"
multiformats "^9.9.0"
@@ -44,14 +44,14 @@
"@atproto/syntax" "^0.5.1"
zod "^3.23.8"
-"@atproto/common-web@^0.4.20":
- version "0.4.20"
- resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.20.tgz#bb455868e674d45ed1044c68ccccae3c08168d47"
- integrity sha512-RcsYT28yQgVi/Glb/hHPGpqpzIlKrbMLeldEd7PmmMLWDaJL2j3lb92qytvxjl1yhi2Ssq2TEuMZ2NlWaAbpow==
+"@atproto/common-web@^0.4.21":
+ version "0.4.21"
+ resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.21.tgz#2198583f842a000f495f1caec6f7e4eda207191b"
+ integrity sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==
dependencies:
"@atproto/lex-data" "^0.0.15"
- "@atproto/lex-json" "^0.0.15"
- "@atproto/syntax" "^0.5.3"
+ "@atproto/lex-json" "^0.0.16"
+ "@atproto/syntax" "^0.5.4"
zod "^3.23.8"
"@atproto/lex-data@^0.0.14":
@@ -82,10 +82,10 @@
"@atproto/lex-data" "^0.0.14"
tslib "^2.8.1"
-"@atproto/lex-json@^0.0.15":
- version "0.0.15"
- resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.15.tgz#34d300e5dfd8a0ec76ca7363f264a488e17c1bd9"
- integrity sha512-kCLdP629H6GhgPjBTpZibUoqlpmW0hnVfZVwcD4s4Jch1KAqY/QcfL24Ih8wrW0Ok1YvtMIhjk98evdTA2OJcw==
+"@atproto/lex-json@^0.0.16":
+ version "0.0.16"
+ resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.16.tgz#c99b5147560310f9f7f74405c57858a12c3e365a"
+ integrity sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==
dependencies:
"@atproto/lex-data" "^0.0.15"
tslib "^2.8.1"
@@ -108,10 +108,10 @@
dependencies:
tslib "^2.8.1"
-"@atproto/syntax@^0.5.3":
- version "0.5.3"
- resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.3.tgz#4331d01f63fe56c374dcf95d4432a22b62271a17"
- integrity sha512-gzhlHOJHm5KXdCc17fXi1fXM81ccs5jJfNgCui84ay9JGvczxegpYHNqdMlv+iBuhtBzFIjgx6ChjRxN/kO8kQ==
+"@atproto/syntax@^0.5.4":
+ version "0.5.4"
+ resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.4.tgz#89842eb8b8ab181752b04ed840cc6b100e296b00"
+ integrity sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==
dependencies:
tslib "^2.8.1"