From d3f5093817ac4e1d13f86d502cb15cfa141a7fc5 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 15 Apr 2026 12:15:45 -0700 Subject: [PATCH] Groupchats feature branch (#10181) Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- .../messagePlus_stroke2_corner0_rounded.svg | 1 + package.json | 2 +- ...ct-native-keyboard-controller+1.21.5.patch | 48 + src/Navigation.tsx | 6 + src/analytics/metrics/types.ts | 3 + src/components/AvatarBubbles.tsx | 260 ++++ src/components/ContextMenu/index.tsx | 7 +- src/components/ContextMenu/types.ts | 1 + src/components/dms/ActionsWrapper.tsx | 7 +- src/components/dms/ConvoMenu.tsx | 18 +- src/components/dms/DateDivider.tsx | 18 +- src/components/dms/MessageContextMenu.tsx | 81 +- src/components/dms/MessageItem.tsx | 796 ++++++++---- src/components/dms/MessageItemEmbed.tsx | 42 +- src/components/dms/MessagesListHeader.tsx | 191 +-- src/components/dms/dialogs/NewChatDialog.tsx | 31 +- src/components/icons/Message.tsx | 4 + src/lib/routes/types.ts | 1 + src/routes.ts | 1 + src/screens/Messages/Conversation.tsx | 59 +- src/screens/Messages/ConversationSettings.tsx | 1082 +++++++++++++++++ .../Messages/components/ChatListItem.tsx | 377 ++++-- .../Messages/components/MessageInput.tsx | 21 +- .../Messages/components/MessageInput.web.tsx | 15 +- .../Messages/components/MessageListError.tsx | 45 +- .../Messages/components/MessagesList.tsx | 125 +- src/state/messages/convo/agent.ts | 114 +- src/state/messages/convo/types.ts | 24 + .../queries/messages/create-group-chat.ts | 37 + .../queries/messages/mute-conversation.ts | 4 +- yarn.lock | 40 +- 31 files changed, 2826 insertions(+), 635 deletions(-) create mode 100644 assets/icons/messagePlus_stroke2_corner0_rounded.svg create mode 100644 patches/react-native-keyboard-controller+1.21.5.patch create mode 100644 src/components/AvatarBubbles.tsx create mode 100644 src/screens/Messages/ConversationSettings.tsx create mode 100644 src/state/queries/messages/create-group-chat.ts 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"