Group Clops Feature Branch (#10360)

Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eric Bailey
2026-04-24 13:38:50 -05:00
committed by Samuel Newman
parent a975606188
commit f37a163814
63 changed files with 3517 additions and 1844 deletions
+2
View File
@@ -66,6 +66,7 @@ module.exports = function (_config) {
infoPlist: { infoPlist: {
CADisableMinimumFrameDurationOnPhone: true, CADisableMinimumFrameDurationOnPhone: true,
UIBackgroundModes: ['remote-notification'], UIBackgroundModes: ['remote-notification'],
NSUserActivityTypes: ['INSendMessageIntent'],
NSCameraUsageDescription: NSCameraUsageDescription:
'Used for profile pictures, posts, and other kinds of content.', 'Used for profile pictures, posts, and other kinds of content.',
NSMicrophoneUsageDescription: NSMicrophoneUsageDescription:
@@ -123,6 +124,7 @@ module.exports = function (_config) {
'com.apple.developer.kernel.increased-memory-limit': true, 'com.apple.developer.kernel.increased-memory-limit': true,
'com.apple.developer.kernel.extended-virtual-addressing': true, 'com.apple.developer.kernel.extended-virtual-addressing': true,
'com.apple.security.application-groups': 'group.app.bsky', 'com.apple.security.application-groups': 'group.app.bsky',
'com.apple.developer.usernotifications.communication': true,
// 'com.apple.developer.device-information.user-assigned-device-name': true, // 'com.apple.developer.device-information.user-assigned-device-name': true,
}, },
privacyManifests: { privacyManifests: {
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z"/></svg>

After

Width:  |  Height:  |  Size: 502 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 13a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z"/><path fill="#000" fill-rule="evenodd" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5Zm-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Z" clip-rule="evenodd"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1"/></svg>

Before

Width:  |  Height:  |  Size: 446 B

After

Width:  |  Height:  |  Size: 376 B

+7
View File
@@ -8,6 +8,13 @@
<string>com.apple.usernotifications.service</string> <string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key> <key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string> <string>$(PRODUCT_MODULE_NAME).NotificationService</string>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
</dict> </dict>
<key>MainAppScheme</key> <key>MainAppScheme</key>
<string>bluesky</string> <string>bluesky</string>
+84 -7
View File
@@ -1,5 +1,6 @@
import UserNotifications import UserNotifications
import UIKit import UIKit
import Intents
let APP_GROUP = "group.app.bsky" let APP_GROUP = "group.app.bsky"
typealias ContentHandler = (UNNotificationContent) -> Void typealias ContentHandler = (UNNotificationContent) -> Void
@@ -40,17 +41,18 @@ class NotificationService: UNNotificationServiceExtension {
} }
self.bestAttempt = bestAttempt self.bestAttempt = bestAttempt
if reason == "chat-message" {
if reason == "chat-message" || reason == "chat-reaction" {
mutateWithChatMessage(bestAttempt) mutateWithChatMessage(bestAttempt)
let finalContent = createCommunicationNotification(
from: bestAttempt,
userInfo: request.content.userInfo
)
contentHandler(finalContent)
} else { } else {
mutateWithBadge(bestAttempt) 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() { override func serviceExtensionTimeWillExpire() {
@@ -61,6 +63,81 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(bestAttempt) 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 // MARK: Mutations
func mutateWithBadge(_ content: UNMutableNotificationContent) { func mutateWithBadge(_ content: UNMutableNotificationContent) {
@@ -13,7 +13,7 @@ class BackgroundNotificationHandler(
return return
} }
if (remoteMessage.data["reason"] == "chat-message") { if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") {
mutateWithChatMessage(remoteMessage) mutateWithChatMessage(remoteMessage)
} else { } else {
mutateWithOtherReason(remoteMessage) mutateWithOtherReason(remoteMessage)
+6 -1
View File
@@ -556,7 +556,11 @@ export type Events = {
| 'FindContacts' | 'FindContacts'
} }
'chat:create': { 'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' logContext:
| 'ProfileHeader'
| 'NewChatDialog'
| 'SendViaChatDialog'
| 'ConvoSettings'
} }
'chat:open': { 'chat:open': {
logContext: logContext:
@@ -564,6 +568,7 @@ export type Events = {
| 'NewChatDialog' | 'NewChatDialog'
| 'ChatsList' | 'ChatsList'
| 'SendViaChatDialog' | 'SendViaChatDialog'
| 'ConvoSettings'
} }
'groupchat:create': { 'groupchat:create': {
logContext: 'NewChatDialog' logContext: 'NewChatDialog'
+87 -177
View File
@@ -1,8 +1,7 @@
import {useCallback, useEffect} from 'react' import {useEffect} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native' import {View} from 'react-native'
import Animated, { import Animated, {
Easing, Easing,
interpolate,
type SharedValue, type SharedValue,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
@@ -16,44 +15,32 @@ import {atoms as a, useTheme} from '#/alf'
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
type Layout = {
size: number
x: number
y: number
zIndex?: number
border?: boolean
}
type Props = { type Props = {
animate?: boolean animate?: boolean
profiles: bsky.profile.AnyProfileView[] profiles: bsky.profile.AnyProfileView[]
size?: 'small' | 'medium' | 'large' | number size?: number
} }
export function AvatarBubbles({ export function AvatarBubbles({
animate = false, animate = false,
profiles: allProfiles, profiles: allProfiles,
size = 'large', size = 120,
}: Props) { }: Props) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const profiles = const profiles =
allProfiles.length > 2 allProfiles.length > 2
? allProfiles.filter(p => p.did !== currentAccount?.did) ? allProfiles.filter(p => p.did !== currentAccount?.did)
: allProfiles : allProfiles
const containerSize = const scale = profiles.length <= 1 ? 1 : size / 120
typeof size === 'number' const marginOffset = size < 120 ? -2 : 0
? 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 initialValue = animate ? 0 : 1 const initialValue = animate ? 0 : 1
const p0 = useSharedValue(initialValue) const p0 = useSharedValue(initialValue)
@@ -61,130 +48,50 @@ export function AvatarBubbles({
const p2 = useSharedValue(initialValue) const p2 = useSharedValue(initialValue)
const p3 = useSharedValue(initialValue) const p3 = useSharedValue(initialValue)
const animateScale = (p: Animated.SharedValue<number>, 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(() => { useEffect(() => {
if (!animate) return if (!animate) return
playScaleAnimation() const animateBubble = (p: SharedValue<number>, i: number) => {
}, [animate, playScaleAnimation]) 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 = ( const scales = [p0, p1, p2, p3]
<> const layouts = getLayouts(profiles.length)
<AvatarBubble
profile={profiles[0]}
scale={p0}
size={76}
x={-2}
y={-2}
style={[a.z_20]}
includeProfileBorder
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={76}
x={42}
y={42}
style={[a.z_10]}
includeProfileBorder
/>
</>
)
if (profiles.length === 3) {
avatars = (
<>
<AvatarBubble
profile={profiles[0]}
scale={p0}
size={68}
x={-2}
y={-2}
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={56}
x={38}
y={62}
/>
<AvatarBubble
profile={profiles[2]}
scale={p2}
size={46}
x={71}
y={18}
/>
</>
)
}
if (profiles.length >= 4) {
avatars = (
<>
<AvatarBubble
profile={profiles[0]}
scale={p0}
size={68}
x={-2}
y={-2}
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={56}
x={60}
y={49}
/>
<AvatarBubble
profile={profiles[2]}
scale={p2}
size={42}
x={14}
y={74}
/>
<AvatarBubble profile={profiles[3]} scale={p3} size={32} x={72} y={9} />
</>
)
}
return ( return (
<Animated.View <Animated.View style={[a.p_2xs, {height: size, width: size}]}>
style={[
a.p_2xs,
{
height: containerSize,
width: containerSize,
},
]}>
<View <View
style={[ style={{
{ marginTop: marginOffset,
marginTop: marginOffset, marginLeft: marginOffset,
marginLeft: marginOffset, transform: [{scale}],
transform: [{scale}], transformOrigin: 'top left',
transformOrigin: 'top left', }}>
}, {layouts.map((layout, i) => (
]}> <AvatarBubble
{avatars} key={i}
profile={profiles[i]}
scale={scales[i]}
size={layout.size}
x={layout.x}
y={layout.y}
zIndex={layout.zIndex}
includeProfileBorder={layout.border}
/>
))}
</View> </View>
</Animated.View> </Animated.View>
) )
@@ -194,27 +101,23 @@ function AvatarBubble({
profile, profile,
scale, scale,
size, size,
style,
x, x,
y, y,
zIndex,
includeProfileBorder, includeProfileBorder,
}: { }: {
profile?: bsky.profile.AnyProfileView profile?: bsky.profile.AnyProfileView
scale: SharedValue<number> scale: SharedValue<number>
size: number size: number
style?: StyleProp<ViewStyle>
x: number x: number
y: number y: number
zIndex?: number
includeProfileBorder?: boolean includeProfileBorder?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
transform: [ transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
{translateX: x},
{translateY: y},
{scale: interpolate(scale.get(), [0, 1], [0, 1])},
],
})) }))
return ( return (
@@ -227,11 +130,17 @@ function AvatarBubble({
borderColor: t.atoms.text_inverted.color, borderColor: t.atoms.text_inverted.color,
borderWidth: 2, borderWidth: 2,
}, },
style, zIndex != null && {zIndex},
animatedStyle, animatedStyle,
]}> ]}>
{profile ? ( {profile ? (
<Avatar profile={profile} size={size} /> <UserAvatar
avatar={profile.avatar}
size={size}
type="user"
hideLiveBadge
noBorder
/>
) : ( ) : (
<AvatarPlaceholder size={size} /> <AvatarPlaceholder size={size} />
)} )}
@@ -239,25 +148,7 @@ function AvatarBubble({
) )
} }
function Avatar({ function AvatarPlaceholder({size}: {size: number}) {
profile,
size = 76,
}: {
profile: bsky.profile.AnyProfileView
size?: number
}) {
return (
<UserAvatar
avatar={profile.avatar}
size={size}
type="user"
hideLiveBadge
noBorder
/>
)
}
function AvatarPlaceholder({size = 76}: {size?: number}) {
const t = useTheme() const t = useTheme()
return ( return (
@@ -267,10 +158,7 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
a.justify_center, a.justify_center,
a.rounded_full, a.rounded_full,
t.atoms.bg_contrast_200, t.atoms.bg_contrast_200,
{ {width: size, height: size},
width: size,
height: size,
},
]}> ]}>
<PersonIcon <PersonIcon
width={size * 0.5} width={size * 0.5}
@@ -280,3 +168,25 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
</View> </View>
) )
} }
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},
]
}
+6
View File
@@ -77,6 +77,10 @@ export type ButtonState = {
focused: boolean focused: boolean
pressed: boolean pressed: boolean
disabled: boolean disabled: boolean
/**
* Alias for hovered || focused || pressed
*/
interacting: boolean
} }
export type ButtonContext = VariantProps & ButtonState export type ButtonContext = VariantProps & ButtonState
@@ -120,6 +124,7 @@ const Context = createContext<VariantProps & ButtonState>({
focused: false, focused: false,
pressed: false, pressed: false,
disabled: false, disabled: false,
interacting: false,
}) })
Context.displayName = 'ButtonContext' Context.displayName = 'ButtonContext'
@@ -536,6 +541,7 @@ export const Button = forwardRef<View, ButtonProps>(
const context = useMemo<ButtonContext>( const context = useMemo<ButtonContext>(
() => ({ () => ({
...state, ...state,
interacting: state.hovered || state.focused || state.pressed,
variant, variant,
color, color,
size, size,
+6 -6
View File
@@ -201,7 +201,7 @@ export function AvatarPlaceholder({size = 40}: {size?: number}) {
<View <View
style={[ style={[
a.rounded_full, a.rounded_full,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
{ {
width: size, width: size,
height: size, height: size,
@@ -348,7 +348,7 @@ export function NameAndHandlePlaceholder() {
<View <View
style={[ style={[
a.rounded_xs, a.rounded_xs,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
{ {
width: '60%', width: '60%',
height: 14, height: 14,
@@ -359,7 +359,7 @@ export function NameAndHandlePlaceholder() {
<View <View
style={[ style={[
a.rounded_xs, a.rounded_xs,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
{ {
width: '40%', width: '40%',
height: 10, height: 10,
@@ -377,7 +377,7 @@ export function NamePlaceholder({style}: ViewStyleProp) {
<View <View
style={[ style={[
a.rounded_xs, a.rounded_xs,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
{ {
width: '60%', width: '60%',
height: 14, height: 14,
@@ -439,7 +439,7 @@ export function DescriptionPlaceholder({
style={[ style={[
a.rounded_xs, a.rounded_xs,
a.w_full, a.w_full,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'}, {height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
]} ]}
/> />
@@ -600,7 +600,7 @@ export function FollowButtonPlaceholder({style}: ViewStyleProp) {
<View <View
style={[ style={[
a.rounded_sm, a.rounded_sm,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
a.w_full, a.w_full,
{ {
height: 33, height: 33,
@@ -512,7 +512,7 @@ function ExistingChatCard({
]}> ]}>
<ProfileCard.Header> <ProfileCard.Header>
{convo.kind === 'group' ? ( {convo.kind === 'group' ? (
<AvatarBubbles profiles={convo.members} size="small" /> <AvatarBubbles profiles={convo.members} size={40} />
) : ( ) : (
<ProfileCard.Avatar <ProfileCard.Avatar
profile={convo.primaryMember} profile={convo.primaryMember}
+7 -1
View File
@@ -4,23 +4,29 @@ import {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu' import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
import type * as bsky from '#/types/bsky'
export function ActionsWrapper({ export function ActionsWrapper({
message, message,
isFromSelf, isFromSelf,
senderProfile,
children, children,
onTap, onTap,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean hasReactions?: boolean
isFromSelf: boolean isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView
children: React.ReactNode children: React.ReactNode
onTap?: () => void onTap?: () => void
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
return ( return (
<MessageContextMenu message={message} onTap={onTap}> <MessageContextMenu
message={message}
senderProfile={senderProfile}
onTap={onTap}>
{trigger => {trigger =>
// will always be true, since this file is platform split // will always be true, since this file is platform split
trigger.IS_NATIVE && ( trigger.IS_NATIVE && (
+4 -1
View File
@@ -10,6 +10,7 @@ import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky'
import {EmojiReactionPicker} from './EmojiReactionPicker' import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util' import {hasReachedReactionLimit} from './util'
@@ -17,12 +18,14 @@ export function ActionsWrapper({
message, message,
hasReactions, hasReactions,
isFromSelf, isFromSelf,
senderProfile,
children, children,
onTap, onTap,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean hasReactions?: boolean
isFromSelf: boolean isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView
children: React.ReactNode children: React.ReactNode
onTap?: () => void onTap?: () => void
}) { }) {
@@ -114,7 +117,7 @@ export function ActionsWrapper({
) )
}} }}
</EmojiReactionPicker> </EmojiReactionPicker>
<MessageContextMenu message={message}> <MessageContextMenu message={message} senderProfile={senderProfile}>
{({props, state, IS_NATIVE, control}) => { {({props, state, IS_NATIVE, control}) => {
// always false, file is platform split // always false, file is platform split
if (IS_NATIVE) return null if (IS_NATIVE) return null
+14 -5
View File
@@ -98,11 +98,16 @@ function reducer(state: State, action: Action): State {
} }
export function AddMembersFlow({ export function AddMembersFlow({
members,
title, title,
onAddMembers, onAddMembers,
}: { }: {
members: string[]
title: string title: string
onAddMembers: (dids: string[]) => void onAddMembers: (
dids: string[],
profiles: bsky.profile.AnyProfileView[],
) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -154,7 +159,11 @@ export function AddMembersFlow({
} else if (searchText.length) { } else if (searchText.length) {
if (results?.length) { if (results?.length) {
for (const profile of results) { for (const profile of results) {
if (profile.did === currentAccount?.did) continue if (
profile.did === currentAccount?.did ||
members.includes(profile.did)
)
continue
_items.push({ _items.push({
type: 'profile', type: 'profile',
key: profile.did, key: profile.did,
@@ -202,7 +211,7 @@ export function AddMembersFlow({
} }
return _items return _items
}, [isError, searchText, l, results, currentAccount?.did, follows]) }, [isError, searchText, l, results, currentAccount?.did, members, follows])
if (searchText && !isFetching && !items.length && !isError) { if (searchText && !isFetching && !items.length && !isError) {
items.push({type: 'empty', key: 'empty', message: l`No results`}) items.push({type: 'empty', key: 'empty', message: l`No results`})
@@ -213,8 +222,8 @@ export function AddMembersFlow({
}, [control]) }, [control])
const handlePressAdd = useCallback(() => { const handlePressAdd = useCallback(() => {
onAddMembers(groupChatDids) onAddMembers(groupChatDids, groupChatProfiles)
}, [groupChatDids, onAddMembers]) }, [groupChatDids, groupChatProfiles, onAddMembers])
const renderItems = useCallback( const renderItems = useCallback(
({item}: {item: Item}) => { ({item}: {item: Item}) => {
+1 -1
View File
@@ -4,7 +4,7 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {subDays} from 'date-fns' import {subDays} from 'date-fns'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Text} from '../Typography' import {Text} from '#/components/Typography'
import {localDateString} from './util' import {localDateString} from './util'
const timeFormatter = new Intl.DateTimeFormat(undefined, { const timeFormatter = new Intl.DateTimeFormat(undefined, {
+6 -5
View File
@@ -25,15 +25,18 @@ import {usePromptControl} from '#/components/Prompt'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import type * as bsky from '#/types/bsky'
import {EmojiReactionPicker} from './EmojiReactionPicker' import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util' import {hasReachedReactionLimit} from './util'
export let MessageContextMenu = ({ export let MessageContextMenu = ({
message, message,
senderProfile,
children, children,
onTap, onTap,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
senderProfile?: bsky.profile.AnyProfileView
children: TriggerProps['children'] children: TriggerProps['children']
onTap?: () => void onTap?: () => void
}): React.ReactNode => { }): React.ReactNode => {
@@ -110,9 +113,7 @@ export let MessageContextMenu = ({
[l, convo, message, currentAccount?.did], [l, convo, message, currentAccount?.did],
) )
const sender = convo.convo.members.find( const sender = senderProfile
member => member.did === message.sender.did,
)
return ( return (
<> <>
@@ -183,7 +184,7 @@ export let MessageContextMenu = ({
control={reportControl} control={reportControl}
subject={{ subject={{
view: 'message', view: 'message',
convoId: convo.convo.id, convoId: convo.convo.view.id,
message, message,
}} }}
onAfterSubmit={() => { onAfterSubmit={() => {
@@ -197,7 +198,7 @@ export let MessageContextMenu = ({
control={blockOrDeleteControl} control={blockOrDeleteControl}
currentScreen="conversation" currentScreen="conversation"
params={{ params={{
convoId: convo.convo.id, convoId: convo.convo.view.id,
message, message,
}} }}
/> />
+19 -15
View File
@@ -30,7 +30,6 @@ import {useQueryClient} from '@tanstack/react-query'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {useConvoActive} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types' import {type ConvoItem} from '#/state/messages/convo/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' 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 * as ProfileCard from '#/components/ProfileCard'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {DateDivider} from './DateDivider' import {DateDivider} from './DateDivider'
import {useDateDividerToggle} from './DateDividerToggle' import {useDateDividerToggle} from './DateDividerToggle'
import {MessageItemEmbed} from './MessageItemEmbed' import {MessageItemEmbed} from './MessageItemEmbed'
@@ -93,19 +91,18 @@ function isWithinClusterBoundary({
let MessageItem = ({ let MessageItem = ({
item, item,
isGroupChat = false, isGroupChat = false,
profile,
}: { }: {
item: ConvoItem & {type: 'message' | 'pending-message'} item: ConvoItem & {type: 'message' | 'pending-message'}
isGroupChat?: boolean isGroupChat?: boolean
profile?: bsky.profile.AnyProfileView
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {t: l} = useLingui() const {t: l} = useLingui()
const {convo} = useConvoActive()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const profile = item.relatedProfiles.get(item.message.sender.did)
const reactionsControl = useDialogControl() const reactionsControl = useDialogControl()
const reactionTapRef = useRef(false) const reactionTapRef = useRef(false)
@@ -277,9 +274,7 @@ let MessageItem = ({
return l`You reacted ${reaction.value}` return l`You reacted ${reaction.value}`
} else { } else {
const senderDid = reaction.sender.did const senderDid = reaction.sender.did
const memberSender = convo.members.find( const memberSender = item.relatedProfiles.get(senderDid)
member => member.did === senderDid,
)
if (memberSender) { if (memberSender) {
return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}` return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}`
} }
@@ -290,7 +285,13 @@ let MessageItem = ({
one: '# person', one: '# person',
other: '# people', other: '# people',
})} reacted ${groupedReactions.map(g => g.value).join(' ')}` })} reacted ${groupedReactions.map(g => g.value).join(' ')}`
}, [reactions, groupedReactions, currentAccount?.did, convo.members, l]) }, [
reactions,
groupedReactions,
currentAccount?.did,
item.relatedProfiles,
l,
])
const appliedReactions = ( const appliedReactions = (
<LayoutAnimationConfig skipEntering skipExiting> <LayoutAnimationConfig skipEntering skipExiting>
@@ -375,7 +376,7 @@ let MessageItem = ({
) : null} ) : null}
<ReactionsDialog <ReactionsDialog
control={reactionsControl} control={reactionsControl}
members={convo.members} relatedProfiles={item.relatedProfiles}
message={message} message={message}
reactions={message.reactions} reactions={message.reactions}
groupedReactions={groupedReactions} groupedReactions={groupedReactions}
@@ -391,11 +392,13 @@ let MessageItem = ({
return ( return (
<> <>
{(hasLargeGapFromPrev || isDateDividerToggled) && ( <LayoutAnimationConfig skipExiting skipEntering>
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}> {(hasLargeGapFromPrev || isDateDividerToggled) && (
<DateDivider date={message.sentAt} /> <Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
</Animated.View> <DateDivider date={message.sentAt} />
)} </Animated.View>
)}
</LayoutAnimationConfig>
<View style={[messageInset, effectiveFirstInCluster && a.mt_md]}> <View style={[messageInset, effectiveFirstInCluster && a.mt_md]}>
<View style={[a.relative]}> <View style={[a.relative]}>
{showAvatar ? ( {showAvatar ? (
@@ -434,6 +437,7 @@ let MessageItem = ({
hasReactions={hasReactions} hasReactions={hasReactions}
isFromSelf={isFromSelf} isFromSelf={isFromSelf}
message={message} message={message}
senderProfile={profile}
onTap={() => { onTap={() => {
if (reactionTapRef.current) return if (reactionTapRef.current) return
if (!hasLargeGapFromPrev) { if (!hasLargeGapFromPrev) {
+4 -1
View File
@@ -161,11 +161,13 @@ function GroupHeaderReady({
}) })
} }
const lockStatus = convo.details.lockStatus
return ( return (
<Wrapper <Wrapper
heading={ heading={
<> <>
<AvatarBubbles size="small" profiles={convo.members} /> <AvatarBubbles size={40} profiles={convo.members} />
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}> <Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
{convo.details.name} {convo.details.name}
</Text> </Text>
@@ -175,6 +177,7 @@ function GroupHeaderReady({
settings={ settings={
<Button <Button
label={l`Open group chat settings`} label={l`Open group chat settings`}
disabled={lockStatus === 'locked-permanently'}
size="small" size="small"
color="secondary" color="secondary"
shape="round" shape="round"
+4 -4
View File
@@ -7,7 +7,7 @@ import {
View, View,
} from 'react-native' } from 'react-native'
import Animated from 'react-native-reanimated' import Animated from 'react-native-reanimated'
import {type ChatBskyConvoDefs} from '@atproto/api' import {type ChatBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {HITSLOP_10} from '#/lib/constants' import {HITSLOP_10} from '#/lib/constants'
@@ -33,13 +33,13 @@ type Reaction = {
export function ReactionsDialog({ export function ReactionsDialog({
control, control,
members, relatedProfiles,
message, message,
reactions, reactions,
groupedReactions, groupedReactions,
}: { }: {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
members: bsky.profile.AnyProfileView[] relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
reactions?: ChatBskyConvoDefs.ReactionView[] reactions?: ChatBskyConvoDefs.ReactionView[]
groupedReactions?: Reaction[] groupedReactions?: Reaction[]
@@ -100,7 +100,7 @@ export function ReactionsDialog({
return 0 return 0
}) })
.map(reaction => { .map(reaction => {
const sender = members.find(m => m.did === reaction.sender.did) const sender = relatedProfiles.get(reaction.sender.did)
if (!sender) return null if (!sender) return null
return ( return (
<ReactionRow <ReactionRow
+3 -3
View File
@@ -23,15 +23,15 @@ export type SystemMessageInfo = {
function getReferredDisplayName( function getReferredDisplayName(
user: ChatBskyConvoDefs.SystemMessageReferredUser, user: ChatBskyConvoDefs.SystemMessageReferredUser,
relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[], relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>,
): string | null { ): string | null {
const profile = relatedProfiles.find(p => p.did === user.did) const profile = relatedProfiles.get(user.did)
return profile ? createSanitizedDisplayName(profile) : null return profile ? createSanitizedDisplayName(profile) : null
} }
export function getSystemMessageInfo( export function getSystemMessageInfo(
data: ChatBskyConvoDefs.SystemMessageView['data'], data: ChatBskyConvoDefs.SystemMessageView['data'],
relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[], relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>,
): SystemMessageInfo | null { ): SystemMessageInfo | null {
if (ChatBskyConvoDefs.isSystemMessageDataAddMember(data)) { if (ChatBskyConvoDefs.isSystemMessageDataAddMember(data)) {
const name = getReferredDisplayName(data.member, relatedProfiles) const name = getReferredDisplayName(data.member, relatedProfiles)
+2 -2
View File
@@ -68,13 +68,13 @@ export type DirectConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & ( export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & (
| { | {
kind: 'group' kind: 'group'
details: ChatBskyConvoDefs.GroupConvo details: $Typed<ChatBskyConvoDefs.GroupConvo>
primaryMember: GroupConvoMember // the owner primaryMember: GroupConvoMember // the owner
members: Array<GroupConvoMember> members: Array<GroupConvoMember>
} }
| { | {
kind: 'direct' kind: 'direct'
details: ChatBskyConvoDefs.DirectConvo details: $Typed<ChatBskyConvoDefs.DirectConvo>
primaryMember: DirectConvoMember // the other user primaryMember: DirectConvoMember // the other user
members: Array<DirectConvoMember> members: Array<DirectConvoMember>
} }
+11 -1
View File
@@ -81,6 +81,7 @@ export type ItemProps = ViewStyleProp & {
isInvalid?: boolean isInvalid?: boolean
children: ((props: ItemState) => React.ReactNode) | React.ReactNode children: ((props: ItemState) => React.ReactNode) | React.ReactNode
hitSlop?: PressableProps['hitSlop'] hitSlop?: PressableProps['hitSlop']
highlightRow?: boolean
} }
export function useItemContext() { export function useItemContext() {
@@ -160,8 +161,11 @@ export function Item({
style, style,
type = 'checkbox', type = 'checkbox',
label, label,
highlightRow,
...rest ...rest
}: ItemProps) { }: ItemProps) {
const t = useTheme()
const { const {
values: selectedValues, values: selectedValues,
type: groupType, type: groupType,
@@ -207,6 +211,12 @@ export function Item({
[name, selected, disabled, hovered, pressed, focused, isInvalid], [name, selected, disabled, hovered, pressed, focused, isInvalid],
) )
const highlightStyle = highlightRow
? selected
? [a.rounded_full, a.p_md, {backgroundColor: t.palette.primary_50}]
: [a.rounded_full, a.p_md]
: null
return ( return (
<ItemContext.Provider value={state}> <ItemContext.Provider value={state}>
<Pressable <Pressable
@@ -232,7 +242,7 @@ export function Item({
onPressOut={onPressOut} onPressOut={onPressOut}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
style={[a.flex_row, a.align_center, a.gap_sm, style]}> style={[a.flex_row, a.align_center, a.gap_sm, highlightStyle, style]}>
{typeof children === 'function' ? children(state) : children} {typeof children === 'function' ? children(state) : children}
</Pressable> </Pressable>
</ItemContext.Provider> </ItemContext.Provider>
+7
View File
@@ -5,6 +5,13 @@ export const EditBig_Stroke1_Corner0_Rounded = createSinglePathSVG({
path: 'M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z', path: 'M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z',
}) })
/**
* @deprecated Use EditBig_Stroke2_Corner2_Rounded
*/
export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z', path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z',
}) })
export const EditBig_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z',
})
+1 -1
View File
@@ -9,5 +9,5 @@ export const Lock_Stroke2_Corner2_Rounded = createSinglePathSVG({
}) })
export const Unlock_Stroke2_Corner2_Rounded = createSinglePathSVG({ export const Unlock_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M12 13a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z"/><path fill="#000" fill-rule="evenodd" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5Zm-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Z', path: 'M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1',
}) })
@@ -1,5 +1,12 @@
import {createSinglePathSVG} from './TEMPLATE' import {createSinglePathSVG} from './TEMPLATE'
/**
* @deprecated Use SquareBehindSquare_Stroke2_Corner2_Rounded
*/
export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z', path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z',
}) })
export const SquareBehindSquare_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z',
})
+18 -5
View File
@@ -29,6 +29,7 @@ export type NotificationReason =
| 'reply' | 'reply'
| 'quote' | 'quote'
| 'chat-message' | 'chat-message'
| 'chat-reaction'
| 'starterpack-joined' | 'starterpack-joined'
| 'like-via-repost' | 'like-via-repost'
| 'repost-via-repost' | 'repost-via-repost'
@@ -44,7 +45,7 @@ export type NotificationReason =
export type NotificationPayload = export type NotificationPayload =
| undefined | undefined
| { | {
reason: Exclude<NotificationReason, 'chat-message'> reason: Exclude<NotificationReason, 'chat-message' | 'chat-reaction'>
uri: string uri: string
subject: string subject: string
recipientDid: string recipientDid: string
@@ -55,6 +56,12 @@ export type NotificationPayload =
messageId: string messageId: string
recipientDid: string recipientDid: string
} }
| {
reason: 'chat-reaction'
convoId: string
messageId: string
recipientDid: string
}
const DEFAULT_HANDLER_OPTIONS = { const DEFAULT_HANDLER_OPTIONS = {
shouldShowBanner: false, shouldShowBanner: false,
@@ -192,8 +199,11 @@ export function useNotificationsHandler() {
const handleNotification = (payload?: NotificationPayload) => { const handleNotification = (payload?: NotificationPayload) => {
if (!payload) return if (!payload) return
if (payload.reason === 'chat-message') { if (
logger.debug(`useNotificationsHandler: handling chat message`, { payload.reason === 'chat-message' ||
payload.reason === 'chat-reaction'
) {
logger.debug(`useNotificationsHandler: handling chat notification`, {
payload, payload,
}) })
@@ -270,7 +280,8 @@ export function useNotificationsHandler() {
logger.debug('useNotificationsHandler: incoming', {e, payload}) logger.debug('useNotificationsHandler: incoming', {e, payload})
if ( if (
payload.reason === 'chat-message' && (payload.reason === 'chat-message' ||
payload.reason === 'chat-reaction') &&
payload.recipientDid === currentAccount?.did payload.recipientDid === currentAccount?.did
) { ) {
const shouldAlert = payload.convoId !== currentConvoId const shouldAlert = payload.convoId !== currentConvoId
@@ -341,7 +352,8 @@ export function useNotificationsHandler() {
// Whenever there's a stored payload, that means we had to switch accounts before handling the notification. // Whenever there's a stored payload, that means we had to switch accounts before handling the notification.
// Whenever currentAccount changes, we should try to handle it again. // Whenever currentAccount changes, we should try to handle it again.
if ( if (
storedAccountSwitchPayload?.reason === 'chat-message' && (storedAccountSwitchPayload?.reason === 'chat-message' ||
storedAccountSwitchPayload?.reason === 'chat-reaction') &&
currentAccount?.did === storedAccountSwitchPayload.recipientDid currentAccount?.did === storedAccountSwitchPayload.recipientDid
) { ) {
handleNotification(storedAccountSwitchPayload) handleNotification(storedAccountSwitchPayload)
@@ -429,6 +441,7 @@ export function notificationToURL(payload: NotificationPayload): string | null {
return `/profile/${urip.host}` return `/profile/${urip.host}`
} }
case 'chat-message': case 'chat-message':
case 'chat-reaction':
// should be handled separately // should be handled separately
return null return null
case 'verified': case 'verified':
File diff suppressed because it is too large Load Diff
@@ -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 (
<>
<Button
disabled={isAddPending}
label={l`Add members`}
onPress={addMembersControl.open}>
{({interacting}) => (
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_between,
a.px_xl,
a.py_sm,
interacting ? [t.atoms.bg_contrast_25] : [],
]}>
<View style={[a.flex_row, a.align_center]}>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.p_lg,
a.rounded_full,
interacting
? t.atoms.bg_contrast_100
: t.atoms.bg_contrast_50,
{
height: 48,
width: 48,
},
]}>
<PlusIcon style={[t.atoms.text_contrast_high]} size="sm" />
</View>
<Text
numberOfLines={1}
style={[a.text_md, a.font_semi_bold, a.mx_sm, t.atoms.text]}>
<Trans>Add members</Trans>
</Text>
</View>
{isAddPending ? (
<Loader size="md" />
) : (
<ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" />
)}
</View>
)}
</Button>
<Dialog.Outer
control={addMembersControl}
testID="addChatMembersDialog"
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<AddMembersFlow
members={members}
title={l`Add members`}
onAddMembers={(members, profiles) => {
addGroupMembers({members, profiles})
}}
/>
</Dialog.Outer>
</>
)
}
@@ -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 <MemberPlaceholder />
}
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 = <StatusBadge label={l`Admin`} />
}
} else {
statusBadge = (
<MemberMenu
convo={convo}
profile={profile}
displayName={displayName}
type={status}
isOwner={isOwner}
/>
)
}
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 (
<SubtleHoverWrapper>
<View style={outerStyles}>
<ProfileCard.Link profile={profile} style={[a.flex_1]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
size={48}
profile={profile}
moderationOpts={moderationOpts}
/>
<View style={[a.flex_1]}>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.Handle
profile={profile}
textStyle={[a.text_xs, native({top: -1})]}
/>
{!isProfileOwner && (
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
web(a.pt_2xs),
]}>
{joinedReason}
</Text>
)}
</View>
</ProfileCard.Header>
</ProfileCard.Outer>
</ProfileCard.Link>
{statusBadge}
</View>
</SubtleHoverWrapper>
)
}
export function MemberPlaceholder() {
return (
<View style={outerStyles}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.AvatarPlaceholder size={48} />
<ProfileCard.NameAndHandlePlaceholder />
</ProfileCard.Header>
</ProfileCard.Outer>
</View>
)
}
@@ -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<bsky.profile.AnyProfileView>
type: 'owner' | 'standard' | 'invited'
displayName: string
isOwner: boolean
}) {
const navigation = useNavigation<NavigationProp>()
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: [
<Trans key="message">
Before you can message another user, you must first verify your email.
</Trans>,
],
})
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 (
<>
<Menu.Root>
<Menu.Trigger label={l`Open chat member options for ${displayName}`}>
{({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' ? (
<StatusBadge
label={type === 'owner' ? l`Admin` : l`Invited`}
pressableProps={triggerProps}
style={[
isActive
? {
backgroundColor: t.palette.contrast_0,
}
: null,
]}
/>
) : (
<Pressable
{...triggerProps}
style={[
a.rounded_full,
a.p_sm,
isActive
? {
backgroundColor: t.palette.contrast_0,
}
: null,
]}>
<EllipsisIcon
style={[t.atoms.text_contrast_medium]}
size="md"
/>
</Pressable>
)
}}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={l`View ${displayName}s profile`}
onPress={() => {
navigation.navigate('Profile', {name: profile.did})
}}>
<Menu.ItemText>
<Trans>Go to profile</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={PersonIcon} />
</Menu.Item>
<Menu.Item
label={l`Message ${displayName}`}
onPress={handleMessageMember}>
<Menu.ItemText>
<Trans context="action">Message</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={MessageIcon} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
{canBlockMember ? (
<Menu.Item
label={
profile.viewer?.blocking
? l`Unblock ${displayName}`
: l`Block ${displayName}`
}
onPress={
profile.viewer?.blocking
? handleBlockMember
: blockMemberPrompt.open
}>
<Menu.ItemText>
<Trans>Block</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={PersonXIcon} />
</Menu.Item>
) : null}
{canRemoveMember ? (
<Menu.Item
label={l`Remove ${displayName} from this group chat`}
onPress={() => removeMembers({members: [profile.did]})}>
<Menu.ItemText>
<Trans>Remove from chat</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
) : null}
{canUninviteMember ? (
<Menu.Item
label={l`Uninvite ${displayName} from this group chat`}
// TODO Need to wire up the uninvite flow. -dsb
onPress={() => {}}>
<Menu.ItemText>
<Trans>Uninvite</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
) : null}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<BlockMemberPrompt
control={blockMemberPrompt}
onConfirm={() => void handleBlockMember()}
/>
</>
)
}
@@ -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 (
<View style={[a.flex_row, a.justify_between, a.px_xl, a.pt_xl, a.pb_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Text style={[a.text_lg, a.font_semi_bold, t.atoms.text]}>
<Trans>Members</Trans>
</Text>
<View
style={[a.px_xs, a.py_2xs, t.atoms.bg_contrast_50, a.rounded_full]}>
<Text
style={[a.text_xs, a.font_medium, {color: t.palette.contrast_500}]}>
{l({
message: `${memberCount}/${MEMBER_LIMIT}`,
comment:
'The number of group chat members out of the total number of permitted users.',
})}
</Text>
</View>
</View>
{isOwner && requestCount > 0 ? (
<InlineLinkText
label={l`View incoming group chat requests`}
style={[a.text_sm, a.text_right, a.font_semi_bold]}
// TODO Need to implement this. -dsb
to="#">
{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.',
})}
</InlineLinkText>
) : null}
</View>
)
}
@@ -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<ViewStyle>
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 = (
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}>
{label}
</Text>
)
if (pressableProps) {
return (
<Pressable style={badgeStyle} {...pressableProps}>
{labelText}
</Pressable>
)
}
return <View style={badgeStyle}>{labelText}</View>
}
@@ -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<unknown>) {
const {
state: hover,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
return (
<View
// Web-only
onPointerEnter={onHoverIn}
// Web-only
onPointerLeave={onHoverOut}
style={a.pointer}>
<SubtleHover hover={hover} />
{children}
</View>
)
}
@@ -0,0 +1 @@
export const MEMBER_LIMIT = 50
@@ -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 (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content align={gtTablet ? 'left' : 'platform'}>
<Layout.Header.TitleText>
<Trans>Group chat settings</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<ConvoProvider key={convoId} convoId={convoId}>
<SettingsInner />
</ConvoProvider>
</Layout.Screen>
)
}
function SettingsInner() {
const {t: l} = useLingui()
const convoState = useConvo()
const navigation = useNavigation<NavigationProp>()
if (convoState.status === ConvoStatus.Error) {
return (
<Error
title={l`Something went wrong`}
message={l`We couldnt load this conversations settings`}
onRetry={() => convoState.error.retry()}
sideBorders={false}
/>
)
}
if (!isConvoActive(convoState)) {
return (
<Layout.Content>
<View style={[a.align_center, a.justify_center, a.flex_1, a.py_4xl]}>
<Loader size="xl" />
</View>
</Layout.Content>
)
}
if (convoState.convo?.kind !== 'group') {
return (
<Error
title={l`Wrong kind of conversation`}
message={l`This screen is only available for group conversations.`}
onGoBack={() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.replace('Messages', {animation: 'pop'})
}
}}
/>
)
}
return <GroupSettings convo={convoState.convo} />
}
function keyExtractor(item: Item) {
return item.key
}
function GroupSettings({
convo,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
}) {
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 (
<MembersAndRequests
memberCount={convo.details.memberCount}
requestCount={requestCount}
hasMoreRequests={!!hasMoreRequests}
isOwner={isOwner}
/>
)
case 'ADD_MEMBERS_LINK':
return convo ? (
<AddMembersLink
convo={convo}
members={memberListData.map(profile => profile.did)}
/>
) : null
case 'CHAT_MEMBER':
return convo ? (
<Member
convo={convo}
profile={item.profile}
status={item.status}
isOwner={isOwner}
/>
) : null
case 'CHAT_MEMBER_PLACEHOLDER':
return <MemberPlaceholder />
default:
return null
}
}
return (
<List
data={items}
contentContainerStyle={{
paddingBottom: bottomBarOffset + a.pb_xl.paddingBottom,
}}
desktopFixedHeight
initialNumToRender={initialNumToRender}
keyExtractor={keyExtractor}
ListHeaderComponent={
convo?.kind === 'group' ? (
<SettingsHeader convo={convo} isOwner={isOwner} />
) : (
<SettingsHeaderPlaceholder />
)
}
renderItem={renderItem}
sideBorders={false}
windowSize={11}
/>
)
}
function SettingsHeader({
convo,
isOwner,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
isOwner: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
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 (
<>
<View
style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}>
<View style={[a.align_center, a.justify_center]}>
<AvatarBubbles profiles={convo.members} />
</View>
<Text
style={[
a.text_2xl,
a.font_bold,
a.text_center,
a.pt_lg,
t.atoms.text,
]}>
{groupName}
</Text>
{showCreatedAt ? (
<Text
style={[
a.text_sm,
a.text_center,
a.pt_xs,
a.px_xl,
t.atoms.text_contrast_high,
]}>
<Trans>Created {dateFormatter.format(createdAt)}</Trans>
</Text>
) : null}
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.gap_2xl,
a.pt_2xl,
]}>
<SettingsButton
color={convo.view.muted ? 'negative_subtle' : 'secondary'}
icon={convo.view.muted ? BellOffIcon : BellIcon}
label={
convo.view.muted
? l`Unmute this group chat`
: l`Mute this group chat`
}
text={convo.view.muted ? l`Muted` : l`Mute`}
onPress={handleToggleMute}
/>
{isOwner ? (
<SettingsButton
icon={EditIcon}
label={l`Edit this group chats name`}
text={l`Edit name`}
onPress={handlePromptName}
/>
) : null}
{isJoinLinkEnabled ? (
<SettingsButton
icon={ChainLinkIcon}
label={
isOwner
? l`Create or modify an invite link for this group chat`
: l`View the invite link for this group chat`
}
text={l`Invite link`}
onPress={inviteLinkDialog.open}
/>
) : null}
{canLockGroupChat ? (
<SettingsButton
color={lockStatus === 'locked' ? 'negative_subtle' : 'secondary'}
icon={LockIcon}
label={
lockStatus === 'locked'
? l`Unlock this group chat`
: l`Lock this group chat`
}
text={lockStatus === 'locked' ? l`Locked` : l`Lock`}
onPress={
lockStatus === 'locked' ? handleUnlock : lockChatPrompt.open
}
/>
) : null}
{isOwner ? null : isReportLinkEnabled ? (
<SettingsButton
color="secondary"
icon={FlagIcon}
label={l`Report this group chat`}
text={l`Report`}
onPress={handleReportChat}
/>
) : null}
{isOwner ? null : (
<SettingsButton
color="secondary"
icon={ArrowBoxLeftIcon}
label={l`Leave this group chat`}
text={l`Leave`}
onPress={leaveChatPrompt.open}
/>
)}
</View>
</View>
<EditNamePrompt
control={editNamePrompt}
value={newGroupName}
onChangeText={setNewGroupName}
onConfirm={handleEditName}
/>
<InviteLinkDialog
convo={convo}
control={inviteLinkDialog}
isOwner={isOwner}
/>
<LockChatPrompt control={lockChatPrompt} onConfirm={handleConfirmLock} />
<LeaveChatPrompt
control={leaveChatPrompt}
groupName={groupName}
onConfirm={leaveConvo}
/>
</>
)
}
function SettingsHeaderPlaceholder() {
const t = useTheme()
return (
<View style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}>
<View style={[a.align_center, a.justify_center]}>
<AvatarBubbles profiles={[]} />
</View>
<Text
style={[a.text_2xl, a.font_bold, a.text_center, a.pt_lg, t.atoms.text]}>
</Text>
<Text
style={[
a.text_sm,
a.text_center,
a.pt_xs,
a.px_xl,
t.atoms.text_contrast_high,
]}>
</Text>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.gap_2xl,
a.pt_2xl,
]}>
<SettingsButtonPlaceholder />
<SettingsButtonPlaceholder />
<SettingsButtonPlaceholder />
<SettingsButtonPlaceholder />
</View>
</View>
)
}
function SettingsButton({
color = 'secondary',
disabled,
icon,
label,
text,
onPress,
}: {
color?: ButtonColor
disabled?: boolean
icon: React.ComponentType<SVGIconProps>
label: string
text: string
onPress: () => void
}) {
const t = useTheme()
return (
<View style={[a.align_center]}>
<Button
color={color}
disabled={disabled}
size="large"
shape="round"
label={label}
onPress={onPress}
style={[
{
width: 48,
height: 48,
},
]}>
<ButtonIcon icon={icon} size="md" />
</Button>
<Text
numberOfLines={1}
style={[
a.text_xs,
a.font_medium,
a.text_center,
a.pt_xs,
t.atoms.text_contrast_medium,
]}>
{text}
</Text>
</View>
)
}
function SettingsButtonPlaceholder() {
const t = useTheme()
const {t: l} = useLingui()
return (
<View style={[a.align_center]}>
<Button color="secondary" size="large" shape="round" label={l`Loading…`}>
<ButtonIcon icon={EllipsisIcon} size="md" />
</Button>
<Text
numberOfLines={1}
style={[
a.text_xs,
a.font_medium,
a.text_center,
a.pt_xs,
t.atoms.text,
]}>
</Text>
</View>
)
}
@@ -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 (
<Prompt.Outer control={control}>
<>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Edit group name</Trans>
</Prompt.TitleText>
<View style={[a.my_sm]}>
<TextField.Root isInvalid={false}>
<TextField.Input
label={l`Edit group name`}
placeholder={l`Group name`}
value={value}
onChangeText={onChangeText}
returnKeyType="done"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
autoFocus
onSubmitEditing={onConfirm}
/>
</TextField.Root>
</View>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action cta={l`Save`} onPress={onConfirm} />
<Prompt.Cancel />
</Prompt.Actions>
</>
</Prompt.Outer>
)
}
export function LockChatPrompt({
control,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
onConfirm: () => void
}) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Lock group chat?`}
description={l`Members can still read chat history but cant send new messages.`}
confirmButtonCta={l`Lock group chat`}
cancelButtonCta={l`Cancel`}
onConfirm={onConfirm}
/>
)
}
export function LeaveChatPrompt({
control,
groupName,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
groupName: string
onConfirm: () => void
}) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Are you sure you want to leave ${groupName}?`}
description={l`You wont be able to rejoin unless youre invited.`}
confirmButtonCta={l`Leave group chat`}
confirmButtonColor="negative"
cancelButtonCta={l`Cancel`}
onConfirm={onConfirm}
/>
)
}
export function BlockMemberPrompt({
control,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
onConfirm: () => void
}) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Block account?`}
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
onConfirm={onConfirm}
confirmButtonCta={l`Block`}
confirmButtonColor="negative"
/>
)
}
@@ -298,7 +298,10 @@ function BaseChatItem({
// System message // System message
if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) { 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) { if (info) {
lastMessage = i18n._(info.message) lastMessage = i18n._(info.message)
lastMessageSentAt = convo.lastMessage.sentAt lastMessageSentAt = convo.lastMessage.sentAt
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
import {type ActiveConvoStates} from '#/state/messages/convo' import {type ActiveConvoStates} from '#/state/messages/convo'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {KnownFollowers} from '#/components/KnownFollowers' import {KnownFollowers} from '#/components/KnownFollowers'
@@ -16,16 +15,15 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const {currentAccount} = useSession()
const leaveConvoControl = usePromptControl() const leaveConvoControl = usePromptControl()
const onAcceptChat = useCallback(() => { const onAcceptChat = useCallback(() => {
convoState.markConvoAccepted() convoState.markConvoAccepted()
}, [convoState]) }, [convoState])
const otherUser = convoState.recipients.find( // either the other person, or the chat owner
user => user.did !== currentAccount?.did, // if we ever allow someone other than the owner to invite people, this will need to change
) const otherUser = convoState.convo.primaryMember
if (!moderationOpts) { if (!moderationOpts) {
return null return null
@@ -44,7 +42,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
{otherUser && ( {otherUser && (
<RejectMenu <RejectMenu
label={_(msg`Block or report`)} label={_(msg`Block or report`)}
convo={convoState.convo} convo={convoState.convo.view}
profile={otherUser} profile={otherUser}
color="negative_subtle" color="negative_subtle"
size="small" size="small"
@@ -53,14 +51,14 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
)} )}
<DeleteChatButton <DeleteChatButton
label={_(msg`Delete`)} label={_(msg`Delete`)}
convo={convoState.convo} convo={convoState.convo.view}
color="secondary" color="secondary"
size="small" size="small"
currentScreen="conversation" currentScreen="conversation"
onPress={leaveConvoControl.open} onPress={leaveConvoControl.open}
/> />
<LeaveConvoPrompt <LeaveConvoPrompt
convoId={convoState.convo.id} convoId={convoState.convo.view.id}
control={leaveConvoControl} control={leaveConvoControl}
currentScreen="conversation" currentScreen="conversation"
hasMessages={false} hasMessages={false}
@@ -69,7 +67,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
<View style={[a.w_full, a.flex_row]}> <View style={[a.w_full, a.flex_row]}>
<AcceptChatButton <AcceptChatButton
onAcceptConvo={onAcceptChat} onAcceptConvo={onAcceptChat}
convo={convoState.convo} convo={convoState.convo.view}
color="primary_subtle" color="primary_subtle"
size="small" size="small"
currentScreen="conversation" currentScreen="conversation"
@@ -0,0 +1,90 @@
import {useCallback, useEffect, useState} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import Animated, {
FadeOutUp,
useReducedMotion,
ZoomIn,
} from 'react-native-reanimated'
import * as Clipboard from 'expo-clipboard'
import {Trans} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
import {SquareBehindSquare_Stroke2_Corner2_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4'
import {Text} from '#/components/Typography'
export function CopyTextButton({
children,
disabled,
style,
value,
onPress: onPressProp,
...props
}: ButtonProps & {value: string}) {
const t = useTheme()
const [hasBeenCopied, setHasBeenCopied] = useState(false)
const isReducedMotionEnabled = useReducedMotion()
useEffect(() => {
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 (
<View style={[a.relative]}>
{hasBeenCopied && (
<Animated.View
entering={ZoomIn.duration(100)}
exiting={FadeOutUp.duration(2000)}
style={[
a.absolute,
{bottom: '100%', right: 0},
a.justify_center,
a.gap_sm,
a.z_10,
a.pb_sm,
]}
pointerEvents="none">
<Text
style={[
a.font_medium,
a.text_right,
a.text_sm,
t.atoms.text_contrast_high,
]}>
<Trans>Copied!</Trans>
</Text>
</Animated.View>
)}
<Button
color="secondary"
disabled={disabled}
style={[a.flex_1, a.justify_between, {borderRadius: 10}, style]}
onPress={onPress}
{...props}>
{context => (
<View style={[a.flex_1, a.flex_row, a.justify_between, a.p_md]}>
{typeof children === 'function' ? children(context) : children}
{disabled ? null : <ButtonIcon icon={CopyIcon} size="lg" />}
</View>
)}
</Button>
</View>
)
}
@@ -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 (
<View style={[a.relative]}>
<Button
color="secondary"
style={[
a.flex_1,
a.justify_between,
a.rounded_full,
a.border,
t.atoms.bg,
t.atoms.border_contrast_low,
style,
]}
onPress={onPress}
{...props}>
{context => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.px_md,
a.py_sm,
]}>
{typeof children === 'function' ? children(context) : children}
<View
style={[
a.ml_sm,
a.rounded_full,
t.atoms.bg_contrast_50,
{paddingHorizontal: 10, paddingVertical: 8},
]}>
<Text
style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
<Trans>Edit</Trans>
</Text>
</View>
</View>
)}
</Button>
</View>
)
}
@@ -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<ConvoWithDetails, {kind: 'group'}>
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<Step>(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 = (
<>
<View>
<Text style={[a.text_md, t.atoms.text]}>
<Trans>
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.
</Trans>
</Text>
<Text style={[a.mt_lg, a.text_md, t.atoms.text]}>
<Trans>
Your name, avatar, and the name of the group chat will be
visible to everyone.
</Trans>
</Text>
</View>
<View style={[a.mt_4xl]}>
<Button
label={l`Get started`}
color="primary"
size="large"
onPress={() => {
setStep(Step.GENERATE)
}}>
<ButtonText>
<Trans>Get started</Trans>
</ButtonText>
<ButtonIcon icon={ArrowRightIcon} />
</Button>
</View>
</>
)
break
case Step.GENERATE:
header = l`Generate invite link`
content = (
<>
<View>
<Text style={[a.text_md, t.atoms.text]}>
<Trans>Choose who can join this group chat and how.</Trans>
</Text>
</View>
<View style={[a.mt_lg]}>
<Toggle.Group
label={l`Who can join this group chat and how`}
type="radio"
values={whoCanJoin}
onChange={setWhoCanJoin}>
<View style={[a.gap_sm]}>
{whoCanJoinOptions.map(option => (
<Toggle.Item
key={option.name}
highlightRow={true}
label={isOwner ? option.owner : option.member}
name={option.name}
style={[a.flex_1]}>
{({selected}) => (
<TargetOption
label={isOwner ? option.owner : option.member}
selected={selected}
/>
)}
</Toggle.Item>
))}
</View>
</Toggle.Group>
</View>
<View style={[a.mt_4xl]}>
<Button
label={l`Generate invite link`}
color="primary"
size="large"
disabled={isSaving}
onPress={() => {
const parts = whoCanJoin[0].split(':')
const joinRule = parts[0]
const requireApproval = parts[1] === 'requireApproval'
if (joinLink && enabledStatus === 'enabled') {
editJoinLink({
joinRule,
requireApproval,
})
} else {
createJoinLink({
joinRule,
requireApproval,
})
}
}}>
<ButtonText>
{joinLink && enabledStatus === 'enabled'
? l`Update invite link`
: l`Generate invite link`}
</ButtonText>
<ButtonIcon icon={isSaving ? Loader : ArrowRightIcon} />
</Button>
</View>
</>
)
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 = (
<>
<View style={[a.mt_lg]}>
<CopyTextButton
disabled={enabledStatus === 'disabled' || !hasJoinLinkCode}
label={l`Invite link`}
value={joinLinkURI}>
<Text
numberOfLines={1}
style={[
a.mr_xs,
a.text_md,
enabledStatus === 'disabled'
? t.atoms.text_contrast_low
: t.atoms.text,
]}>
{joinLinkURI}
</Text>
</CopyTextButton>
{createdAt ? (
<Text style={[a.mt_xs, a.text_xs, t.atoms.text_contrast_medium]}>
<Trans>
Created {timeFormatter.format(createdAt)}{' '}
{dateFormatter.format(createdAt)}
</Trans>
</Text>
) : null}
</View>
{enabledStatus === 'enabled' ? (
<View style={[a.mt_lg]}>
{isOwner ? (
<EditTextButton
label={l`Edit link settings`}
value={ownerValue}
onPress={() => setStep(Step.GENERATE)}>
<Text
numberOfLines={1}
style={[
a.mr_xs,
a.text_md,
t.atoms.text,
{maxWidth: '80%'},
]}>
{ownerValue}
</Text>
</EditTextButton>
) : (
<Text style={[a.text_sm, t.atoms.text]}>{memberValue}</Text>
)}
</View>
) : null}
{enabledStatus === 'enabled' ? (
<View style={[a.flex_row, a.justify_between, a.gap_sm, a.mt_lg]}>
{isOwner ? (
<StackedButton
label={l`Disable`}
icon={isDisabling ? Loader : ChainLinkBrokenIcon}
color="negative_subtle"
style={[a.flex_1, a.rounded_full]}
disabled={isDisabling}
onPress={() => {
disableJoinLink()
}}>
<Trans>Disable</Trans>
</StackedButton>
) : null}
<StackedButton
disabled={enabledStatus === 'disabled'}
label={l`Post link`}
icon={EditIcon}
color="primary_subtle"
style={[a.flex_1, a.rounded_full]}
onPress={() => {
control.close(() => {
openComposer({
text: joinLinkURI,
logContext: 'Other',
})
})
}}>
<Trans>Post link</Trans>
</StackedButton>
<StackedButton
disabled={enabledStatus === 'disabled'}
label={l`Share`}
icon={ArrowShareRightIcon}
color="primary_subtle"
style={[a.flex_1, a.rounded_full]}
onPress={() => {
void shareUrl(joinLinkURI)
}}>
<Trans>Share</Trans>
</StackedButton>
</View>
) : (
<View style={[a.gap_md, a.mt_lg]}>
<Button
label={l`Re-enable invite link`}
color="primary"
size="large"
disabled={isEnabling}
onPress={() => {
enableJoinLink()
}}>
<ButtonText>
<Trans>Re-enable link</Trans>
</ButtonText>
{isEnabling && <ButtonIcon icon={Loader} />}
</Button>
<Button
label={l`Generate new invite link`}
color="secondary"
size="large"
onPress={() => setStep(Step.GENERATE)}>
<ButtonText>
<Trans>Generate new link</Trans>
</ButtonText>
</Button>
</View>
)}
</>
)
break
}
}
if (!isOwner && (!joinLink || joinLink?.enabledStatus === 'disabled')) {
header = l`Invite link`
content = (
<>
<View style={[a.mt_lg]}>
<Text style={[a.text_sm, t.atoms.text]}>
<Trans>There is no invite link for this group chat.</Trans>
</Text>
</View>
<View style={[a.gap_md, a.mt_lg]}>
<Button
label={l`Close`}
color="primary"
size="large"
onPress={() => control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
</>
)
}
return (
<Dialog.Outer
control={control}
onClose={() => {
setStep(defaultStep)
setWhoCanJoin(defaultWhoCanJoin)
}}>
<Dialog.Handle />
<Dialog.ScrollableInner
header={
<View>
<View style={[IS_WEB ? [a.px_2xl, a.pt_xl] : {paddingTop: 10}]}>
<Text style={[a.font_bold, a.text_2xl, a.mb_sm, t.atoms.text]}>
{header}
</Text>
</View>
<Dialog.Close />
</View>
}
label={l`Group chat invite link dialog`}
style={web({maxWidth: 400})}>
{content}
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function TargetOption({label, selected}: {label: string; selected: boolean}) {
const t = useTheme()
return (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText
style={[
a.font_normal,
a.flex_1,
a.leading_tight,
selected ? t.atoms.text : t.atoms.text_contrast_high,
]}>
{label}
</Toggle.LabelText>
</View>
)
}
@@ -250,10 +250,8 @@ export function MessagesList({
) )
const onStartReached = useCallback(() => { const onStartReached = useCallback(() => {
if (hasScrolled && prevContentHeight.current > layoutHeight.get()) { void convoState.fetchMessageHistory()
void convoState.fetchMessageHistory() }, [convoState])
}
}, [convoState, hasScrolled, layoutHeight])
const onScroll = useCallback( const onScroll = useCallback(
(e: ScrollEvent) => { (e: ScrollEvent) => {
@@ -376,10 +374,7 @@ export function MessagesList({
return ( return (
<MessageItem <MessageItem
item={item} item={item}
profile={convoState.convo.members.find( isGroupChat={convoState.convo.kind === 'group'}
member => member.did === item.message.sender.did,
)}
isGroupChat={convoState.isGroup()}
/> />
) )
} else if (item.type === 'deleted-message') { } else if (item.type === 'deleted-message') {
@@ -448,8 +443,9 @@ export function MessagesList({
ListHeaderComponent={ ListHeaderComponent={
<> <>
<MaybeLoader isLoading={convoState.isFetchingHistory} /> <MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.isGroup() && convoState.hasAllHistory ? ( {convoState.convo?.kind === 'group' &&
<MessagesListInfoPanel convoState={convoState} /> convoState.hasAllHistory ? (
<MessagesListInfoPanel convo={convoState.convo} />
) : null} ) : null}
</> </>
} }
@@ -577,7 +573,7 @@ function getFooterState(
} }
} }
if (convoState.convo.status === 'request' && !hasAcceptOverride) { if (convoState.convo.view.status === 'request' && !hasAcceptOverride) {
return 'request' return 'request'
} }
@@ -1,72 +1,90 @@
import {View} from 'react-native' import {View} from 'react-native'
import {Plural, Trans, useLingui} from '@lingui/react/macro' 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 {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles' import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {AddMembersFlow} from '#/components/dms/AddMembersFlow' 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 {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {InviteLinkDialog} from './InviteLinkDialog'
export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) { export function MessagesListInfoPanel({
convo,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
}) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const addMembersControl = Dialog.useDialogControl() const addMembersControl = Dialog.useDialogControl()
const inviteLinkControl = Dialog.useDialogControl()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const isOwner = const convoId = convo.view.id
currentAccount?.did == null const {mutate: addGroupMembers} = useAddGroupMembers(convoId, {
? false onSuccess: () => {
: convoState.getPrimaryMember?.()?.did === currentAccount.did addMembersControl.close()
// TODO Get this from @api/atproto - dsb },
const isLinkEnabled = false 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, profile => profile.did !== currentAccount?.did,
) )
let names: React.ReactNode | null = null let names: React.ReactNode = null
if (members.length === 1) { if (members.length === 1) {
names = <Trans>New chat with {members[0].displayName}</Trans> names = <Trans>New chat with {members[0].displayName}</Trans>
} } else if (members.length === 2) {
if (members.length === 2) {
names = ( names = (
<Trans> <Trans>
New chat with {members[0].displayName} and {members[1].displayName} New chat with {members[0].displayName} and {members[1].displayName}
</Trans> </Trans>
) )
} } else if (members.length > 2) {
if (members.length > 2) { const memberCount = convo.details.memberCount - 2
names = ( names = (
<Trans> <Trans>
New chat with {members[0].displayName}, {members[1].displayName}, and{' '} New chat with {members[0].displayName}, {members[1].displayName}, and{' '}
<Plural <Plural
value={members.length - 2} value={memberCount}
one={`${members.length - 2} more`} one={`${memberCount} more`}
other={`${members.length - 2} more`} other={`${memberCount} more`}
/> />
. .
</Trans> </Trans>
) )
} }
const showButtons = isOwner || isLinkEnabled const showButtons = isOwner || isJoinLinkEnabled
return ( return (
<> <>
<View style={[a.align_center, a.justify_center]}> <View style={[a.align_center, a.justify_center]}>
<AvatarBubbles animate={true} profiles={members} /> <AvatarBubbles animate={true} profiles={convo?.members} />
{groupName ? ( {convo.details.name ? (
<Text style={[a.text_2xl, a.font_bold, a.mt_lg, t.atoms.text]}> <Text style={[a.text_2xl, a.font_bold, a.mt_lg, t.atoms.text]}>
{groupName} {convo.details.name}
</Text> </Text>
) : null} ) : null}
{names ? ( {names ? (
@@ -102,12 +120,16 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
</ButtonText> </ButtonText>
</Button> </Button>
) : null} ) : null}
{isOwner || isLinkEnabled ? ( {isJoinLinkEnabled ? (
<Button <Button
color="secondary" color="secondary"
size="small" size="small"
label={l`Click here to view or create an invite link for this group chat`} label={
onPress={() => {}}> isOwner
? l`Click here to create or manage an invite link for this group chat`
: l`Click here to view the invite link for this group chat`
}
onPress={inviteLinkControl.open}>
<ButtonIcon icon={ChainLinkIcon} /> <ButtonIcon icon={ChainLinkIcon} />
<ButtonText> <ButtonText>
<Trans>Invite link</Trans> <Trans>Invite link</Trans>
@@ -117,17 +139,22 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
</View> </View>
) : null} ) : null}
</View> </View>
<InviteLinkDialog
isOwner={isOwner}
convo={convo}
control={inviteLinkControl}
/>
<Dialog.Outer <Dialog.Outer
control={addMembersControl} control={addMembersControl}
testID="addChatMembersDialog" testID="addChatMembersDialog"
nativeOptions={{fullHeight: true}}> nativeOptions={{fullHeight: true}}>
<Dialog.Handle /> <Dialog.Handle />
<AddMembersFlow <AddMembersFlow
members={members.map(profile => profile.did)}
title={l`Add people`} title={l`Add people`}
onAddMembers={(_dids: string[]) => { onAddMembers={(members, profiles) =>
// TODO Add members here addGroupMembers({members, profiles})
addMembersControl.close() }
}}
/> />
</Dialog.Outer> </Dialog.Outer>
</> </>
+2
View File
@@ -11,6 +11,7 @@ import {findAllProfilesInQueryData as findAllProfilesInContactMatchesQueryData}
import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers' import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers'
import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members' import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members'
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations' 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 findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts'
import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts' import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts'
import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed' import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed'
@@ -264,4 +265,5 @@ function* findProfilesInCache(
yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did) yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did)
yield* findAllProfilesInNotifsQueryData(queryClient, did) yield* findAllProfilesInNotifsQueryData(queryClient, did)
yield* findAllProfilesInContactMatchesQueryData(queryClient, did) yield* findAllProfilesInContactMatchesQueryData(queryClient, did)
yield* findAllProfilesInMessagesQueryData(queryClient, did)
} }
+224 -192
View File
@@ -1,9 +1,10 @@
import { import {
type AtpAgent, type AtpAgent,
ChatBskyActorDefs, type ChatBskyActorDefs,
ChatBskyConvoDefs, ChatBskyConvoDefs,
type ChatBskyConvoGetLog, type ChatBskyConvoGetLog,
type ChatBskyConvoSendMessage, type ChatBskyConvoSendMessage,
type ChatBskyGroupDefs,
} from '@atproto/api' } from '@atproto/api'
import {XRPCError} from '@atproto/api' import {XRPCError} from '@atproto/api'
import {EventEmitter} from 'eventemitter3' import {EventEmitter} from 'eventemitter3'
@@ -36,8 +37,12 @@ import {
} from '#/state/messages/convo/types' } from '#/state/messages/convo/types'
import {type MessagesEventBus} from '#/state/messages/events/agent' import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type MessagesEventBusError} from '#/state/messages/events/types' import {type MessagesEventBusError} from '#/state/messages/events/types'
import {
type ConvoWithDetails,
type GroupConvoMember,
parseConvoView,
} from '#/components/dms/util'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import * as bsky from '#/types/bsky'
const logger = Logger.create(Logger.Context.ConversationAgent) const logger = Logger.create(Logger.Context.ConversationAgent)
@@ -102,10 +107,8 @@ export class Convo {
{id: string; message: ChatBskyConvoSendMessage.InputSchema['message']} {id: string; message: ChatBskyConvoSendMessage.InputSchema['message']}
> = new Map() > = new Map()
private deletedMessages: Set<string> = new Set() private deletedMessages: Set<string> = new Set()
private systemMessageProfiles: Map< private relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> =
string, new Map()
ChatBskyActorDefs.ProfileViewBasic
> = new Map()
private isProcessingPendingMessages = false private isProcessingPendingMessages = false
@@ -114,7 +117,7 @@ export class Convo {
private emitter = new EventEmitter<{event: [ConvoEvent]}>() private emitter = new EventEmitter<{event: [ConvoEvent]}>()
convoId: string convoId: string
convo: ChatBskyConvoDefs.ConvoView | undefined convo: ConvoWithDetails | undefined
sender: ChatBskyActorDefs.ProfileViewBasic | undefined sender: ChatBskyActorDefs.ProfileViewBasic | undefined
recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
snapshot: ConvoState | undefined snapshot: ConvoState | undefined
@@ -130,6 +133,7 @@ export class Convo {
this.setupPlaceholderData(params.placeholderData) this.setupPlaceholderData(params.placeholderData)
} }
this.setConvo = this.setConvo.bind(this)
this.subscribe = this.subscribe.bind(this) this.subscribe = this.subscribe.bind(this)
this.getSnapshot = this.getSnapshot.bind(this) this.getSnapshot = this.getSnapshot.bind(this)
this.sendMessage = this.sendMessage.bind(this) this.sendMessage = this.sendMessage.bind(this)
@@ -141,10 +145,10 @@ export class Convo {
this.markConvoAccepted = this.markConvoAccepted.bind(this) this.markConvoAccepted = this.markConvoAccepted.bind(this)
this.addReaction = this.addReaction.bind(this) this.addReaction = this.addReaction.bind(this)
this.removeReaction = this.removeReaction.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.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() { private commit() {
@@ -172,6 +176,30 @@ export class Convo {
} }
private generateSnapshot(): ConvoState { 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) { switch (this.status) {
case ConvoStatus.Initializing: { case ConvoStatus.Initializing: {
return { return {
@@ -179,45 +207,48 @@ export class Convo {
items: [], items: [],
convo: this.convo, convo: this.convo,
error: undefined, error: undefined,
sender: this.sender, ...shared,
recipients: this.recipients, ...emptyMethods,
isFetchingHistory: this.isFetchingHistory, }
// Explicit null check since the value is initially undefined. }
hasAllHistory: this.oldestRev === null, case ConvoStatus.Disabled: {
deleteMessage: undefined, return {
sendMessage: undefined, status: this.status,
fetchMessageHistory: undefined, items: this.getItems(),
markConvoAccepted: undefined, convo: this.convo!,
addReaction: undefined, error: undefined,
removeReaction: undefined, ...shared,
isGroup: this.isGroup, ...methods,
getGroupInfo: this.getGroupInfo, }
getPrimaryMember: this.getPrimaryMember, }
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: { case ConvoStatus.Ready: {
return { return {
status: this.status, status: this.status,
items: this.getItems(), items: this.getItems(),
convo: this.convo!, convo: this.convo!,
error: undefined, error: undefined,
sender: this.sender!, ...shared,
recipients: this.recipients!, ...methods,
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,
} }
} }
case ConvoStatus.Error: { case ConvoStatus.Error: {
@@ -226,19 +257,9 @@ export class Convo {
items: [], items: [],
convo: undefined, convo: undefined,
error: this.error!, error: this.error!,
sender: undefined,
recipients: undefined,
isFetchingHistory: false, isFetchingHistory: false,
hasAllHistory: false, hasAllHistory: false,
deleteMessage: undefined, ...emptyMethods,
sendMessage: undefined,
fetchMessageHistory: undefined,
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
isGroup: undefined,
getGroupInfo: undefined,
getPrimaryMember: undefined,
} }
} }
default: { default: {
@@ -247,20 +268,10 @@ export class Convo {
items: [], items: [],
convo: this.convo, convo: this.convo,
error: undefined, error: undefined,
sender: this.sender,
recipients: this.recipients,
isFetchingHistory: false, isFetchingHistory: false,
// Explicit null check since the value is initially undefined. // Explicit null check since the value is initially undefined.
hasAllHistory: this.oldestRev === null, hasAllHistory: this.oldestRev === null,
deleteMessage: undefined, ...emptyMethods,
sendMessage: undefined,
fetchMessageHistory: undefined,
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
isGroup: this.isGroup,
getGroupInfo: this.getGroupInfo,
getPrimaryMember: this.getPrimaryMember,
} }
} }
} }
@@ -460,8 +471,6 @@ export class Convo {
private reset() { private reset() {
this.convo = undefined this.convo = undefined
this.sender = undefined
this.recipients = undefined
this.snapshot = undefined this.snapshot = undefined
this.status = ConvoStatus.Uninitialized this.status = ConvoStatus.Uninitialized
@@ -473,7 +482,7 @@ export class Convo {
this.newMessages = new Map() this.newMessages = new Map()
this.pendingMessages = new Map() this.pendingMessages = new Map()
this.deletedMessages = new Set() this.deletedMessages = new Set()
this.systemMessageProfiles = new Map() this.relatedProfiles = new Map()
this.pendingMessageFailure = null this.pendingMessageFailure = null
this.fetchMessageHistoryError = undefined 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<ChatBskyConvoDefs.ConvoView>) {
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, * 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. * but this allows us to render the convo header immediately.
@@ -505,20 +534,14 @@ export class Convo {
private setupPlaceholderData( private setupPlaceholderData(
data: NonNullable<ConvoParams['placeholderData']>, data: NonNullable<ConvoParams['placeholderData']>,
) { ) {
this.convo = data.convo this.setConvo(data.convo)
this.sender = data.convo.members.find(m => m.did === this.senderUserDid)
this.recipients = data.convo.members.filter(
m => m.did !== this.senderUserDid,
)
} }
private async setup() { private async setup() {
try { try {
const {convo, sender, recipients} = await this.fetchConvo() const {convo} = await this.fetchConvo()
this.convo = convo this.setConvo(convo)
this.sender = sender
this.recipients = recipients
/* /*
* Some validation prior to `Ready` status * Some validation prior to `Ready` status
@@ -526,14 +549,14 @@ export class Convo {
if (!this.convo) { if (!this.convo) {
throw new Error('could not find convo') throw new Error('could not find convo')
} }
if (!this.sender) {
throw new Error('could not find sender in convo') const self = this.convo.members.find(m => m.did === this.senderUserDid)
}
if (!this.recipients) { if (!self) {
throw new Error('could not find recipients in convo') throw new Error('could not find self in convo')
} }
const userIsDisabled = Boolean(this.sender.chatDisabled) const userIsDisabled = Boolean(self.chatDisabled)
if (userIsDisabled) { if (userIsDisabled) {
this.dispatch({event: ConvoDispatchEvent.Disable}) this.dispatch({event: ConvoDispatchEvent.Disable})
@@ -602,22 +625,19 @@ export class Convo {
} }
private pendingFetchConvo: private pendingFetchConvo:
| Promise<{ | Promise<{convo: ChatBskyConvoDefs.ConvoView}>
convo: ChatBskyConvoDefs.ConvoView
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
recipients: ChatBskyActorDefs.ProfileViewBasic[]
}>
| undefined | undefined
async fetchConvo() { async fetchConvo() {
if (this.pendingFetchConvo) return this.pendingFetchConvo if (this.pendingFetchConvo) return this.pendingFetchConvo
// non-blocking
void this.fetchMemberList()
this.pendingFetchConvo = (async () => { this.pendingFetchConvo = (async () => {
try { try {
const response = await networkRetry(2, () => { const response = await networkRetry(2, () => {
return this.agent.api.chat.bsky.convo.getConvo( return this.agent.chat.bsky.convo.getConvo(
{ {convoId: this.convoId},
convoId: this.convoId,
},
{headers: DM_SERVICE_HEADERS}, {headers: DM_SERVICE_HEADERS},
) )
}) })
@@ -626,8 +646,6 @@ export class Convo {
return { return {
convo, convo,
sender: convo.members.find(m => m.did === this.senderUserDid),
recipients: convo.members.filter(m => m.did !== this.senderUserDid),
} }
} finally { } finally {
this.pendingFetchConvo = undefined this.pendingFetchConvo = undefined
@@ -639,11 +657,10 @@ export class Convo {
async refreshConvo() { async refreshConvo() {
try { try {
const {convo, sender, recipients} = await this.fetchConvo() void this.fetchMemberList()
const {convo} = await this.fetchConvo()
// throw new Error('UNCOMMENT TO TEST REFRESH FAILURE') // throw new Error('UNCOMMENT TO TEST REFRESH FAILURE')
this.convo = convo || this.convo this.setConvo(convo)
this.sender = sender || this.sender
this.recipients = recipients || this.recipients
} catch (err) { } catch (err) {
const e = err as Error const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
@@ -654,11 +671,32 @@ export class Convo {
} }
} }
private fetchMessageHistoryError: // purely for populating `this.relatedProfiles` - we do not pipe it
| { // into the ConvoWithDetails. If you want to drive UI based on the member list,
retry: () => void // 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() { async fetchMessageHistory() {
logger.debug('fetch message history', {}) logger.debug('fetch message history', {})
@@ -700,7 +738,7 @@ export class Convo {
if (relatedProfiles) { if (relatedProfiles) {
for (const profile of 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 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 ( if (
ChatBskyConvoDefs.isLogCreateMessage(ev) && ChatBskyConvoDefs.isLogCreateMessage(ev) &&
ChatBskyConvoDefs.isMessageView(ev.message) ChatBskyConvoDefs.isMessageView(ev.message)
@@ -872,14 +916,6 @@ export class Convo {
const systemView = toSystemMessageView(ev) const systemView = toSystemMessageView(ev)
if (systemView) { if (systemView) {
this.newMessages.set(systemView.id, 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 needsCommit = true
} }
} }
@@ -907,11 +943,10 @@ export class Convo {
id: tempId, id: tempId,
message, message,
}) })
if (this.convo?.status === 'request') { if (this.convo?.view.status === 'request') {
this.convo = { this.updateConvo({
...this.convo,
status: 'accepted', status: 'accepted',
} })
} }
this.commit() this.commit()
@@ -921,41 +956,79 @@ export class Convo {
} }
markConvoAccepted() { markConvoAccepted() {
if (this.convo) { this.updateConvo({
this.convo = { status: 'accepted',
...this.convo, })
status: 'accepted',
}
}
this.commit() this.commit()
} }
updateMuted(muted: boolean) { updateMuted(muted: boolean) {
if (this.convo) { this.updateConvo({
this.convo = { muted,
...this.convo, })
muted,
}
}
this.commit() this.commit()
} }
updateGroupName(name: string) { updateGroupName(name: string) {
if ( if (this.convo?.kind !== 'group') {
this.convo && throw new Error('updateGroupName can only be called on group convo')
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
this.convo.kind,
ChatBskyConvoDefs.isGroupConvo,
)
) {
this.convo = {
...this.convo,
kind: {
...this.convo.kind,
name,
},
}
} }
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() this.commit()
} }
@@ -980,7 +1053,7 @@ export class Convo {
const {id, message} = pendingMessage 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, convoId: this.convoId,
message, message,
@@ -1023,7 +1096,7 @@ export class Convo {
this.emitter.emit('event', { this.emitter.emit('event', {
type: 'invalidate-block-state', type: 'invalidate-block-state',
accountDids: [ accountDids: [
this.sender!.did, this.senderUserDid,
...this.recipients!.map(r => r.did), ...this.recipients!.map(r => r.did),
], ],
}) })
@@ -1075,7 +1148,7 @@ export class Convo {
) )
try { try {
const {data} = await this.agent.api.chat.bsky.convo.sendMessageBatch( const {data} = await this.agent.chat.bsky.convo.sendMessageBatch(
{ {
items: messageArray.map(({message}) => ({ items: messageArray.map(({message}) => ({
convoId: this.convoId, convoId: this.convoId,
@@ -1117,7 +1190,7 @@ export class Convo {
try { try {
await networkRetry(2, () => { await networkRetry(2, () => {
return this.agent.api.chat.bsky.convo.deleteMessageForSelf( return this.agent.chat.bsky.convo.deleteMessageForSelf(
{ {
convoId: this.convoId, convoId: this.convoId,
messageId, messageId,
@@ -1158,6 +1231,7 @@ export class Convo {
type: 'message', type: 'message',
key: m.id, key: m.id,
message: m, message: m,
relatedProfiles: this.relatedProfiles,
nextMessage: null, nextMessage: null,
prevMessage: null, prevMessage: null,
}) })
@@ -1166,6 +1240,7 @@ export class Convo {
type: 'deleted-message', type: 'deleted-message',
key: m.id, key: m.id,
message: m, message: m,
relatedProfiles: this.relatedProfiles,
nextMessage: null, nextMessage: null,
prevMessage: null, prevMessage: null,
}) })
@@ -1174,7 +1249,7 @@ export class Convo {
type: 'system-message', type: 'system-message',
key: m.id, key: m.id,
message: m, message: m,
relatedProfiles: Array.from(this.systemMessageProfiles.values()), relatedProfiles: this.relatedProfiles,
}) })
} }
}) })
@@ -1196,6 +1271,7 @@ export class Convo {
type: 'message', type: 'message',
key: m.id, key: m.id,
message: m, message: m,
relatedProfiles: this.relatedProfiles,
nextMessage: null, nextMessage: null,
prevMessage: null, prevMessage: null,
}) })
@@ -1204,6 +1280,7 @@ export class Convo {
type: 'deleted-message', type: 'deleted-message',
key: m.id, key: m.id,
message: m, message: m,
relatedProfiles: this.relatedProfiles,
nextMessage: null, nextMessage: null,
prevMessage: null, prevMessage: null,
}) })
@@ -1212,7 +1289,7 @@ export class Convo {
type: 'system-message', type: 'system-message',
key: m.id, key: m.id,
message: m, message: m,
relatedProfiles: Array.from(this.systemMessageProfiles.values()), relatedProfiles: this.relatedProfiles,
}) })
} }
}) })
@@ -1228,15 +1305,12 @@ export class Convo {
id: nanoid(), id: nanoid(),
rev: '__fake__', rev: '__fake__',
sentAt: new Date().toISOString(), sentAt: new Date().toISOString(),
/*
* `getItems` is only run in "active" status states, where
* `this.sender` is defined
*/
sender: { sender: {
$type: 'chat.bsky.convo.defs#messageViewSender', $type: 'chat.bsky.convo.defs#messageViewSender',
did: this.sender!.did, did: this.senderUserDid,
}, },
}, },
relatedProfiles: this.relatedProfiles,
nextMessage: null, nextMessage: null,
prevMessage: null, prevMessage: null,
failed: this.pendingMessageFailure !== null, failed: this.pendingMessageFailure !== null,
@@ -1448,46 +1522,4 @@ export class Convo {
throw error 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<ChatBskyConvoDefs.GroupConvo>(
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<ChatBskyActorDefs.GroupConvoMember>(
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)
}
}
} }
+35 -7
View File
@@ -29,9 +29,19 @@ import {
import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations' import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations'
import {RQKEY as createProfileQueryKey} from '#/state/queries/profile' import {RQKEY as createProfileQueryKey} from '#/state/queries/profile'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {type GroupConvoMember} from '#/components/dms/util'
export * from '#/state/messages/convo/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<ConvoState | null>(null) const ChatContext = createContext<ConvoState | null>(null)
ChatContext.displayName = 'ChatContext' ChatContext.displayName = 'ChatContext'
@@ -107,11 +117,11 @@ export function ConvoProvider({
switch (event.type) { switch (event.type) {
case 'invalidate-block-state': { case 'invalidate-block-state': {
for (const did of event.accountDids) { for (const did of event.accountDids) {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: createProfileQueryKey(did), queryKey: createProfileQueryKey(did),
}) })
} }
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [ListConvosQueryKeyRoot], queryKey: [ListConvosQueryKeyRoot],
}) })
} }
@@ -127,17 +137,35 @@ export function ConvoProvider({
const data = event.query.state.data as const data = event.query.state.data as
| ChatBskyConvoDefs.ConvoView | ChatBskyConvoDefs.ConvoView
| undefined | undefined
if (data && convo.convo && data.muted !== convo.convo.muted) { if (data && convo.convo && data.muted !== convo.convo.view.muted) {
convo.updateMuted(data.muted) convo.updateMuted(data.muted)
} }
if ( if (
data && data &&
convo.convo &&
ChatBskyConvoDefs.isGroupConvo(data.kind) && ChatBskyConvoDefs.isGroupConvo(data.kind) &&
ChatBskyConvoDefs.isGroupConvo(convo.convo.kind) && convo.convo?.kind === 'group'
data.kind.name !== convo.convo.kind.name
) { ) {
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,
)
} }
} }
}) })
+18 -67
View File
@@ -6,6 +6,7 @@ import {
} from '@atproto/api' } from '@atproto/api'
import {type MessagesEventBus} from '#/state/messages/events/agent' import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type ConvoWithDetails} from '#/components/dms/util'
export type ConvoParams = { export type ConvoParams = {
convoId: string convoId: string
@@ -58,34 +59,20 @@ export enum ConvoDispatchEvent {
} }
export type ConvoDispatch = export type ConvoDispatch =
| { | {event: ConvoDispatchEvent.Init}
event: ConvoDispatchEvent.Init | {event: ConvoDispatchEvent.Ready}
} | {event: ConvoDispatchEvent.Resume}
| { | {event: ConvoDispatchEvent.Background}
event: ConvoDispatchEvent.Ready | {event: ConvoDispatchEvent.Suspend}
} | {event: ConvoDispatchEvent.Error; payload: ConvoError}
| { | {event: ConvoDispatchEvent.Disable}
event: ConvoDispatchEvent.Resume
}
| {
event: ConvoDispatchEvent.Background
}
| {
event: ConvoDispatchEvent.Suspend
}
| {
event: ConvoDispatchEvent.Error
payload: ConvoError
}
| {
event: ConvoDispatchEvent.Disable
}
export type ConvoItem = export type ConvoItem =
| { | {
type: 'message' type: 'message'
key: string key: string
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
nextMessage: nextMessage:
| ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView | ChatBskyConvoDefs.DeletedMessageView
@@ -99,6 +86,7 @@ export type ConvoItem =
type: 'pending-message' type: 'pending-message'
key: string key: string
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
nextMessage: nextMessage:
| ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView | ChatBskyConvoDefs.DeletedMessageView
@@ -117,6 +105,7 @@ export type ConvoItem =
type: 'deleted-message' type: 'deleted-message'
key: string key: string
message: ChatBskyConvoDefs.DeletedMessageView message: ChatBskyConvoDefs.DeletedMessageView
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
nextMessage: nextMessage:
| ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView | ChatBskyConvoDefs.DeletedMessageView
@@ -130,7 +119,7 @@ export type ConvoItem =
type: 'system-message' type: 'system-message'
key: string key: string
message: ChatBskyConvoDefs.SystemMessageView message: ChatBskyConvoDefs.SystemMessageView
relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[] relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
} }
| { | {
type: 'error' type: 'error'
@@ -150,17 +139,12 @@ type FetchMessageHistory = () => Promise<void>
type MarkConvoAccepted = () => void type MarkConvoAccepted = () => void
type AddReaction = (messageId: string, reaction: string) => Promise<void> type AddReaction = (messageId: string, reaction: string) => Promise<void>
type RemoveReaction = (messageId: string, reaction: string) => Promise<void> type RemoveReaction = (messageId: string, reaction: string) => Promise<void>
type IsGroup = () => boolean | undefined
type GetGroupInfo = () => ChatBskyConvoDefs.GroupConvo | undefined
type GetPrimaryMember = () => ChatBskyActorDefs.ProfileViewBasic | undefined
export type ConvoStateUninitialized = { export type ConvoStateUninitialized = {
status: ConvoStatus.Uninitialized status: ConvoStatus.Uninitialized
items: [] items: []
convo: ChatBskyConvoDefs.ConvoView | undefined convo: ConvoWithDetails | undefined
error: undefined error: undefined
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
isFetchingHistory: false isFetchingHistory: false
hasAllHistory: boolean hasAllHistory: boolean
deleteMessage: undefined deleteMessage: undefined
@@ -169,17 +153,12 @@ export type ConvoStateUninitialized = {
markConvoAccepted: undefined markConvoAccepted: undefined
addReaction: undefined addReaction: undefined
removeReaction: undefined removeReaction: undefined
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
} }
export type ConvoStateInitializing = { export type ConvoStateInitializing = {
status: ConvoStatus.Initializing status: ConvoStatus.Initializing
items: [] items: []
convo: ChatBskyConvoDefs.ConvoView | undefined convo: ConvoWithDetails | undefined
error: undefined error: undefined
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
isFetchingHistory: boolean isFetchingHistory: boolean
hasAllHistory: boolean hasAllHistory: boolean
deleteMessage: undefined deleteMessage: undefined
@@ -188,17 +167,12 @@ export type ConvoStateInitializing = {
markConvoAccepted: undefined markConvoAccepted: undefined
addReaction: undefined addReaction: undefined
removeReaction: undefined removeReaction: undefined
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
} }
export type ConvoStateReady = { export type ConvoStateReady = {
status: ConvoStatus.Ready status: ConvoStatus.Ready
items: ConvoItem[] items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView convo: ConvoWithDetails
error: undefined error: undefined
sender: ChatBskyActorDefs.ProfileViewBasic
recipients: ChatBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean isFetchingHistory: boolean
hasAllHistory: boolean hasAllHistory: boolean
deleteMessage: DeleteMessage deleteMessage: DeleteMessage
@@ -207,17 +181,12 @@ export type ConvoStateReady = {
markConvoAccepted: MarkConvoAccepted markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction addReaction: AddReaction
removeReaction: RemoveReaction removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
} }
export type ConvoStateBackgrounded = { export type ConvoStateBackgrounded = {
status: ConvoStatus.Backgrounded status: ConvoStatus.Backgrounded
items: ConvoItem[] items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView convo: ConvoWithDetails
error: undefined error: undefined
sender: ChatBskyActorDefs.ProfileViewBasic
recipients: ChatBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean isFetchingHistory: boolean
hasAllHistory: boolean hasAllHistory: boolean
deleteMessage: DeleteMessage deleteMessage: DeleteMessage
@@ -226,17 +195,12 @@ export type ConvoStateBackgrounded = {
markConvoAccepted: MarkConvoAccepted markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction addReaction: AddReaction
removeReaction: RemoveReaction removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
} }
export type ConvoStateSuspended = { export type ConvoStateSuspended = {
status: ConvoStatus.Suspended status: ConvoStatus.Suspended
items: ConvoItem[] items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView convo: ConvoWithDetails
error: undefined error: undefined
sender: ChatBskyActorDefs.ProfileViewBasic
recipients: ChatBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean isFetchingHistory: boolean
hasAllHistory: boolean hasAllHistory: boolean
deleteMessage: DeleteMessage deleteMessage: DeleteMessage
@@ -245,17 +209,12 @@ export type ConvoStateSuspended = {
markConvoAccepted: MarkConvoAccepted markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction addReaction: AddReaction
removeReaction: RemoveReaction removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
} }
export type ConvoStateError = { export type ConvoStateError = {
status: ConvoStatus.Error status: ConvoStatus.Error
items: [] items: []
convo: undefined convo: undefined
error: ConvoError error: ConvoError
sender: undefined
recipients: undefined
isFetchingHistory: false isFetchingHistory: false
hasAllHistory: false hasAllHistory: false
deleteMessage: undefined deleteMessage: undefined
@@ -264,17 +223,12 @@ export type ConvoStateError = {
markConvoAccepted: undefined markConvoAccepted: undefined
addReaction: undefined addReaction: undefined
removeReaction: undefined removeReaction: undefined
isGroup: undefined
getGroupInfo: undefined
getPrimaryMember: undefined
} }
export type ConvoStateDisabled = { export type ConvoStateDisabled = {
status: ConvoStatus.Disabled status: ConvoStatus.Disabled
items: ConvoItem[] items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView convo: ConvoWithDetails
error: undefined error: undefined
sender: ChatBskyActorDefs.ProfileViewBasic
recipients: ChatBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean isFetchingHistory: boolean
hasAllHistory: boolean hasAllHistory: boolean
deleteMessage: DeleteMessage deleteMessage: DeleteMessage
@@ -283,9 +237,6 @@ export type ConvoStateDisabled = {
markConvoAccepted: MarkConvoAccepted markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction addReaction: AddReaction
removeReaction: RemoveReaction removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
} }
export type ConvoState = export type ConvoState =
| ConvoStateUninitialized | ConvoStateUninitialized
@@ -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<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
)
const prevListEntries = queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({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<ChatBskyConvoDefs.ConvoView>(
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<ChatBskyConvoListConvos.OutputSchema>
>({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<ChatBskyActorDefs.ProfileViewBasic[]>(
listConvoMembersQueryKey(convoId),
prev => {
if (!prev) return
return [...prev, ...optimisticMembers]
},
)
return {prevConvo, prevListEntries, prevMemberList}
},
onSuccess: data => {
if (convoId) {
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
data.convo,
)
queryClient.setQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({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)
},
})
}
+1 -1
View File
@@ -16,7 +16,7 @@ import {
RQKEY_ROOT as LIST_CONVOS_KEY, RQKEY_ROOT as LIST_CONVOS_KEY,
} from './list-conversations' } from './list-conversations'
const RQKEY_ROOT = 'convo' export const RQKEY_ROOT = 'convo'
export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId] export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId]
export function useConvoQuery({convoId}: {convoId: string}) { export function useConvoQuery({convoId}: {convoId: string}) {
@@ -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)
},
})
}
@@ -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)
},
})
}
@@ -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)
},
})
}
@@ -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)
},
})
}
@@ -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)
},
})
}
@@ -7,7 +7,10 @@ import {STALE} from '..'
const RQKEY_ROOT = 'convo-availability' const RQKEY_ROOT = 'convo-availability'
export const RQKEY = (did: string) => [RQKEY_ROOT, did] 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() const agent = useAgent()
return useQuery({ return useQuery({
@@ -21,5 +24,6 @@ export function useGetConvoAvailabilityQuery(did: string) {
return data return data
}, },
staleTime: STALE.INFINITY, staleTime: STALE.INFINITY,
enabled,
}) })
} }
@@ -71,7 +71,7 @@ export function useLeaveConvo(
return {prevPages} return {prevPages}
}, },
onSuccess: data => { onSuccess: data => {
queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
onSuccess?.(data) onSuccess?.(data)
}, },
onError: (error, _, context) => { 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) onError?.(error)
}, },
}) })
@@ -105,8 +105,8 @@ export function ListConvosProviderInner({
const debouncedRefetch = useMemo(() => { const debouncedRefetch = useMemo(() => {
const refetchAndInvalidate = () => { const refetchAndInvalidate = () => {
refetch() void refetch()
queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]}) void queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
} }
return throttle(refetchAndInvalidate, 500, { return throttle(refetchAndInvalidate, 500, {
leading: true, leading: true,
@@ -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<ChatBskyActorDefs.ProfileViewBasic[]>(
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<ChatBskyConvoDefs.SystemMessageDataAddMember>(
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<ChatBskyConvoDefs.SystemMessageDataRemoveMember>(
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<ChatBskyActorDefs.ProfileViewBasic, void> {
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
}
}
}
}
@@ -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)
},
})
}
+12 -60
View File
@@ -1,18 +1,12 @@
import { import {type ChatBskyConvoMuteConvo} from '@atproto/api'
type ChatBskyConvoDefs, import {useMutation, useQueryClient} from '@tanstack/react-query'
type ChatBskyConvoListConvos,
type ChatBskyConvoMuteConvo,
} from '@atproto/api'
import {
type InfiniteData,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants' import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {RQKEY as CONVO_KEY} from './conversation' import {
import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' rollbackConvoOptimistic,
updateConvoOptimistic,
} from './utils/convo-cache'
export function useMuteConvo( export function useMuteConvo(
convoId: string | undefined, convoId: string | undefined,
@@ -46,59 +40,17 @@ export function useMuteConvo(
}, },
onMutate: ({mute}) => { onMutate: ({mute}) => {
if (!convoId) return if (!convoId) return
return updateConvoOptimistic(queryClient, convoId, prev => ({
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>( ...prev,
CONVO_KEY(convoId), muted: mute,
) }))
const prevListEntries = queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]})
// Update for a single chat thread
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
prev => {
if (!prev) return
return {
...prev,
muted: mute,
}
},
)
// Update for the chat list
queryClient.setQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({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}
}, },
onSuccess: data => { onSuccess: data => {
onSuccess?.(data) onSuccess?.(data)
}, },
onError: (e, _variables, context) => { onError: (e, _variables, context) => {
if (context?.prevConvo && convoId) { if (convoId && context) {
queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo) rollbackConvoOptimistic(queryClient, convoId, context)
}
if (context?.prevListEntries) {
for (const [key, data] of context.prevListEntries) {
queryClient.setQueryData(key, data)
}
} }
onError?.(e) onError?.(e)
}, },
@@ -1,7 +1,8 @@
import { import {
ChatBskyConvoDefs, type ChatBskyActorDefs,
type ChatBskyConvoDefs,
type ChatBskyConvoListConvos, type ChatBskyConvoListConvos,
type ChatBskyGroupEditGroup, type ChatBskyGroupRemoveMembers,
} from '@atproto/api' } from '@atproto/api'
import { import {
type InfiniteData, type InfiniteData,
@@ -14,14 +15,15 @@ import {logger} from '#/logger'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {RQKEY as CONVO_KEY} from './conversation' import {RQKEY as CONVO_KEY} from './conversation'
import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' 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, convoId: string | undefined,
{ {
onSuccess, onSuccess,
onError, onError,
}: { }: {
onSuccess?: (data: ChatBskyGroupEditGroup.OutputSchema) => void onSuccess?: (data: ChatBskyGroupRemoveMembers.OutputSchema) => void
onError?: (error: Error) => void onError?: (error: Error) => void
}, },
) { ) {
@@ -29,15 +31,15 @@ export function useEditGroupName(
const agent = useAgent() const agent = useAgent()
return useMutation({ return useMutation({
mutationFn: async ({name: groupName}: {name: string}) => { mutationFn: async ({members}: {members: string[]}) => {
if (!convoId) throw new Error('No convoId provided') if (!convoId) throw new Error('No convoId provided')
const {data} = await agent.chat.bsky.group.editGroup( const {data} = await agent.chat.bsky.group.removeMembers(
{convoId, name: groupName}, {convoId, members},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
) )
return data return data
}, },
onMutate: ({name: groupName}) => { onMutate: ({members}) => {
if (!convoId) return if (!convoId) return
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>( const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
@@ -46,24 +48,21 @@ export function useEditGroupName(
const prevListEntries = queryClient.getQueriesData< const prevListEntries = queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema> InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]}) >({queryKey: [CONVO_LIST_KEY]})
const prevMemberList = queryClient.getQueryData<
ChatBskyActorDefs.ProfileViewBasic[]
>(listConvoMembersQueryKey(convoId))
// Update for a single chat thread
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>( queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId), CONVO_KEY(convoId),
prev => { prev => {
if (!prev) return if (!prev) return
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return prev
return { return {
...prev, ...prev,
kind: { members: prev.members.filter(m => !members.includes(m.did)),
...prev.kind,
name: groupName,
},
} }
}, },
) )
// Update for the chat list
queryClient.setQueriesData< queryClient.setQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema> InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]}, prev => { >({queryKey: [CONVO_LIST_KEY]}, prev => {
@@ -74,20 +73,24 @@ export function useEditGroupName(
...page, ...page,
convos: page.convos.map(convo => { convos: page.convos.map(convo => {
if (convo.id !== convoId) return convo if (convo.id !== convoId) return convo
if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo
return { return {
...convo, ...convo,
kind: { members: convo.members.filter(m => !members.includes(m.did)),
...convo.kind,
name: groupName,
},
} }
}), }),
})), })),
} }
}) })
return {prevConvo, prevListEntries} queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>(
listConvoMembersQueryKey(convoId),
prev => {
if (!prev) return
return prev.filter(m => !members.includes(m.did))
},
)
return {prevConvo, prevListEntries, prevMemberList}
}, },
onSuccess: data => { onSuccess: data => {
onSuccess?.(data) onSuccess?.(data)
@@ -102,6 +105,12 @@ export function useEditGroupName(
queryClient.setQueryData(key, data) queryClient.setQueryData(key, data)
} }
} }
if (context?.prevMemberList && convoId) {
queryClient.setQueryData(
listConvoMembersQueryKey(convoId),
context.prevMemberList,
)
}
onError?.(e) onError?.(e)
}, },
}) })
@@ -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<ChatBskyConvoListConvos.OutputSchema> | 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<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
)
const prevListEntries = queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]})
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
prev => {
if (!prev) return
const next = updater(prev)
return next ?? prev
},
)
queryClient.setQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({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)
}
}