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 GitHub
parent e832791367
commit cdb8d4bfb8
63 changed files with 3517 additions and 1844 deletions
+2
View File
@@ -66,6 +66,7 @@ module.exports = function (_config) {
infoPlist: {
CADisableMinimumFrameDurationOnPhone: true,
UIBackgroundModes: ['remote-notification'],
NSUserActivityTypes: ['INSendMessageIntent'],
NSCameraUsageDescription:
'Used for profile pictures, posts, and other kinds of content.',
NSMicrophoneUsageDescription:
@@ -123,6 +124,7 @@ module.exports = function (_config) {
'com.apple.developer.kernel.increased-memory-limit': true,
'com.apple.developer.kernel.extended-virtual-addressing': true,
'com.apple.security.application-groups': 'group.app.bsky',
'com.apple.developer.usernotifications.communication': true,
// 'com.apple.developer.device-information.user-assigned-device-name': true,
},
privacyManifests: {
@@ -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>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
</dict>
<key>MainAppScheme</key>
<string>bluesky</string>
+84 -7
View File
@@ -1,5 +1,6 @@
import UserNotifications
import UIKit
import Intents
let APP_GROUP = "group.app.bsky"
typealias ContentHandler = (UNNotificationContent) -> Void
@@ -40,17 +41,18 @@ class NotificationService: UNNotificationServiceExtension {
}
self.bestAttempt = bestAttempt
if reason == "chat-message" {
if reason == "chat-message" || reason == "chat-reaction" {
mutateWithChatMessage(bestAttempt)
let finalContent = createCommunicationNotification(
from: bestAttempt,
userInfo: request.content.userInfo
)
contentHandler(finalContent)
} else {
mutateWithBadge(bestAttempt)
contentHandler(bestAttempt)
}
// Any image downloading (or other network tasks) should be handled at the end
// of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire
// gets called, we might not have all the needed mutations completed in time.
contentHandler(bestAttempt)
}
override func serviceExtensionTimeWillExpire() {
@@ -61,6 +63,81 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(bestAttempt)
}
// MARK: Communication Notification
func createCommunicationNotification(
from content: UNMutableNotificationContent,
userInfo: [AnyHashable: Any]
) -> UNNotificationContent {
let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown"
let convoId = userInfo["convoId"] as? String
var avatarImage: INImage? = nil
if let avatarUrlString = userInfo["senderAvatarUrl"] as? String {
avatarImage = downloadAvatarImage(from: avatarUrlString)
}
let senderHandleValue = userInfo["senderHandle"] as? String
let senderHandle = INPersonHandle(value: senderHandleValue, type: .unknown)
let sender = INPerson(
personHandle: senderHandle,
nameComponents: nil,
displayName: senderDisplayName,
image: avatarImage,
contactIdentifier: nil,
customIdentifier: nil
)
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: nil,
conversationIdentifier: convoId,
serviceName: nil,
sender: sender,
attachments: nil
)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)
do {
return try content.updating(from: intent)
} catch {
return content
}
}
func downloadAvatarImage(from urlString: String) -> INImage? {
let thumbnailUrlString = urlString.replacingOccurrences(
of: "/img/avatar/",
with: "/img/avatar_thumbnail/"
)
guard let url = URL(string: thumbnailUrlString) else { return nil }
var request = URLRequest(url: url)
request.timeoutInterval = 5
var imageData: Data? = nil
let semaphore = DispatchSemaphore(value: 0)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data,
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
imageData = data
}
semaphore.signal()
}
task.resume()
semaphore.wait()
guard let data = imageData else { return nil }
return INImage(imageData: data)
}
// MARK: Mutations
func mutateWithBadge(_ content: UNMutableNotificationContent) {
@@ -13,7 +13,7 @@ class BackgroundNotificationHandler(
return
}
if (remoteMessage.data["reason"] == "chat-message") {
if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") {
mutateWithChatMessage(remoteMessage)
} else {
mutateWithOtherReason(remoteMessage)
+6 -1
View File
@@ -556,7 +556,11 @@ export type Events = {
| 'FindContacts'
}
'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
logContext:
| 'ProfileHeader'
| 'NewChatDialog'
| 'SendViaChatDialog'
| 'ConvoSettings'
}
'chat:open': {
logContext:
@@ -564,6 +568,7 @@ export type Events = {
| 'NewChatDialog'
| 'ChatsList'
| 'SendViaChatDialog'
| 'ConvoSettings'
}
'groupchat:create': {
logContext: 'NewChatDialog'
+87 -177
View File
@@ -1,8 +1,7 @@
import {useCallback, useEffect} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {useEffect} from 'react'
import {View} from 'react-native'
import Animated, {
Easing,
interpolate,
type SharedValue,
useAnimatedStyle,
useSharedValue,
@@ -16,44 +15,32 @@ import {atoms as a, useTheme} from '#/alf'
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
import type * as bsky from '#/types/bsky'
type Layout = {
size: number
x: number
y: number
zIndex?: number
border?: boolean
}
type Props = {
animate?: boolean
profiles: bsky.profile.AnyProfileView[]
size?: 'small' | 'medium' | 'large' | number
size?: number
}
export function AvatarBubbles({
animate = false,
profiles: allProfiles,
size = 'large',
size = 120,
}: Props) {
const {currentAccount} = useSession()
const profiles =
allProfiles.length > 2
? allProfiles.filter(p => p.did !== currentAccount?.did)
: allProfiles
const containerSize =
typeof size === 'number'
? size
: size === 'small'
? 40
: size === 'medium'
? 56
: 120
const scale =
typeof size === 'number'
? size / 120
: size === 'small'
? 40 / 120
: size === 'medium'
? 56 / 120
: 1
const marginOffset =
(typeof size === 'number' && size < 120) ||
size === 'small' ||
size === 'medium'
? -2
: 0
const scale = profiles.length <= 1 ? 1 : size / 120
const marginOffset = size < 120 ? -2 : 0
const initialValue = animate ? 0 : 1
const p0 = useSharedValue(initialValue)
@@ -61,130 +48,50 @@ export function AvatarBubbles({
const p2 = useSharedValue(initialValue)
const p3 = useSharedValue(initialValue)
const animateScale = (p: Animated.SharedValue<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(() => {
if (!animate) return
playScaleAnimation()
}, [animate, playScaleAnimation])
const animateBubble = (p: SharedValue<number>, i: number) => {
p.set(0)
p.set(() =>
withDelay(
500 + i * 100,
withTiming(1, {
duration: 250,
easing: Easing.out(Easing.back(1.75)),
}),
),
)
}
animateBubble(p0, 0)
animateBubble(p1, 1)
animateBubble(p2, 2)
animateBubble(p3, 3)
}, [animate, p0, p1, p2, p3])
let avatars = (
<>
<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} />
</>
)
}
const scales = [p0, p1, p2, p3]
const layouts = getLayouts(profiles.length)
return (
<Animated.View
style={[
a.p_2xs,
{
height: containerSize,
width: containerSize,
},
]}>
<Animated.View style={[a.p_2xs, {height: size, width: size}]}>
<View
style={[
{
marginTop: marginOffset,
marginLeft: marginOffset,
transform: [{scale}],
transformOrigin: 'top left',
},
]}>
{avatars}
style={{
marginTop: marginOffset,
marginLeft: marginOffset,
transform: [{scale}],
transformOrigin: 'top left',
}}>
{layouts.map((layout, i) => (
<AvatarBubble
key={i}
profile={profiles[i]}
scale={scales[i]}
size={layout.size}
x={layout.x}
y={layout.y}
zIndex={layout.zIndex}
includeProfileBorder={layout.border}
/>
))}
</View>
</Animated.View>
)
@@ -194,27 +101,23 @@ function AvatarBubble({
profile,
scale,
size,
style,
x,
y,
zIndex,
includeProfileBorder,
}: {
profile?: bsky.profile.AnyProfileView
scale: SharedValue<number>
size: number
style?: StyleProp<ViewStyle>
x: number
y: number
zIndex?: number
includeProfileBorder?: boolean
}) {
const t = useTheme()
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{translateX: x},
{translateY: y},
{scale: interpolate(scale.get(), [0, 1], [0, 1])},
],
transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
}))
return (
@@ -227,11 +130,17 @@ function AvatarBubble({
borderColor: t.atoms.text_inverted.color,
borderWidth: 2,
},
style,
zIndex != null && {zIndex},
animatedStyle,
]}>
{profile ? (
<Avatar profile={profile} size={size} />
<UserAvatar
avatar={profile.avatar}
size={size}
type="user"
hideLiveBadge
noBorder
/>
) : (
<AvatarPlaceholder size={size} />
)}
@@ -239,25 +148,7 @@ function AvatarBubble({
)
}
function Avatar({
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}) {
function AvatarPlaceholder({size}: {size: number}) {
const t = useTheme()
return (
@@ -267,10 +158,7 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_200,
{
width: size,
height: size,
},
{width: size, height: size},
]}>
<PersonIcon
width={size * 0.5}
@@ -280,3 +168,25 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
</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
pressed: boolean
disabled: boolean
/**
* Alias for hovered || focused || pressed
*/
interacting: boolean
}
export type ButtonContext = VariantProps & ButtonState
@@ -120,6 +124,7 @@ const Context = createContext<VariantProps & ButtonState>({
focused: false,
pressed: false,
disabled: false,
interacting: false,
})
Context.displayName = 'ButtonContext'
@@ -536,6 +541,7 @@ export const Button = forwardRef<View, ButtonProps>(
const context = useMemo<ButtonContext>(
() => ({
...state,
interacting: state.hovered || state.focused || state.pressed,
variant,
color,
size,
+6 -6
View File
@@ -201,7 +201,7 @@ export function AvatarPlaceholder({size = 40}: {size?: number}) {
<View
style={[
a.rounded_full,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
{
width: size,
height: size,
@@ -348,7 +348,7 @@ export function NameAndHandlePlaceholder() {
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
{
width: '60%',
height: 14,
@@ -359,7 +359,7 @@ export function NameAndHandlePlaceholder() {
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
{
width: '40%',
height: 10,
@@ -377,7 +377,7 @@ export function NamePlaceholder({style}: ViewStyleProp) {
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
{
width: '60%',
height: 14,
@@ -439,7 +439,7 @@ export function DescriptionPlaceholder({
style={[
a.rounded_xs,
a.w_full,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
]}
/>
@@ -600,7 +600,7 @@ export function FollowButtonPlaceholder({style}: ViewStyleProp) {
<View
style={[
a.rounded_sm,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
a.w_full,
{
height: 33,
@@ -512,7 +512,7 @@ function ExistingChatCard({
]}>
<ProfileCard.Header>
{convo.kind === 'group' ? (
<AvatarBubbles profiles={convo.members} size="small" />
<AvatarBubbles profiles={convo.members} size={40} />
) : (
<ProfileCard.Avatar
profile={convo.primaryMember}
+7 -1
View File
@@ -4,23 +4,29 @@ import {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
import type * as bsky from '#/types/bsky'
export function ActionsWrapper({
message,
isFromSelf,
senderProfile,
children,
onTap,
}: {
message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean
isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView
children: React.ReactNode
onTap?: () => void
}) {
const {t: l} = useLingui()
return (
<MessageContextMenu message={message} onTap={onTap}>
<MessageContextMenu
message={message}
senderProfile={senderProfile}
onTap={onTap}>
{trigger =>
// will always be true, since this file is platform split
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 {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky'
import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util'
@@ -17,12 +18,14 @@ export function ActionsWrapper({
message,
hasReactions,
isFromSelf,
senderProfile,
children,
onTap,
}: {
message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean
isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView
children: React.ReactNode
onTap?: () => void
}) {
@@ -114,7 +117,7 @@ export function ActionsWrapper({
)
}}
</EmojiReactionPicker>
<MessageContextMenu message={message}>
<MessageContextMenu message={message} senderProfile={senderProfile}>
{({props, state, IS_NATIVE, control}) => {
// always false, file is platform split
if (IS_NATIVE) return null
+14 -5
View File
@@ -98,11 +98,16 @@ function reducer(state: State, action: Action): State {
}
export function AddMembersFlow({
members,
title,
onAddMembers,
}: {
members: string[]
title: string
onAddMembers: (dids: string[]) => void
onAddMembers: (
dids: string[],
profiles: bsky.profile.AnyProfileView[],
) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
@@ -154,7 +159,11 @@ export function AddMembersFlow({
} else if (searchText.length) {
if (results?.length) {
for (const profile of results) {
if (profile.did === currentAccount?.did) continue
if (
profile.did === currentAccount?.did ||
members.includes(profile.did)
)
continue
_items.push({
type: 'profile',
key: profile.did,
@@ -202,7 +211,7 @@ export function AddMembersFlow({
}
return _items
}, [isError, searchText, l, results, currentAccount?.did, follows])
}, [isError, searchText, l, results, currentAccount?.did, members, follows])
if (searchText && !isFetching && !items.length && !isError) {
items.push({type: 'empty', key: 'empty', message: l`No results`})
@@ -213,8 +222,8 @@ export function AddMembersFlow({
}, [control])
const handlePressAdd = useCallback(() => {
onAddMembers(groupChatDids)
}, [groupChatDids, onAddMembers])
onAddMembers(groupChatDids, groupChatProfiles)
}, [groupChatDids, groupChatProfiles, onAddMembers])
const renderItems = useCallback(
({item}: {item: Item}) => {
+1 -1
View File
@@ -4,7 +4,7 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {subDays} from 'date-fns'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '../Typography'
import {Text} from '#/components/Typography'
import {localDateString} from './util'
const timeFormatter = new Intl.DateTimeFormat(undefined, {
+6 -5
View File
@@ -25,15 +25,18 @@ import {usePromptControl} from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import type * as bsky from '#/types/bsky'
import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util'
export let MessageContextMenu = ({
message,
senderProfile,
children,
onTap,
}: {
message: ChatBskyConvoDefs.MessageView
senderProfile?: bsky.profile.AnyProfileView
children: TriggerProps['children']
onTap?: () => void
}): React.ReactNode => {
@@ -110,9 +113,7 @@ export let MessageContextMenu = ({
[l, convo, message, currentAccount?.did],
)
const sender = convo.convo.members.find(
member => member.did === message.sender.did,
)
const sender = senderProfile
return (
<>
@@ -183,7 +184,7 @@ export let MessageContextMenu = ({
control={reportControl}
subject={{
view: 'message',
convoId: convo.convo.id,
convoId: convo.convo.view.id,
message,
}}
onAfterSubmit={() => {
@@ -197,7 +198,7 @@ export let MessageContextMenu = ({
control={blockOrDeleteControl}
currentScreen="conversation"
params={{
convoId: convo.convo.id,
convoId: convo.convo.view.id,
message,
}}
/>
+19 -15
View File
@@ -30,7 +30,6 @@ import {useQueryClient} from '@tanstack/react-query'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links'
import {useConvoActive} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
@@ -43,7 +42,6 @@ import {InlineLinkText, Link} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {DateDivider} from './DateDivider'
import {useDateDividerToggle} from './DateDividerToggle'
import {MessageItemEmbed} from './MessageItemEmbed'
@@ -93,19 +91,18 @@ function isWithinClusterBoundary({
let MessageItem = ({
item,
isGroupChat = false,
profile,
}: {
item: ConvoItem & {type: 'message' | 'pending-message'}
isGroupChat?: boolean
profile?: bsky.profile.AnyProfileView
}): React.ReactNode => {
const t = useTheme()
const {currentAccount} = useSession()
const {t: l} = useLingui()
const {convo} = useConvoActive()
const moderationOpts = useModerationOpts()
const queryClient = useQueryClient()
const profile = item.relatedProfiles.get(item.message.sender.did)
const reactionsControl = useDialogControl()
const reactionTapRef = useRef(false)
@@ -277,9 +274,7 @@ let MessageItem = ({
return l`You reacted ${reaction.value}`
} else {
const senderDid = reaction.sender.did
const memberSender = convo.members.find(
member => member.did === senderDid,
)
const memberSender = item.relatedProfiles.get(senderDid)
if (memberSender) {
return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}`
}
@@ -290,7 +285,13 @@ let MessageItem = ({
one: '# person',
other: '# people',
})} reacted ${groupedReactions.map(g => g.value).join(' ')}`
}, [reactions, groupedReactions, currentAccount?.did, convo.members, l])
}, [
reactions,
groupedReactions,
currentAccount?.did,
item.relatedProfiles,
l,
])
const appliedReactions = (
<LayoutAnimationConfig skipEntering skipExiting>
@@ -375,7 +376,7 @@ let MessageItem = ({
) : null}
<ReactionsDialog
control={reactionsControl}
members={convo.members}
relatedProfiles={item.relatedProfiles}
message={message}
reactions={message.reactions}
groupedReactions={groupedReactions}
@@ -391,11 +392,13 @@ let MessageItem = ({
return (
<>
{(hasLargeGapFromPrev || isDateDividerToggled) && (
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
<DateDivider date={message.sentAt} />
</Animated.View>
)}
<LayoutAnimationConfig skipExiting skipEntering>
{(hasLargeGapFromPrev || isDateDividerToggled) && (
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
<DateDivider date={message.sentAt} />
</Animated.View>
)}
</LayoutAnimationConfig>
<View style={[messageInset, effectiveFirstInCluster && a.mt_md]}>
<View style={[a.relative]}>
{showAvatar ? (
@@ -434,6 +437,7 @@ let MessageItem = ({
hasReactions={hasReactions}
isFromSelf={isFromSelf}
message={message}
senderProfile={profile}
onTap={() => {
if (reactionTapRef.current) return
if (!hasLargeGapFromPrev) {
+4 -1
View File
@@ -161,11 +161,13 @@ function GroupHeaderReady({
})
}
const lockStatus = convo.details.lockStatus
return (
<Wrapper
heading={
<>
<AvatarBubbles size="small" profiles={convo.members} />
<AvatarBubbles size={40} profiles={convo.members} />
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
{convo.details.name}
</Text>
@@ -175,6 +177,7 @@ function GroupHeaderReady({
settings={
<Button
label={l`Open group chat settings`}
disabled={lockStatus === 'locked-permanently'}
size="small"
color="secondary"
shape="round"
+4 -4
View File
@@ -7,7 +7,7 @@ import {
View,
} from 'react-native'
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 {HITSLOP_10} from '#/lib/constants'
@@ -33,13 +33,13 @@ type Reaction = {
export function ReactionsDialog({
control,
members,
relatedProfiles,
message,
reactions,
groupedReactions,
}: {
control: Dialog.DialogControlProps
members: bsky.profile.AnyProfileView[]
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
message: ChatBskyConvoDefs.MessageView
reactions?: ChatBskyConvoDefs.ReactionView[]
groupedReactions?: Reaction[]
@@ -100,7 +100,7 @@ export function ReactionsDialog({
return 0
})
.map(reaction => {
const sender = members.find(m => m.did === reaction.sender.did)
const sender = relatedProfiles.get(reaction.sender.did)
if (!sender) return null
return (
<ReactionRow
+3 -3
View File
@@ -23,15 +23,15 @@ export type SystemMessageInfo = {
function getReferredDisplayName(
user: ChatBskyConvoDefs.SystemMessageReferredUser,
relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[],
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>,
): string | null {
const profile = relatedProfiles.find(p => p.did === user.did)
const profile = relatedProfiles.get(user.did)
return profile ? createSanitizedDisplayName(profile) : null
}
export function getSystemMessageInfo(
data: ChatBskyConvoDefs.SystemMessageView['data'],
relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[],
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>,
): SystemMessageInfo | null {
if (ChatBskyConvoDefs.isSystemMessageDataAddMember(data)) {
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} & (
| {
kind: 'group'
details: ChatBskyConvoDefs.GroupConvo
details: $Typed<ChatBskyConvoDefs.GroupConvo>
primaryMember: GroupConvoMember // the owner
members: Array<GroupConvoMember>
}
| {
kind: 'direct'
details: ChatBskyConvoDefs.DirectConvo
details: $Typed<ChatBskyConvoDefs.DirectConvo>
primaryMember: DirectConvoMember // the other user
members: Array<DirectConvoMember>
}
+11 -1
View File
@@ -81,6 +81,7 @@ export type ItemProps = ViewStyleProp & {
isInvalid?: boolean
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
hitSlop?: PressableProps['hitSlop']
highlightRow?: boolean
}
export function useItemContext() {
@@ -160,8 +161,11 @@ export function Item({
style,
type = 'checkbox',
label,
highlightRow,
...rest
}: ItemProps) {
const t = useTheme()
const {
values: selectedValues,
type: groupType,
@@ -207,6 +211,12 @@ export function Item({
[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 (
<ItemContext.Provider value={state}>
<Pressable
@@ -232,7 +242,7 @@ export function Item({
onPressOut={onPressOut}
onFocus={onFocus}
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}
</Pressable>
</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',
})
/**
* @deprecated Use EditBig_Stroke2_Corner2_Rounded
*/
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',
})
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({
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'
/**
* @deprecated Use SquareBehindSquare_Stroke2_Corner2_Rounded
*/
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',
})
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'
| 'quote'
| 'chat-message'
| 'chat-reaction'
| 'starterpack-joined'
| 'like-via-repost'
| 'repost-via-repost'
@@ -44,7 +45,7 @@ export type NotificationReason =
export type NotificationPayload =
| undefined
| {
reason: Exclude<NotificationReason, 'chat-message'>
reason: Exclude<NotificationReason, 'chat-message' | 'chat-reaction'>
uri: string
subject: string
recipientDid: string
@@ -55,6 +56,12 @@ export type NotificationPayload =
messageId: string
recipientDid: string
}
| {
reason: 'chat-reaction'
convoId: string
messageId: string
recipientDid: string
}
const DEFAULT_HANDLER_OPTIONS = {
shouldShowBanner: false,
@@ -192,8 +199,11 @@ export function useNotificationsHandler() {
const handleNotification = (payload?: NotificationPayload) => {
if (!payload) return
if (payload.reason === 'chat-message') {
logger.debug(`useNotificationsHandler: handling chat message`, {
if (
payload.reason === 'chat-message' ||
payload.reason === 'chat-reaction'
) {
logger.debug(`useNotificationsHandler: handling chat notification`, {
payload,
})
@@ -270,7 +280,8 @@ export function useNotificationsHandler() {
logger.debug('useNotificationsHandler: incoming', {e, payload})
if (
payload.reason === 'chat-message' &&
(payload.reason === 'chat-message' ||
payload.reason === 'chat-reaction') &&
payload.recipientDid === currentAccount?.did
) {
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 currentAccount changes, we should try to handle it again.
if (
storedAccountSwitchPayload?.reason === 'chat-message' &&
(storedAccountSwitchPayload?.reason === 'chat-message' ||
storedAccountSwitchPayload?.reason === 'chat-reaction') &&
currentAccount?.did === storedAccountSwitchPayload.recipientDid
) {
handleNotification(storedAccountSwitchPayload)
@@ -429,6 +441,7 @@ export function notificationToURL(payload: NotificationPayload): string | null {
return `/profile/${urip.host}`
}
case 'chat-message':
case 'chat-reaction':
// should be handled separately
return null
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
if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) {
const info = getSystemMessageInfo(convo.lastMessage.data, convo.members)
const info = getSystemMessageInfo(
convo.lastMessage.data,
new Map(convo.members.map(m => [m.did, m])),
)
if (info) {
lastMessage = i18n._(info.message)
lastMessageSentAt = convo.lastMessage.sentAt
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
import {type ActiveConvoStates} from '#/state/messages/convo'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {KnownFollowers} from '#/components/KnownFollowers'
@@ -16,16 +15,15 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
const t = useTheme()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const {currentAccount} = useSession()
const leaveConvoControl = usePromptControl()
const onAcceptChat = useCallback(() => {
convoState.markConvoAccepted()
}, [convoState])
const otherUser = convoState.recipients.find(
user => user.did !== currentAccount?.did,
)
// either the other person, or the chat owner
// if we ever allow someone other than the owner to invite people, this will need to change
const otherUser = convoState.convo.primaryMember
if (!moderationOpts) {
return null
@@ -44,7 +42,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
{otherUser && (
<RejectMenu
label={_(msg`Block or report`)}
convo={convoState.convo}
convo={convoState.convo.view}
profile={otherUser}
color="negative_subtle"
size="small"
@@ -53,14 +51,14 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
)}
<DeleteChatButton
label={_(msg`Delete`)}
convo={convoState.convo}
convo={convoState.convo.view}
color="secondary"
size="small"
currentScreen="conversation"
onPress={leaveConvoControl.open}
/>
<LeaveConvoPrompt
convoId={convoState.convo.id}
convoId={convoState.convo.view.id}
control={leaveConvoControl}
currentScreen="conversation"
hasMessages={false}
@@ -69,7 +67,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
<View style={[a.w_full, a.flex_row]}>
<AcceptChatButton
onAcceptConvo={onAcceptChat}
convo={convoState.convo}
convo={convoState.convo.view}
color="primary_subtle"
size="small"
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(() => {
if (hasScrolled && prevContentHeight.current > layoutHeight.get()) {
void convoState.fetchMessageHistory()
}
}, [convoState, hasScrolled, layoutHeight])
void convoState.fetchMessageHistory()
}, [convoState])
const onScroll = useCallback(
(e: ScrollEvent) => {
@@ -376,10 +374,7 @@ export function MessagesList({
return (
<MessageItem
item={item}
profile={convoState.convo.members.find(
member => member.did === item.message.sender.did,
)}
isGroupChat={convoState.isGroup()}
isGroupChat={convoState.convo.kind === 'group'}
/>
)
} else if (item.type === 'deleted-message') {
@@ -448,8 +443,9 @@ export function MessagesList({
ListHeaderComponent={
<>
<MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.isGroup() && convoState.hasAllHistory ? (
<MessagesListInfoPanel convoState={convoState} />
{convoState.convo?.kind === 'group' &&
convoState.hasAllHistory ? (
<MessagesListInfoPanel convo={convoState.convo} />
) : null}
</>
}
@@ -577,7 +573,7 @@ function getFooterState(
}
}
if (convoState.convo.status === 'request' && !hasAcceptOverride) {
if (convoState.convo.view.status === 'request' && !hasAcceptOverride) {
return 'request'
}
@@ -1,72 +1,90 @@
import {View} from 'react-native'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {type ConvoState} from '#/state/messages/convo/types'
import {logger} from '#/logger'
import {useAddGroupMembers} from '#/state/queries/messages/add-group-members'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
import {type ConvoWithDetails} from '#/components/dms/util'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {InviteLinkDialog} from './InviteLinkDialog'
export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
export function MessagesListInfoPanel({
convo,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
}) {
const t = useTheme()
const {t: l} = useLingui()
const addMembersControl = Dialog.useDialogControl()
const inviteLinkControl = Dialog.useDialogControl()
const {currentAccount} = useSession()
const isOwner =
currentAccount?.did == null
? false
: convoState.getPrimaryMember?.()?.did === currentAccount.did
// TODO Get this from @api/atproto - dsb
const isLinkEnabled = false
const convoId = convo.view.id
const {mutate: addGroupMembers} = useAddGroupMembers(convoId, {
onSuccess: () => {
addMembersControl.close()
},
onError: e => {
logger.error('Failed to add group chat members', {message: e})
Toast.show(l`Failed to add members`, {type: 'error'})
},
})
const groupName = convoState.getGroupInfo?.()?.name
// TODO Enable this once the feature is working end-to-end. -dsb
// const joinLink = groupConvo?.details.joinLink
const isJoinLinkEnabled = false
// (isOwner && groupConvo) ||
// (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled')
const members = (convoState?.convo?.members ?? []).filter(
const isOwner = convo?.primaryMember.did === currentAccount?.did
const members = (convo?.members ?? []).filter(
profile => profile.did !== currentAccount?.did,
)
let names: React.ReactNode | null = null
let names: React.ReactNode = null
if (members.length === 1) {
names = <Trans>New chat with {members[0].displayName}</Trans>
}
if (members.length === 2) {
} else if (members.length === 2) {
names = (
<Trans>
New chat with {members[0].displayName} and {members[1].displayName}
</Trans>
)
}
if (members.length > 2) {
} else if (members.length > 2) {
const memberCount = convo.details.memberCount - 2
names = (
<Trans>
New chat with {members[0].displayName}, {members[1].displayName}, and{' '}
<Plural
value={members.length - 2}
one={`${members.length - 2} more`}
other={`${members.length - 2} more`}
value={memberCount}
one={`${memberCount} more`}
other={`${memberCount} more`}
/>
.
</Trans>
)
}
const showButtons = isOwner || isLinkEnabled
const showButtons = isOwner || isJoinLinkEnabled
return (
<>
<View style={[a.align_center, a.justify_center]}>
<AvatarBubbles animate={true} profiles={members} />
{groupName ? (
<AvatarBubbles animate={true} profiles={convo?.members} />
{convo.details.name ? (
<Text style={[a.text_2xl, a.font_bold, a.mt_lg, t.atoms.text]}>
{groupName}
{convo.details.name}
</Text>
) : null}
{names ? (
@@ -102,12 +120,16 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
</ButtonText>
</Button>
) : null}
{isOwner || isLinkEnabled ? (
{isJoinLinkEnabled ? (
<Button
color="secondary"
size="small"
label={l`Click here to view or create an invite link for this group chat`}
onPress={() => {}}>
label={
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} />
<ButtonText>
<Trans>Invite link</Trans>
@@ -117,17 +139,22 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
</View>
) : null}
</View>
<InviteLinkDialog
isOwner={isOwner}
convo={convo}
control={inviteLinkControl}
/>
<Dialog.Outer
control={addMembersControl}
testID="addChatMembersDialog"
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<AddMembersFlow
members={members.map(profile => profile.did)}
title={l`Add people`}
onAddMembers={(_dids: string[]) => {
// TODO Add members here
addMembersControl.close()
}}
onAddMembers={(members, profiles) =>
addGroupMembers({members, profiles})
}
/>
</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 findAllProfilesInListMembersQueryData} from '#/state/queries/list-members'
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations'
import {findAllProfilesInQueryData as findAllProfilesInMessagesQueryData} from '#/state/queries/messages/list-convo-members'
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts'
import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts'
import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed'
@@ -264,4 +265,5 @@ function* findProfilesInCache(
yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did)
yield* findAllProfilesInNotifsQueryData(queryClient, did)
yield* findAllProfilesInContactMatchesQueryData(queryClient, did)
yield* findAllProfilesInMessagesQueryData(queryClient, did)
}
+224 -192
View File
@@ -1,9 +1,10 @@
import {
type AtpAgent,
ChatBskyActorDefs,
type ChatBskyActorDefs,
ChatBskyConvoDefs,
type ChatBskyConvoGetLog,
type ChatBskyConvoSendMessage,
type ChatBskyGroupDefs,
} from '@atproto/api'
import {XRPCError} from '@atproto/api'
import {EventEmitter} from 'eventemitter3'
@@ -36,8 +37,12 @@ import {
} from '#/state/messages/convo/types'
import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type MessagesEventBusError} from '#/state/messages/events/types'
import {
type ConvoWithDetails,
type GroupConvoMember,
parseConvoView,
} from '#/components/dms/util'
import {IS_NATIVE} from '#/env'
import * as bsky from '#/types/bsky'
const logger = Logger.create(Logger.Context.ConversationAgent)
@@ -102,10 +107,8 @@ export class Convo {
{id: string; message: ChatBskyConvoSendMessage.InputSchema['message']}
> = new Map()
private deletedMessages: Set<string> = new Set()
private systemMessageProfiles: Map<
string,
ChatBskyActorDefs.ProfileViewBasic
> = new Map()
private relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> =
new Map()
private isProcessingPendingMessages = false
@@ -114,7 +117,7 @@ export class Convo {
private emitter = new EventEmitter<{event: [ConvoEvent]}>()
convoId: string
convo: ChatBskyConvoDefs.ConvoView | undefined
convo: ConvoWithDetails | undefined
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
snapshot: ConvoState | undefined
@@ -130,6 +133,7 @@ export class Convo {
this.setupPlaceholderData(params.placeholderData)
}
this.setConvo = this.setConvo.bind(this)
this.subscribe = this.subscribe.bind(this)
this.getSnapshot = this.getSnapshot.bind(this)
this.sendMessage = this.sendMessage.bind(this)
@@ -141,10 +145,10 @@ export class Convo {
this.markConvoAccepted = this.markConvoAccepted.bind(this)
this.addReaction = this.addReaction.bind(this)
this.removeReaction = this.removeReaction.bind(this)
this.isGroup = this.isGroup.bind(this)
this.getGroupInfo = this.getGroupInfo.bind(this)
this.getPrimaryMember = this.getPrimaryMember.bind(this)
this.updateGroupName = this.updateGroupName.bind(this)
this.updateGroupMembers = this.updateGroupMembers.bind(this)
this.updateJoinLink = this.updateJoinLink.bind(this)
this.updateLockStatus = this.updateLockStatus.bind(this)
}
private commit() {
@@ -172,6 +176,30 @@ export class Convo {
}
private generateSnapshot(): ConvoState {
const shared = {
isFetchingHistory: this.isFetchingHistory,
// Explicit null check since the value is initially undefined.
hasAllHistory: this.oldestRev === null,
}
const methods = {
deleteMessage: this.deleteMessage,
sendMessage: this.sendMessage,
fetchMessageHistory: this.fetchMessageHistory,
markConvoAccepted: this.markConvoAccepted,
addReaction: this.addReaction,
removeReaction: this.removeReaction,
}
const emptyMethods = {
deleteMessage: undefined,
sendMessage: undefined,
fetchMessageHistory: undefined,
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
}
switch (this.status) {
case ConvoStatus.Initializing: {
return {
@@ -179,45 +207,48 @@ export class Convo {
items: [],
convo: this.convo,
error: undefined,
sender: this.sender,
recipients: this.recipients,
isFetchingHistory: this.isFetchingHistory,
// Explicit null check since the value is initially undefined.
hasAllHistory: this.oldestRev === null,
deleteMessage: undefined,
sendMessage: undefined,
fetchMessageHistory: undefined,
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
isGroup: this.isGroup,
getGroupInfo: this.getGroupInfo,
getPrimaryMember: this.getPrimaryMember,
...shared,
...emptyMethods,
}
}
case ConvoStatus.Disabled: {
return {
status: this.status,
items: this.getItems(),
convo: this.convo!,
error: undefined,
...shared,
...methods,
}
}
case ConvoStatus.Suspended: {
return {
status: this.status,
items: this.getItems(),
convo: this.convo!,
error: undefined,
...shared,
...methods,
}
}
case ConvoStatus.Backgrounded: {
return {
status: this.status,
items: this.getItems(),
convo: this.convo!,
error: undefined,
...shared,
...methods,
}
}
case ConvoStatus.Disabled:
case ConvoStatus.Suspended:
case ConvoStatus.Backgrounded:
case ConvoStatus.Ready: {
return {
status: this.status,
items: this.getItems(),
convo: this.convo!,
error: undefined,
sender: this.sender!,
recipients: this.recipients!,
isFetchingHistory: this.isFetchingHistory,
// Explicit null check since the value is initially undefined.
hasAllHistory: this.oldestRev === null,
deleteMessage: this.deleteMessage,
sendMessage: this.sendMessage,
fetchMessageHistory: this.fetchMessageHistory,
markConvoAccepted: this.markConvoAccepted,
addReaction: this.addReaction,
removeReaction: this.removeReaction,
isGroup: this.isGroup,
getGroupInfo: this.getGroupInfo,
getPrimaryMember: this.getPrimaryMember,
...shared,
...methods,
}
}
case ConvoStatus.Error: {
@@ -226,19 +257,9 @@ export class Convo {
items: [],
convo: undefined,
error: this.error!,
sender: undefined,
recipients: undefined,
isFetchingHistory: false,
hasAllHistory: false,
deleteMessage: undefined,
sendMessage: undefined,
fetchMessageHistory: undefined,
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
isGroup: undefined,
getGroupInfo: undefined,
getPrimaryMember: undefined,
...emptyMethods,
}
}
default: {
@@ -247,20 +268,10 @@ export class Convo {
items: [],
convo: this.convo,
error: undefined,
sender: this.sender,
recipients: this.recipients,
isFetchingHistory: false,
// Explicit null check since the value is initially undefined.
hasAllHistory: this.oldestRev === null,
deleteMessage: undefined,
sendMessage: undefined,
fetchMessageHistory: undefined,
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
isGroup: this.isGroup,
getGroupInfo: this.getGroupInfo,
getPrimaryMember: this.getPrimaryMember,
...emptyMethods,
}
}
}
@@ -460,8 +471,6 @@ export class Convo {
private reset() {
this.convo = undefined
this.sender = undefined
this.recipients = undefined
this.snapshot = undefined
this.status = ConvoStatus.Uninitialized
@@ -473,7 +482,7 @@ export class Convo {
this.newMessages = new Map()
this.pendingMessages = new Map()
this.deletedMessages = new Set()
this.systemMessageProfiles = new Map()
this.relatedProfiles = new Map()
this.pendingMessageFailure = null
this.fetchMessageHistoryError = undefined
@@ -498,6 +507,26 @@ export class Convo {
}
}
private setConvo(convo: ChatBskyConvoDefs.ConvoView) {
this.convo = parseConvoView(convo, this.senderUserDid) ?? this.convo
if (this.convo) {
for (const member of this.convo.members) {
this.relatedProfiles.set(member.did, member)
}
}
}
private updateConvo(convo: Partial<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,
* but this allows us to render the convo header immediately.
@@ -505,20 +534,14 @@ export class Convo {
private setupPlaceholderData(
data: NonNullable<ConvoParams['placeholderData']>,
) {
this.convo = data.convo
this.sender = data.convo.members.find(m => m.did === this.senderUserDid)
this.recipients = data.convo.members.filter(
m => m.did !== this.senderUserDid,
)
this.setConvo(data.convo)
}
private async setup() {
try {
const {convo, sender, recipients} = await this.fetchConvo()
const {convo} = await this.fetchConvo()
this.convo = convo
this.sender = sender
this.recipients = recipients
this.setConvo(convo)
/*
* Some validation prior to `Ready` status
@@ -526,14 +549,14 @@ export class Convo {
if (!this.convo) {
throw new Error('could not find convo')
}
if (!this.sender) {
throw new Error('could not find sender in convo')
}
if (!this.recipients) {
throw new Error('could not find recipients in convo')
const self = this.convo.members.find(m => m.did === this.senderUserDid)
if (!self) {
throw new Error('could not find self in convo')
}
const userIsDisabled = Boolean(this.sender.chatDisabled)
const userIsDisabled = Boolean(self.chatDisabled)
if (userIsDisabled) {
this.dispatch({event: ConvoDispatchEvent.Disable})
@@ -602,22 +625,19 @@ export class Convo {
}
private pendingFetchConvo:
| Promise<{
convo: ChatBskyConvoDefs.ConvoView
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
recipients: ChatBskyActorDefs.ProfileViewBasic[]
}>
| Promise<{convo: ChatBskyConvoDefs.ConvoView}>
| undefined
async fetchConvo() {
if (this.pendingFetchConvo) return this.pendingFetchConvo
// non-blocking
void this.fetchMemberList()
this.pendingFetchConvo = (async () => {
try {
const response = await networkRetry(2, () => {
return this.agent.api.chat.bsky.convo.getConvo(
{
convoId: this.convoId,
},
return this.agent.chat.bsky.convo.getConvo(
{convoId: this.convoId},
{headers: DM_SERVICE_HEADERS},
)
})
@@ -626,8 +646,6 @@ export class Convo {
return {
convo,
sender: convo.members.find(m => m.did === this.senderUserDid),
recipients: convo.members.filter(m => m.did !== this.senderUserDid),
}
} finally {
this.pendingFetchConvo = undefined
@@ -639,11 +657,10 @@ export class Convo {
async refreshConvo() {
try {
const {convo, sender, recipients} = await this.fetchConvo()
void this.fetchMemberList()
const {convo} = await this.fetchConvo()
// throw new Error('UNCOMMENT TO TEST REFRESH FAILURE')
this.convo = convo || this.convo
this.sender = sender || this.sender
this.recipients = recipients || this.recipients
this.setConvo(convo)
} catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
@@ -654,11 +671,32 @@ export class Convo {
}
}
private fetchMessageHistoryError:
| {
retry: () => void
// purely for populating `this.relatedProfiles` - we do not pipe it
// into the ConvoWithDetails. If you want to drive UI based on the member list,
// use `useListConvoMembersQuery`
// we shouldn't also block loading off of this - the UI should be resilient
async fetchMemberList() {
let cursor: string | undefined
do {
const result = await networkRetry(2, () => {
return this.agent.chat.bsky.convo.getConvoMembers(
{
convoId: this.convoId,
limit: 50,
cursor,
},
{headers: DM_SERVICE_HEADERS},
)
})
cursor = result.data.cursor
for (const member of result.data.members) {
this.relatedProfiles.set(member.did, member)
}
| undefined
} while (cursor)
}
private fetchMessageHistoryError: {retry: () => void} | undefined
async fetchMessageHistory() {
logger.debug('fetch message history', {})
@@ -700,7 +738,7 @@ export class Convo {
if (relatedProfiles) {
for (const profile of relatedProfiles) {
this.systemMessageProfiles.set(profile.did, profile)
this.relatedProfiles.set(profile.did, profile)
}
}
@@ -820,6 +858,12 @@ export class Convo {
*/
this.latestRev = ev.rev
if ('relatedProfiles' in ev && Array.isArray(ev.relatedProfiles)) {
for (const profile of ev.relatedProfiles) {
this.relatedProfiles.set(profile.did, profile)
}
}
if (
ChatBskyConvoDefs.isLogCreateMessage(ev) &&
ChatBskyConvoDefs.isMessageView(ev.message)
@@ -872,14 +916,6 @@ export class Convo {
const systemView = toSystemMessageView(ev)
if (systemView) {
this.newMessages.set(systemView.id, systemView)
if (
'relatedProfiles' in ev &&
Array.isArray(ev.relatedProfiles)
) {
for (const profile of ev.relatedProfiles) {
this.systemMessageProfiles.set(profile.did, profile)
}
}
needsCommit = true
}
}
@@ -907,11 +943,10 @@ export class Convo {
id: tempId,
message,
})
if (this.convo?.status === 'request') {
this.convo = {
...this.convo,
if (this.convo?.view.status === 'request') {
this.updateConvo({
status: 'accepted',
}
})
}
this.commit()
@@ -921,41 +956,79 @@ export class Convo {
}
markConvoAccepted() {
if (this.convo) {
this.convo = {
...this.convo,
status: 'accepted',
}
}
this.updateConvo({
status: 'accepted',
})
this.commit()
}
updateMuted(muted: boolean) {
if (this.convo) {
this.convo = {
...this.convo,
muted,
}
}
this.updateConvo({
muted,
})
this.commit()
}
updateGroupName(name: string) {
if (
this.convo &&
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
this.convo.kind,
ChatBskyConvoDefs.isGroupConvo,
)
) {
this.convo = {
...this.convo,
kind: {
...this.convo.kind,
name,
},
}
if (this.convo?.kind !== 'group') {
throw new Error('updateGroupName can only be called on group convo')
}
this.updateConvo({
kind: {
...this.convo.details,
name,
},
})
this.commit()
}
updateGroupMembers(members: GroupConvoMember[], memberCount: number) {
if (this.convo?.kind !== 'group') {
throw new Error('updateGroupMembers can only be called on group convo')
}
this.updateConvo({
members,
kind: {
...this.convo.details,
memberCount,
},
})
this.commit()
}
updateJoinLink(joinLink: ChatBskyGroupDefs.JoinLinkView | undefined) {
if (this.convo?.kind !== 'group') {
throw new Error('updateJoinLink can only be called on group convo')
}
this.updateConvo({
kind: {
...this.convo.details,
joinLink,
},
})
this.commit()
}
updateLockStatus(lockStatus: ChatBskyConvoDefs.ConvoLockStatus) {
if (this.convo?.kind !== 'group') {
throw new Error('updateLockStatus can only be called on group convo')
}
this.updateConvo({
kind: {
...this.convo.details,
lockStatus,
},
})
this.commit()
}
@@ -980,7 +1053,7 @@ export class Convo {
const {id, message} = pendingMessage
const response = await this.agent.api.chat.bsky.convo.sendMessage(
const response = await this.agent.chat.bsky.convo.sendMessage(
{
convoId: this.convoId,
message,
@@ -1023,7 +1096,7 @@ export class Convo {
this.emitter.emit('event', {
type: 'invalidate-block-state',
accountDids: [
this.sender!.did,
this.senderUserDid,
...this.recipients!.map(r => r.did),
],
})
@@ -1075,7 +1148,7 @@ export class Convo {
)
try {
const {data} = await this.agent.api.chat.bsky.convo.sendMessageBatch(
const {data} = await this.agent.chat.bsky.convo.sendMessageBatch(
{
items: messageArray.map(({message}) => ({
convoId: this.convoId,
@@ -1117,7 +1190,7 @@ export class Convo {
try {
await networkRetry(2, () => {
return this.agent.api.chat.bsky.convo.deleteMessageForSelf(
return this.agent.chat.bsky.convo.deleteMessageForSelf(
{
convoId: this.convoId,
messageId,
@@ -1158,6 +1231,7 @@ export class Convo {
type: 'message',
key: m.id,
message: m,
relatedProfiles: this.relatedProfiles,
nextMessage: null,
prevMessage: null,
})
@@ -1166,6 +1240,7 @@ export class Convo {
type: 'deleted-message',
key: m.id,
message: m,
relatedProfiles: this.relatedProfiles,
nextMessage: null,
prevMessage: null,
})
@@ -1174,7 +1249,7 @@ export class Convo {
type: 'system-message',
key: m.id,
message: m,
relatedProfiles: Array.from(this.systemMessageProfiles.values()),
relatedProfiles: this.relatedProfiles,
})
}
})
@@ -1196,6 +1271,7 @@ export class Convo {
type: 'message',
key: m.id,
message: m,
relatedProfiles: this.relatedProfiles,
nextMessage: null,
prevMessage: null,
})
@@ -1204,6 +1280,7 @@ export class Convo {
type: 'deleted-message',
key: m.id,
message: m,
relatedProfiles: this.relatedProfiles,
nextMessage: null,
prevMessage: null,
})
@@ -1212,7 +1289,7 @@ export class Convo {
type: 'system-message',
key: m.id,
message: m,
relatedProfiles: Array.from(this.systemMessageProfiles.values()),
relatedProfiles: this.relatedProfiles,
})
}
})
@@ -1228,15 +1305,12 @@ export class Convo {
id: nanoid(),
rev: '__fake__',
sentAt: new Date().toISOString(),
/*
* `getItems` is only run in "active" status states, where
* `this.sender` is defined
*/
sender: {
$type: 'chat.bsky.convo.defs#messageViewSender',
did: this.sender!.did,
did: this.senderUserDid,
},
},
relatedProfiles: this.relatedProfiles,
nextMessage: null,
prevMessage: null,
failed: this.pendingMessageFailure !== null,
@@ -1448,46 +1522,4 @@ export class Convo {
throw error
}
}
// Group utilities
isGroup(): boolean | undefined {
if (!this.convo) return undefined
const info = this.getGroupInfo()
return !!info
}
getGroupInfo(): ChatBskyConvoDefs.GroupConvo | undefined {
if (
this.convo &&
bsky.dangerousIsType<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 as createProfileQueryKey} from '#/state/queries/profile'
import {useAgent} from '#/state/session'
import {type GroupConvoMember} from '#/components/dms/util'
export * from '#/state/messages/convo/util'
function membersChanged(
a: ChatBskyConvoDefs.ConvoView['members'],
b: ChatBskyConvoDefs.ConvoView['members'],
) {
if (a.length !== b.length) return true
const aDids = new Set(a.map(m => m.did))
return b.some(m => !aDids.has(m.did))
}
const ChatContext = createContext<ConvoState | null>(null)
ChatContext.displayName = 'ChatContext'
@@ -107,11 +117,11 @@ export function ConvoProvider({
switch (event.type) {
case 'invalidate-block-state': {
for (const did of event.accountDids) {
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: createProfileQueryKey(did),
})
}
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [ListConvosQueryKeyRoot],
})
}
@@ -127,17 +137,35 @@ export function ConvoProvider({
const data = event.query.state.data as
| ChatBskyConvoDefs.ConvoView
| undefined
if (data && convo.convo && data.muted !== convo.convo.muted) {
if (data && convo.convo && data.muted !== convo.convo.view.muted) {
convo.updateMuted(data.muted)
}
if (
data &&
convo.convo &&
ChatBskyConvoDefs.isGroupConvo(data.kind) &&
ChatBskyConvoDefs.isGroupConvo(convo.convo.kind) &&
data.kind.name !== convo.convo.kind.name
convo.convo?.kind === 'group'
) {
convo.updateGroupName(data.kind.name)
if (data.kind.name !== convo.convo.details.name) {
convo.updateGroupName(data.kind.name)
}
if (data.kind.joinLink !== convo.convo.details.joinLink) {
convo.updateJoinLink(data.kind.joinLink)
}
if (data.kind.lockStatus !== convo.convo.details.lockStatus) {
convo.updateLockStatus(data.kind.lockStatus)
}
}
if (
data &&
ChatBskyConvoDefs.isGroupConvo(data.kind) &&
convo.convo?.kind === 'group' &&
(membersChanged(data.members, convo.convo.members) ||
data.kind.memberCount !== convo.convo.details.memberCount)
) {
convo.updateGroupMembers(
data.members as GroupConvoMember[],
data.kind.memberCount,
)
}
}
})
+18 -67
View File
@@ -6,6 +6,7 @@ import {
} from '@atproto/api'
import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type ConvoWithDetails} from '#/components/dms/util'
export type ConvoParams = {
convoId: string
@@ -58,34 +59,20 @@ export enum ConvoDispatchEvent {
}
export type ConvoDispatch =
| {
event: ConvoDispatchEvent.Init
}
| {
event: ConvoDispatchEvent.Ready
}
| {
event: ConvoDispatchEvent.Resume
}
| {
event: ConvoDispatchEvent.Background
}
| {
event: ConvoDispatchEvent.Suspend
}
| {
event: ConvoDispatchEvent.Error
payload: ConvoError
}
| {
event: ConvoDispatchEvent.Disable
}
| {event: ConvoDispatchEvent.Init}
| {event: ConvoDispatchEvent.Ready}
| {event: ConvoDispatchEvent.Resume}
| {event: ConvoDispatchEvent.Background}
| {event: ConvoDispatchEvent.Suspend}
| {event: ConvoDispatchEvent.Error; payload: ConvoError}
| {event: ConvoDispatchEvent.Disable}
export type ConvoItem =
| {
type: 'message'
key: string
message: ChatBskyConvoDefs.MessageView
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
nextMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
@@ -99,6 +86,7 @@ export type ConvoItem =
type: 'pending-message'
key: string
message: ChatBskyConvoDefs.MessageView
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
nextMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
@@ -117,6 +105,7 @@ export type ConvoItem =
type: 'deleted-message'
key: string
message: ChatBskyConvoDefs.DeletedMessageView
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
nextMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
@@ -130,7 +119,7 @@ export type ConvoItem =
type: 'system-message'
key: string
message: ChatBskyConvoDefs.SystemMessageView
relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[]
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
}
| {
type: 'error'
@@ -150,17 +139,12 @@ type FetchMessageHistory = () => Promise<void>
type MarkConvoAccepted = () => void
type AddReaction = (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 = {
status: ConvoStatus.Uninitialized
items: []
convo: ChatBskyConvoDefs.ConvoView | undefined
convo: ConvoWithDetails | undefined
error: undefined
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
isFetchingHistory: false
hasAllHistory: boolean
deleteMessage: undefined
@@ -169,17 +153,12 @@ export type ConvoStateUninitialized = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateInitializing = {
status: ConvoStatus.Initializing
items: []
convo: ChatBskyConvoDefs.ConvoView | undefined
convo: ConvoWithDetails | undefined
error: undefined
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
isFetchingHistory: boolean
hasAllHistory: boolean
deleteMessage: undefined
@@ -188,17 +167,12 @@ export type ConvoStateInitializing = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateReady = {
status: ConvoStatus.Ready
items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView
convo: ConvoWithDetails
error: undefined
sender: ChatBskyActorDefs.ProfileViewBasic
recipients: ChatBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean
hasAllHistory: boolean
deleteMessage: DeleteMessage
@@ -207,17 +181,12 @@ export type ConvoStateReady = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateBackgrounded = {
status: ConvoStatus.Backgrounded
items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView
convo: ConvoWithDetails
error: undefined
sender: ChatBskyActorDefs.ProfileViewBasic
recipients: ChatBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean
hasAllHistory: boolean
deleteMessage: DeleteMessage
@@ -226,17 +195,12 @@ export type ConvoStateBackgrounded = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateSuspended = {
status: ConvoStatus.Suspended
items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView
convo: ConvoWithDetails
error: undefined
sender: ChatBskyActorDefs.ProfileViewBasic
recipients: ChatBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean
hasAllHistory: boolean
deleteMessage: DeleteMessage
@@ -245,17 +209,12 @@ export type ConvoStateSuspended = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateError = {
status: ConvoStatus.Error
items: []
convo: undefined
error: ConvoError
sender: undefined
recipients: undefined
isFetchingHistory: false
hasAllHistory: false
deleteMessage: undefined
@@ -264,17 +223,12 @@ export type ConvoStateError = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
isGroup: undefined
getGroupInfo: undefined
getPrimaryMember: undefined
}
export type ConvoStateDisabled = {
status: ConvoStatus.Disabled
items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView
convo: ConvoWithDetails
error: undefined
sender: ChatBskyActorDefs.ProfileViewBasic
recipients: ChatBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean
hasAllHistory: boolean
deleteMessage: DeleteMessage
@@ -283,9 +237,6 @@ export type ConvoStateDisabled = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoState =
| ConvoStateUninitialized
@@ -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,
} from './list-conversations'
const RQKEY_ROOT = 'convo'
export const RQKEY_ROOT = 'convo'
export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId]
export function useConvoQuery({convoId}: {convoId: string}) {
@@ -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'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useGetConvoAvailabilityQuery(did: string) {
export function useGetConvoAvailabilityQuery(
did: string,
{enabled = true}: {enabled?: boolean} = {},
) {
const agent = useAgent()
return useQuery({
@@ -21,5 +24,6 @@ export function useGetConvoAvailabilityQuery(did: string) {
return data
},
staleTime: STALE.INFINITY,
enabled,
})
}
@@ -71,7 +71,7 @@ export function useLeaveConvo(
return {prevPages}
},
onSuccess: data => {
queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
onSuccess?.(data)
},
onError: (error, _, context) => {
@@ -89,7 +89,7 @@ export function useLeaveConvo(
}
},
)
queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
onError?.(error)
},
})
@@ -105,8 +105,8 @@ export function ListConvosProviderInner({
const debouncedRefetch = useMemo(() => {
const refetchAndInvalidate = () => {
refetch()
queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
void refetch()
void queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
}
return throttle(refetchAndInvalidate, 500, {
leading: true,
@@ -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 {
type ChatBskyConvoDefs,
type ChatBskyConvoListConvos,
type ChatBskyConvoMuteConvo,
} from '@atproto/api'
import {
type InfiniteData,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
import {type ChatBskyConvoMuteConvo} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {useAgent} from '#/state/session'
import {RQKEY as CONVO_KEY} from './conversation'
import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
import {
rollbackConvoOptimistic,
updateConvoOptimistic,
} from './utils/convo-cache'
export function useMuteConvo(
convoId: string | undefined,
@@ -46,59 +40,17 @@ export function useMuteConvo(
},
onMutate: ({mute}) => {
if (!convoId) return
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
)
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}
return updateConvoOptimistic(queryClient, convoId, prev => ({
...prev,
muted: mute,
}))
},
onSuccess: data => {
onSuccess?.(data)
},
onError: (e, _variables, context) => {
if (context?.prevConvo && convoId) {
queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo)
}
if (context?.prevListEntries) {
for (const [key, data] of context.prevListEntries) {
queryClient.setQueryData(key, data)
}
if (convoId && context) {
rollbackConvoOptimistic(queryClient, convoId, context)
}
onError?.(e)
},
@@ -1,7 +1,8 @@
import {
ChatBskyConvoDefs,
type ChatBskyActorDefs,
type ChatBskyConvoDefs,
type ChatBskyConvoListConvos,
type ChatBskyGroupEditGroup,
type ChatBskyGroupRemoveMembers,
} from '@atproto/api'
import {
type InfiniteData,
@@ -14,14 +15,15 @@ import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {RQKEY as CONVO_KEY} from './conversation'
import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
import {listConvoMembersQueryKey} from './list-convo-members'
export function useEditGroupName(
export function useRemoveFromGroupChat(
convoId: string | undefined,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupEditGroup.OutputSchema) => void
onSuccess?: (data: ChatBskyGroupRemoveMembers.OutputSchema) => void
onError?: (error: Error) => void
},
) {
@@ -29,15 +31,15 @@ export function useEditGroupName(
const agent = useAgent()
return useMutation({
mutationFn: async ({name: groupName}: {name: string}) => {
mutationFn: async ({members}: {members: string[]}) => {
if (!convoId) throw new Error('No convoId provided')
const {data} = await agent.chat.bsky.group.editGroup(
{convoId, name: groupName},
const {data} = await agent.chat.bsky.group.removeMembers(
{convoId, members},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
},
onMutate: ({name: groupName}) => {
onMutate: ({members}) => {
if (!convoId) return
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
@@ -46,24 +48,21 @@ export function useEditGroupName(
const prevListEntries = queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]})
const prevMemberList = queryClient.getQueryData<
ChatBskyActorDefs.ProfileViewBasic[]
>(listConvoMembersQueryKey(convoId))
// Update for a single chat thread
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
prev => {
if (!prev) return
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return prev
return {
...prev,
kind: {
...prev.kind,
name: groupName,
},
members: prev.members.filter(m => !members.includes(m.did)),
}
},
)
// Update for the chat list
queryClient.setQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]}, prev => {
@@ -74,20 +73,24 @@ export function useEditGroupName(
...page,
convos: page.convos.map(convo => {
if (convo.id !== convoId) return convo
if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo
return {
...convo,
kind: {
...convo.kind,
name: groupName,
},
members: convo.members.filter(m => !members.includes(m.did)),
}
}),
})),
}
})
return {prevConvo, prevListEntries}
queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>(
listConvoMembersQueryKey(convoId),
prev => {
if (!prev) return
return prev.filter(m => !members.includes(m.did))
},
)
return {prevConvo, prevListEntries, prevMemberList}
},
onSuccess: data => {
onSuccess?.(data)
@@ -102,6 +105,12 @@ export function useEditGroupName(
queryClient.setQueryData(key, data)
}
}
if (context?.prevMemberList && convoId) {
queryClient.setQueryData(
listConvoMembersQueryKey(convoId),
context.prevMemberList,
)
}
onError?.(e)
},
})
@@ -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)
}
}