diff --git a/app.config.js b/app.config.js
index 5b32a2e296..1f8971209a 100644
--- a/app.config.js
+++ b/app.config.js
@@ -66,6 +66,7 @@ module.exports = function (_config) {
infoPlist: {
CADisableMinimumFrameDurationOnPhone: true,
UIBackgroundModes: ['remote-notification'],
+ NSUserActivityTypes: ['INSendMessageIntent'],
NSCameraUsageDescription:
'Used for profile pictures, posts, and other kinds of content.',
NSMicrophoneUsageDescription:
@@ -123,6 +124,7 @@ module.exports = function (_config) {
'com.apple.developer.kernel.increased-memory-limit': true,
'com.apple.developer.kernel.extended-virtual-addressing': true,
'com.apple.security.application-groups': 'group.app.bsky',
+ 'com.apple.developer.usernotifications.communication': true,
// 'com.apple.developer.device-information.user-assigned-device-name': true,
},
privacyManifests: {
diff --git a/assets/icons/editBig_stroke2_corner2_rounded.svg b/assets/icons/editBig_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..7adbd1cfb1
--- /dev/null
+++ b/assets/icons/editBig_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg b/assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..69c48eead1
--- /dev/null
+++ b/assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/unlock_stroke2_corner2_rounded.svg b/assets/icons/unlock_stroke2_corner2_rounded.svg
index a9fefda12e..941a6ef2e5 100644
--- a/assets/icons/unlock_stroke2_corner2_rounded.svg
+++ b/assets/icons/unlock_stroke2_corner2_rounded.svg
@@ -1 +1 @@
-
+
diff --git a/modules/BlueskyNSE/Info.plist b/modules/BlueskyNSE/Info.plist
index c2dd7eda69..e9271925e0 100644
--- a/modules/BlueskyNSE/Info.plist
+++ b/modules/BlueskyNSE/Info.plist
@@ -8,6 +8,13 @@
com.apple.usernotifications.service
NSExtensionPrincipalClass
$(PRODUCT_MODULE_NAME).NotificationService
+ NSExtensionAttributes
+
+ IntentsSupported
+
+ INSendMessageIntent
+
+
MainAppScheme
bluesky
diff --git a/modules/BlueskyNSE/NotificationService.swift b/modules/BlueskyNSE/NotificationService.swift
index 481402890f..b441f48a91 100644
--- a/modules/BlueskyNSE/NotificationService.swift
+++ b/modules/BlueskyNSE/NotificationService.swift
@@ -1,5 +1,6 @@
import UserNotifications
import UIKit
+import Intents
let APP_GROUP = "group.app.bsky"
typealias ContentHandler = (UNNotificationContent) -> Void
@@ -40,17 +41,18 @@ class NotificationService: UNNotificationServiceExtension {
}
self.bestAttempt = bestAttempt
- if reason == "chat-message" {
+
+ if reason == "chat-message" || reason == "chat-reaction" {
mutateWithChatMessage(bestAttempt)
+ let finalContent = createCommunicationNotification(
+ from: bestAttempt,
+ userInfo: request.content.userInfo
+ )
+ contentHandler(finalContent)
} else {
mutateWithBadge(bestAttempt)
+ contentHandler(bestAttempt)
}
-
- // Any image downloading (or other network tasks) should be handled at the end
- // of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire
- // gets called, we might not have all the needed mutations completed in time.
-
- contentHandler(bestAttempt)
}
override func serviceExtensionTimeWillExpire() {
@@ -61,6 +63,81 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(bestAttempt)
}
+ // MARK: Communication Notification
+
+ func createCommunicationNotification(
+ from content: UNMutableNotificationContent,
+ userInfo: [AnyHashable: Any]
+ ) -> UNNotificationContent {
+ let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown"
+ let convoId = userInfo["convoId"] as? String
+ var avatarImage: INImage? = nil
+ if let avatarUrlString = userInfo["senderAvatarUrl"] as? String {
+ avatarImage = downloadAvatarImage(from: avatarUrlString)
+ }
+
+ let senderHandleValue = userInfo["senderHandle"] as? String
+ let senderHandle = INPersonHandle(value: senderHandleValue, type: .unknown)
+ let sender = INPerson(
+ personHandle: senderHandle,
+ nameComponents: nil,
+ displayName: senderDisplayName,
+ image: avatarImage,
+ contactIdentifier: nil,
+ customIdentifier: nil
+ )
+
+ let intent = INSendMessageIntent(
+ recipients: nil,
+ outgoingMessageType: .outgoingMessageText,
+ content: content.body,
+ speakableGroupName: nil,
+ conversationIdentifier: convoId,
+ serviceName: nil,
+ sender: sender,
+ attachments: nil
+ )
+
+ let interaction = INInteraction(intent: intent, response: nil)
+ interaction.direction = .incoming
+ interaction.donate(completion: nil)
+
+ do {
+ return try content.updating(from: intent)
+ } catch {
+ return content
+ }
+ }
+
+ func downloadAvatarImage(from urlString: String) -> INImage? {
+ let thumbnailUrlString = urlString.replacingOccurrences(
+ of: "/img/avatar/",
+ with: "/img/avatar_thumbnail/"
+ )
+
+ guard let url = URL(string: thumbnailUrlString) else { return nil }
+
+ var request = URLRequest(url: url)
+ request.timeoutInterval = 5
+
+ var imageData: Data? = nil
+ let semaphore = DispatchSemaphore(value: 0)
+
+ let task = URLSession.shared.dataTask(with: request) { data, response, error in
+ if let data = data,
+ let httpResponse = response as? HTTPURLResponse,
+ httpResponse.statusCode == 200 {
+ imageData = data
+ }
+ semaphore.signal()
+ }
+ task.resume()
+ semaphore.wait()
+
+ guard let data = imageData else { return nil }
+ return INImage(imageData: data)
+ }
+
// MARK: Mutations
func mutateWithBadge(_ content: UNMutableNotificationContent) {
diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
index 4f8a6b892a..fba23dfa0c 100644
--- a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
+++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
@@ -13,7 +13,7 @@ class BackgroundNotificationHandler(
return
}
- if (remoteMessage.data["reason"] == "chat-message") {
+ if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") {
mutateWithChatMessage(remoteMessage)
} else {
mutateWithOtherReason(remoteMessage)
diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
index df0d08c52b..40da5021c5 100644
--- a/src/analytics/metrics/types.ts
+++ b/src/analytics/metrics/types.ts
@@ -556,7 +556,11 @@ export type Events = {
| 'FindContacts'
}
'chat:create': {
- logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
+ logContext:
+ | 'ProfileHeader'
+ | 'NewChatDialog'
+ | 'SendViaChatDialog'
+ | 'ConvoSettings'
}
'chat:open': {
logContext:
@@ -564,6 +568,7 @@ export type Events = {
| 'NewChatDialog'
| 'ChatsList'
| 'SendViaChatDialog'
+ | 'ConvoSettings'
}
'groupchat:create': {
logContext: 'NewChatDialog'
diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx
index ff6f4ba1b9..d3c7b249c9 100644
--- a/src/components/AvatarBubbles.tsx
+++ b/src/components/AvatarBubbles.tsx
@@ -1,8 +1,7 @@
-import {useCallback, useEffect} from 'react'
-import {type StyleProp, View, type ViewStyle} from 'react-native'
+import {useEffect} from 'react'
+import {View} from 'react-native'
import Animated, {
Easing,
- interpolate,
type SharedValue,
useAnimatedStyle,
useSharedValue,
@@ -16,44 +15,32 @@ 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 Layout = {
+ size: number
+ x: number
+ y: number
+ zIndex?: number
+ border?: boolean
+}
+
type Props = {
animate?: boolean
profiles: bsky.profile.AnyProfileView[]
- size?: 'small' | 'medium' | 'large' | number
+ size?: number
}
export function AvatarBubbles({
animate = false,
profiles: allProfiles,
- size = 'large',
+ size = 120,
}: Props) {
const {currentAccount} = useSession()
const profiles =
allProfiles.length > 2
? allProfiles.filter(p => p.did !== currentAccount?.did)
: allProfiles
- const containerSize =
- typeof size === 'number'
- ? size
- : size === 'small'
- ? 40
- : size === 'medium'
- ? 56
- : 120
- const scale =
- typeof size === 'number'
- ? size / 120
- : size === 'small'
- ? 40 / 120
- : size === 'medium'
- ? 56 / 120
- : 1
- const marginOffset =
- (typeof size === 'number' && size < 120) ||
- size === 'small' ||
- size === 'medium'
- ? -2
- : 0
+ const scale = profiles.length <= 1 ? 1 : size / 120
+ const marginOffset = size < 120 ? -2 : 0
const initialValue = animate ? 0 : 1
const p0 = useSharedValue(initialValue)
@@ -61,130 +48,50 @@ export function AvatarBubbles({
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])
+ const animateBubble = (p: SharedValue, i: number) => {
+ p.set(0)
+ p.set(() =>
+ withDelay(
+ 500 + i * 100,
+ withTiming(1, {
+ duration: 250,
+ easing: Easing.out(Easing.back(1.75)),
+ }),
+ ),
+ )
+ }
+ animateBubble(p0, 0)
+ animateBubble(p1, 1)
+ animateBubble(p2, 2)
+ animateBubble(p3, 3)
+ }, [animate, p0, p1, p2, p3])
- let avatars = (
- <>
-
-
- >
- )
-
- if (profiles.length === 3) {
- avatars = (
- <>
-
-
-
- >
- )
- }
-
- if (profiles.length >= 4) {
- avatars = (
- <>
-
-
-
-
- >
- )
- }
+ const scales = [p0, p1, p2, p3]
+ const layouts = getLayouts(profiles.length)
return (
-
+
- {avatars}
+ style={{
+ marginTop: marginOffset,
+ marginLeft: marginOffset,
+ transform: [{scale}],
+ transformOrigin: 'top left',
+ }}>
+ {layouts.map((layout, i) => (
+
+ ))}
)
@@ -194,27 +101,23 @@ function AvatarBubble({
profile,
scale,
size,
- style,
x,
y,
+ zIndex,
includeProfileBorder,
}: {
profile?: bsky.profile.AnyProfileView
scale: SharedValue
size: number
- style?: StyleProp
x: number
y: number
+ zIndex?: number
includeProfileBorder?: boolean
}) {
const t = useTheme()
const animatedStyle = useAnimatedStyle(() => ({
- transform: [
- {translateX: x},
- {translateY: y},
- {scale: interpolate(scale.get(), [0, 1], [0, 1])},
- ],
+ transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
}))
return (
@@ -227,11 +130,17 @@ function AvatarBubble({
borderColor: t.atoms.text_inverted.color,
borderWidth: 2,
},
- style,
+ zIndex != null && {zIndex},
animatedStyle,
]}>
{profile ? (
-
+
) : (
)}
@@ -239,25 +148,7 @@ function AvatarBubble({
)
}
-function Avatar({
- profile,
- size = 76,
-}: {
- profile: bsky.profile.AnyProfileView
- size?: number
-}) {
- return (
-
- )
-}
-
-function AvatarPlaceholder({size = 76}: {size?: number}) {
+function AvatarPlaceholder({size}: {size: number}) {
const t = useTheme()
return (
@@ -267,10 +158,7 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_200,
- {
- width: size,
- height: size,
- },
+ {width: size, height: size},
]}>
)
}
+
+function getLayouts(count: number): Layout[] {
+ if (count === 3) {
+ return [
+ {size: 68, x: -2, y: -2},
+ {size: 56, x: 38, y: 62},
+ {size: 46, x: 71, y: 18},
+ ]
+ }
+ if (count >= 4) {
+ return [
+ {size: 68, x: -2, y: -2},
+ {size: 56, x: 60, y: 49},
+ {size: 42, x: 14, y: 74},
+ {size: 32, x: 72, y: 9},
+ ]
+ }
+ return [
+ {size: 76, x: -2, y: -2, zIndex: 20, border: true},
+ {size: 76, x: 42, y: 42, zIndex: 10, border: true},
+ ]
+}
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 9168adfafe..45c54d4a7a 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -77,6 +77,10 @@ export type ButtonState = {
focused: boolean
pressed: boolean
disabled: boolean
+ /**
+ * Alias for hovered || focused || pressed
+ */
+ interacting: boolean
}
export type ButtonContext = VariantProps & ButtonState
@@ -120,6 +124,7 @@ const Context = createContext({
focused: false,
pressed: false,
disabled: false,
+ interacting: false,
})
Context.displayName = 'ButtonContext'
@@ -536,6 +541,7 @@ export const Button = forwardRef(
const context = useMemo(
() => ({
...state,
+ interacting: state.hovered || state.focused || state.pressed,
variant,
color,
size,
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index 8c82e774c4..21c18e2769 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -201,7 +201,7 @@ export function AvatarPlaceholder({size = 40}: {size?: number}) {
@@ -600,7 +600,7 @@ export function FollowButtonPlaceholder({style}: ViewStyleProp) {
{convo.kind === 'group' ? (
-
+
) : (
void
}) {
const {t: l} = useLingui()
return (
-
+
{trigger =>
// will always be true, since this file is platform split
trigger.IS_NATIVE && (
diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx
index 05df7b0324..12ccea03ab 100644
--- a/src/components/dms/ActionsWrapper.web.tsx
+++ b/src/components/dms/ActionsWrapper.web.tsx
@@ -10,6 +10,7 @@ import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import * as Toast from '#/components/Toast'
+import type * as bsky from '#/types/bsky'
import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util'
@@ -17,12 +18,14 @@ export function ActionsWrapper({
message,
hasReactions,
isFromSelf,
+ senderProfile,
children,
onTap,
}: {
message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean
isFromSelf: boolean
+ senderProfile?: bsky.profile.AnyProfileView
children: React.ReactNode
onTap?: () => void
}) {
@@ -114,7 +117,7 @@ export function ActionsWrapper({
)
}}
-
+
{({props, state, IS_NATIVE, control}) => {
// always false, file is platform split
if (IS_NATIVE) return null
diff --git a/src/components/dms/AddMembersFlow.tsx b/src/components/dms/AddMembersFlow.tsx
index 103ad0aca4..bc3313b07c 100644
--- a/src/components/dms/AddMembersFlow.tsx
+++ b/src/components/dms/AddMembersFlow.tsx
@@ -98,11 +98,16 @@ function reducer(state: State, action: Action): State {
}
export function AddMembersFlow({
+ members,
title,
onAddMembers,
}: {
+ members: string[]
title: string
- onAddMembers: (dids: string[]) => void
+ onAddMembers: (
+ dids: string[],
+ profiles: bsky.profile.AnyProfileView[],
+ ) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
@@ -154,7 +159,11 @@ export function AddMembersFlow({
} else if (searchText.length) {
if (results?.length) {
for (const profile of results) {
- if (profile.did === currentAccount?.did) continue
+ if (
+ profile.did === currentAccount?.did ||
+ members.includes(profile.did)
+ )
+ continue
_items.push({
type: 'profile',
key: profile.did,
@@ -202,7 +211,7 @@ export function AddMembersFlow({
}
return _items
- }, [isError, searchText, l, results, currentAccount?.did, follows])
+ }, [isError, searchText, l, results, currentAccount?.did, members, follows])
if (searchText && !isFetching && !items.length && !isError) {
items.push({type: 'empty', key: 'empty', message: l`No results`})
@@ -213,8 +222,8 @@ export function AddMembersFlow({
}, [control])
const handlePressAdd = useCallback(() => {
- onAddMembers(groupChatDids)
- }, [groupChatDids, onAddMembers])
+ onAddMembers(groupChatDids, groupChatProfiles)
+ }, [groupChatDids, groupChatProfiles, onAddMembers])
const renderItems = useCallback(
({item}: {item: Item}) => {
diff --git a/src/components/dms/DateDivider.tsx b/src/components/dms/DateDivider.tsx
index 35dcd8b850..6e898b8f48 100644
--- a/src/components/dms/DateDivider.tsx
+++ b/src/components/dms/DateDivider.tsx
@@ -4,7 +4,7 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {subDays} from 'date-fns'
import {atoms as a, useTheme} from '#/alf'
-import {Text} from '../Typography'
+import {Text} from '#/components/Typography'
import {localDateString} from './util'
const timeFormatter = new Intl.DateTimeFormat(undefined, {
diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx
index 3a923133f1..bc18eae21a 100644
--- a/src/components/dms/MessageContextMenu.tsx
+++ b/src/components/dms/MessageContextMenu.tsx
@@ -25,15 +25,18 @@ import {usePromptControl} from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
+import type * as bsky from '#/types/bsky'
import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util'
export let MessageContextMenu = ({
message,
+ senderProfile,
children,
onTap,
}: {
message: ChatBskyConvoDefs.MessageView
+ senderProfile?: bsky.profile.AnyProfileView
children: TriggerProps['children']
onTap?: () => void
}): React.ReactNode => {
@@ -110,9 +113,7 @@ export let MessageContextMenu = ({
[l, convo, message, currentAccount?.did],
)
- const sender = convo.convo.members.find(
- member => member.did === message.sender.did,
- )
+ const sender = senderProfile
return (
<>
@@ -183,7 +184,7 @@ export let MessageContextMenu = ({
control={reportControl}
subject={{
view: 'message',
- convoId: convo.convo.id,
+ convoId: convo.convo.view.id,
message,
}}
onAfterSubmit={() => {
@@ -197,7 +198,7 @@ export let MessageContextMenu = ({
control={blockOrDeleteControl}
currentScreen="conversation"
params={{
- convoId: convo.convo.id,
+ convoId: convo.convo.view.id,
message,
}}
/>
diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx
index afbd4bfcd2..7706e9cec8 100644
--- a/src/components/dms/MessageItem.tsx
+++ b/src/components/dms/MessageItem.tsx
@@ -30,7 +30,6 @@ import {useQueryClient} from '@tanstack/react-query'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links'
-import {useConvoActive} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
@@ -43,7 +42,6 @@ import {InlineLinkText, Link} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
-import type * as bsky from '#/types/bsky'
import {DateDivider} from './DateDivider'
import {useDateDividerToggle} from './DateDividerToggle'
import {MessageItemEmbed} from './MessageItemEmbed'
@@ -93,19 +91,18 @@ function isWithinClusterBoundary({
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 {t: l} = useLingui()
- const {convo} = useConvoActive()
const moderationOpts = useModerationOpts()
const queryClient = useQueryClient()
+ const profile = item.relatedProfiles.get(item.message.sender.did)
+
const reactionsControl = useDialogControl()
const reactionTapRef = useRef(false)
@@ -277,9 +274,7 @@ let MessageItem = ({
return l`You reacted ${reaction.value}`
} else {
const senderDid = reaction.sender.did
- const memberSender = convo.members.find(
- member => member.did === senderDid,
- )
+ const memberSender = item.relatedProfiles.get(senderDid)
if (memberSender) {
return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}`
}
@@ -290,7 +285,13 @@ let MessageItem = ({
one: '# person',
other: '# people',
})} reacted – ${groupedReactions.map(g => g.value).join(' ')}`
- }, [reactions, groupedReactions, currentAccount?.did, convo.members, l])
+ }, [
+ reactions,
+ groupedReactions,
+ currentAccount?.did,
+ item.relatedProfiles,
+ l,
+ ])
const appliedReactions = (
@@ -375,7 +376,7 @@ let MessageItem = ({
) : null}
- {(hasLargeGapFromPrev || isDateDividerToggled) && (
-
-
-
- )}
+
+ {(hasLargeGapFromPrev || isDateDividerToggled) && (
+
+
+
+ )}
+
{showAvatar ? (
@@ -434,6 +437,7 @@ let MessageItem = ({
hasReactions={hasReactions}
isFromSelf={isFromSelf}
message={message}
+ senderProfile={profile}
onTap={() => {
if (reactionTapRef.current) return
if (!hasLargeGapFromPrev) {
diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx
index 0806f3a116..3f5344faea 100644
--- a/src/components/dms/MessagesListHeader.tsx
+++ b/src/components/dms/MessagesListHeader.tsx
@@ -161,11 +161,13 @@ function GroupHeaderReady({
})
}
+ const lockStatus = convo.details.lockStatus
+
return (
-
+
{convo.details.name}
@@ -175,6 +177,7 @@ function GroupHeaderReady({
settings={
- )
-}
-
-function AddMembersLink({isOwner}: {isOwner: boolean}) {
- const t = useTheme()
- const {t: l} = useLingui()
-
- const addMembersControl = Dialog.useDialogControl()
-
- if (!isOwner) {
- return null
- }
-
- return (
- <>
-
-
- [
- a.flex_row,
- a.align_center,
- a.justify_between,
- pressed && web({outline: 'none'}),
- ]}
- onPress={() => addMembersControl.open()}>
- {({pressed}) => (
- <>
-
-
-
-
-
-
- Add members
-
-
-
-
- >
- )}
-
-
-
-
-
-
- {
- // TODO Add members here
- addMembersControl.close()
- }}
- />
-
- >
- )
-}
-
-function Member({
- profile,
- status,
- isOwner,
-}: {
- profile: Shadow
- status: 'owner' | 'member' | 'invited'
- isOwner: boolean
-}) {
- 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,
- isOwner,
-}: {
- profile: Shadow
- type: 'owner' | 'member' | 'invited'
- isOwner: boolean
-}) {
- const navigation = useNavigation()
- const t = useTheme()
- const {t: l} = useLingui()
- const ax = useAnalytics()
-
- const requireEmailVerification = useRequireEmailVerification()
-
- const blockMemberPrompt = Prompt.usePromptControl()
-
- 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,
- isOwner,
-}: {
- convo: ConvoWithDetails
- isOwner: boolean
-}) {
- const t = useTheme()
- const {t: l} = useLingui()
-
- const navigation = useNavigation()
-
- const groupName = convo.kind === 'group' ? convo.details.name : ''
- const [newGroupName, setNewGroupName] = useState(groupName)
-
- const [isLocked, setIsLocked] = useState(false)
-
- const {mutate: editGroupName} = useEditGroupName(convo.view.id, {
- onError: e => {
- setNewGroupName(groupName)
- logger.error('Failed to edit group chat name', {message: e})
- Toast.show(l`Failed to edit group chat name`, {
- type: 'error',
- })
- },
- })
-
- const {mutate: muteConvo} = useMuteConvo(convo.view.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: e => {
- logger.error('Failed to mute group chat', {message: e})
- Toast.show(l`Failed to mute group chat`, {
- type: 'error',
- })
- },
- })
-
- const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, {
- onMutate: () => {
- navigation.dispatch(StackActions.pop(2))
- },
- onError: e => {
- logger.error('Failed to leave group chat', {message: e})
- Toast.show(l({message: 'Failed to leave group chat', context: 'toast'}), {
- type: 'error',
- })
- },
- })
-
- const editNamePrompt = Prompt.usePromptControl()
- const inviteLinkPrompt = Prompt.usePromptControl()
- const lockChatPrompt = Prompt.usePromptControl()
- const leaveChatPrompt = Prompt.usePromptControl()
-
- const handleToggleMute = () => {
- muteConvo({mute: !convo.view.muted})
- }
-
- const handleLeaveChat = () => {
- leaveChatPrompt.open()
- }
-
- const handleReportChat = () => {}
-
- const handlePromptName = () => {
- editNamePrompt.open()
- }
-
- const handleEditName = () => {
- editGroupName({name: 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()
-
- return (
-
-
-
-
-
- …
-
-
- …
-
-
-
-
-
-
-
-
- )
-}
-
-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 (
-
-
-
- …
-
-
- )
-}
-
-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 LeaveChatPrompt({
- control,
- groupName,
- onConfirm,
-}: {
- control: Dialog.DialogOuterProps['control']
- groupName: string
- 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/ConversationSettings/AddMembersLink.tsx b/src/screens/Messages/ConversationSettings/AddMembersLink.tsx
new file mode 100644
index 0000000000..67c32f6a77
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/AddMembersLink.tsx
@@ -0,0 +1,108 @@
+import {View} from 'react-native'
+import {Trans, useLingui} from '@lingui/react/macro'
+
+import {logger} from '#/logger'
+import {useAddGroupMembers} from '#/state/queries/messages/add-group-members'
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
+import {type ConvoWithDetails} from '#/components/dms/util'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
+import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
+import {Loader} from '#/components/Loader'
+import * as Toast from '#/components/Toast'
+import {Text} from '#/components/Typography'
+
+export function AddMembersLink({
+ convo,
+ members,
+}: {
+ convo: ConvoWithDetails
+ members: string[]
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const addMembersControl = Dialog.useDialogControl()
+
+ const convoId = convo.view.id
+ const {mutate: addGroupMembers, isPending: isAddPending} = useAddGroupMembers(
+ convoId,
+ {
+ onSuccess: () => {
+ addMembersControl.close()
+ },
+ onError: e => {
+ logger.error('Failed to add group chat members', {message: e})
+ Toast.show(l`Failed to add members`, {type: 'error'})
+ },
+ },
+ )
+
+ return (
+ <>
+
+
+
+
+ {
+ addGroupMembers({members, profiles})
+ }}
+ />
+
+ >
+ )
+}
diff --git a/src/screens/Messages/ConversationSettings/Member.tsx b/src/screens/Messages/ConversationSettings/Member.tsx
new file mode 100644
index 0000000000..5daefce761
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/Member.tsx
@@ -0,0 +1,129 @@
+import {View} from 'react-native'
+import {moderateProfile} from '@atproto/api'
+import {useLingui} from '@lingui/react/macro'
+
+import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useSession} from '#/state/session'
+import {atoms as a, native, useTheme, web} from '#/alf'
+import {
+ type ConvoWithDetails,
+ type GroupConvoMember,
+} from '#/components/dms/util'
+import * as ProfileCard from '#/components/ProfileCard'
+import {Text} from '#/components/Typography'
+import {MemberMenu} from './MemberMenu'
+import {StatusBadge} from './StatusBadge'
+import {SubtleHoverWrapper} from './SubtleHoverWrapper'
+
+const outerStyles = [a.px_xl, a.py_sm, a.flex_row, a.align_center, a.gap_sm]
+
+export function Member({
+ convo,
+ profile: profileUnshadowed,
+ status,
+ isOwner,
+}: {
+ convo: ConvoWithDetails
+ profile: GroupConvoMember
+ status: 'owner' | 'standard' | 'invited'
+ isOwner: boolean
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const profile = useProfileShadow(profileUnshadowed)
+ const {currentAccount} = useSession()
+ const moderationOpts = useModerationOpts()
+
+ if (!moderationOpts) {
+ return
+ }
+
+ const moderation = moderateProfile(profile, moderationOpts)
+
+ const isDeletedAccount = profile.handle === 'missing.invalid'
+ const displayName = isDeletedAccount
+ ? l`Deleted Account`
+ : createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
+ const isProfileOwner = profile.did === convo.primaryMember.did
+ const isSelf = currentAccount?.did === profile.did
+ let statusBadge: React.ReactNode | null = null
+ if (isSelf) {
+ if (status === 'owner') {
+ statusBadge =
+ }
+ } else {
+ statusBadge = (
+
+ )
+ }
+
+ const joinedReason = profile.kind?.addedBy
+ ? l`Added by ${createSanitizedDisplayName(
+ profile.kind.addedBy,
+ true,
+ moderateProfile(profile.kind.addedBy, moderationOpts).ui('displayName'),
+ )}`
+ : `Added by invite link`
+
+ return (
+
+
+
+
+
+
+
+
+
+ {!isProfileOwner && (
+
+ {joinedReason}
+
+ )}
+
+
+
+
+ {statusBadge}
+
+
+ )
+}
+
+export function MemberPlaceholder() {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/screens/Messages/ConversationSettings/MemberMenu.tsx b/src/screens/Messages/ConversationSettings/MemberMenu.tsx
new file mode 100644
index 0000000000..127a474239
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/MemberMenu.tsx
@@ -0,0 +1,252 @@
+import {useState} from 'react'
+import {Pressable} from 'react-native'
+import {Trans, useLingui} from '@lingui/react/macro'
+import {useNavigation} from '@react-navigation/native'
+
+import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
+import {type NavigationProp} from '#/lib/routes/types'
+import {logger} from '#/logger'
+import {type Shadow} from '#/state/cache/types'
+import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
+import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
+import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group'
+import {useProfileBlockMutationQueue} from '#/state/queries/profile'
+import {atoms as a, useTheme} from '#/alf'
+import {type ConvoWithDetails} from '#/components/dms/util'
+import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
+import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
+import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
+import {
+ Person_Stroke2_Corner2_Rounded as PersonIcon,
+ PersonX_Stroke2_Corner0_Rounded as PersonXIcon,
+} from '#/components/icons/Person'
+import * as Menu from '#/components/Menu'
+import * as Prompt from '#/components/Prompt'
+import * as Toast from '#/components/Toast'
+import {useAnalytics} from '#/analytics'
+import type * as bsky from '#/types/bsky'
+import {BlockMemberPrompt} from './prompts'
+import {StatusBadge} from './StatusBadge'
+
+export function MemberMenu({
+ convo,
+ profile,
+ displayName,
+ type,
+ isOwner,
+}: {
+ convo: ConvoWithDetails
+ profile: Shadow
+ type: 'owner' | 'standard' | 'invited'
+ displayName: string
+ isOwner: boolean
+}) {
+ const navigation = useNavigation()
+ const t = useTheme()
+ const {t: l} = useLingui()
+ const ax = useAnalytics()
+
+ const requireEmailVerification = useRequireEmailVerification()
+
+ const blockMemberPrompt = Prompt.usePromptControl()
+
+ const [menuDidOpen, setMenuDidOpen] = useState(false)
+ const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, {
+ enabled: menuDidOpen,
+ })
+ const {mutate: initiateConvo} = useGetConvoForMembers({
+ onSuccess: ({convo}) => {
+ ax.metric('chat:open', {logContext: 'ConvoSettings'})
+ navigation.navigate('MessagesConversation', {conversation: convo.id})
+ },
+ onError: () => {
+ Toast.show(l`Failed to create conversation`, {type: 'error'})
+ },
+ })
+ const convoId = convo.view.id
+ const {mutate: removeMembers} = useRemoveFromGroupChat(convoId, {
+ onError: e => {
+ logger.error('Failed to remove group chat member', {message: e})
+ Toast.show(l`Failed to remove group chat member`, {type: 'error'})
+ },
+ })
+ const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
+
+ const messageMember = () => {
+ if (!convoAvailability?.canChat) {
+ return
+ }
+
+ if (convoAvailability.convo) {
+ ax.metric('chat:open', {logContext: 'ConvoSettings'})
+ navigation.navigate('MessagesConversation', {
+ conversation: convoAvailability.convo.id,
+ })
+ } else {
+ ax.metric('chat:create', {logContext: 'ConvoSettings'})
+ 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') {
+ 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') {
+ logger.error('Failed to block account', {message: e})
+ Toast.show(l`There was an issue! ${e.toString()}`, {
+ type: 'error',
+ })
+ }
+ }
+ }
+ }
+
+ const canBlockMember = type === 'owner' || type === 'standard'
+ const canRemoveMember = isOwner && type !== 'invited'
+ // TODO Need to integrate this. -dsb
+ const canUninviteMember = false
+ // const canUninviteMember = isOwner && type === 'invited'
+
+ return (
+ <>
+
+
+ {({props, state, control: menuControl}) => {
+ const isActive =
+ state.hovered || state.pressed || menuControl.isOpen
+ const triggerProps = {
+ ...props,
+ onPress: () => {
+ setMenuDidOpen(true)
+ props.onPress()
+ },
+ }
+ return type === 'owner' || type === 'invited' ? (
+
+ ) : (
+
+
+
+ )
+ }}
+
+
+
+ {
+ navigation.navigate('Profile', {name: profile.did})
+ }}>
+
+ Go to profile
+
+
+
+
+
+ Message
+
+
+
+
+
+
+ {canBlockMember ? (
+
+
+ Block
+
+
+
+ ) : null}
+ {canRemoveMember ? (
+ removeMembers({members: [profile.did]})}>
+
+ Remove from chat
+
+
+
+ ) : null}
+ {canUninviteMember ? (
+ {}}>
+
+ Uninvite
+
+
+
+ ) : null}
+
+
+
+ void handleBlockMember()}
+ />
+ >
+ )
+}
diff --git a/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx b/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx
new file mode 100644
index 0000000000..fe11dfeb44
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx
@@ -0,0 +1,65 @@
+import {View} from 'react-native'
+import {plural} from '@lingui/core/macro'
+import {Trans, useLingui} from '@lingui/react/macro'
+
+import {atoms as a, useTheme} from '#/alf'
+import {InlineLinkText} from '#/components/Link'
+import {Text} from '#/components/Typography'
+import {MEMBER_LIMIT} from './constants'
+
+export function MembersAndRequests({
+ memberCount,
+ requestCount,
+ hasMoreRequests,
+ isOwner,
+}: {
+ memberCount: number
+ requestCount: number
+ hasMoreRequests: boolean
+ isOwner: boolean
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ return (
+
+
+
+ Members
+
+
+
+ {l({
+ message: `${memberCount}/${MEMBER_LIMIT}`,
+ comment:
+ 'The number of group chat members out of the total number of permitted users.',
+ })}
+
+
+
+ {isOwner && requestCount > 0 ? (
+
+ {hasMoreRequests
+ ? l({
+ message: `${requestCount}+ requests`,
+ comment:
+ 'Displayed when there are more than 50 requests to join a group chat',
+ })
+ : l({
+ message: plural(requestCount, {
+ one: '# request',
+ other: '# requests',
+ }),
+ comment: 'The number of requests to join a group chat.',
+ })}
+
+ ) : null}
+
+ )
+}
diff --git a/src/screens/Messages/ConversationSettings/StatusBadge.tsx b/src/screens/Messages/ConversationSettings/StatusBadge.tsx
new file mode 100644
index 0000000000..e5416bd033
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/StatusBadge.tsx
@@ -0,0 +1,44 @@
+import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import {type TriggerChildProps} from '#/components/Menu/types'
+import {Text} from '#/components/Typography'
+
+export function StatusBadge({
+ label,
+ style,
+ pressableProps,
+}: {
+ label: string
+ style?: StyleProp
+ pressableProps?: TriggerChildProps['props']
+}) {
+ const t = useTheme()
+
+ const badgeStyle = [
+ a.rounded_xs,
+ t.atoms.bg_contrast_50,
+ {
+ paddingTop: 3,
+ paddingBottom: 3,
+ paddingLeft: 6,
+ paddingRight: 6,
+ },
+ style,
+ ]
+
+ const labelText = (
+
+ {label}
+
+ )
+
+ if (pressableProps) {
+ return (
+
+ {labelText}
+
+ )
+ }
+ return {labelText}
+}
diff --git a/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx b/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx
new file mode 100644
index 0000000000..d7f032b06e
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx
@@ -0,0 +1,27 @@
+import {View} from 'react-native'
+
+import {atoms as a} from '#/alf'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {SubtleHover} from '#/components/SubtleHover'
+
+export function SubtleHoverWrapper({
+ children,
+}: React.PropsWithChildren) {
+ const {
+ state: hover,
+ onIn: onHoverIn,
+ onOut: onHoverOut,
+ } = useInteractionState()
+
+ return (
+
+
+ {children}
+
+ )
+}
diff --git a/src/screens/Messages/ConversationSettings/constants.ts b/src/screens/Messages/ConversationSettings/constants.ts
new file mode 100644
index 0000000000..8e1058c05f
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/constants.ts
@@ -0,0 +1 @@
+export const MEMBER_LIMIT = 50
diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx
new file mode 100644
index 0000000000..6e88146f77
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/index.tsx
@@ -0,0 +1,637 @@
+import {useState} from 'react'
+import {View} from 'react-native'
+import {type ChatBskyConvoDefs} from '@atproto/api'
+import {Trans, useLingui} from '@lingui/react/macro'
+import {StackActions, useNavigation} from '@react-navigation/native'
+
+import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
+import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
+import {
+ type CommonNavigatorParams,
+ type NativeStackScreenProps,
+ type NavigationProp,
+} from '#/lib/routes/types'
+import {logger} from '#/logger'
+import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo'
+import {ConvoStatus} from '#/state/messages/convo/types'
+import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name'
+import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
+import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
+import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests'
+import {useLockConvo} from '#/state/queries/messages/lock-conversation'
+import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
+import {useSession} from '#/state/session'
+import {List} from '#/view/com/util/List'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {AvatarBubbles} from '#/components/AvatarBubbles'
+import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {
+ type ConvoWithDetails,
+ type GroupConvoMember,
+} from '#/components/dms/util'
+import {Error} from '#/components/Error'
+import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
+import {
+ Bell2_Stroke2_Corner0_Rounded as BellIcon,
+ Bell2Off_Stroke2_Corner0_Rounded as BellOffIcon,
+} from '#/components/icons/Bell2'
+import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
+import {type Props as SVGIconProps} from '#/components/icons/common'
+import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
+import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig'
+import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
+import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
+import * as Layout from '#/components/Layout'
+import {Loader} from '#/components/Loader'
+import * as Prompt from '#/components/Prompt'
+import * as Toast from '#/components/Toast'
+import {Text} from '#/components/Typography'
+import {InviteLinkDialog} from '../components/InviteLinkDialog'
+import {AddMembersLink} from './AddMembersLink'
+import {Member, MemberPlaceholder} from './Member'
+import {MembersAndRequests} from './MembersAndRequests'
+import {EditNamePrompt, LeaveChatPrompt, LockChatPrompt} from './prompts'
+
+const dateFormatter = new Intl.DateTimeFormat(undefined, {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+})
+
+type Item =
+ | {type: 'MEMBERS_AND_REQUESTS'; key: string}
+ | {type: 'ADD_MEMBERS_LINK'; key: string}
+ | {
+ type: 'CHAT_MEMBER'
+ key: string
+ profile: GroupConvoMember
+ status: 'owner' | 'standard' | 'invited'
+ }
+ | {
+ type: 'CHAT_MEMBER_PLACEHOLDER'
+ key: string
+ }
+
+type Props = NativeStackScreenProps<
+ CommonNavigatorParams,
+ 'MessagesConversationSettings'
+>
+
+export function MessagesConversationSettingsScreen({route}: Props) {
+ const {gtTablet} = useBreakpoints()
+
+ const convoId = route.params.conversation
+
+ return (
+
+
+
+
+
+ Group chat settings
+
+
+
+
+
+
+
+
+ )
+}
+
+function SettingsInner() {
+ const {t: l} = useLingui()
+ const convoState = useConvo()
+ const navigation = useNavigation()
+
+ if (convoState.status === ConvoStatus.Error) {
+ return (
+ convoState.error.retry()}
+ sideBorders={false}
+ />
+ )
+ }
+
+ if (!isConvoActive(convoState)) {
+ return (
+
+
+
+
+
+ )
+ }
+
+ if (convoState.convo?.kind !== 'group') {
+ return (
+ {
+ if (navigation.canGoBack()) {
+ navigation.goBack()
+ } else {
+ navigation.replace('Messages', {animation: 'pop'})
+ }
+ }}
+ />
+ )
+ }
+
+ return
+}
+
+function keyExtractor(item: Item) {
+ return item.key
+}
+
+function GroupSettings({
+ convo,
+}: {
+ convo: Extract
+}) {
+ const initialNumToRender = useInitialNumToRender({minItemHeight: 68})
+ const bottomBarOffset = useBottomBarOffset()
+
+ const {currentAccount} = useSession()
+
+ const primaryMember = convo?.primaryMember
+ const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did
+
+ const {data: memberListData = [], isPending} = useListConvoMembersQuery({
+ convoId: convo.view.id,
+ placeholderData: convo?.members,
+ })
+
+ // TODO Need this data in order to populate this array. -dsb
+ const invites: string[] = []
+
+ const {data: joinRequestsData, hasNextPage: hasMoreRequests} =
+ useListJoinRequestsQuery({
+ convoId: convo.view.id,
+ enabled: isOwner,
+ })
+ const requestCount =
+ joinRequestsData?.pages.reduce(
+ (sum, page) => sum + page.requests.length,
+ 0,
+ ) ?? 0
+
+ const items: Item[] = [
+ {
+ type: 'MEMBERS_AND_REQUESTS',
+ key: 'members-and-requests',
+ },
+ ...(isOwner
+ ? [{type: 'ADD_MEMBERS_LINK', key: 'add-members-link'} as const]
+ : []),
+ ]
+ if (isPending) {
+ // should never be pending if we correctly set the query cache data
+ Array.from({length: 5}).forEach((_, i) =>
+ items.push({
+ type: 'CHAT_MEMBER_PLACEHOLDER',
+ key: `chat-member-placeholder-${i}`,
+ }),
+ )
+ } else {
+ items.push(
+ ...memberListData
+ .sort((a, b) => {
+ const aIsOwner = a.did === primaryMember?.did
+ const bIsOwner = b.did === primaryMember?.did
+ const aIsSelf = a.did === currentAccount?.did
+ const bIsSelf = b.did === currentAccount?.did
+ if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1
+ if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1
+ return 0
+ })
+ .map(
+ (profile): Item => ({
+ type: 'CHAT_MEMBER',
+ key: profile.did,
+ profile: profile as GroupConvoMember,
+ status:
+ primaryMember?.did === profile.did
+ ? 'owner'
+ : invites.includes(profile.did)
+ ? 'invited'
+ : 'standard',
+ }),
+ ),
+ )
+ }
+
+ function renderItem({item}: {item: Item}) {
+ switch (item.type) {
+ case 'MEMBERS_AND_REQUESTS':
+ return (
+
+ )
+ case 'ADD_MEMBERS_LINK':
+ return convo ? (
+ profile.did)}
+ />
+ ) : null
+ case 'CHAT_MEMBER':
+ return convo ? (
+
+ ) : null
+ case 'CHAT_MEMBER_PLACEHOLDER':
+ return
+ default:
+ return null
+ }
+ }
+
+ return (
+
+ ) : (
+
+ )
+ }
+ renderItem={renderItem}
+ sideBorders={false}
+ windowSize={11}
+ />
+ )
+}
+
+function SettingsHeader({
+ convo,
+ isOwner,
+}: {
+ convo: Extract
+ isOwner: boolean
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const navigation = useNavigation()
+
+ const groupName = convo.details.name
+ const [newGroupName, setNewGroupName] = useState(groupName)
+
+ const lockStatus = convo.details.lockStatus
+
+ // TODO Enable this once the feature is working end-to-end. -dsb
+ // const {joinLink} = convo.details
+ const isJoinLinkEnabled = false
+ // const isJoinLinkEnabled =
+ // isOwner || (!isOwner && joinLink?.enabledStatus === 'enabled')
+
+ // TODO Enable this once the feature is working end-to-end. -dsb
+ const isReportLinkEnabled = false
+
+ const {mutate: editGroupName} = useEditGroupChatName(convo.view.id, {
+ onError: e => {
+ setNewGroupName(groupName)
+ logger.error('Failed to edit group chat name', {message: e})
+ Toast.show(l`Failed to edit group chat name`, {type: 'error'})
+ },
+ })
+
+ const {mutate: muteConvo} = useMuteConvo(convo.view.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: e => {
+ logger.error('Failed to mute group chat', {message: e})
+ Toast.show(l`Failed to mute group chat`, {type: 'error'})
+ },
+ })
+
+ const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, {
+ onSuccess: () => {
+ // Settings > Chat > Chat list
+ navigation.dispatch(StackActions.pop(2))
+ },
+ onError: e => {
+ logger.error('Failed to leave group chat', {message: e})
+ Toast.show(l({message: 'Failed to leave group chat', context: 'toast'}), {
+ type: 'error',
+ })
+ },
+ })
+
+ const {mutate: lockConvo} = useLockConvo(convo.view.id, {
+ onSuccess: data => {
+ const kind = data.convo.kind as ChatBskyConvoDefs.GroupConvo
+ if (kind.lockStatus === 'locked') {
+ Toast.show(l({message: 'Group chat locked', context: 'toast'}))
+ } else {
+ Toast.show(l({message: 'Group chat unlocked', context: 'toast'}))
+ }
+ },
+ onError: (e, {lock}) => {
+ if (lock) {
+ logger.error('Failed to lock group chat', {message: e})
+ Toast.show(l`Failed to lock group chat`, {type: 'error'})
+ } else {
+ logger.error('Failed to unlock group chat', {message: e})
+ Toast.show(l`Failed to unlock group chat`, {type: 'error'})
+ }
+ },
+ })
+
+ const inviteLinkDialog = Dialog.useDialogControl()
+ const editNamePrompt = Prompt.usePromptControl()
+ const lockChatPrompt = Prompt.usePromptControl()
+ const leaveChatPrompt = Prompt.usePromptControl()
+
+ const handleToggleMute = () => {
+ muteConvo({mute: !convo.view.muted})
+ }
+
+ // TODO Need to implement this when the backend is ready. -dsb
+ const handleReportChat = () => {}
+
+ const handlePromptName = () => {
+ setNewGroupName(groupName)
+ editNamePrompt.open()
+ }
+
+ const handleEditName = () => {
+ editGroupName({name: newGroupName})
+ }
+
+ const handleConfirmLock = () => {
+ lockConvo({lock: true})
+ }
+
+ const handleUnlock = () => {
+ lockConvo({lock: false})
+ }
+
+ // TODO The creation date doesn't exist yet. -dsb
+ const showCreatedAt = false
+ const createdAt = new Date()
+
+ const canLockGroupChat = isOwner && lockStatus !== 'locked-permanently'
+
+ return (
+ <>
+
+
+
+
+
+ {groupName}
+
+ {showCreatedAt ? (
+
+ Created {dateFormatter.format(createdAt)}
+
+ ) : null}
+
+
+ {isOwner ? (
+
+ ) : null}
+ {isJoinLinkEnabled ? (
+
+ ) : null}
+ {canLockGroupChat ? (
+
+ ) : null}
+ {isOwner ? null : isReportLinkEnabled ? (
+
+ ) : null}
+ {isOwner ? null : (
+
+ )}
+
+
+
+
+
+
+ >
+ )
+}
+
+function SettingsHeaderPlaceholder() {
+ const t = useTheme()
+
+ return (
+
+
+
+
+
+ …
+
+
+ …
+
+
+
+
+
+
+
+
+ )
+}
+
+function SettingsButton({
+ color = 'secondary',
+ disabled,
+ icon,
+ label,
+ text,
+ onPress,
+}: {
+ color?: ButtonColor
+ disabled?: boolean
+ icon: React.ComponentType
+ label: string
+ text: string
+ onPress: () => void
+}) {
+ const t = useTheme()
+
+ return (
+
+
+
+ {text}
+
+
+ )
+}
+
+function SettingsButtonPlaceholder() {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ return (
+
+
+
+ …
+
+
+ )
+}
diff --git a/src/screens/Messages/ConversationSettings/prompts.tsx b/src/screens/Messages/ConversationSettings/prompts.tsx
new file mode 100644
index 0000000000..f5b980b990
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/prompts.tsx
@@ -0,0 +1,119 @@
+import {View} from 'react-native'
+import {Trans, useLingui} from '@lingui/react/macro'
+
+import {atoms as a} from '#/alf'
+import type * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import * as Prompt from '#/components/Prompt'
+
+export 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
+
+
+
+
+
+
+
+
+
+
+
+ >
+
+ )
+}
+
+export function LockChatPrompt({
+ control,
+ onConfirm,
+}: {
+ control: Dialog.DialogOuterProps['control']
+ onConfirm: () => void
+}) {
+ const {t: l} = useLingui()
+
+ return (
+
+ )
+}
+
+export function LeaveChatPrompt({
+ control,
+ groupName,
+ onConfirm,
+}: {
+ control: Dialog.DialogOuterProps['control']
+ groupName: string
+ onConfirm: () => void
+}) {
+ const {t: l} = useLingui()
+
+ return (
+
+ )
+}
+
+export function BlockMemberPrompt({
+ control,
+ onConfirm,
+}: {
+ control: Dialog.DialogOuterProps['control']
+ onConfirm: () => void
+}) {
+ const {t: l} = useLingui()
+
+ return (
+
+ )
+}
diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx
index 7cedb5d4be..842332dede 100644
--- a/src/screens/Messages/components/ChatListItem.tsx
+++ b/src/screens/Messages/components/ChatListItem.tsx
@@ -298,7 +298,10 @@ function BaseChatItem({
// System message
if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) {
- const info = getSystemMessageInfo(convo.lastMessage.data, convo.members)
+ const info = getSystemMessageInfo(
+ convo.lastMessage.data,
+ new Map(convo.members.map(m => [m.did, m])),
+ )
if (info) {
lastMessage = i18n._(info.message)
lastMessageSentAt = convo.lastMessage.sentAt
diff --git a/src/screens/Messages/components/ChatStatusInfo.tsx b/src/screens/Messages/components/ChatStatusInfo.tsx
index ca2b1f3685..8f27c55b46 100644
--- a/src/screens/Messages/components/ChatStatusInfo.tsx
+++ b/src/screens/Messages/components/ChatStatusInfo.tsx
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
import {type ActiveConvoStates} from '#/state/messages/convo'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {KnownFollowers} from '#/components/KnownFollowers'
@@ -16,16 +15,15 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
const t = useTheme()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
- const {currentAccount} = useSession()
const leaveConvoControl = usePromptControl()
const onAcceptChat = useCallback(() => {
convoState.markConvoAccepted()
}, [convoState])
- const otherUser = convoState.recipients.find(
- user => user.did !== currentAccount?.did,
- )
+ // either the other person, or the chat owner
+ // if we ever allow someone other than the owner to invite people, this will need to change
+ const otherUser = convoState.convo.primaryMember
if (!moderationOpts) {
return null
@@ -44,7 +42,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
{otherUser && (
{
+ if (hasBeenCopied) {
+ const timeout = setTimeout(
+ () => setHasBeenCopied(false),
+ isReducedMotionEnabled ? 2000 : 100,
+ )
+ return () => clearTimeout(timeout)
+ }
+ }, [hasBeenCopied, isReducedMotionEnabled])
+
+ const onPress = useCallback(
+ (evt: GestureResponderEvent) => {
+ void Clipboard.setStringAsync(value)
+ setHasBeenCopied(true)
+ onPressProp?.(evt)
+ },
+ [value, onPressProp],
+ )
+
+ return (
+
+ {hasBeenCopied && (
+
+
+ Copied!
+
+
+ )}
+
+
+ )
+}
diff --git a/src/screens/Messages/components/EditTextButton.tsx b/src/screens/Messages/components/EditTextButton.tsx
new file mode 100644
index 0000000000..8a1ccc620a
--- /dev/null
+++ b/src/screens/Messages/components/EditTextButton.tsx
@@ -0,0 +1,59 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/react/macro'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, type ButtonProps} from '#/components/Button'
+import {Text} from '#/components/Typography'
+
+export function EditTextButton({
+ children,
+ style,
+ onPress,
+ ...props
+}: ButtonProps & {value: string}) {
+ const t = useTheme()
+
+ return (
+
+
+
+ )
+}
diff --git a/src/screens/Messages/components/InviteLinkDialog.tsx b/src/screens/Messages/components/InviteLinkDialog.tsx
new file mode 100644
index 0000000000..843f6a833e
--- /dev/null
+++ b/src/screens/Messages/components/InviteLinkDialog.tsx
@@ -0,0 +1,462 @@
+import {useState} from 'react'
+import {View} from 'react-native'
+import {Trans, useLingui} from '@lingui/react/macro'
+
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
+import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
+import {shareUrl} from '#/lib/sharing'
+import {useCreateJoinLink} from '#/state/queries/messages/create-join-link'
+import {useDisableJoinLink} from '#/state/queries/messages/disable-join-link'
+import {useEditJoinLink} from '#/state/queries/messages/edit-join-link'
+import {useEnableJoinLink} from '#/state/queries/messages/enable-join-link'
+import {atoms as a, useTheme, web} from '#/alf'
+import {
+ Button,
+ ButtonIcon,
+ ButtonText,
+ StackedButton,
+} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {type ConvoWithDetails} from '#/components/dms/util'
+import * as Toggle from '#/components/forms/Toggle'
+import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
+import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight'
+import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink'
+import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig'
+import {Loader} from '#/components/Loader'
+import * as Toast from '#/components/Toast'
+import {Text} from '#/components/Typography'
+import {IS_WEB} from '#/env'
+import {CopyTextButton} from './CopyTextButton'
+import {EditTextButton} from './EditTextButton'
+
+enum Step {
+ INFO,
+ GENERATE,
+ MANAGE,
+}
+
+const timeFormatter = new Intl.DateTimeFormat(undefined, {
+ hour: 'numeric',
+ minute: 'numeric',
+})
+const dateFormatter = new Intl.DateTimeFormat(undefined, {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+})
+
+export function InviteLinkDialog({
+ convo,
+ control,
+ isOwner,
+}: {
+ convo: Extract
+ control: Dialog.DialogOuterProps['control']
+ isOwner: boolean
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const ownerName = createSanitizedDisplayName(convo.primaryMember)
+
+ const {joinLink} = convo.details
+ const enabledStatus = joinLink?.enabledStatus
+
+ const defaultStep = joinLink ? Step.MANAGE : Step.INFO
+ const defaultWhoCanJoin = joinLink
+ ? [
+ `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`,
+ ]
+ : ['anyone']
+
+ const [step, setStep] = useState(defaultStep)
+ const [whoCanJoin, setWhoCanJoin] = useState(defaultWhoCanJoin)
+
+ const {openComposer} = useOpenComposer()
+
+ const {mutate: createJoinLink, isPending: isCreating} = useCreateJoinLink(
+ convo.view.id,
+ {
+ onSuccess: () => {
+ setStep(Step.MANAGE)
+ },
+ onError: () => {
+ Toast.show(l`Failed to create invite link`, {
+ type: 'error',
+ })
+ },
+ },
+ )
+ const {mutate: editJoinLink, isPending: isEditing} = useEditJoinLink(
+ convo.view.id,
+ {
+ onSuccess: () => {
+ setStep(Step.MANAGE)
+ },
+ onError: () => {
+ Toast.show(l`Failed to edit invite link`, {
+ type: 'error',
+ })
+ },
+ },
+ )
+ const {mutate: disableJoinLink, isPending: isDisabling} = useDisableJoinLink(
+ convo.view.id,
+ {
+ onError: () => {
+ Toast.show(l`Failed to disable invite link`, {
+ type: 'error',
+ })
+ },
+ },
+ )
+ const {mutate: enableJoinLink, isPending: isEnabling} = useEnableJoinLink(
+ convo.view.id,
+ {
+ onError: () => {
+ Toast.show(l`Failed to enable invite link`, {
+ type: 'error',
+ })
+ },
+ },
+ )
+ const isSaving = isCreating || isEditing
+
+ const whoCanJoinOptions = [
+ {
+ name: 'anyone',
+ owner: l`Anyone can join instantly`,
+ member: l`Anyone can join instantly`,
+ },
+ {
+ name: 'anyone:requireApproval',
+ owner: l`Anyone can request to join`,
+ member: l`Anyone can request to join`,
+ },
+ {
+ name: 'followedByOwner',
+ owner: l`People I follow can join instantly`,
+ member: l`People ${ownerName} follows can join instantly`,
+ },
+ {
+ name: 'followedByOwner:requireApproval',
+ owner: l`People I follow can request to join`,
+ member: l`People ${ownerName} follows can request to join`,
+ },
+ ]
+
+ let content: React.ReactNode = null
+ let header: string | null = null
+ switch (step) {
+ case Step.INFO:
+ header = l`Invite link`
+ content = (
+ <>
+
+
+
+ An invite link lets people join this group chat without being
+ added directly. You control who can use the link and whether
+ they need your approval. You can disable the link at any time.
+
+
+
+
+ Your name, avatar, and the name of the group chat will be
+ visible to everyone.
+
+
+
+
+
+
+ >
+ )
+ break
+ case Step.GENERATE:
+ header = l`Generate invite link`
+ content = (
+ <>
+
+
+ Choose who can join this group chat and how.
+
+
+
+
+
+ {whoCanJoinOptions.map(option => (
+
+ {({selected}) => (
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ >
+ )
+ break
+ case Step.MANAGE: {
+ const hasJoinLinkCode = joinLink && joinLink.code !== ''
+ const joinLinkURI = hasJoinLinkCode
+ ? `https://bsky.app/chat/${joinLink.code}`
+ : 'https://bsky.app/chat'
+ const createdAt = joinLink ? new Date(joinLink.createdAt) : null
+ const currentOption = whoCanJoinOptions.find(
+ o => o.name === whoCanJoin[0],
+ )
+ const ownerValue = currentOption?.owner ?? whoCanJoinOptions[0].owner
+ const memberValue = currentOption?.member ?? whoCanJoinOptions[0].member
+ header =
+ enabledStatus === 'enabled' ? l`Invite link` : l`Invite link disabled`
+ content = (
+ <>
+
+
+
+ {joinLinkURI}
+
+
+ {createdAt ? (
+
+
+ Created {timeFormatter.format(createdAt)}{' '}
+ {dateFormatter.format(createdAt)}
+
+
+ ) : null}
+
+ {enabledStatus === 'enabled' ? (
+
+ {isOwner ? (
+ setStep(Step.GENERATE)}>
+
+ {ownerValue}
+
+
+ ) : (
+ {memberValue}
+ )}
+
+ ) : null}
+ {enabledStatus === 'enabled' ? (
+
+ {isOwner ? (
+ {
+ disableJoinLink()
+ }}>
+ Disable
+
+ ) : null}
+ {
+ control.close(() => {
+ openComposer({
+ text: joinLinkURI,
+ logContext: 'Other',
+ })
+ })
+ }}>
+ Post link
+
+ {
+ void shareUrl(joinLinkURI)
+ }}>
+ Share
+
+
+ ) : (
+
+
+
+
+ )}
+ >
+ )
+ break
+ }
+ }
+
+ if (!isOwner && (!joinLink || joinLink?.enabledStatus === 'disabled')) {
+ header = l`Invite link`
+ content = (
+ <>
+
+
+ There is no invite link for this group chat.
+
+
+
+
+
+ >
+ )
+ }
+
+ return (
+ {
+ setStep(defaultStep)
+ setWhoCanJoin(defaultWhoCanJoin)
+ }}>
+
+
+
+
+ {header}
+
+
+
+
+ }
+ label={l`Group chat invite link dialog`}
+ style={web({maxWidth: 400})}>
+ {content}
+
+
+ )
+}
+
+function TargetOption({label, selected}: {label: string; selected: boolean}) {
+ const t = useTheme()
+
+ return (
+
+
+
+ {label}
+
+
+ )
+}
diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx
index 5d480db312..e8263e423d 100644
--- a/src/screens/Messages/components/MessagesList.tsx
+++ b/src/screens/Messages/components/MessagesList.tsx
@@ -250,10 +250,8 @@ export function MessagesList({
)
const onStartReached = useCallback(() => {
- if (hasScrolled && prevContentHeight.current > layoutHeight.get()) {
- void convoState.fetchMessageHistory()
- }
- }, [convoState, hasScrolled, layoutHeight])
+ void convoState.fetchMessageHistory()
+ }, [convoState])
const onScroll = useCallback(
(e: ScrollEvent) => {
@@ -376,10 +374,7 @@ export function MessagesList({
return (
member.did === item.message.sender.did,
- )}
- isGroupChat={convoState.isGroup()}
+ isGroupChat={convoState.convo.kind === 'group'}
/>
)
} else if (item.type === 'deleted-message') {
@@ -448,8 +443,9 @@ export function MessagesList({
ListHeaderComponent={
<>
- {convoState.isGroup() && convoState.hasAllHistory ? (
-
+ {convoState.convo?.kind === 'group' &&
+ convoState.hasAllHistory ? (
+
) : null}
>
}
@@ -577,7 +573,7 @@ function getFooterState(
}
}
- if (convoState.convo.status === 'request' && !hasAcceptOverride) {
+ if (convoState.convo.view.status === 'request' && !hasAcceptOverride) {
return 'request'
}
diff --git a/src/screens/Messages/components/MessagesListInfoPanel.tsx b/src/screens/Messages/components/MessagesListInfoPanel.tsx
index 9f1a82dbc4..a5c035dfc8 100644
--- a/src/screens/Messages/components/MessagesListInfoPanel.tsx
+++ b/src/screens/Messages/components/MessagesListInfoPanel.tsx
@@ -1,72 +1,90 @@
import {View} from 'react-native'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
-import {type ConvoState} from '#/state/messages/convo/types'
+import {logger} from '#/logger'
+import {useAddGroupMembers} from '#/state/queries/messages/add-group-members'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
+import {type ConvoWithDetails} from '#/components/dms/util'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
+import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
+import {InviteLinkDialog} from './InviteLinkDialog'
-export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
+export function MessagesListInfoPanel({
+ convo,
+}: {
+ convo: Extract
+}) {
const t = useTheme()
const {t: l} = useLingui()
const addMembersControl = Dialog.useDialogControl()
+ const inviteLinkControl = Dialog.useDialogControl()
const {currentAccount} = useSession()
- const isOwner =
- currentAccount?.did == null
- ? false
- : convoState.getPrimaryMember?.()?.did === currentAccount.did
- // TODO Get this from @api/atproto - dsb
- const isLinkEnabled = false
+ const convoId = convo.view.id
+ const {mutate: addGroupMembers} = useAddGroupMembers(convoId, {
+ onSuccess: () => {
+ addMembersControl.close()
+ },
+ onError: e => {
+ logger.error('Failed to add group chat members', {message: e})
+ Toast.show(l`Failed to add members`, {type: 'error'})
+ },
+ })
- const groupName = convoState.getGroupInfo?.()?.name
+ // TODO Enable this once the feature is working end-to-end. -dsb
+ // const joinLink = groupConvo?.details.joinLink
+ const isJoinLinkEnabled = false
+ // (isOwner && groupConvo) ||
+ // (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled')
- const members = (convoState?.convo?.members ?? []).filter(
+ const isOwner = convo?.primaryMember.did === currentAccount?.did
+
+ const members = (convo?.members ?? []).filter(
profile => profile.did !== currentAccount?.did,
)
- let names: React.ReactNode | null = null
+ let names: React.ReactNode = null
if (members.length === 1) {
names = New chat with {members[0].displayName}
- }
- if (members.length === 2) {
+ } else if (members.length === 2) {
names = (
New chat with {members[0].displayName} and {members[1].displayName}
)
- }
- if (members.length > 2) {
+ } else if (members.length > 2) {
+ const memberCount = convo.details.memberCount - 2
names = (
New chat with {members[0].displayName}, {members[1].displayName}, and{' '}
.
)
}
- const showButtons = isOwner || isLinkEnabled
+ const showButtons = isOwner || isJoinLinkEnabled
return (
<>
-
- {groupName ? (
+
+ {convo.details.name ? (
- {groupName}
+ {convo.details.name}
) : null}
{names ? (
@@ -102,12 +120,16 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
) : null}
- {isOwner || isLinkEnabled ? (
+ {isJoinLinkEnabled ? (
) : null}
+
profile.did)}
title={l`Add people`}
- onAddMembers={(_dids: string[]) => {
- // TODO Add members here
- addMembersControl.close()
- }}
+ onAddMembers={(members, profiles) =>
+ addGroupMembers({members, profiles})
+ }
/>
>
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index eae0b8b90c..85e5b73396 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -11,6 +11,7 @@ import {findAllProfilesInQueryData as findAllProfilesInContactMatchesQueryData}
import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers'
import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members'
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations'
+import {findAllProfilesInQueryData as findAllProfilesInMessagesQueryData} from '#/state/queries/messages/list-convo-members'
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts'
import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts'
import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed'
@@ -264,4 +265,5 @@ function* findProfilesInCache(
yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did)
yield* findAllProfilesInNotifsQueryData(queryClient, did)
yield* findAllProfilesInContactMatchesQueryData(queryClient, did)
+ yield* findAllProfilesInMessagesQueryData(queryClient, did)
}
diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts
index 3552de39e6..8552a490e2 100644
--- a/src/state/messages/convo/agent.ts
+++ b/src/state/messages/convo/agent.ts
@@ -1,9 +1,10 @@
import {
type AtpAgent,
- ChatBskyActorDefs,
+ type ChatBskyActorDefs,
ChatBskyConvoDefs,
type ChatBskyConvoGetLog,
type ChatBskyConvoSendMessage,
+ type ChatBskyGroupDefs,
} from '@atproto/api'
import {XRPCError} from '@atproto/api'
import {EventEmitter} from 'eventemitter3'
@@ -36,8 +37,12 @@ import {
} from '#/state/messages/convo/types'
import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type MessagesEventBusError} from '#/state/messages/events/types'
+import {
+ type ConvoWithDetails,
+ type GroupConvoMember,
+ parseConvoView,
+} from '#/components/dms/util'
import {IS_NATIVE} from '#/env'
-import * as bsky from '#/types/bsky'
const logger = Logger.create(Logger.Context.ConversationAgent)
@@ -102,10 +107,8 @@ export class Convo {
{id: string; message: ChatBskyConvoSendMessage.InputSchema['message']}
> = new Map()
private deletedMessages: Set = new Set()
- private systemMessageProfiles: Map<
- string,
- ChatBskyActorDefs.ProfileViewBasic
- > = new Map()
+ private relatedProfiles: Map =
+ new Map()
private isProcessingPendingMessages = false
@@ -114,7 +117,7 @@ export class Convo {
private emitter = new EventEmitter<{event: [ConvoEvent]}>()
convoId: string
- convo: ChatBskyConvoDefs.ConvoView | undefined
+ convo: ConvoWithDetails | undefined
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
snapshot: ConvoState | undefined
@@ -130,6 +133,7 @@ export class Convo {
this.setupPlaceholderData(params.placeholderData)
}
+ this.setConvo = this.setConvo.bind(this)
this.subscribe = this.subscribe.bind(this)
this.getSnapshot = this.getSnapshot.bind(this)
this.sendMessage = this.sendMessage.bind(this)
@@ -141,10 +145,10 @@ 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)
this.updateGroupName = this.updateGroupName.bind(this)
+ this.updateGroupMembers = this.updateGroupMembers.bind(this)
+ this.updateJoinLink = this.updateJoinLink.bind(this)
+ this.updateLockStatus = this.updateLockStatus.bind(this)
}
private commit() {
@@ -172,6 +176,30 @@ export class Convo {
}
private generateSnapshot(): ConvoState {
+ const shared = {
+ isFetchingHistory: this.isFetchingHistory,
+ // Explicit null check since the value is initially undefined.
+ hasAllHistory: this.oldestRev === null,
+ }
+
+ const methods = {
+ deleteMessage: this.deleteMessage,
+ sendMessage: this.sendMessage,
+ fetchMessageHistory: this.fetchMessageHistory,
+ markConvoAccepted: this.markConvoAccepted,
+ addReaction: this.addReaction,
+ removeReaction: this.removeReaction,
+ }
+
+ const emptyMethods = {
+ deleteMessage: undefined,
+ sendMessage: undefined,
+ fetchMessageHistory: undefined,
+ markConvoAccepted: undefined,
+ addReaction: undefined,
+ removeReaction: undefined,
+ }
+
switch (this.status) {
case ConvoStatus.Initializing: {
return {
@@ -179,45 +207,48 @@ export class Convo {
items: [],
convo: this.convo,
error: undefined,
- sender: this.sender,
- recipients: this.recipients,
- isFetchingHistory: this.isFetchingHistory,
- // Explicit null check since the value is initially undefined.
- hasAllHistory: this.oldestRev === null,
- deleteMessage: undefined,
- sendMessage: undefined,
- fetchMessageHistory: undefined,
- markConvoAccepted: undefined,
- addReaction: undefined,
- removeReaction: undefined,
- isGroup: this.isGroup,
- getGroupInfo: this.getGroupInfo,
- getPrimaryMember: this.getPrimaryMember,
+ ...shared,
+ ...emptyMethods,
+ }
+ }
+ case ConvoStatus.Disabled: {
+ return {
+ status: this.status,
+ items: this.getItems(),
+ convo: this.convo!,
+ error: undefined,
+ ...shared,
+ ...methods,
+ }
+ }
+ case ConvoStatus.Suspended: {
+ return {
+ status: this.status,
+ items: this.getItems(),
+ convo: this.convo!,
+ error: undefined,
+ ...shared,
+ ...methods,
+ }
+ }
+ case ConvoStatus.Backgrounded: {
+ return {
+ status: this.status,
+ items: this.getItems(),
+ convo: this.convo!,
+ error: undefined,
+ ...shared,
+ ...methods,
}
}
- case ConvoStatus.Disabled:
- case ConvoStatus.Suspended:
- case ConvoStatus.Backgrounded:
case ConvoStatus.Ready: {
return {
status: this.status,
items: this.getItems(),
convo: this.convo!,
error: undefined,
- sender: this.sender!,
- recipients: this.recipients!,
- isFetchingHistory: this.isFetchingHistory,
- // Explicit null check since the value is initially undefined.
- hasAllHistory: this.oldestRev === null,
- deleteMessage: this.deleteMessage,
- sendMessage: this.sendMessage,
- fetchMessageHistory: this.fetchMessageHistory,
- markConvoAccepted: this.markConvoAccepted,
- addReaction: this.addReaction,
- removeReaction: this.removeReaction,
- isGroup: this.isGroup,
- getGroupInfo: this.getGroupInfo,
- getPrimaryMember: this.getPrimaryMember,
+ ...shared,
+ ...methods,
}
}
case ConvoStatus.Error: {
@@ -226,19 +257,9 @@ export class Convo {
items: [],
convo: undefined,
error: this.error!,
- sender: undefined,
- recipients: undefined,
isFetchingHistory: false,
hasAllHistory: false,
- deleteMessage: undefined,
- sendMessage: undefined,
- fetchMessageHistory: undefined,
- markConvoAccepted: undefined,
- addReaction: undefined,
- removeReaction: undefined,
- isGroup: undefined,
- getGroupInfo: undefined,
- getPrimaryMember: undefined,
+ ...emptyMethods,
}
}
default: {
@@ -247,20 +268,10 @@ export class Convo {
items: [],
convo: this.convo,
error: undefined,
- sender: this.sender,
- recipients: this.recipients,
isFetchingHistory: false,
// Explicit null check since the value is initially undefined.
hasAllHistory: this.oldestRev === null,
- deleteMessage: undefined,
- sendMessage: undefined,
- fetchMessageHistory: undefined,
- markConvoAccepted: undefined,
- addReaction: undefined,
- removeReaction: undefined,
- isGroup: this.isGroup,
- getGroupInfo: this.getGroupInfo,
- getPrimaryMember: this.getPrimaryMember,
+ ...emptyMethods,
}
}
}
@@ -460,8 +471,6 @@ export class Convo {
private reset() {
this.convo = undefined
- this.sender = undefined
- this.recipients = undefined
this.snapshot = undefined
this.status = ConvoStatus.Uninitialized
@@ -473,7 +482,7 @@ export class Convo {
this.newMessages = new Map()
this.pendingMessages = new Map()
this.deletedMessages = new Set()
- this.systemMessageProfiles = new Map()
+ this.relatedProfiles = new Map()
this.pendingMessageFailure = null
this.fetchMessageHistoryError = undefined
@@ -498,6 +507,26 @@ export class Convo {
}
}
+ private setConvo(convo: ChatBskyConvoDefs.ConvoView) {
+ this.convo = parseConvoView(convo, this.senderUserDid) ?? this.convo
+ if (this.convo) {
+ for (const member of this.convo.members) {
+ this.relatedProfiles.set(member.did, member)
+ }
+ }
+ }
+
+ private updateConvo(convo: Partial) {
+ if (this.convo) {
+ this.convo =
+ parseConvoView({...this.convo.view, ...convo}, this.senderUserDid) ??
+ this.convo
+ for (const member of this.convo.members) {
+ this.relatedProfiles.set(member.did, member)
+ }
+ }
+ }
+
/**
* Initialises the convo with placeholder data, if provided. We still refetch it before rendering the convo,
* but this allows us to render the convo header immediately.
@@ -505,20 +534,14 @@ export class Convo {
private setupPlaceholderData(
data: NonNullable,
) {
- this.convo = data.convo
- this.sender = data.convo.members.find(m => m.did === this.senderUserDid)
- this.recipients = data.convo.members.filter(
- m => m.did !== this.senderUserDid,
- )
+ this.setConvo(data.convo)
}
private async setup() {
try {
- const {convo, sender, recipients} = await this.fetchConvo()
+ const {convo} = await this.fetchConvo()
- this.convo = convo
- this.sender = sender
- this.recipients = recipients
+ this.setConvo(convo)
/*
* Some validation prior to `Ready` status
@@ -526,14 +549,14 @@ export class Convo {
if (!this.convo) {
throw new Error('could not find convo')
}
- if (!this.sender) {
- throw new Error('could not find sender in convo')
- }
- if (!this.recipients) {
- throw new Error('could not find recipients in convo')
+
+ const self = this.convo.members.find(m => m.did === this.senderUserDid)
+
+ if (!self) {
+ throw new Error('could not find self in convo')
}
- const userIsDisabled = Boolean(this.sender.chatDisabled)
+ const userIsDisabled = Boolean(self.chatDisabled)
if (userIsDisabled) {
this.dispatch({event: ConvoDispatchEvent.Disable})
@@ -602,22 +625,19 @@ export class Convo {
}
private pendingFetchConvo:
- | Promise<{
- convo: ChatBskyConvoDefs.ConvoView
- sender: ChatBskyActorDefs.ProfileViewBasic | undefined
- recipients: ChatBskyActorDefs.ProfileViewBasic[]
- }>
+ | Promise<{convo: ChatBskyConvoDefs.ConvoView}>
| undefined
async fetchConvo() {
if (this.pendingFetchConvo) return this.pendingFetchConvo
+ // non-blocking
+ void this.fetchMemberList()
+
this.pendingFetchConvo = (async () => {
try {
const response = await networkRetry(2, () => {
- return this.agent.api.chat.bsky.convo.getConvo(
- {
- convoId: this.convoId,
- },
+ return this.agent.chat.bsky.convo.getConvo(
+ {convoId: this.convoId},
{headers: DM_SERVICE_HEADERS},
)
})
@@ -626,8 +646,6 @@ export class Convo {
return {
convo,
- sender: convo.members.find(m => m.did === this.senderUserDid),
- recipients: convo.members.filter(m => m.did !== this.senderUserDid),
}
} finally {
this.pendingFetchConvo = undefined
@@ -639,11 +657,10 @@ export class Convo {
async refreshConvo() {
try {
- const {convo, sender, recipients} = await this.fetchConvo()
+ void this.fetchMemberList()
+ const {convo} = await this.fetchConvo()
// throw new Error('UNCOMMENT TO TEST REFRESH FAILURE')
- this.convo = convo || this.convo
- this.sender = sender || this.sender
- this.recipients = recipients || this.recipients
+ this.setConvo(convo)
} catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
@@ -654,11 +671,32 @@ export class Convo {
}
}
- private fetchMessageHistoryError:
- | {
- retry: () => void
+ // purely for populating `this.relatedProfiles` - we do not pipe it
+ // into the ConvoWithDetails. If you want to drive UI based on the member list,
+ // use `useListConvoMembersQuery`
+ // we shouldn't also block loading off of this - the UI should be resilient
+ async fetchMemberList() {
+ let cursor: string | undefined
+ do {
+ const result = await networkRetry(2, () => {
+ return this.agent.chat.bsky.convo.getConvoMembers(
+ {
+ convoId: this.convoId,
+ limit: 50,
+ cursor,
+ },
+ {headers: DM_SERVICE_HEADERS},
+ )
+ })
+ cursor = result.data.cursor
+
+ for (const member of result.data.members) {
+ this.relatedProfiles.set(member.did, member)
}
- | undefined
+ } while (cursor)
+ }
+
+ private fetchMessageHistoryError: {retry: () => void} | undefined
async fetchMessageHistory() {
logger.debug('fetch message history', {})
@@ -700,7 +738,7 @@ export class Convo {
if (relatedProfiles) {
for (const profile of relatedProfiles) {
- this.systemMessageProfiles.set(profile.did, profile)
+ this.relatedProfiles.set(profile.did, profile)
}
}
@@ -820,6 +858,12 @@ export class Convo {
*/
this.latestRev = ev.rev
+ if ('relatedProfiles' in ev && Array.isArray(ev.relatedProfiles)) {
+ for (const profile of ev.relatedProfiles) {
+ this.relatedProfiles.set(profile.did, profile)
+ }
+ }
+
if (
ChatBskyConvoDefs.isLogCreateMessage(ev) &&
ChatBskyConvoDefs.isMessageView(ev.message)
@@ -872,14 +916,6 @@ export class Convo {
const systemView = toSystemMessageView(ev)
if (systemView) {
this.newMessages.set(systemView.id, systemView)
- if (
- 'relatedProfiles' in ev &&
- Array.isArray(ev.relatedProfiles)
- ) {
- for (const profile of ev.relatedProfiles) {
- this.systemMessageProfiles.set(profile.did, profile)
- }
- }
needsCommit = true
}
}
@@ -907,11 +943,10 @@ export class Convo {
id: tempId,
message,
})
- if (this.convo?.status === 'request') {
- this.convo = {
- ...this.convo,
+ if (this.convo?.view.status === 'request') {
+ this.updateConvo({
status: 'accepted',
- }
+ })
}
this.commit()
@@ -921,41 +956,79 @@ export class Convo {
}
markConvoAccepted() {
- if (this.convo) {
- this.convo = {
- ...this.convo,
- status: 'accepted',
- }
- }
+ this.updateConvo({
+ status: 'accepted',
+ })
+
this.commit()
}
updateMuted(muted: boolean) {
- if (this.convo) {
- this.convo = {
- ...this.convo,
- muted,
- }
- }
+ this.updateConvo({
+ muted,
+ })
+
this.commit()
}
updateGroupName(name: string) {
- if (
- this.convo &&
- bsky.dangerousIsType(
- this.convo.kind,
- ChatBskyConvoDefs.isGroupConvo,
- )
- ) {
- this.convo = {
- ...this.convo,
- kind: {
- ...this.convo.kind,
- name,
- },
- }
+ if (this.convo?.kind !== 'group') {
+ throw new Error('updateGroupName can only be called on group convo')
}
+
+ this.updateConvo({
+ kind: {
+ ...this.convo.details,
+ name,
+ },
+ })
+
+ this.commit()
+ }
+
+ updateGroupMembers(members: GroupConvoMember[], memberCount: number) {
+ if (this.convo?.kind !== 'group') {
+ throw new Error('updateGroupMembers can only be called on group convo')
+ }
+
+ this.updateConvo({
+ members,
+ kind: {
+ ...this.convo.details,
+ memberCount,
+ },
+ })
+
+ this.commit()
+ }
+
+ updateJoinLink(joinLink: ChatBskyGroupDefs.JoinLinkView | undefined) {
+ if (this.convo?.kind !== 'group') {
+ throw new Error('updateJoinLink can only be called on group convo')
+ }
+
+ this.updateConvo({
+ kind: {
+ ...this.convo.details,
+ joinLink,
+ },
+ })
+
+ this.commit()
+ }
+
+ updateLockStatus(lockStatus: ChatBskyConvoDefs.ConvoLockStatus) {
+ if (this.convo?.kind !== 'group') {
+ throw new Error('updateLockStatus can only be called on group convo')
+ }
+
+ this.updateConvo({
+ kind: {
+ ...this.convo.details,
+ lockStatus,
+ },
+ })
+
this.commit()
}
@@ -980,7 +1053,7 @@ export class Convo {
const {id, message} = pendingMessage
- const response = await this.agent.api.chat.bsky.convo.sendMessage(
+ const response = await this.agent.chat.bsky.convo.sendMessage(
{
convoId: this.convoId,
message,
@@ -1023,7 +1096,7 @@ export class Convo {
this.emitter.emit('event', {
type: 'invalidate-block-state',
accountDids: [
- this.sender!.did,
+ this.senderUserDid,
...this.recipients!.map(r => r.did),
],
})
@@ -1075,7 +1148,7 @@ export class Convo {
)
try {
- const {data} = await this.agent.api.chat.bsky.convo.sendMessageBatch(
+ const {data} = await this.agent.chat.bsky.convo.sendMessageBatch(
{
items: messageArray.map(({message}) => ({
convoId: this.convoId,
@@ -1117,7 +1190,7 @@ export class Convo {
try {
await networkRetry(2, () => {
- return this.agent.api.chat.bsky.convo.deleteMessageForSelf(
+ return this.agent.chat.bsky.convo.deleteMessageForSelf(
{
convoId: this.convoId,
messageId,
@@ -1158,6 +1231,7 @@ export class Convo {
type: 'message',
key: m.id,
message: m,
+ relatedProfiles: this.relatedProfiles,
nextMessage: null,
prevMessage: null,
})
@@ -1166,6 +1240,7 @@ export class Convo {
type: 'deleted-message',
key: m.id,
message: m,
+ relatedProfiles: this.relatedProfiles,
nextMessage: null,
prevMessage: null,
})
@@ -1174,7 +1249,7 @@ export class Convo {
type: 'system-message',
key: m.id,
message: m,
- relatedProfiles: Array.from(this.systemMessageProfiles.values()),
+ relatedProfiles: this.relatedProfiles,
})
}
})
@@ -1196,6 +1271,7 @@ export class Convo {
type: 'message',
key: m.id,
message: m,
+ relatedProfiles: this.relatedProfiles,
nextMessage: null,
prevMessage: null,
})
@@ -1204,6 +1280,7 @@ export class Convo {
type: 'deleted-message',
key: m.id,
message: m,
+ relatedProfiles: this.relatedProfiles,
nextMessage: null,
prevMessage: null,
})
@@ -1212,7 +1289,7 @@ export class Convo {
type: 'system-message',
key: m.id,
message: m,
- relatedProfiles: Array.from(this.systemMessageProfiles.values()),
+ relatedProfiles: this.relatedProfiles,
})
}
})
@@ -1228,15 +1305,12 @@ export class Convo {
id: nanoid(),
rev: '__fake__',
sentAt: new Date().toISOString(),
- /*
- * `getItems` is only run in "active" status states, where
- * `this.sender` is defined
- */
sender: {
$type: 'chat.bsky.convo.defs#messageViewSender',
- did: this.sender!.did,
+ did: this.senderUserDid,
},
},
+ relatedProfiles: this.relatedProfiles,
nextMessage: null,
prevMessage: null,
failed: this.pendingMessageFailure !== null,
@@ -1448,46 +1522,4 @@ 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.convo?.members.find(m => {
- if (
- bsky.dangerousIsType(
- m.kind,
- ChatBskyActorDefs.isGroupConvoMember,
- )
- ) {
- return m.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/index.tsx b/src/state/messages/convo/index.tsx
index 5461301fb3..14a297e6b9 100644
--- a/src/state/messages/convo/index.tsx
+++ b/src/state/messages/convo/index.tsx
@@ -29,9 +29,19 @@ import {
import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations'
import {RQKEY as createProfileQueryKey} from '#/state/queries/profile'
import {useAgent} from '#/state/session'
+import {type GroupConvoMember} from '#/components/dms/util'
export * from '#/state/messages/convo/util'
+function membersChanged(
+ a: ChatBskyConvoDefs.ConvoView['members'],
+ b: ChatBskyConvoDefs.ConvoView['members'],
+) {
+ if (a.length !== b.length) return true
+ const aDids = new Set(a.map(m => m.did))
+ return b.some(m => !aDids.has(m.did))
+}
+
const ChatContext = createContext(null)
ChatContext.displayName = 'ChatContext'
@@ -107,11 +117,11 @@ export function ConvoProvider({
switch (event.type) {
case 'invalidate-block-state': {
for (const did of event.accountDids) {
- queryClient.invalidateQueries({
+ void queryClient.invalidateQueries({
queryKey: createProfileQueryKey(did),
})
}
- queryClient.invalidateQueries({
+ void queryClient.invalidateQueries({
queryKey: [ListConvosQueryKeyRoot],
})
}
@@ -127,17 +137,35 @@ export function ConvoProvider({
const data = event.query.state.data as
| ChatBskyConvoDefs.ConvoView
| undefined
- if (data && convo.convo && data.muted !== convo.convo.muted) {
+ if (data && convo.convo && data.muted !== convo.convo.view.muted) {
convo.updateMuted(data.muted)
}
if (
data &&
- convo.convo &&
ChatBskyConvoDefs.isGroupConvo(data.kind) &&
- ChatBskyConvoDefs.isGroupConvo(convo.convo.kind) &&
- data.kind.name !== convo.convo.kind.name
+ convo.convo?.kind === 'group'
) {
- convo.updateGroupName(data.kind.name)
+ if (data.kind.name !== convo.convo.details.name) {
+ convo.updateGroupName(data.kind.name)
+ }
+ if (data.kind.joinLink !== convo.convo.details.joinLink) {
+ convo.updateJoinLink(data.kind.joinLink)
+ }
+ if (data.kind.lockStatus !== convo.convo.details.lockStatus) {
+ convo.updateLockStatus(data.kind.lockStatus)
+ }
+ }
+ if (
+ data &&
+ ChatBskyConvoDefs.isGroupConvo(data.kind) &&
+ convo.convo?.kind === 'group' &&
+ (membersChanged(data.members, convo.convo.members) ||
+ data.kind.memberCount !== convo.convo.details.memberCount)
+ ) {
+ convo.updateGroupMembers(
+ data.members as GroupConvoMember[],
+ data.kind.memberCount,
+ )
}
}
})
diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts
index dad3b7f82a..ad98afc3da 100644
--- a/src/state/messages/convo/types.ts
+++ b/src/state/messages/convo/types.ts
@@ -6,6 +6,7 @@ import {
} from '@atproto/api'
import {type MessagesEventBus} from '#/state/messages/events/agent'
+import {type ConvoWithDetails} from '#/components/dms/util'
export type ConvoParams = {
convoId: string
@@ -58,34 +59,20 @@ export enum ConvoDispatchEvent {
}
export type ConvoDispatch =
- | {
- event: ConvoDispatchEvent.Init
- }
- | {
- event: ConvoDispatchEvent.Ready
- }
- | {
- event: ConvoDispatchEvent.Resume
- }
- | {
- event: ConvoDispatchEvent.Background
- }
- | {
- event: ConvoDispatchEvent.Suspend
- }
- | {
- event: ConvoDispatchEvent.Error
- payload: ConvoError
- }
- | {
- event: ConvoDispatchEvent.Disable
- }
+ | {event: ConvoDispatchEvent.Init}
+ | {event: ConvoDispatchEvent.Ready}
+ | {event: ConvoDispatchEvent.Resume}
+ | {event: ConvoDispatchEvent.Background}
+ | {event: ConvoDispatchEvent.Suspend}
+ | {event: ConvoDispatchEvent.Error; payload: ConvoError}
+ | {event: ConvoDispatchEvent.Disable}
export type ConvoItem =
| {
type: 'message'
key: string
message: ChatBskyConvoDefs.MessageView
+ relatedProfiles: Map
nextMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
@@ -99,6 +86,7 @@ export type ConvoItem =
type: 'pending-message'
key: string
message: ChatBskyConvoDefs.MessageView
+ relatedProfiles: Map
nextMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
@@ -117,6 +105,7 @@ export type ConvoItem =
type: 'deleted-message'
key: string
message: ChatBskyConvoDefs.DeletedMessageView
+ relatedProfiles: Map
nextMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
@@ -130,7 +119,7 @@ export type ConvoItem =
type: 'system-message'
key: string
message: ChatBskyConvoDefs.SystemMessageView
- relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[]
+ relatedProfiles: Map
}
| {
type: 'error'
@@ -150,17 +139,12 @@ 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
items: []
- convo: ChatBskyConvoDefs.ConvoView | undefined
+ convo: ConvoWithDetails | undefined
error: undefined
- sender: ChatBskyActorDefs.ProfileViewBasic | undefined
- recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
isFetchingHistory: false
hasAllHistory: boolean
deleteMessage: undefined
@@ -169,17 +153,12 @@ export type ConvoStateUninitialized = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
- isGroup: IsGroup
- getGroupInfo: GetGroupInfo
- getPrimaryMember: GetPrimaryMember
}
export type ConvoStateInitializing = {
status: ConvoStatus.Initializing
items: []
- convo: ChatBskyConvoDefs.ConvoView | undefined
+ convo: ConvoWithDetails | undefined
error: undefined
- sender: ChatBskyActorDefs.ProfileViewBasic | undefined
- recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
isFetchingHistory: boolean
hasAllHistory: boolean
deleteMessage: undefined
@@ -188,17 +167,12 @@ export type ConvoStateInitializing = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
- isGroup: IsGroup
- getGroupInfo: GetGroupInfo
- getPrimaryMember: GetPrimaryMember
}
export type ConvoStateReady = {
status: ConvoStatus.Ready
items: ConvoItem[]
- convo: ChatBskyConvoDefs.ConvoView
+ convo: ConvoWithDetails
error: undefined
- sender: ChatBskyActorDefs.ProfileViewBasic
- recipients: ChatBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean
hasAllHistory: boolean
deleteMessage: DeleteMessage
@@ -207,17 +181,12 @@ export type ConvoStateReady = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
- isGroup: IsGroup
- getGroupInfo: GetGroupInfo
- getPrimaryMember: GetPrimaryMember
}
export type ConvoStateBackgrounded = {
status: ConvoStatus.Backgrounded
items: ConvoItem[]
- convo: ChatBskyConvoDefs.ConvoView
+ convo: ConvoWithDetails
error: undefined
- sender: ChatBskyActorDefs.ProfileViewBasic
- recipients: ChatBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean
hasAllHistory: boolean
deleteMessage: DeleteMessage
@@ -226,17 +195,12 @@ export type ConvoStateBackgrounded = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
- isGroup: IsGroup
- getGroupInfo: GetGroupInfo
- getPrimaryMember: GetPrimaryMember
}
export type ConvoStateSuspended = {
status: ConvoStatus.Suspended
items: ConvoItem[]
- convo: ChatBskyConvoDefs.ConvoView
+ convo: ConvoWithDetails
error: undefined
- sender: ChatBskyActorDefs.ProfileViewBasic
- recipients: ChatBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean
hasAllHistory: boolean
deleteMessage: DeleteMessage
@@ -245,17 +209,12 @@ export type ConvoStateSuspended = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
- isGroup: IsGroup
- getGroupInfo: GetGroupInfo
- getPrimaryMember: GetPrimaryMember
}
export type ConvoStateError = {
status: ConvoStatus.Error
items: []
convo: undefined
error: ConvoError
- sender: undefined
- recipients: undefined
isFetchingHistory: false
hasAllHistory: false
deleteMessage: undefined
@@ -264,17 +223,12 @@ export type ConvoStateError = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
- isGroup: undefined
- getGroupInfo: undefined
- getPrimaryMember: undefined
}
export type ConvoStateDisabled = {
status: ConvoStatus.Disabled
items: ConvoItem[]
- convo: ChatBskyConvoDefs.ConvoView
+ convo: ConvoWithDetails
error: undefined
- sender: ChatBskyActorDefs.ProfileViewBasic
- recipients: ChatBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean
hasAllHistory: boolean
deleteMessage: DeleteMessage
@@ -283,9 +237,6 @@ 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/add-group-members.ts b/src/state/queries/messages/add-group-members.ts
new file mode 100644
index 0000000000..b5ddacbbff
--- /dev/null
+++ b/src/state/queries/messages/add-group-members.ts
@@ -0,0 +1,176 @@
+import {
+ type ChatBskyActorDefs,
+ ChatBskyConvoDefs,
+ type ChatBskyConvoListConvos,
+ type ChatBskyGroupAddMembers,
+} from '@atproto/api'
+import {
+ type InfiniteData,
+ useMutation,
+ useQueryClient,
+} from '@tanstack/react-query'
+
+import {DM_SERVICE_HEADERS} from '#/lib/constants'
+import {logger} from '#/logger'
+import {useProfileQuery} from '#/state/queries/profile'
+import {useAgent, useSession} from '#/state/session'
+import type * as bsky from '#/types/bsky'
+import {RQKEY as CONVO_KEY} from './conversation'
+import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
+import {listConvoMembersQueryKey} from './list-convo-members'
+
+export function useAddGroupMembers(
+ convoId: string | undefined,
+ {
+ onSuccess,
+ onError,
+ }: {
+ onSuccess?: (data: ChatBskyGroupAddMembers.OutputSchema) => void
+ onError?: (error: Error) => void
+ },
+) {
+ const queryClient = useQueryClient()
+ const agent = useAgent()
+ const {currentAccount} = useSession()
+ const {data: myProfile} = useProfileQuery({did: currentAccount?.did})
+
+ return useMutation({
+ mutationFn: async ({
+ members,
+ }: {
+ members: string[]
+ profiles: bsky.profile.AnyProfileView[]
+ }) => {
+ if (!convoId) throw new Error('No convoId provided')
+ const {data} = await agent.chat.bsky.group.addMembers(
+ {convoId, members},
+ {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
+ )
+ return data
+ },
+ onMutate: ({profiles}) => {
+ if (!convoId) return
+
+ const prevConvo = queryClient.getQueryData(
+ CONVO_KEY(convoId),
+ )
+ const prevListEntries = queryClient.getQueriesData<
+ InfiniteData
+ >({queryKey: [CONVO_LIST_KEY]})
+ const prevMemberList = queryClient.getQueryData<
+ ChatBskyActorDefs.ProfileViewBasic[]
+ >(listConvoMembersQueryKey(convoId))
+
+ const addedBy: ChatBskyActorDefs.ProfileViewBasic | undefined = myProfile
+ ? {
+ ...myProfile,
+ $type: 'chat.bsky.actor.defs#profileViewBasic',
+ }
+ : undefined
+
+ const optimisticMembers: ChatBskyActorDefs.ProfileViewBasic[] =
+ profiles.map(profile => ({
+ ...profile,
+ $type: 'chat.bsky.actor.defs#profileViewBasic',
+ kind: {
+ $type: 'chat.bsky.actor.defs#groupConvoMember',
+ role: 'standard',
+ addedBy,
+ },
+ }))
+
+ queryClient.setQueryData(
+ CONVO_KEY(convoId),
+ prev => {
+ if (!prev) return
+ if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return prev
+ return {
+ ...prev,
+ members: [...prev.members, ...optimisticMembers],
+ kind: {
+ ...prev.kind,
+ memberCount: prev.kind.memberCount + optimisticMembers.length,
+ },
+ }
+ },
+ )
+
+ queryClient.setQueriesData<
+ InfiniteData
+ >({queryKey: [CONVO_LIST_KEY]}, prev => {
+ if (!prev?.pages) return
+ return {
+ ...prev,
+ pages: prev.pages.map(page => ({
+ ...page,
+ convos: page.convos.map(convo => {
+ if (convo.id !== convoId) return convo
+ if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo
+ return {
+ ...convo,
+ members: [...convo.members, ...optimisticMembers],
+ kind: {
+ ...convo.kind,
+ memberCount:
+ convo.kind.memberCount + optimisticMembers.length,
+ },
+ }
+ }),
+ })),
+ }
+ })
+
+ queryClient.setQueryData(
+ listConvoMembersQueryKey(convoId),
+ prev => {
+ if (!prev) return
+ return [...prev, ...optimisticMembers]
+ },
+ )
+
+ return {prevConvo, prevListEntries, prevMemberList}
+ },
+ onSuccess: data => {
+ if (convoId) {
+ queryClient.setQueryData(
+ CONVO_KEY(convoId),
+ data.convo,
+ )
+
+ queryClient.setQueriesData<
+ InfiniteData
+ >({queryKey: [CONVO_LIST_KEY]}, prev => {
+ if (!prev?.pages) return
+ return {
+ ...prev,
+ pages: prev.pages.map(page => ({
+ ...page,
+ convos: page.convos.map(convo =>
+ convo.id === convoId ? data.convo : convo,
+ ),
+ })),
+ }
+ })
+ }
+ onSuccess?.(data)
+ },
+ onError: (e, _variables, context) => {
+ logger.error(e)
+ if (context?.prevConvo && convoId) {
+ queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo)
+ }
+ if (context?.prevListEntries) {
+ for (const [key, data] of context.prevListEntries) {
+ queryClient.setQueryData(key, data)
+ }
+ }
+ if (context?.prevMemberList && convoId) {
+ queryClient.setQueryData(
+ listConvoMembersQueryKey(convoId),
+ context.prevMemberList,
+ )
+ }
+ onError?.(e)
+ },
+ })
+}
diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts
index b8f26cc88c..76557991d1 100644
--- a/src/state/queries/messages/conversation.ts
+++ b/src/state/queries/messages/conversation.ts
@@ -16,7 +16,7 @@ import {
RQKEY_ROOT as LIST_CONVOS_KEY,
} from './list-conversations'
-const RQKEY_ROOT = 'convo'
+export const RQKEY_ROOT = 'convo'
export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId]
export function useConvoQuery({convoId}: {convoId: string}) {
diff --git a/src/state/queries/messages/create-join-link.ts b/src/state/queries/messages/create-join-link.ts
new file mode 100644
index 0000000000..fbac855466
--- /dev/null
+++ b/src/state/queries/messages/create-join-link.ts
@@ -0,0 +1,84 @@
+import {
+ ChatBskyConvoDefs,
+ type ChatBskyGroupCreateJoinLink,
+ type ChatBskyGroupDefs,
+} 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 {
+ rollbackConvoOptimistic,
+ updateConvoOptimistic,
+} from './utils/convo-cache'
+
+export function useCreateJoinLink(
+ convoId: string | undefined,
+ {
+ onSuccess,
+ onError,
+ }: {
+ onSuccess?: (data: ChatBskyGroupCreateJoinLink.OutputSchema) => void
+ onError?: (error: Error) => void
+ },
+) {
+ const queryClient = useQueryClient()
+ const agent = useAgent()
+
+ return useMutation({
+ mutationFn: async ({
+ joinRule,
+ requireApproval,
+ }: {
+ joinRule: ChatBskyGroupDefs.JoinRule
+ requireApproval: boolean
+ }) => {
+ if (!convoId) throw new Error('No convoId provided')
+ const {data} = await agent.chat.bsky.group.createJoinLink(
+ {convoId, joinRule, requireApproval},
+ {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
+ )
+ return data
+ },
+ onMutate: ({joinRule, requireApproval}) => {
+ if (!convoId) return
+ return updateConvoOptimistic(queryClient, convoId, prev => {
+ if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
+ return {
+ ...prev,
+ kind: {
+ ...prev.kind,
+ joinLink: {
+ $type: 'chat.bsky.group.defs#joinLinkView',
+ code: '',
+ enabledStatus: 'enabled',
+ joinRule,
+ requireApproval,
+ createdAt: new Date().toISOString(),
+ },
+ },
+ }
+ })
+ },
+ onSuccess: data => {
+ if (convoId) {
+ updateConvoOptimistic(queryClient, convoId, prev => {
+ if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
+ return {
+ ...prev,
+ kind: {...prev.kind, joinLink: data.joinLink},
+ }
+ })
+ }
+ onSuccess?.(data)
+ },
+ onError: (e, _variables, context) => {
+ logger.error(e)
+ if (convoId && context) {
+ rollbackConvoOptimistic(queryClient, convoId, context)
+ }
+ onError?.(e)
+ },
+ })
+}
diff --git a/src/state/queries/messages/disable-join-link.ts b/src/state/queries/messages/disable-join-link.ts
new file mode 100644
index 0000000000..522b5939eb
--- /dev/null
+++ b/src/state/queries/messages/disable-join-link.ts
@@ -0,0 +1,72 @@
+import {
+ ChatBskyConvoDefs,
+ type ChatBskyGroupDisableJoinLink,
+} 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 {
+ rollbackConvoOptimistic,
+ updateConvoOptimistic,
+} from './utils/convo-cache'
+
+export function useDisableJoinLink(
+ convoId: string | undefined,
+ {
+ onSuccess,
+ onError,
+ }: {
+ onSuccess?: (data: ChatBskyGroupDisableJoinLink.OutputSchema) => void
+ onError?: (error: Error) => void
+ },
+) {
+ const queryClient = useQueryClient()
+ const agent = useAgent()
+
+ return useMutation({
+ mutationFn: async () => {
+ if (!convoId) throw new Error('No convoId provided')
+ const {data} = await agent.chat.bsky.group.disableJoinLink(
+ {convoId},
+ {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
+ )
+ return data
+ },
+ onMutate: () => {
+ if (!convoId) return
+ return updateConvoOptimistic(queryClient, convoId, prev => {
+ if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) {
+ return undefined
+ }
+ return {
+ ...prev,
+ kind: {
+ ...prev.kind,
+ joinLink: {...prev.kind.joinLink, enabledStatus: 'disabled'},
+ },
+ }
+ })
+ },
+ onSuccess: data => {
+ if (convoId) {
+ updateConvoOptimistic(queryClient, convoId, prev => {
+ if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
+ return {
+ ...prev,
+ kind: {...prev.kind, joinLink: data.joinLink},
+ }
+ })
+ }
+ onSuccess?.(data)
+ },
+ onError: (e, _variables, context) => {
+ logger.error(e)
+ if (convoId && context) {
+ rollbackConvoOptimistic(queryClient, convoId, context)
+ }
+ onError?.(e)
+ },
+ })
+}
diff --git a/src/state/queries/messages/edit-group-chat-name.ts b/src/state/queries/messages/edit-group-chat-name.ts
new file mode 100644
index 0000000000..ef2a72f4ec
--- /dev/null
+++ b/src/state/queries/messages/edit-group-chat-name.ts
@@ -0,0 +1,55 @@
+import {ChatBskyConvoDefs, type ChatBskyGroupEditGroup} 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 {
+ rollbackConvoOptimistic,
+ updateConvoOptimistic,
+} from './utils/convo-cache'
+
+export function useEditGroupChatName(
+ convoId: string | undefined,
+ {
+ onSuccess,
+ onError,
+ }: {
+ onSuccess?: (data: ChatBskyGroupEditGroup.OutputSchema) => void
+ onError?: (error: Error) => void
+ },
+) {
+ const queryClient = useQueryClient()
+ const agent = useAgent()
+
+ return useMutation({
+ mutationFn: async ({name: groupName}: {name: string}) => {
+ if (!convoId) throw new Error('No convoId provided')
+ const {data} = await agent.chat.bsky.group.editGroup(
+ {convoId, name: groupName},
+ {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
+ )
+ return data
+ },
+ onMutate: ({name: groupName}) => {
+ if (!convoId) return
+ return updateConvoOptimistic(queryClient, convoId, prev => {
+ if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
+ return {
+ ...prev,
+ kind: {...prev.kind, name: groupName},
+ }
+ })
+ },
+ onSuccess: data => {
+ onSuccess?.(data)
+ },
+ onError: (e, _variables, context) => {
+ logger.error(e)
+ if (convoId && context) {
+ rollbackConvoOptimistic(queryClient, convoId, context)
+ }
+ onError?.(e)
+ },
+ })
+}
diff --git a/src/state/queries/messages/edit-join-link.ts b/src/state/queries/messages/edit-join-link.ts
new file mode 100644
index 0000000000..7e34ce88d9
--- /dev/null
+++ b/src/state/queries/messages/edit-join-link.ts
@@ -0,0 +1,79 @@
+import {
+ ChatBskyConvoDefs,
+ type ChatBskyGroupDefs,
+ type ChatBskyGroupEditJoinLink,
+} 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 {
+ rollbackConvoOptimistic,
+ updateConvoOptimistic,
+} from './utils/convo-cache'
+
+export function useEditJoinLink(
+ convoId: string | undefined,
+ {
+ onSuccess,
+ onError,
+ }: {
+ onSuccess?: (data: ChatBskyGroupEditJoinLink.OutputSchema) => void
+ onError?: (error: Error) => void
+ },
+) {
+ const queryClient = useQueryClient()
+ const agent = useAgent()
+
+ return useMutation({
+ mutationFn: async ({
+ joinRule,
+ requireApproval,
+ }: {
+ joinRule: ChatBskyGroupDefs.JoinRule
+ requireApproval: boolean
+ }) => {
+ if (!convoId) throw new Error('No convoId provided')
+ const {data} = await agent.chat.bsky.group.editJoinLink(
+ {convoId, joinRule, requireApproval},
+ {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
+ )
+ return data
+ },
+ onMutate: ({joinRule, requireApproval}) => {
+ if (!convoId) return
+ return updateConvoOptimistic(queryClient, convoId, prev => {
+ if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) {
+ return undefined
+ }
+ return {
+ ...prev,
+ kind: {
+ ...prev.kind,
+ joinLink: {...prev.kind.joinLink, joinRule, requireApproval},
+ },
+ }
+ })
+ },
+ onSuccess: data => {
+ if (convoId) {
+ updateConvoOptimistic(queryClient, convoId, prev => {
+ if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
+ return {
+ ...prev,
+ kind: {...prev.kind, joinLink: data.joinLink},
+ }
+ })
+ }
+ onSuccess?.(data)
+ },
+ onError: (e, _variables, context) => {
+ logger.error(e)
+ if (convoId && context) {
+ rollbackConvoOptimistic(queryClient, convoId, context)
+ }
+ onError?.(e)
+ },
+ })
+}
diff --git a/src/state/queries/messages/enable-join-link.ts b/src/state/queries/messages/enable-join-link.ts
new file mode 100644
index 0000000000..a2ba505f1d
--- /dev/null
+++ b/src/state/queries/messages/enable-join-link.ts
@@ -0,0 +1,69 @@
+import {ChatBskyConvoDefs, type ChatBskyGroupEnableJoinLink} 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 {
+ rollbackConvoOptimistic,
+ updateConvoOptimistic,
+} from './utils/convo-cache'
+
+export function useEnableJoinLink(
+ convoId: string | undefined,
+ {
+ onSuccess,
+ onError,
+ }: {
+ onSuccess?: (data: ChatBskyGroupEnableJoinLink.OutputSchema) => void
+ onError?: (error: Error) => void
+ },
+) {
+ const queryClient = useQueryClient()
+ const agent = useAgent()
+
+ return useMutation({
+ mutationFn: async () => {
+ if (!convoId) throw new Error('No convoId provided')
+ const {data} = await agent.chat.bsky.group.enableJoinLink(
+ {convoId},
+ {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
+ )
+ return data
+ },
+ onMutate: () => {
+ if (!convoId) return
+ return updateConvoOptimistic(queryClient, convoId, prev => {
+ if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) {
+ return undefined
+ }
+ return {
+ ...prev,
+ kind: {
+ ...prev.kind,
+ joinLink: {...prev.kind.joinLink, enabledStatus: 'enabled'},
+ },
+ }
+ })
+ },
+ onSuccess: data => {
+ if (convoId) {
+ updateConvoOptimistic(queryClient, convoId, prev => {
+ if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
+ return {
+ ...prev,
+ kind: {...prev.kind, joinLink: data.joinLink},
+ }
+ })
+ }
+ onSuccess?.(data)
+ },
+ onError: (e, _variables, context) => {
+ logger.error(e)
+ if (convoId && context) {
+ rollbackConvoOptimistic(queryClient, convoId, context)
+ }
+ onError?.(e)
+ },
+ })
+}
diff --git a/src/state/queries/messages/get-convo-availability.ts b/src/state/queries/messages/get-convo-availability.ts
index 2392edb099..b73efe7953 100644
--- a/src/state/queries/messages/get-convo-availability.ts
+++ b/src/state/queries/messages/get-convo-availability.ts
@@ -7,7 +7,10 @@ import {STALE} from '..'
const RQKEY_ROOT = 'convo-availability'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
-export function useGetConvoAvailabilityQuery(did: string) {
+export function useGetConvoAvailabilityQuery(
+ did: string,
+ {enabled = true}: {enabled?: boolean} = {},
+) {
const agent = useAgent()
return useQuery({
@@ -21,5 +24,6 @@ export function useGetConvoAvailabilityQuery(did: string) {
return data
},
staleTime: STALE.INFINITY,
+ enabled,
})
}
diff --git a/src/state/queries/messages/leave-conversation.ts b/src/state/queries/messages/leave-conversation.ts
index 986351a072..620ce7ee2c 100644
--- a/src/state/queries/messages/leave-conversation.ts
+++ b/src/state/queries/messages/leave-conversation.ts
@@ -71,7 +71,7 @@ export function useLeaveConvo(
return {prevPages}
},
onSuccess: data => {
- queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
+ void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
onSuccess?.(data)
},
onError: (error, _, context) => {
@@ -89,7 +89,7 @@ export function useLeaveConvo(
}
},
)
- queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
+ void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
onError?.(error)
},
})
diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx
index 4c21bbbfeb..1c93ee565e 100644
--- a/src/state/queries/messages/list-conversations.tsx
+++ b/src/state/queries/messages/list-conversations.tsx
@@ -105,8 +105,8 @@ export function ListConvosProviderInner({
const debouncedRefetch = useMemo(() => {
const refetchAndInvalidate = () => {
- refetch()
- queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
+ void refetch()
+ void queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
}
return throttle(refetchAndInvalidate, 500, {
leading: true,
diff --git a/src/state/queries/messages/list-convo-members.ts b/src/state/queries/messages/list-convo-members.ts
new file mode 100644
index 0000000000..0a89cbc19f
--- /dev/null
+++ b/src/state/queries/messages/list-convo-members.ts
@@ -0,0 +1,122 @@
+import {useEffect} from 'react'
+import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
+import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
+
+import {DM_SERVICE_HEADERS} from '#/lib/constants'
+import {useMessagesEventBus} from '#/state/messages/events'
+import {STALE} from '#/state/queries'
+import {createQueryKey} from '#/state/queries/util'
+import {useAgent} from '#/state/session'
+import * as bsky from '#/types/bsky'
+
+const RQKEY_ROOT = 'listConvoMembers'
+export const listConvoMembersQueryKey = (convoId: string) =>
+ createQueryKey(RQKEY_ROOT, {convoId})
+
+// group chat size is 50, so should fetch the whole list in one go
+const LIMIT = 50
+
+export function useListConvoMembersQuery({
+ convoId,
+ placeholderData,
+}: {
+ convoId: string
+ placeholderData?: ChatBskyActorDefs.ProfileViewBasic[]
+}) {
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+ const messagesBus = useMessagesEventBus()
+
+ useEffect(() => {
+ const unsub = messagesBus.on(
+ ev => {
+ if (ev.type !== 'logs') return
+
+ function mutateList(
+ fn: (
+ update: ChatBskyActorDefs.ProfileViewBasic[],
+ ) => ChatBskyActorDefs.ProfileViewBasic[],
+ ) {
+ queryClient.setQueryData(
+ listConvoMembersQueryKey(convoId),
+ old => {
+ if (!old) return // query doesn't exist yet, skip
+ return fn(old)
+ },
+ )
+ }
+
+ for (const log of ev.logs) {
+ if (ChatBskyConvoDefs.isLogAddMember(log)) {
+ const data = log.message.data
+ if (
+ bsky.dangerousIsType(
+ data,
+ ChatBskyConvoDefs.isSystemMessageDataAddMember,
+ )
+ ) {
+ const newMember = log.relatedProfiles.find(
+ r => r.did === data.member.did,
+ )
+ if (newMember) {
+ mutateList(list => list.concat(newMember))
+ }
+ }
+ } else if (ChatBskyConvoDefs.isLogRemoveMember(log)) {
+ const data = log.message.data
+ if (
+ bsky.dangerousIsType(
+ data,
+ ChatBskyConvoDefs.isSystemMessageDataRemoveMember,
+ )
+ ) {
+ mutateList(list => list.filter(m => m.did !== data.member.did))
+ }
+ }
+ }
+ },
+ {convoId},
+ )
+ return () => unsub()
+ }, [convoId, messagesBus, queryClient])
+
+ return useQuery({
+ queryKey: listConvoMembersQueryKey(convoId),
+ queryFn: async () => {
+ const members = []
+ let cursor
+
+ do {
+ const {data} = await agent.chat.bsky.convo.getConvoMembers(
+ {convoId, cursor, limit: LIMIT},
+ {headers: DM_SERVICE_HEADERS},
+ )
+ members.push(...data.members)
+ cursor = data.cursor
+ } while (cursor)
+
+ return members
+ },
+ staleTime: STALE.MINUTES.THIRTY,
+ placeholderData,
+ })
+}
+
+export function* findAllProfilesInQueryData(
+ queryClient: QueryClient,
+ did: string,
+): Generator {
+ const queryDatas = queryClient.getQueriesData<
+ ChatBskyActorDefs.ProfileViewBasic[]
+ >({
+ queryKey: [RQKEY_ROOT],
+ })
+ for (const [_queryKey, queryData] of queryDatas) {
+ if (!queryData) continue
+ for (const member of queryData) {
+ if (member.did === did) {
+ yield member
+ }
+ }
+ }
+}
diff --git a/src/state/queries/messages/lock-conversation.ts b/src/state/queries/messages/lock-conversation.ts
new file mode 100644
index 0000000000..b10db13462
--- /dev/null
+++ b/src/state/queries/messages/lock-conversation.ts
@@ -0,0 +1,64 @@
+import {ChatBskyConvoDefs, type ChatBskyConvoLockConvo} from '@atproto/api'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+
+import {DM_SERVICE_HEADERS} from '#/lib/constants'
+import {useAgent} from '#/state/session'
+import {
+ rollbackConvoOptimistic,
+ updateConvoOptimistic,
+} from './utils/convo-cache'
+
+export function useLockConvo(
+ convoId: string | undefined,
+ {
+ onSuccess,
+ onError,
+ }: {
+ onSuccess?: (data: ChatBskyConvoLockConvo.OutputSchema) => void
+ onError?: (error: Error, variables: {lock: boolean}) => void
+ },
+) {
+ const queryClient = useQueryClient()
+ const agent = useAgent()
+
+ return useMutation({
+ mutationFn: async ({lock}: {lock: boolean}) => {
+ if (!convoId) throw new Error('No convoId provided')
+ if (lock) {
+ const {data} = await agent.chat.bsky.convo.lockConvo(
+ {convoId},
+ {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
+ )
+ return data
+ } else {
+ const {data} = await agent.chat.bsky.convo.unlockConvo(
+ {convoId},
+ {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
+ )
+ return data
+ }
+ },
+ onMutate: ({lock}) => {
+ if (!convoId) return
+ return updateConvoOptimistic(queryClient, convoId, prev => {
+ if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
+ return {
+ ...prev,
+ kind: {
+ ...prev.kind,
+ lockStatus: lock ? 'locked' : 'unlocked',
+ },
+ }
+ })
+ },
+ onSuccess: data => {
+ onSuccess?.(data)
+ },
+ onError: (e, variables, context) => {
+ if (convoId && context) {
+ rollbackConvoOptimistic(queryClient, convoId, context)
+ }
+ onError?.(e, variables)
+ },
+ })
+}
diff --git a/src/state/queries/messages/mute-conversation.ts b/src/state/queries/messages/mute-conversation.ts
index d90ebb1b55..03a9ab0b4a 100644
--- a/src/state/queries/messages/mute-conversation.ts
+++ b/src/state/queries/messages/mute-conversation.ts
@@ -1,18 +1,12 @@
-import {
- type ChatBskyConvoDefs,
- type ChatBskyConvoListConvos,
- type ChatBskyConvoMuteConvo,
-} from '@atproto/api'
-import {
- type InfiniteData,
- useMutation,
- useQueryClient,
-} from '@tanstack/react-query'
+import {type ChatBskyConvoMuteConvo} from '@atproto/api'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {useAgent} from '#/state/session'
-import {RQKEY as CONVO_KEY} from './conversation'
-import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
+import {
+ rollbackConvoOptimistic,
+ updateConvoOptimistic,
+} from './utils/convo-cache'
export function useMuteConvo(
convoId: string | undefined,
@@ -46,59 +40,17 @@ export function useMuteConvo(
},
onMutate: ({mute}) => {
if (!convoId) return
-
- const prevConvo = queryClient.getQueryData(
- CONVO_KEY(convoId),
- )
- const prevListEntries = queryClient.getQueriesData<
- InfiniteData
- >({queryKey: [CONVO_LIST_KEY]})
-
- // Update for a single chat thread
- queryClient.setQueryData(
- CONVO_KEY(convoId),
- prev => {
- if (!prev) return
- return {
- ...prev,
- muted: mute,
- }
- },
- )
-
- // Update for the chat list
- queryClient.setQueriesData<
- InfiniteData
- >({queryKey: [CONVO_LIST_KEY]}, prev => {
- if (!prev?.pages) return
- return {
- ...prev,
- pages: prev.pages.map(page => ({
- ...page,
- convos: page.convos.map(convo => {
- if (convo.id !== convoId) return convo
- return {
- ...convo,
- muted: mute,
- }
- }),
- })),
- }
- })
-
- return {prevConvo, prevListEntries}
+ return updateConvoOptimistic(queryClient, convoId, prev => ({
+ ...prev,
+ muted: mute,
+ }))
},
onSuccess: data => {
onSuccess?.(data)
},
onError: (e, _variables, context) => {
- if (context?.prevConvo && convoId) {
- queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo)
- }
- if (context?.prevListEntries) {
- for (const [key, data] of context.prevListEntries) {
- queryClient.setQueryData(key, data)
- }
+ if (convoId && context) {
+ rollbackConvoOptimistic(queryClient, convoId, context)
}
onError?.(e)
},
diff --git a/src/state/queries/messages/edit-group-name.ts b/src/state/queries/messages/remove-from-group.ts
similarity index 64%
rename from src/state/queries/messages/edit-group-name.ts
rename to src/state/queries/messages/remove-from-group.ts
index cbff0331ed..566542438c 100644
--- a/src/state/queries/messages/edit-group-name.ts
+++ b/src/state/queries/messages/remove-from-group.ts
@@ -1,7 +1,8 @@
import {
- ChatBskyConvoDefs,
+ type ChatBskyActorDefs,
+ type ChatBskyConvoDefs,
type ChatBskyConvoListConvos,
- type ChatBskyGroupEditGroup,
+ type ChatBskyGroupRemoveMembers,
} from '@atproto/api'
import {
type InfiniteData,
@@ -14,14 +15,15 @@ import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {RQKEY as CONVO_KEY} from './conversation'
import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
+import {listConvoMembersQueryKey} from './list-convo-members'
-export function useEditGroupName(
+export function useRemoveFromGroupChat(
convoId: string | undefined,
{
onSuccess,
onError,
}: {
- onSuccess?: (data: ChatBskyGroupEditGroup.OutputSchema) => void
+ onSuccess?: (data: ChatBskyGroupRemoveMembers.OutputSchema) => void
onError?: (error: Error) => void
},
) {
@@ -29,15 +31,15 @@ export function useEditGroupName(
const agent = useAgent()
return useMutation({
- mutationFn: async ({name: groupName}: {name: string}) => {
+ mutationFn: async ({members}: {members: string[]}) => {
if (!convoId) throw new Error('No convoId provided')
- const {data} = await agent.chat.bsky.group.editGroup(
- {convoId, name: groupName},
+ const {data} = await agent.chat.bsky.group.removeMembers(
+ {convoId, members},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
},
- onMutate: ({name: groupName}) => {
+ onMutate: ({members}) => {
if (!convoId) return
const prevConvo = queryClient.getQueryData(
@@ -46,24 +48,21 @@ export function useEditGroupName(
const prevListEntries = queryClient.getQueriesData<
InfiniteData
>({queryKey: [CONVO_LIST_KEY]})
+ const prevMemberList = queryClient.getQueryData<
+ ChatBskyActorDefs.ProfileViewBasic[]
+ >(listConvoMembersQueryKey(convoId))
- // Update for a single chat thread
queryClient.setQueryData(
CONVO_KEY(convoId),
prev => {
if (!prev) return
- if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return prev
return {
...prev,
- kind: {
- ...prev.kind,
- name: groupName,
- },
+ members: prev.members.filter(m => !members.includes(m.did)),
}
},
)
- // Update for the chat list
queryClient.setQueriesData<
InfiniteData
>({queryKey: [CONVO_LIST_KEY]}, prev => {
@@ -74,20 +73,24 @@ export function useEditGroupName(
...page,
convos: page.convos.map(convo => {
if (convo.id !== convoId) return convo
- if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo
return {
...convo,
- kind: {
- ...convo.kind,
- name: groupName,
- },
+ members: convo.members.filter(m => !members.includes(m.did)),
}
}),
})),
}
})
- return {prevConvo, prevListEntries}
+ queryClient.setQueryData(
+ listConvoMembersQueryKey(convoId),
+ prev => {
+ if (!prev) return
+ return prev.filter(m => !members.includes(m.did))
+ },
+ )
+
+ return {prevConvo, prevListEntries, prevMemberList}
},
onSuccess: data => {
onSuccess?.(data)
@@ -102,6 +105,12 @@ export function useEditGroupName(
queryClient.setQueryData(key, data)
}
}
+ if (context?.prevMemberList && convoId) {
+ queryClient.setQueryData(
+ listConvoMembersQueryKey(convoId),
+ context.prevMemberList,
+ )
+ }
onError?.(e)
},
})
diff --git a/src/state/queries/messages/utils/convo-cache.ts b/src/state/queries/messages/utils/convo-cache.ts
new file mode 100644
index 0000000000..2c9e2b3f96
--- /dev/null
+++ b/src/state/queries/messages/utils/convo-cache.ts
@@ -0,0 +1,87 @@
+import {
+ type ChatBskyConvoDefs,
+ type ChatBskyConvoListConvos,
+} from '@atproto/api'
+import {
+ type InfiniteData,
+ type QueryClient,
+ type QueryKey,
+} from '@tanstack/react-query'
+
+import {RQKEY as CONVO_KEY} from '../conversation'
+import {RQKEY_ROOT as CONVO_LIST_KEY} from '../list-conversations'
+
+type ConvoUpdater = (
+ prev: ChatBskyConvoDefs.ConvoView,
+) => ChatBskyConvoDefs.ConvoView | undefined
+
+export type ConvoCacheSnapshot = {
+ prevConvo: ChatBskyConvoDefs.ConvoView | undefined
+ prevListEntries: Array<
+ [QueryKey, InfiniteData | undefined]
+ >
+}
+
+/**
+ * Writes an optimistic update to a convo across both the single-convo and
+ * convo-list caches. The updater receives the current ConvoView and returns
+ * the next one - return undefined to bail out (e.g. when the convo's kind
+ * doesn't match what the mutation requires). Returns a snapshot that can be
+ * passed to `rollbackConvoOptimistic`.
+ */
+export function updateConvoOptimistic(
+ queryClient: QueryClient,
+ convoId: string,
+ updater: ConvoUpdater,
+): ConvoCacheSnapshot {
+ const prevConvo = queryClient.getQueryData(
+ CONVO_KEY(convoId),
+ )
+ const prevListEntries = queryClient.getQueriesData<
+ InfiniteData
+ >({queryKey: [CONVO_LIST_KEY]})
+
+ queryClient.setQueryData(
+ CONVO_KEY(convoId),
+ prev => {
+ if (!prev) return
+ const next = updater(prev)
+ return next ?? prev
+ },
+ )
+
+ queryClient.setQueriesData<
+ InfiniteData
+ >({queryKey: [CONVO_LIST_KEY]}, prev => {
+ if (!prev?.pages) return
+ return {
+ ...prev,
+ pages: prev.pages.map(page => ({
+ ...page,
+ convos: page.convos.map(convo => {
+ if (convo.id !== convoId) return convo
+ const next = updater(convo)
+ return next ?? convo
+ }),
+ })),
+ }
+ })
+
+ return {prevConvo, prevListEntries}
+}
+
+/**
+ * Restores the caches to the state captured by `updateConvoOptimistic`.
+ */
+export function rollbackConvoOptimistic(
+ queryClient: QueryClient,
+ convoId: string,
+ snapshot: ConvoCacheSnapshot,
+) {
+ if (snapshot.prevConvo) {
+ queryClient.setQueryData(CONVO_KEY(convoId), snapshot.prevConvo)
+ }
+ for (const [key, data] of snapshot.prevListEntries) {
+ queryClient.setQueryData(key, data)
+ }
+}