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:
committed by
Samuel Newman
parent
a975606188
commit
f37a163814
@@ -66,6 +66,7 @@ module.exports = function (_config) {
|
|||||||
infoPlist: {
|
infoPlist: {
|
||||||
CADisableMinimumFrameDurationOnPhone: true,
|
CADisableMinimumFrameDurationOnPhone: true,
|
||||||
UIBackgroundModes: ['remote-notification'],
|
UIBackgroundModes: ['remote-notification'],
|
||||||
|
NSUserActivityTypes: ['INSendMessageIntent'],
|
||||||
NSCameraUsageDescription:
|
NSCameraUsageDescription:
|
||||||
'Used for profile pictures, posts, and other kinds of content.',
|
'Used for profile pictures, posts, and other kinds of content.',
|
||||||
NSMicrophoneUsageDescription:
|
NSMicrophoneUsageDescription:
|
||||||
@@ -123,6 +124,7 @@ module.exports = function (_config) {
|
|||||||
'com.apple.developer.kernel.increased-memory-limit': true,
|
'com.apple.developer.kernel.increased-memory-limit': true,
|
||||||
'com.apple.developer.kernel.extended-virtual-addressing': true,
|
'com.apple.developer.kernel.extended-virtual-addressing': true,
|
||||||
'com.apple.security.application-groups': 'group.app.bsky',
|
'com.apple.security.application-groups': 'group.app.bsky',
|
||||||
|
'com.apple.developer.usernotifications.communication': true,
|
||||||
// 'com.apple.developer.device-information.user-assigned-device-name': true,
|
// 'com.apple.developer.device-information.user-assigned-device-name': true,
|
||||||
},
|
},
|
||||||
privacyManifests: {
|
privacyManifests: {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z"/></svg>
|
||||||
|
After Width: | Height: | Size: 502 B |
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 13a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z"/><path fill="#000" fill-rule="evenodd" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5Zm-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Z" clip-rule="evenodd"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1"/></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 446 B After Width: | Height: | Size: 376 B |
@@ -8,6 +8,13 @@
|
|||||||
<string>com.apple.usernotifications.service</string>
|
<string>com.apple.usernotifications.service</string>
|
||||||
<key>NSExtensionPrincipalClass</key>
|
<key>NSExtensionPrincipalClass</key>
|
||||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>IntentsSupported</key>
|
||||||
|
<array>
|
||||||
|
<string>INSendMessageIntent</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>MainAppScheme</key>
|
<key>MainAppScheme</key>
|
||||||
<string>bluesky</string>
|
<string>bluesky</string>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import UserNotifications
|
import UserNotifications
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Intents
|
||||||
|
|
||||||
let APP_GROUP = "group.app.bsky"
|
let APP_GROUP = "group.app.bsky"
|
||||||
typealias ContentHandler = (UNNotificationContent) -> Void
|
typealias ContentHandler = (UNNotificationContent) -> Void
|
||||||
@@ -40,17 +41,18 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.bestAttempt = bestAttempt
|
self.bestAttempt = bestAttempt
|
||||||
if reason == "chat-message" {
|
|
||||||
|
if reason == "chat-message" || reason == "chat-reaction" {
|
||||||
mutateWithChatMessage(bestAttempt)
|
mutateWithChatMessage(bestAttempt)
|
||||||
|
let finalContent = createCommunicationNotification(
|
||||||
|
from: bestAttempt,
|
||||||
|
userInfo: request.content.userInfo
|
||||||
|
)
|
||||||
|
contentHandler(finalContent)
|
||||||
} else {
|
} else {
|
||||||
mutateWithBadge(bestAttempt)
|
mutateWithBadge(bestAttempt)
|
||||||
|
contentHandler(bestAttempt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any image downloading (or other network tasks) should be handled at the end
|
|
||||||
// of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire
|
|
||||||
// gets called, we might not have all the needed mutations completed in time.
|
|
||||||
|
|
||||||
contentHandler(bestAttempt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func serviceExtensionTimeWillExpire() {
|
override func serviceExtensionTimeWillExpire() {
|
||||||
@@ -61,6 +63,81 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
contentHandler(bestAttempt)
|
contentHandler(bestAttempt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Communication Notification
|
||||||
|
|
||||||
|
func createCommunicationNotification(
|
||||||
|
from content: UNMutableNotificationContent,
|
||||||
|
userInfo: [AnyHashable: Any]
|
||||||
|
) -> UNNotificationContent {
|
||||||
|
let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown"
|
||||||
|
let convoId = userInfo["convoId"] as? String
|
||||||
|
var avatarImage: INImage? = nil
|
||||||
|
if let avatarUrlString = userInfo["senderAvatarUrl"] as? String {
|
||||||
|
avatarImage = downloadAvatarImage(from: avatarUrlString)
|
||||||
|
}
|
||||||
|
|
||||||
|
let senderHandleValue = userInfo["senderHandle"] as? String
|
||||||
|
let senderHandle = INPersonHandle(value: senderHandleValue, type: .unknown)
|
||||||
|
let sender = INPerson(
|
||||||
|
personHandle: senderHandle,
|
||||||
|
nameComponents: nil,
|
||||||
|
displayName: senderDisplayName,
|
||||||
|
image: avatarImage,
|
||||||
|
contactIdentifier: nil,
|
||||||
|
customIdentifier: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let intent = INSendMessageIntent(
|
||||||
|
recipients: nil,
|
||||||
|
outgoingMessageType: .outgoingMessageText,
|
||||||
|
content: content.body,
|
||||||
|
speakableGroupName: nil,
|
||||||
|
conversationIdentifier: convoId,
|
||||||
|
serviceName: nil,
|
||||||
|
sender: sender,
|
||||||
|
attachments: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let interaction = INInteraction(intent: intent, response: nil)
|
||||||
|
interaction.direction = .incoming
|
||||||
|
interaction.donate(completion: nil)
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try content.updating(from: intent)
|
||||||
|
} catch {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadAvatarImage(from urlString: String) -> INImage? {
|
||||||
|
let thumbnailUrlString = urlString.replacingOccurrences(
|
||||||
|
of: "/img/avatar/",
|
||||||
|
with: "/img/avatar_thumbnail/"
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let url = URL(string: thumbnailUrlString) else { return nil }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.timeoutInterval = 5
|
||||||
|
|
||||||
|
var imageData: Data? = nil
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
|
||||||
|
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||||
|
if let data = data,
|
||||||
|
let httpResponse = response as? HTTPURLResponse,
|
||||||
|
httpResponse.statusCode == 200 {
|
||||||
|
imageData = data
|
||||||
|
}
|
||||||
|
semaphore.signal()
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
semaphore.wait()
|
||||||
|
|
||||||
|
guard let data = imageData else { return nil }
|
||||||
|
return INImage(imageData: data)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Mutations
|
// MARK: Mutations
|
||||||
|
|
||||||
func mutateWithBadge(_ content: UNMutableNotificationContent) {
|
func mutateWithBadge(_ content: UNMutableNotificationContent) {
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ class BackgroundNotificationHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remoteMessage.data["reason"] == "chat-message") {
|
if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") {
|
||||||
mutateWithChatMessage(remoteMessage)
|
mutateWithChatMessage(remoteMessage)
|
||||||
} else {
|
} else {
|
||||||
mutateWithOtherReason(remoteMessage)
|
mutateWithOtherReason(remoteMessage)
|
||||||
|
|||||||
@@ -556,7 +556,11 @@ export type Events = {
|
|||||||
| 'FindContacts'
|
| 'FindContacts'
|
||||||
}
|
}
|
||||||
'chat:create': {
|
'chat:create': {
|
||||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
logContext:
|
||||||
|
| 'ProfileHeader'
|
||||||
|
| 'NewChatDialog'
|
||||||
|
| 'SendViaChatDialog'
|
||||||
|
| 'ConvoSettings'
|
||||||
}
|
}
|
||||||
'chat:open': {
|
'chat:open': {
|
||||||
logContext:
|
logContext:
|
||||||
@@ -564,6 +568,7 @@ export type Events = {
|
|||||||
| 'NewChatDialog'
|
| 'NewChatDialog'
|
||||||
| 'ChatsList'
|
| 'ChatsList'
|
||||||
| 'SendViaChatDialog'
|
| 'SendViaChatDialog'
|
||||||
|
| 'ConvoSettings'
|
||||||
}
|
}
|
||||||
'groupchat:create': {
|
'groupchat:create': {
|
||||||
logContext: 'NewChatDialog'
|
logContext: 'NewChatDialog'
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import {useCallback, useEffect} from 'react'
|
import {useEffect} from 'react'
|
||||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
interpolate,
|
|
||||||
type SharedValue,
|
type SharedValue,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -16,44 +15,32 @@ import {atoms as a, useTheme} from '#/alf'
|
|||||||
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
|
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
type Layout = {
|
||||||
|
size: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
zIndex?: number
|
||||||
|
border?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
animate?: boolean
|
animate?: boolean
|
||||||
profiles: bsky.profile.AnyProfileView[]
|
profiles: bsky.profile.AnyProfileView[]
|
||||||
size?: 'small' | 'medium' | 'large' | number
|
size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AvatarBubbles({
|
export function AvatarBubbles({
|
||||||
animate = false,
|
animate = false,
|
||||||
profiles: allProfiles,
|
profiles: allProfiles,
|
||||||
size = 'large',
|
size = 120,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const profiles =
|
const profiles =
|
||||||
allProfiles.length > 2
|
allProfiles.length > 2
|
||||||
? allProfiles.filter(p => p.did !== currentAccount?.did)
|
? allProfiles.filter(p => p.did !== currentAccount?.did)
|
||||||
: allProfiles
|
: allProfiles
|
||||||
const containerSize =
|
const scale = profiles.length <= 1 ? 1 : size / 120
|
||||||
typeof size === 'number'
|
const marginOffset = size < 120 ? -2 : 0
|
||||||
? size
|
|
||||||
: size === 'small'
|
|
||||||
? 40
|
|
||||||
: size === 'medium'
|
|
||||||
? 56
|
|
||||||
: 120
|
|
||||||
const scale =
|
|
||||||
typeof size === 'number'
|
|
||||||
? size / 120
|
|
||||||
: size === 'small'
|
|
||||||
? 40 / 120
|
|
||||||
: size === 'medium'
|
|
||||||
? 56 / 120
|
|
||||||
: 1
|
|
||||||
const marginOffset =
|
|
||||||
(typeof size === 'number' && size < 120) ||
|
|
||||||
size === 'small' ||
|
|
||||||
size === 'medium'
|
|
||||||
? -2
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const initialValue = animate ? 0 : 1
|
const initialValue = animate ? 0 : 1
|
||||||
const p0 = useSharedValue(initialValue)
|
const p0 = useSharedValue(initialValue)
|
||||||
@@ -61,130 +48,50 @@ export function AvatarBubbles({
|
|||||||
const p2 = useSharedValue(initialValue)
|
const p2 = useSharedValue(initialValue)
|
||||||
const p3 = useSharedValue(initialValue)
|
const p3 = useSharedValue(initialValue)
|
||||||
|
|
||||||
const animateScale = (p: Animated.SharedValue<number>, index: number) => {
|
|
||||||
p.set(0)
|
|
||||||
p.set(() =>
|
|
||||||
withDelay(
|
|
||||||
500 + index * 100,
|
|
||||||
withTiming(1, {
|
|
||||||
duration: 250,
|
|
||||||
easing: Easing.out(Easing.back(1.75)),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const playScaleAnimation = useCallback(() => {
|
|
||||||
animateScale(p0, 0)
|
|
||||||
animateScale(p1, 1)
|
|
||||||
animateScale(p2, 2)
|
|
||||||
animateScale(p3, 3)
|
|
||||||
}, [p0, p1, p2, p3])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!animate) return
|
if (!animate) return
|
||||||
playScaleAnimation()
|
const animateBubble = (p: SharedValue<number>, i: number) => {
|
||||||
}, [animate, playScaleAnimation])
|
p.set(0)
|
||||||
|
p.set(() =>
|
||||||
|
withDelay(
|
||||||
|
500 + i * 100,
|
||||||
|
withTiming(1, {
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.back(1.75)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
animateBubble(p0, 0)
|
||||||
|
animateBubble(p1, 1)
|
||||||
|
animateBubble(p2, 2)
|
||||||
|
animateBubble(p3, 3)
|
||||||
|
}, [animate, p0, p1, p2, p3])
|
||||||
|
|
||||||
let avatars = (
|
const scales = [p0, p1, p2, p3]
|
||||||
<>
|
const layouts = getLayouts(profiles.length)
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[0]}
|
|
||||||
scale={p0}
|
|
||||||
size={76}
|
|
||||||
x={-2}
|
|
||||||
y={-2}
|
|
||||||
style={[a.z_20]}
|
|
||||||
includeProfileBorder
|
|
||||||
/>
|
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[1]}
|
|
||||||
scale={p1}
|
|
||||||
size={76}
|
|
||||||
x={42}
|
|
||||||
y={42}
|
|
||||||
style={[a.z_10]}
|
|
||||||
includeProfileBorder
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (profiles.length === 3) {
|
|
||||||
avatars = (
|
|
||||||
<>
|
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[0]}
|
|
||||||
scale={p0}
|
|
||||||
size={68}
|
|
||||||
x={-2}
|
|
||||||
y={-2}
|
|
||||||
/>
|
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[1]}
|
|
||||||
scale={p1}
|
|
||||||
size={56}
|
|
||||||
x={38}
|
|
||||||
y={62}
|
|
||||||
/>
|
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[2]}
|
|
||||||
scale={p2}
|
|
||||||
size={46}
|
|
||||||
x={71}
|
|
||||||
y={18}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profiles.length >= 4) {
|
|
||||||
avatars = (
|
|
||||||
<>
|
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[0]}
|
|
||||||
scale={p0}
|
|
||||||
size={68}
|
|
||||||
x={-2}
|
|
||||||
y={-2}
|
|
||||||
/>
|
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[1]}
|
|
||||||
scale={p1}
|
|
||||||
size={56}
|
|
||||||
x={60}
|
|
||||||
y={49}
|
|
||||||
/>
|
|
||||||
<AvatarBubble
|
|
||||||
profile={profiles[2]}
|
|
||||||
scale={p2}
|
|
||||||
size={42}
|
|
||||||
x={14}
|
|
||||||
y={74}
|
|
||||||
/>
|
|
||||||
<AvatarBubble profile={profiles[3]} scale={p3} size={32} x={72} y={9} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View style={[a.p_2xs, {height: size, width: size}]}>
|
||||||
style={[
|
|
||||||
a.p_2xs,
|
|
||||||
{
|
|
||||||
height: containerSize,
|
|
||||||
width: containerSize,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={{
|
||||||
{
|
marginTop: marginOffset,
|
||||||
marginTop: marginOffset,
|
marginLeft: marginOffset,
|
||||||
marginLeft: marginOffset,
|
transform: [{scale}],
|
||||||
transform: [{scale}],
|
transformOrigin: 'top left',
|
||||||
transformOrigin: 'top left',
|
}}>
|
||||||
},
|
{layouts.map((layout, i) => (
|
||||||
]}>
|
<AvatarBubble
|
||||||
{avatars}
|
key={i}
|
||||||
|
profile={profiles[i]}
|
||||||
|
scale={scales[i]}
|
||||||
|
size={layout.size}
|
||||||
|
x={layout.x}
|
||||||
|
y={layout.y}
|
||||||
|
zIndex={layout.zIndex}
|
||||||
|
includeProfileBorder={layout.border}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)
|
)
|
||||||
@@ -194,27 +101,23 @@ function AvatarBubble({
|
|||||||
profile,
|
profile,
|
||||||
scale,
|
scale,
|
||||||
size,
|
size,
|
||||||
style,
|
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
zIndex,
|
||||||
includeProfileBorder,
|
includeProfileBorder,
|
||||||
}: {
|
}: {
|
||||||
profile?: bsky.profile.AnyProfileView
|
profile?: bsky.profile.AnyProfileView
|
||||||
scale: SharedValue<number>
|
scale: SharedValue<number>
|
||||||
size: number
|
size: number
|
||||||
style?: StyleProp<ViewStyle>
|
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
|
zIndex?: number
|
||||||
includeProfileBorder?: boolean
|
includeProfileBorder?: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [
|
transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
|
||||||
{translateX: x},
|
|
||||||
{translateY: y},
|
|
||||||
{scale: interpolate(scale.get(), [0, 1], [0, 1])},
|
|
||||||
],
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -227,11 +130,17 @@ function AvatarBubble({
|
|||||||
borderColor: t.atoms.text_inverted.color,
|
borderColor: t.atoms.text_inverted.color,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
},
|
},
|
||||||
style,
|
zIndex != null && {zIndex},
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
]}>
|
]}>
|
||||||
{profile ? (
|
{profile ? (
|
||||||
<Avatar profile={profile} size={size} />
|
<UserAvatar
|
||||||
|
avatar={profile.avatar}
|
||||||
|
size={size}
|
||||||
|
type="user"
|
||||||
|
hideLiveBadge
|
||||||
|
noBorder
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AvatarPlaceholder size={size} />
|
<AvatarPlaceholder size={size} />
|
||||||
)}
|
)}
|
||||||
@@ -239,25 +148,7 @@ function AvatarBubble({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Avatar({
|
function AvatarPlaceholder({size}: {size: number}) {
|
||||||
profile,
|
|
||||||
size = 76,
|
|
||||||
}: {
|
|
||||||
profile: bsky.profile.AnyProfileView
|
|
||||||
size?: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<UserAvatar
|
|
||||||
avatar={profile.avatar}
|
|
||||||
size={size}
|
|
||||||
type="user"
|
|
||||||
hideLiveBadge
|
|
||||||
noBorder
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AvatarPlaceholder({size = 76}: {size?: number}) {
|
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -267,10 +158,7 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
|
|||||||
a.justify_center,
|
a.justify_center,
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
t.atoms.bg_contrast_200,
|
t.atoms.bg_contrast_200,
|
||||||
{
|
{width: size, height: size},
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
},
|
|
||||||
]}>
|
]}>
|
||||||
<PersonIcon
|
<PersonIcon
|
||||||
width={size * 0.5}
|
width={size * 0.5}
|
||||||
@@ -280,3 +168,25 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLayouts(count: number): Layout[] {
|
||||||
|
if (count === 3) {
|
||||||
|
return [
|
||||||
|
{size: 68, x: -2, y: -2},
|
||||||
|
{size: 56, x: 38, y: 62},
|
||||||
|
{size: 46, x: 71, y: 18},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (count >= 4) {
|
||||||
|
return [
|
||||||
|
{size: 68, x: -2, y: -2},
|
||||||
|
{size: 56, x: 60, y: 49},
|
||||||
|
{size: 42, x: 14, y: 74},
|
||||||
|
{size: 32, x: 72, y: 9},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{size: 76, x: -2, y: -2, zIndex: 20, border: true},
|
||||||
|
{size: 76, x: 42, y: 42, zIndex: 10, border: true},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ export type ButtonState = {
|
|||||||
focused: boolean
|
focused: boolean
|
||||||
pressed: boolean
|
pressed: boolean
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
|
/**
|
||||||
|
* Alias for hovered || focused || pressed
|
||||||
|
*/
|
||||||
|
interacting: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ButtonContext = VariantProps & ButtonState
|
export type ButtonContext = VariantProps & ButtonState
|
||||||
@@ -120,6 +124,7 @@ const Context = createContext<VariantProps & ButtonState>({
|
|||||||
focused: false,
|
focused: false,
|
||||||
pressed: false,
|
pressed: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
interacting: false,
|
||||||
})
|
})
|
||||||
Context.displayName = 'ButtonContext'
|
Context.displayName = 'ButtonContext'
|
||||||
|
|
||||||
@@ -536,6 +541,7 @@ export const Button = forwardRef<View, ButtonProps>(
|
|||||||
const context = useMemo<ButtonContext>(
|
const context = useMemo<ButtonContext>(
|
||||||
() => ({
|
() => ({
|
||||||
...state,
|
...state,
|
||||||
|
interacting: state.hovered || state.focused || state.pressed,
|
||||||
variant,
|
variant,
|
||||||
color,
|
color,
|
||||||
size,
|
size,
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export function AvatarPlaceholder({size = 40}: {size?: number}) {
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_50,
|
||||||
{
|
{
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
@@ -348,7 +348,7 @@ export function NameAndHandlePlaceholder() {
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_xs,
|
a.rounded_xs,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_50,
|
||||||
{
|
{
|
||||||
width: '60%',
|
width: '60%',
|
||||||
height: 14,
|
height: 14,
|
||||||
@@ -359,7 +359,7 @@ export function NameAndHandlePlaceholder() {
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_xs,
|
a.rounded_xs,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_50,
|
||||||
{
|
{
|
||||||
width: '40%',
|
width: '40%',
|
||||||
height: 10,
|
height: 10,
|
||||||
@@ -377,7 +377,7 @@ export function NamePlaceholder({style}: ViewStyleProp) {
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_xs,
|
a.rounded_xs,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_50,
|
||||||
{
|
{
|
||||||
width: '60%',
|
width: '60%',
|
||||||
height: 14,
|
height: 14,
|
||||||
@@ -439,7 +439,7 @@ export function DescriptionPlaceholder({
|
|||||||
style={[
|
style={[
|
||||||
a.rounded_xs,
|
a.rounded_xs,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_50,
|
||||||
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
|
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -600,7 +600,7 @@ export function FollowButtonPlaceholder({style}: ViewStyleProp) {
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_50,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
{
|
{
|
||||||
height: 33,
|
height: 33,
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ function ExistingChatCard({
|
|||||||
]}>
|
]}>
|
||||||
<ProfileCard.Header>
|
<ProfileCard.Header>
|
||||||
{convo.kind === 'group' ? (
|
{convo.kind === 'group' ? (
|
||||||
<AvatarBubbles profiles={convo.members} size="small" />
|
<AvatarBubbles profiles={convo.members} size={40} />
|
||||||
) : (
|
) : (
|
||||||
<ProfileCard.Avatar
|
<ProfileCard.Avatar
|
||||||
profile={convo.primaryMember}
|
profile={convo.primaryMember}
|
||||||
|
|||||||
@@ -4,23 +4,29 @@ import {useLingui} from '@lingui/react/macro'
|
|||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
export function ActionsWrapper({
|
export function ActionsWrapper({
|
||||||
message,
|
message,
|
||||||
isFromSelf,
|
isFromSelf,
|
||||||
|
senderProfile,
|
||||||
children,
|
children,
|
||||||
onTap,
|
onTap,
|
||||||
}: {
|
}: {
|
||||||
message: ChatBskyConvoDefs.MessageView
|
message: ChatBskyConvoDefs.MessageView
|
||||||
hasReactions?: boolean
|
hasReactions?: boolean
|
||||||
isFromSelf: boolean
|
isFromSelf: boolean
|
||||||
|
senderProfile?: bsky.profile.AnyProfileView
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
onTap?: () => void
|
onTap?: () => void
|
||||||
}) {
|
}) {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageContextMenu message={message} onTap={onTap}>
|
<MessageContextMenu
|
||||||
|
message={message}
|
||||||
|
senderProfile={senderProfile}
|
||||||
|
onTap={onTap}>
|
||||||
{trigger =>
|
{trigger =>
|
||||||
// will always be true, since this file is platform split
|
// will always be true, since this file is platform split
|
||||||
trigger.IS_NATIVE && (
|
trigger.IS_NATIVE && (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
|||||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
|
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
|
||||||
import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
||||||
import {hasReachedReactionLimit} from './util'
|
import {hasReachedReactionLimit} from './util'
|
||||||
|
|
||||||
@@ -17,12 +18,14 @@ export function ActionsWrapper({
|
|||||||
message,
|
message,
|
||||||
hasReactions,
|
hasReactions,
|
||||||
isFromSelf,
|
isFromSelf,
|
||||||
|
senderProfile,
|
||||||
children,
|
children,
|
||||||
onTap,
|
onTap,
|
||||||
}: {
|
}: {
|
||||||
message: ChatBskyConvoDefs.MessageView
|
message: ChatBskyConvoDefs.MessageView
|
||||||
hasReactions?: boolean
|
hasReactions?: boolean
|
||||||
isFromSelf: boolean
|
isFromSelf: boolean
|
||||||
|
senderProfile?: bsky.profile.AnyProfileView
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
onTap?: () => void
|
onTap?: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -114,7 +117,7 @@ export function ActionsWrapper({
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</EmojiReactionPicker>
|
</EmojiReactionPicker>
|
||||||
<MessageContextMenu message={message}>
|
<MessageContextMenu message={message} senderProfile={senderProfile}>
|
||||||
{({props, state, IS_NATIVE, control}) => {
|
{({props, state, IS_NATIVE, control}) => {
|
||||||
// always false, file is platform split
|
// always false, file is platform split
|
||||||
if (IS_NATIVE) return null
|
if (IS_NATIVE) return null
|
||||||
|
|||||||
@@ -98,11 +98,16 @@ function reducer(state: State, action: Action): State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AddMembersFlow({
|
export function AddMembersFlow({
|
||||||
|
members,
|
||||||
title,
|
title,
|
||||||
onAddMembers,
|
onAddMembers,
|
||||||
}: {
|
}: {
|
||||||
|
members: string[]
|
||||||
title: string
|
title: string
|
||||||
onAddMembers: (dids: string[]) => void
|
onAddMembers: (
|
||||||
|
dids: string[],
|
||||||
|
profiles: bsky.profile.AnyProfileView[],
|
||||||
|
) => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
@@ -154,7 +159,11 @@ export function AddMembersFlow({
|
|||||||
} else if (searchText.length) {
|
} else if (searchText.length) {
|
||||||
if (results?.length) {
|
if (results?.length) {
|
||||||
for (const profile of results) {
|
for (const profile of results) {
|
||||||
if (profile.did === currentAccount?.did) continue
|
if (
|
||||||
|
profile.did === currentAccount?.did ||
|
||||||
|
members.includes(profile.did)
|
||||||
|
)
|
||||||
|
continue
|
||||||
_items.push({
|
_items.push({
|
||||||
type: 'profile',
|
type: 'profile',
|
||||||
key: profile.did,
|
key: profile.did,
|
||||||
@@ -202,7 +211,7 @@ export function AddMembersFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return _items
|
return _items
|
||||||
}, [isError, searchText, l, results, currentAccount?.did, follows])
|
}, [isError, searchText, l, results, currentAccount?.did, members, follows])
|
||||||
|
|
||||||
if (searchText && !isFetching && !items.length && !isError) {
|
if (searchText && !isFetching && !items.length && !isError) {
|
||||||
items.push({type: 'empty', key: 'empty', message: l`No results`})
|
items.push({type: 'empty', key: 'empty', message: l`No results`})
|
||||||
@@ -213,8 +222,8 @@ export function AddMembersFlow({
|
|||||||
}, [control])
|
}, [control])
|
||||||
|
|
||||||
const handlePressAdd = useCallback(() => {
|
const handlePressAdd = useCallback(() => {
|
||||||
onAddMembers(groupChatDids)
|
onAddMembers(groupChatDids, groupChatProfiles)
|
||||||
}, [groupChatDids, onAddMembers])
|
}, [groupChatDids, groupChatProfiles, onAddMembers])
|
||||||
|
|
||||||
const renderItems = useCallback(
|
const renderItems = useCallback(
|
||||||
({item}: {item: Item}) => {
|
({item}: {item: Item}) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {Trans, useLingui} from '@lingui/react/macro'
|
|||||||
import {subDays} from 'date-fns'
|
import {subDays} from 'date-fns'
|
||||||
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Text} from '../Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {localDateString} from './util'
|
import {localDateString} from './util'
|
||||||
|
|
||||||
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
|||||||
@@ -25,15 +25,18 @@ import {usePromptControl} from '#/components/Prompt'
|
|||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {IS_NATIVE} from '#/env'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
||||||
import {hasReachedReactionLimit} from './util'
|
import {hasReachedReactionLimit} from './util'
|
||||||
|
|
||||||
export let MessageContextMenu = ({
|
export let MessageContextMenu = ({
|
||||||
message,
|
message,
|
||||||
|
senderProfile,
|
||||||
children,
|
children,
|
||||||
onTap,
|
onTap,
|
||||||
}: {
|
}: {
|
||||||
message: ChatBskyConvoDefs.MessageView
|
message: ChatBskyConvoDefs.MessageView
|
||||||
|
senderProfile?: bsky.profile.AnyProfileView
|
||||||
children: TriggerProps['children']
|
children: TriggerProps['children']
|
||||||
onTap?: () => void
|
onTap?: () => void
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
@@ -110,9 +113,7 @@ export let MessageContextMenu = ({
|
|||||||
[l, convo, message, currentAccount?.did],
|
[l, convo, message, currentAccount?.did],
|
||||||
)
|
)
|
||||||
|
|
||||||
const sender = convo.convo.members.find(
|
const sender = senderProfile
|
||||||
member => member.did === message.sender.did,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -183,7 +184,7 @@ export let MessageContextMenu = ({
|
|||||||
control={reportControl}
|
control={reportControl}
|
||||||
subject={{
|
subject={{
|
||||||
view: 'message',
|
view: 'message',
|
||||||
convoId: convo.convo.id,
|
convoId: convo.convo.view.id,
|
||||||
message,
|
message,
|
||||||
}}
|
}}
|
||||||
onAfterSubmit={() => {
|
onAfterSubmit={() => {
|
||||||
@@ -197,7 +198,7 @@ export let MessageContextMenu = ({
|
|||||||
control={blockOrDeleteControl}
|
control={blockOrDeleteControl}
|
||||||
currentScreen="conversation"
|
currentScreen="conversation"
|
||||||
params={{
|
params={{
|
||||||
convoId: convo.convo.id,
|
convoId: convo.convo.view.id,
|
||||||
message,
|
message,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {useQueryClient} from '@tanstack/react-query'
|
|||||||
|
|
||||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {useConvoActive} from '#/state/messages/convo'
|
|
||||||
import {type ConvoItem} from '#/state/messages/convo/types'
|
import {type ConvoItem} from '#/state/messages/convo/types'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||||
@@ -43,7 +42,6 @@ import {InlineLinkText, Link} from '#/components/Link'
|
|||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import type * as bsky from '#/types/bsky'
|
|
||||||
import {DateDivider} from './DateDivider'
|
import {DateDivider} from './DateDivider'
|
||||||
import {useDateDividerToggle} from './DateDividerToggle'
|
import {useDateDividerToggle} from './DateDividerToggle'
|
||||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||||
@@ -93,19 +91,18 @@ function isWithinClusterBoundary({
|
|||||||
let MessageItem = ({
|
let MessageItem = ({
|
||||||
item,
|
item,
|
||||||
isGroupChat = false,
|
isGroupChat = false,
|
||||||
profile,
|
|
||||||
}: {
|
}: {
|
||||||
item: ConvoItem & {type: 'message' | 'pending-message'}
|
item: ConvoItem & {type: 'message' | 'pending-message'}
|
||||||
isGroupChat?: boolean
|
isGroupChat?: boolean
|
||||||
profile?: bsky.profile.AnyProfileView
|
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const {convo} = useConvoActive()
|
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const profile = item.relatedProfiles.get(item.message.sender.did)
|
||||||
|
|
||||||
const reactionsControl = useDialogControl()
|
const reactionsControl = useDialogControl()
|
||||||
const reactionTapRef = useRef(false)
|
const reactionTapRef = useRef(false)
|
||||||
|
|
||||||
@@ -277,9 +274,7 @@ let MessageItem = ({
|
|||||||
return l`You reacted ${reaction.value}`
|
return l`You reacted ${reaction.value}`
|
||||||
} else {
|
} else {
|
||||||
const senderDid = reaction.sender.did
|
const senderDid = reaction.sender.did
|
||||||
const memberSender = convo.members.find(
|
const memberSender = item.relatedProfiles.get(senderDid)
|
||||||
member => member.did === senderDid,
|
|
||||||
)
|
|
||||||
if (memberSender) {
|
if (memberSender) {
|
||||||
return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}`
|
return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}`
|
||||||
}
|
}
|
||||||
@@ -290,7 +285,13 @@ let MessageItem = ({
|
|||||||
one: '# person',
|
one: '# person',
|
||||||
other: '# people',
|
other: '# people',
|
||||||
})} reacted – ${groupedReactions.map(g => g.value).join(' ')}`
|
})} reacted – ${groupedReactions.map(g => g.value).join(' ')}`
|
||||||
}, [reactions, groupedReactions, currentAccount?.did, convo.members, l])
|
}, [
|
||||||
|
reactions,
|
||||||
|
groupedReactions,
|
||||||
|
currentAccount?.did,
|
||||||
|
item.relatedProfiles,
|
||||||
|
l,
|
||||||
|
])
|
||||||
|
|
||||||
const appliedReactions = (
|
const appliedReactions = (
|
||||||
<LayoutAnimationConfig skipEntering skipExiting>
|
<LayoutAnimationConfig skipEntering skipExiting>
|
||||||
@@ -375,7 +376,7 @@ let MessageItem = ({
|
|||||||
) : null}
|
) : null}
|
||||||
<ReactionsDialog
|
<ReactionsDialog
|
||||||
control={reactionsControl}
|
control={reactionsControl}
|
||||||
members={convo.members}
|
relatedProfiles={item.relatedProfiles}
|
||||||
message={message}
|
message={message}
|
||||||
reactions={message.reactions}
|
reactions={message.reactions}
|
||||||
groupedReactions={groupedReactions}
|
groupedReactions={groupedReactions}
|
||||||
@@ -391,11 +392,13 @@ let MessageItem = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(hasLargeGapFromPrev || isDateDividerToggled) && (
|
<LayoutAnimationConfig skipExiting skipEntering>
|
||||||
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
|
{(hasLargeGapFromPrev || isDateDividerToggled) && (
|
||||||
<DateDivider date={message.sentAt} />
|
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
|
||||||
</Animated.View>
|
<DateDivider date={message.sentAt} />
|
||||||
)}
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</LayoutAnimationConfig>
|
||||||
<View style={[messageInset, effectiveFirstInCluster && a.mt_md]}>
|
<View style={[messageInset, effectiveFirstInCluster && a.mt_md]}>
|
||||||
<View style={[a.relative]}>
|
<View style={[a.relative]}>
|
||||||
{showAvatar ? (
|
{showAvatar ? (
|
||||||
@@ -434,6 +437,7 @@ let MessageItem = ({
|
|||||||
hasReactions={hasReactions}
|
hasReactions={hasReactions}
|
||||||
isFromSelf={isFromSelf}
|
isFromSelf={isFromSelf}
|
||||||
message={message}
|
message={message}
|
||||||
|
senderProfile={profile}
|
||||||
onTap={() => {
|
onTap={() => {
|
||||||
if (reactionTapRef.current) return
|
if (reactionTapRef.current) return
|
||||||
if (!hasLargeGapFromPrev) {
|
if (!hasLargeGapFromPrev) {
|
||||||
|
|||||||
@@ -161,11 +161,13 @@ function GroupHeaderReady({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lockStatus = convo.details.lockStatus
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper
|
<Wrapper
|
||||||
heading={
|
heading={
|
||||||
<>
|
<>
|
||||||
<AvatarBubbles size="small" profiles={convo.members} />
|
<AvatarBubbles size={40} profiles={convo.members} />
|
||||||
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
|
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
|
||||||
{convo.details.name}
|
{convo.details.name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -175,6 +177,7 @@ function GroupHeaderReady({
|
|||||||
settings={
|
settings={
|
||||||
<Button
|
<Button
|
||||||
label={l`Open group chat settings`}
|
label={l`Open group chat settings`}
|
||||||
|
disabled={lockStatus === 'locked-permanently'}
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
shape="round"
|
shape="round"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import Animated from 'react-native-reanimated'
|
import Animated from 'react-native-reanimated'
|
||||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
import {type ChatBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {HITSLOP_10} from '#/lib/constants'
|
import {HITSLOP_10} from '#/lib/constants'
|
||||||
@@ -33,13 +33,13 @@ type Reaction = {
|
|||||||
|
|
||||||
export function ReactionsDialog({
|
export function ReactionsDialog({
|
||||||
control,
|
control,
|
||||||
members,
|
relatedProfiles,
|
||||||
message,
|
message,
|
||||||
reactions,
|
reactions,
|
||||||
groupedReactions,
|
groupedReactions,
|
||||||
}: {
|
}: {
|
||||||
control: Dialog.DialogControlProps
|
control: Dialog.DialogControlProps
|
||||||
members: bsky.profile.AnyProfileView[]
|
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||||
message: ChatBskyConvoDefs.MessageView
|
message: ChatBskyConvoDefs.MessageView
|
||||||
reactions?: ChatBskyConvoDefs.ReactionView[]
|
reactions?: ChatBskyConvoDefs.ReactionView[]
|
||||||
groupedReactions?: Reaction[]
|
groupedReactions?: Reaction[]
|
||||||
@@ -100,7 +100,7 @@ export function ReactionsDialog({
|
|||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
.map(reaction => {
|
.map(reaction => {
|
||||||
const sender = members.find(m => m.did === reaction.sender.did)
|
const sender = relatedProfiles.get(reaction.sender.did)
|
||||||
if (!sender) return null
|
if (!sender) return null
|
||||||
return (
|
return (
|
||||||
<ReactionRow
|
<ReactionRow
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ export type SystemMessageInfo = {
|
|||||||
|
|
||||||
function getReferredDisplayName(
|
function getReferredDisplayName(
|
||||||
user: ChatBskyConvoDefs.SystemMessageReferredUser,
|
user: ChatBskyConvoDefs.SystemMessageReferredUser,
|
||||||
relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[],
|
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>,
|
||||||
): string | null {
|
): string | null {
|
||||||
const profile = relatedProfiles.find(p => p.did === user.did)
|
const profile = relatedProfiles.get(user.did)
|
||||||
return profile ? createSanitizedDisplayName(profile) : null
|
return profile ? createSanitizedDisplayName(profile) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSystemMessageInfo(
|
export function getSystemMessageInfo(
|
||||||
data: ChatBskyConvoDefs.SystemMessageView['data'],
|
data: ChatBskyConvoDefs.SystemMessageView['data'],
|
||||||
relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[],
|
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>,
|
||||||
): SystemMessageInfo | null {
|
): SystemMessageInfo | null {
|
||||||
if (ChatBskyConvoDefs.isSystemMessageDataAddMember(data)) {
|
if (ChatBskyConvoDefs.isSystemMessageDataAddMember(data)) {
|
||||||
const name = getReferredDisplayName(data.member, relatedProfiles)
|
const name = getReferredDisplayName(data.member, relatedProfiles)
|
||||||
|
|||||||
@@ -68,13 +68,13 @@ export type DirectConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
|
|||||||
export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & (
|
export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & (
|
||||||
| {
|
| {
|
||||||
kind: 'group'
|
kind: 'group'
|
||||||
details: ChatBskyConvoDefs.GroupConvo
|
details: $Typed<ChatBskyConvoDefs.GroupConvo>
|
||||||
primaryMember: GroupConvoMember // the owner
|
primaryMember: GroupConvoMember // the owner
|
||||||
members: Array<GroupConvoMember>
|
members: Array<GroupConvoMember>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'direct'
|
kind: 'direct'
|
||||||
details: ChatBskyConvoDefs.DirectConvo
|
details: $Typed<ChatBskyConvoDefs.DirectConvo>
|
||||||
primaryMember: DirectConvoMember // the other user
|
primaryMember: DirectConvoMember // the other user
|
||||||
members: Array<DirectConvoMember>
|
members: Array<DirectConvoMember>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export type ItemProps = ViewStyleProp & {
|
|||||||
isInvalid?: boolean
|
isInvalid?: boolean
|
||||||
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
|
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
|
||||||
hitSlop?: PressableProps['hitSlop']
|
hitSlop?: PressableProps['hitSlop']
|
||||||
|
highlightRow?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useItemContext() {
|
export function useItemContext() {
|
||||||
@@ -160,8 +161,11 @@ export function Item({
|
|||||||
style,
|
style,
|
||||||
type = 'checkbox',
|
type = 'checkbox',
|
||||||
label,
|
label,
|
||||||
|
highlightRow,
|
||||||
...rest
|
...rest
|
||||||
}: ItemProps) {
|
}: ItemProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
values: selectedValues,
|
values: selectedValues,
|
||||||
type: groupType,
|
type: groupType,
|
||||||
@@ -207,6 +211,12 @@ export function Item({
|
|||||||
[name, selected, disabled, hovered, pressed, focused, isInvalid],
|
[name, selected, disabled, hovered, pressed, focused, isInvalid],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const highlightStyle = highlightRow
|
||||||
|
? selected
|
||||||
|
? [a.rounded_full, a.p_md, {backgroundColor: t.palette.primary_50}]
|
||||||
|
: [a.rounded_full, a.p_md]
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ItemContext.Provider value={state}>
|
<ItemContext.Provider value={state}>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -232,7 +242,7 @@ export function Item({
|
|||||||
onPressOut={onPressOut}
|
onPressOut={onPressOut}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
style={[a.flex_row, a.align_center, a.gap_sm, style]}>
|
style={[a.flex_row, a.align_center, a.gap_sm, highlightStyle, style]}>
|
||||||
{typeof children === 'function' ? children(state) : children}
|
{typeof children === 'function' ? children(state) : children}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ItemContext.Provider>
|
</ItemContext.Provider>
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ export const EditBig_Stroke1_Corner0_Rounded = createSinglePathSVG({
|
|||||||
path: 'M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z',
|
path: 'M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use EditBig_Stroke2_Corner2_Rounded
|
||||||
|
*/
|
||||||
export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z',
|
path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const EditBig_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z',
|
||||||
|
})
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ export const Lock_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const Unlock_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
export const Unlock_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
path: 'M12 13a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z"/><path fill="#000" fill-rule="evenodd" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5Zm-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Z',
|
path: 'M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import {createSinglePathSVG} from './TEMPLATE'
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use SquareBehindSquare_Stroke2_Corner2_Rounded
|
||||||
|
*/
|
||||||
export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z',
|
path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const SquareBehindSquare_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z',
|
||||||
|
})
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type NotificationReason =
|
|||||||
| 'reply'
|
| 'reply'
|
||||||
| 'quote'
|
| 'quote'
|
||||||
| 'chat-message'
|
| 'chat-message'
|
||||||
|
| 'chat-reaction'
|
||||||
| 'starterpack-joined'
|
| 'starterpack-joined'
|
||||||
| 'like-via-repost'
|
| 'like-via-repost'
|
||||||
| 'repost-via-repost'
|
| 'repost-via-repost'
|
||||||
@@ -44,7 +45,7 @@ export type NotificationReason =
|
|||||||
export type NotificationPayload =
|
export type NotificationPayload =
|
||||||
| undefined
|
| undefined
|
||||||
| {
|
| {
|
||||||
reason: Exclude<NotificationReason, 'chat-message'>
|
reason: Exclude<NotificationReason, 'chat-message' | 'chat-reaction'>
|
||||||
uri: string
|
uri: string
|
||||||
subject: string
|
subject: string
|
||||||
recipientDid: string
|
recipientDid: string
|
||||||
@@ -55,6 +56,12 @@ export type NotificationPayload =
|
|||||||
messageId: string
|
messageId: string
|
||||||
recipientDid: string
|
recipientDid: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
reason: 'chat-reaction'
|
||||||
|
convoId: string
|
||||||
|
messageId: string
|
||||||
|
recipientDid: string
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_HANDLER_OPTIONS = {
|
const DEFAULT_HANDLER_OPTIONS = {
|
||||||
shouldShowBanner: false,
|
shouldShowBanner: false,
|
||||||
@@ -192,8 +199,11 @@ export function useNotificationsHandler() {
|
|||||||
const handleNotification = (payload?: NotificationPayload) => {
|
const handleNotification = (payload?: NotificationPayload) => {
|
||||||
if (!payload) return
|
if (!payload) return
|
||||||
|
|
||||||
if (payload.reason === 'chat-message') {
|
if (
|
||||||
logger.debug(`useNotificationsHandler: handling chat message`, {
|
payload.reason === 'chat-message' ||
|
||||||
|
payload.reason === 'chat-reaction'
|
||||||
|
) {
|
||||||
|
logger.debug(`useNotificationsHandler: handling chat notification`, {
|
||||||
payload,
|
payload,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -270,7 +280,8 @@ export function useNotificationsHandler() {
|
|||||||
logger.debug('useNotificationsHandler: incoming', {e, payload})
|
logger.debug('useNotificationsHandler: incoming', {e, payload})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
payload.reason === 'chat-message' &&
|
(payload.reason === 'chat-message' ||
|
||||||
|
payload.reason === 'chat-reaction') &&
|
||||||
payload.recipientDid === currentAccount?.did
|
payload.recipientDid === currentAccount?.did
|
||||||
) {
|
) {
|
||||||
const shouldAlert = payload.convoId !== currentConvoId
|
const shouldAlert = payload.convoId !== currentConvoId
|
||||||
@@ -341,7 +352,8 @@ export function useNotificationsHandler() {
|
|||||||
// Whenever there's a stored payload, that means we had to switch accounts before handling the notification.
|
// Whenever there's a stored payload, that means we had to switch accounts before handling the notification.
|
||||||
// Whenever currentAccount changes, we should try to handle it again.
|
// Whenever currentAccount changes, we should try to handle it again.
|
||||||
if (
|
if (
|
||||||
storedAccountSwitchPayload?.reason === 'chat-message' &&
|
(storedAccountSwitchPayload?.reason === 'chat-message' ||
|
||||||
|
storedAccountSwitchPayload?.reason === 'chat-reaction') &&
|
||||||
currentAccount?.did === storedAccountSwitchPayload.recipientDid
|
currentAccount?.did === storedAccountSwitchPayload.recipientDid
|
||||||
) {
|
) {
|
||||||
handleNotification(storedAccountSwitchPayload)
|
handleNotification(storedAccountSwitchPayload)
|
||||||
@@ -429,6 +441,7 @@ export function notificationToURL(payload: NotificationPayload): string | null {
|
|||||||
return `/profile/${urip.host}`
|
return `/profile/${urip.host}`
|
||||||
}
|
}
|
||||||
case 'chat-message':
|
case 'chat-message':
|
||||||
|
case 'chat-reaction':
|
||||||
// should be handled separately
|
// should be handled separately
|
||||||
return null
|
return null
|
||||||
case 'verified':
|
case 'verified':
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useAddGroupMembers} from '#/state/queries/messages/add-group-members'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
|
||||||
|
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||||
|
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
|
||||||
|
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function AddMembersLink({
|
||||||
|
convo,
|
||||||
|
members,
|
||||||
|
}: {
|
||||||
|
convo: ConvoWithDetails
|
||||||
|
members: string[]
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
const addMembersControl = Dialog.useDialogControl()
|
||||||
|
|
||||||
|
const convoId = convo.view.id
|
||||||
|
const {mutate: addGroupMembers, isPending: isAddPending} = useAddGroupMembers(
|
||||||
|
convoId,
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
addMembersControl.close()
|
||||||
|
},
|
||||||
|
onError: e => {
|
||||||
|
logger.error('Failed to add group chat members', {message: e})
|
||||||
|
Toast.show(l`Failed to add members`, {type: 'error'})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
disabled={isAddPending}
|
||||||
|
label={l`Add members`}
|
||||||
|
onPress={addMembersControl.open}>
|
||||||
|
{({interacting}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.px_xl,
|
||||||
|
a.py_sm,
|
||||||
|
interacting ? [t.atoms.bg_contrast_25] : [],
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
a.p_lg,
|
||||||
|
a.rounded_full,
|
||||||
|
interacting
|
||||||
|
? t.atoms.bg_contrast_100
|
||||||
|
: t.atoms.bg_contrast_50,
|
||||||
|
{
|
||||||
|
height: 48,
|
||||||
|
width: 48,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<PlusIcon style={[t.atoms.text_contrast_high]} size="sm" />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[a.text_md, a.font_semi_bold, a.mx_sm, t.atoms.text]}>
|
||||||
|
<Trans>Add members</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isAddPending ? (
|
||||||
|
<Loader size="md" />
|
||||||
|
) : (
|
||||||
|
<ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog.Outer
|
||||||
|
control={addMembersControl}
|
||||||
|
testID="addChatMembersDialog"
|
||||||
|
nativeOptions={{fullHeight: true}}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<AddMembersFlow
|
||||||
|
members={members}
|
||||||
|
title={l`Add members`}
|
||||||
|
onAddMembers={(members, profiles) => {
|
||||||
|
addGroupMembers({members, profiles})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Dialog.Outer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
import {moderateProfile} from '@atproto/api'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||||
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||||
|
import {
|
||||||
|
type ConvoWithDetails,
|
||||||
|
type GroupConvoMember,
|
||||||
|
} from '#/components/dms/util'
|
||||||
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {MemberMenu} from './MemberMenu'
|
||||||
|
import {StatusBadge} from './StatusBadge'
|
||||||
|
import {SubtleHoverWrapper} from './SubtleHoverWrapper'
|
||||||
|
|
||||||
|
const outerStyles = [a.px_xl, a.py_sm, a.flex_row, a.align_center, a.gap_sm]
|
||||||
|
|
||||||
|
export function Member({
|
||||||
|
convo,
|
||||||
|
profile: profileUnshadowed,
|
||||||
|
status,
|
||||||
|
isOwner,
|
||||||
|
}: {
|
||||||
|
convo: ConvoWithDetails
|
||||||
|
profile: GroupConvoMember
|
||||||
|
status: 'owner' | 'standard' | 'invited'
|
||||||
|
isOwner: boolean
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
const profile = useProfileShadow(profileUnshadowed)
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
|
if (!moderationOpts) {
|
||||||
|
return <MemberPlaceholder />
|
||||||
|
}
|
||||||
|
|
||||||
|
const moderation = moderateProfile(profile, moderationOpts)
|
||||||
|
|
||||||
|
const isDeletedAccount = profile.handle === 'missing.invalid'
|
||||||
|
const displayName = isDeletedAccount
|
||||||
|
? l`Deleted Account`
|
||||||
|
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
|
||||||
|
const isProfileOwner = profile.did === convo.primaryMember.did
|
||||||
|
const isSelf = currentAccount?.did === profile.did
|
||||||
|
let statusBadge: React.ReactNode | null = null
|
||||||
|
if (isSelf) {
|
||||||
|
if (status === 'owner') {
|
||||||
|
statusBadge = <StatusBadge label={l`Admin`} />
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statusBadge = (
|
||||||
|
<MemberMenu
|
||||||
|
convo={convo}
|
||||||
|
profile={profile}
|
||||||
|
displayName={displayName}
|
||||||
|
type={status}
|
||||||
|
isOwner={isOwner}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinedReason = profile.kind?.addedBy
|
||||||
|
? l`Added by ${createSanitizedDisplayName(
|
||||||
|
profile.kind.addedBy,
|
||||||
|
true,
|
||||||
|
moderateProfile(profile.kind.addedBy, moderationOpts).ui('displayName'),
|
||||||
|
)}`
|
||||||
|
: `Added by invite link`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubtleHoverWrapper>
|
||||||
|
<View style={outerStyles}>
|
||||||
|
<ProfileCard.Link profile={profile} style={[a.flex_1]}>
|
||||||
|
<ProfileCard.Outer>
|
||||||
|
<ProfileCard.Header>
|
||||||
|
<ProfileCard.Avatar
|
||||||
|
size={48}
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<ProfileCard.Name
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
<ProfileCard.Handle
|
||||||
|
profile={profile}
|
||||||
|
textStyle={[a.text_xs, native({top: -1})]}
|
||||||
|
/>
|
||||||
|
{!isProfileOwner && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_xs,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
web(a.pt_2xs),
|
||||||
|
]}>
|
||||||
|
{joinedReason}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ProfileCard.Header>
|
||||||
|
</ProfileCard.Outer>
|
||||||
|
</ProfileCard.Link>
|
||||||
|
{statusBadge}
|
||||||
|
</View>
|
||||||
|
</SubtleHoverWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberPlaceholder() {
|
||||||
|
return (
|
||||||
|
<View style={outerStyles}>
|
||||||
|
<ProfileCard.Outer>
|
||||||
|
<ProfileCard.Header>
|
||||||
|
<ProfileCard.AvatarPlaceholder size={48} />
|
||||||
|
<ProfileCard.NameAndHandlePlaceholder />
|
||||||
|
</ProfileCard.Header>
|
||||||
|
</ProfileCard.Outer>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import {useState} from 'react'
|
||||||
|
import {Pressable} from 'react-native'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
||||||
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {type Shadow} from '#/state/cache/types'
|
||||||
|
import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
|
||||||
|
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||||
|
import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group'
|
||||||
|
import {useProfileBlockMutationQueue} from '#/state/queries/profile'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||||
|
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
|
||||||
|
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
|
||||||
|
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
|
||||||
|
import {
|
||||||
|
Person_Stroke2_Corner2_Rounded as PersonIcon,
|
||||||
|
PersonX_Stroke2_Corner0_Rounded as PersonXIcon,
|
||||||
|
} from '#/components/icons/Person'
|
||||||
|
import * as Menu from '#/components/Menu'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
import {BlockMemberPrompt} from './prompts'
|
||||||
|
import {StatusBadge} from './StatusBadge'
|
||||||
|
|
||||||
|
export function MemberMenu({
|
||||||
|
convo,
|
||||||
|
profile,
|
||||||
|
displayName,
|
||||||
|
type,
|
||||||
|
isOwner,
|
||||||
|
}: {
|
||||||
|
convo: ConvoWithDetails
|
||||||
|
profile: Shadow<bsky.profile.AnyProfileView>
|
||||||
|
type: 'owner' | 'standard' | 'invited'
|
||||||
|
displayName: string
|
||||||
|
isOwner: boolean
|
||||||
|
}) {
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const ax = useAnalytics()
|
||||||
|
|
||||||
|
const requireEmailVerification = useRequireEmailVerification()
|
||||||
|
|
||||||
|
const blockMemberPrompt = Prompt.usePromptControl()
|
||||||
|
|
||||||
|
const [menuDidOpen, setMenuDidOpen] = useState(false)
|
||||||
|
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, {
|
||||||
|
enabled: menuDidOpen,
|
||||||
|
})
|
||||||
|
const {mutate: initiateConvo} = useGetConvoForMembers({
|
||||||
|
onSuccess: ({convo}) => {
|
||||||
|
ax.metric('chat:open', {logContext: 'ConvoSettings'})
|
||||||
|
navigation.navigate('MessagesConversation', {conversation: convo.id})
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Toast.show(l`Failed to create conversation`, {type: 'error'})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const convoId = convo.view.id
|
||||||
|
const {mutate: removeMembers} = useRemoveFromGroupChat(convoId, {
|
||||||
|
onError: e => {
|
||||||
|
logger.error('Failed to remove group chat member', {message: e})
|
||||||
|
Toast.show(l`Failed to remove group chat member`, {type: 'error'})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
|
||||||
|
|
||||||
|
const messageMember = () => {
|
||||||
|
if (!convoAvailability?.canChat) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (convoAvailability.convo) {
|
||||||
|
ax.metric('chat:open', {logContext: 'ConvoSettings'})
|
||||||
|
navigation.navigate('MessagesConversation', {
|
||||||
|
conversation: convoAvailability.convo.id,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ax.metric('chat:create', {logContext: 'ConvoSettings'})
|
||||||
|
initiateConvo([profile.did])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMessageMember = requireEmailVerification(messageMember, {
|
||||||
|
instructions: [
|
||||||
|
<Trans key="message">
|
||||||
|
Before you can message another user, you must first verify your email.
|
||||||
|
</Trans>,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleBlockMember = async () => {
|
||||||
|
if (profile.viewer?.blocking) {
|
||||||
|
try {
|
||||||
|
await queueUnblock()
|
||||||
|
Toast.show(l({message: 'Account unblocked', context: 'toast'}))
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as Error
|
||||||
|
if (e?.name !== 'AbortError') {
|
||||||
|
logger.error('Failed to unblock account', {message: e})
|
||||||
|
Toast.show(l`There was an issue! ${e.toString()}`, {
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await queueBlock()
|
||||||
|
Toast.show(l({message: 'Account blocked', context: 'toast'}))
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as Error
|
||||||
|
if (e?.name !== 'AbortError') {
|
||||||
|
logger.error('Failed to block account', {message: e})
|
||||||
|
Toast.show(l`There was an issue! ${e.toString()}`, {
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canBlockMember = type === 'owner' || type === 'standard'
|
||||||
|
const canRemoveMember = isOwner && type !== 'invited'
|
||||||
|
// TODO Need to integrate this. -dsb
|
||||||
|
const canUninviteMember = false
|
||||||
|
// const canUninviteMember = isOwner && type === 'invited'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger label={l`Open chat member options for ${displayName}`}>
|
||||||
|
{({props, state, control: menuControl}) => {
|
||||||
|
const isActive =
|
||||||
|
state.hovered || state.pressed || menuControl.isOpen
|
||||||
|
const triggerProps = {
|
||||||
|
...props,
|
||||||
|
onPress: () => {
|
||||||
|
setMenuDidOpen(true)
|
||||||
|
props.onPress()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return type === 'owner' || type === 'invited' ? (
|
||||||
|
<StatusBadge
|
||||||
|
label={type === 'owner' ? l`Admin` : l`Invited`}
|
||||||
|
pressableProps={triggerProps}
|
||||||
|
style={[
|
||||||
|
isActive
|
||||||
|
? {
|
||||||
|
backgroundColor: t.palette.contrast_0,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Pressable
|
||||||
|
{...triggerProps}
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.p_sm,
|
||||||
|
isActive
|
||||||
|
? {
|
||||||
|
backgroundColor: t.palette.contrast_0,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
]}>
|
||||||
|
<EllipsisIcon
|
||||||
|
style={[t.atoms.text_contrast_medium]}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Menu.Trigger>
|
||||||
|
<Menu.Outer>
|
||||||
|
<Menu.Group>
|
||||||
|
<Menu.Item
|
||||||
|
label={l`View ${displayName}’s profile`}
|
||||||
|
onPress={() => {
|
||||||
|
navigation.navigate('Profile', {name: profile.did})
|
||||||
|
}}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Go to profile</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={PersonIcon} />
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
label={l`Message ${displayName}`}
|
||||||
|
onPress={handleMessageMember}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans context="action">Message</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={MessageIcon} />
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Group>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Group>
|
||||||
|
{canBlockMember ? (
|
||||||
|
<Menu.Item
|
||||||
|
label={
|
||||||
|
profile.viewer?.blocking
|
||||||
|
? l`Unblock ${displayName}`
|
||||||
|
: l`Block ${displayName}`
|
||||||
|
}
|
||||||
|
onPress={
|
||||||
|
profile.viewer?.blocking
|
||||||
|
? handleBlockMember
|
||||||
|
: blockMemberPrompt.open
|
||||||
|
}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Block</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={PersonXIcon} />
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
|
{canRemoveMember ? (
|
||||||
|
<Menu.Item
|
||||||
|
label={l`Remove ${displayName} from this group chat`}
|
||||||
|
onPress={() => removeMembers({members: [profile.did]})}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Remove from chat</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
|
{canUninviteMember ? (
|
||||||
|
<Menu.Item
|
||||||
|
label={l`Uninvite ${displayName} from this group chat`}
|
||||||
|
// TODO Need to wire up the uninvite flow. -dsb
|
||||||
|
onPress={() => {}}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Uninvite</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
|
</Menu.Group>
|
||||||
|
</Menu.Outer>
|
||||||
|
</Menu.Root>
|
||||||
|
<BlockMemberPrompt
|
||||||
|
control={blockMemberPrompt}
|
||||||
|
onConfirm={() => void handleBlockMember()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
import {plural} from '@lingui/core/macro'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {InlineLinkText} from '#/components/Link'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {MEMBER_LIMIT} from './constants'
|
||||||
|
|
||||||
|
export function MembersAndRequests({
|
||||||
|
memberCount,
|
||||||
|
requestCount,
|
||||||
|
hasMoreRequests,
|
||||||
|
isOwner,
|
||||||
|
}: {
|
||||||
|
memberCount: number
|
||||||
|
requestCount: number
|
||||||
|
hasMoreRequests: boolean
|
||||||
|
isOwner: boolean
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_row, a.justify_between, a.px_xl, a.pt_xl, a.pb_sm]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Text style={[a.text_lg, a.font_semi_bold, t.atoms.text]}>
|
||||||
|
<Trans>Members</Trans>
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={[a.px_xs, a.py_2xs, t.atoms.bg_contrast_50, a.rounded_full]}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_xs, a.font_medium, {color: t.palette.contrast_500}]}>
|
||||||
|
{l({
|
||||||
|
message: `${memberCount}/${MEMBER_LIMIT}`,
|
||||||
|
comment:
|
||||||
|
'The number of group chat members out of the total number of permitted users.',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{isOwner && requestCount > 0 ? (
|
||||||
|
<InlineLinkText
|
||||||
|
label={l`View incoming group chat requests`}
|
||||||
|
style={[a.text_sm, a.text_right, a.font_semi_bold]}
|
||||||
|
// TODO Need to implement this. -dsb
|
||||||
|
to="#">
|
||||||
|
{hasMoreRequests
|
||||||
|
? l({
|
||||||
|
message: `${requestCount}+ requests`,
|
||||||
|
comment:
|
||||||
|
'Displayed when there are more than 50 requests to join a group chat',
|
||||||
|
})
|
||||||
|
: l({
|
||||||
|
message: plural(requestCount, {
|
||||||
|
one: '# request',
|
||||||
|
other: '# requests',
|
||||||
|
}),
|
||||||
|
comment: 'The number of requests to join a group chat.',
|
||||||
|
})}
|
||||||
|
</InlineLinkText>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {type TriggerChildProps} from '#/components/Menu/types'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function StatusBadge({
|
||||||
|
label,
|
||||||
|
style,
|
||||||
|
pressableProps,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
pressableProps?: TriggerChildProps['props']
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const badgeStyle = [
|
||||||
|
a.rounded_xs,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
{
|
||||||
|
paddingTop: 3,
|
||||||
|
paddingBottom: 3,
|
||||||
|
paddingLeft: 6,
|
||||||
|
paddingRight: 6,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]
|
||||||
|
|
||||||
|
const labelText = (
|
||||||
|
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (pressableProps) {
|
||||||
|
return (
|
||||||
|
<Pressable style={badgeStyle} {...pressableProps}>
|
||||||
|
{labelText}
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <View style={badgeStyle}>{labelText}</View>
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
|
import {SubtleHover} from '#/components/SubtleHover'
|
||||||
|
|
||||||
|
export function SubtleHoverWrapper({
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<unknown>) {
|
||||||
|
const {
|
||||||
|
state: hover,
|
||||||
|
onIn: onHoverIn,
|
||||||
|
onOut: onHoverOut,
|
||||||
|
} = useInteractionState()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
// Web-only
|
||||||
|
onPointerEnter={onHoverIn}
|
||||||
|
// Web-only
|
||||||
|
onPointerLeave={onHoverOut}
|
||||||
|
style={a.pointer}>
|
||||||
|
<SubtleHover hover={hover} />
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const MEMBER_LIMIT = 50
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
import {useState} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
import {StackActions, useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
|
||||||
|
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||||
|
import {
|
||||||
|
type CommonNavigatorParams,
|
||||||
|
type NativeStackScreenProps,
|
||||||
|
type NavigationProp,
|
||||||
|
} from '#/lib/routes/types'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo'
|
||||||
|
import {ConvoStatus} from '#/state/messages/convo/types'
|
||||||
|
import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name'
|
||||||
|
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
|
||||||
|
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
|
||||||
|
import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests'
|
||||||
|
import {useLockConvo} from '#/state/queries/messages/lock-conversation'
|
||||||
|
import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {List} from '#/view/com/util/List'
|
||||||
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
|
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||||
|
import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {
|
||||||
|
type ConvoWithDetails,
|
||||||
|
type GroupConvoMember,
|
||||||
|
} from '#/components/dms/util'
|
||||||
|
import {Error} from '#/components/Error'
|
||||||
|
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
|
||||||
|
import {
|
||||||
|
Bell2_Stroke2_Corner0_Rounded as BellIcon,
|
||||||
|
Bell2Off_Stroke2_Corner0_Rounded as BellOffIcon,
|
||||||
|
} from '#/components/icons/Bell2'
|
||||||
|
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
|
||||||
|
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||||
|
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
|
||||||
|
import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig'
|
||||||
|
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
||||||
|
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
|
||||||
|
import * as Layout from '#/components/Layout'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {InviteLinkDialog} from '../components/InviteLinkDialog'
|
||||||
|
import {AddMembersLink} from './AddMembersLink'
|
||||||
|
import {Member, MemberPlaceholder} from './Member'
|
||||||
|
import {MembersAndRequests} from './MembersAndRequests'
|
||||||
|
import {EditNamePrompt, LeaveChatPrompt, LockChatPrompt} from './prompts'
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
type Item =
|
||||||
|
| {type: 'MEMBERS_AND_REQUESTS'; key: string}
|
||||||
|
| {type: 'ADD_MEMBERS_LINK'; key: string}
|
||||||
|
| {
|
||||||
|
type: 'CHAT_MEMBER'
|
||||||
|
key: string
|
||||||
|
profile: GroupConvoMember
|
||||||
|
status: 'owner' | 'standard' | 'invited'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'CHAT_MEMBER_PLACEHOLDER'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<
|
||||||
|
CommonNavigatorParams,
|
||||||
|
'MessagesConversationSettings'
|
||||||
|
>
|
||||||
|
|
||||||
|
export function MessagesConversationSettingsScreen({route}: Props) {
|
||||||
|
const {gtTablet} = useBreakpoints()
|
||||||
|
|
||||||
|
const convoId = route.params.conversation
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.Screen>
|
||||||
|
<Layout.Header.Outer>
|
||||||
|
<Layout.Header.BackButton />
|
||||||
|
<Layout.Header.Content align={gtTablet ? 'left' : 'platform'}>
|
||||||
|
<Layout.Header.TitleText>
|
||||||
|
<Trans>Group chat settings</Trans>
|
||||||
|
</Layout.Header.TitleText>
|
||||||
|
</Layout.Header.Content>
|
||||||
|
<Layout.Header.Slot />
|
||||||
|
</Layout.Header.Outer>
|
||||||
|
<ConvoProvider key={convoId} convoId={convoId}>
|
||||||
|
<SettingsInner />
|
||||||
|
</ConvoProvider>
|
||||||
|
</Layout.Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsInner() {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const convoState = useConvo()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
if (convoState.status === ConvoStatus.Error) {
|
||||||
|
return (
|
||||||
|
<Error
|
||||||
|
title={l`Something went wrong`}
|
||||||
|
message={l`We couldn’t load this conversation’s 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 chat’s 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 can’t 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 won’t be able to rejoin unless you’re invited.`}
|
||||||
|
confirmButtonCta={l`Leave group chat`}
|
||||||
|
confirmButtonColor="negative"
|
||||||
|
cancelButtonCta={l`Cancel`}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlockMemberPrompt({
|
||||||
|
control,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
onConfirm: () => void
|
||||||
|
}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Prompt.Basic
|
||||||
|
control={control}
|
||||||
|
title={l`Block account?`}
|
||||||
|
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
confirmButtonCta={l`Block`}
|
||||||
|
confirmButtonColor="negative"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -298,7 +298,10 @@ function BaseChatItem({
|
|||||||
|
|
||||||
// System message
|
// System message
|
||||||
if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) {
|
if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) {
|
||||||
const info = getSystemMessageInfo(convo.lastMessage.data, convo.members)
|
const info = getSystemMessageInfo(
|
||||||
|
convo.lastMessage.data,
|
||||||
|
new Map(convo.members.map(m => [m.did, m])),
|
||||||
|
)
|
||||||
if (info) {
|
if (info) {
|
||||||
lastMessage = i18n._(info.message)
|
lastMessage = i18n._(info.message)
|
||||||
lastMessageSentAt = convo.lastMessage.sentAt
|
lastMessageSentAt = convo.lastMessage.sentAt
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {type ActiveConvoStates} from '#/state/messages/convo'
|
import {type ActiveConvoStates} from '#/state/messages/convo'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {useSession} from '#/state/session'
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||||
import {KnownFollowers} from '#/components/KnownFollowers'
|
import {KnownFollowers} from '#/components/KnownFollowers'
|
||||||
@@ -16,16 +15,15 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const {currentAccount} = useSession()
|
|
||||||
const leaveConvoControl = usePromptControl()
|
const leaveConvoControl = usePromptControl()
|
||||||
|
|
||||||
const onAcceptChat = useCallback(() => {
|
const onAcceptChat = useCallback(() => {
|
||||||
convoState.markConvoAccepted()
|
convoState.markConvoAccepted()
|
||||||
}, [convoState])
|
}, [convoState])
|
||||||
|
|
||||||
const otherUser = convoState.recipients.find(
|
// either the other person, or the chat owner
|
||||||
user => user.did !== currentAccount?.did,
|
// if we ever allow someone other than the owner to invite people, this will need to change
|
||||||
)
|
const otherUser = convoState.convo.primaryMember
|
||||||
|
|
||||||
if (!moderationOpts) {
|
if (!moderationOpts) {
|
||||||
return null
|
return null
|
||||||
@@ -44,7 +42,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
|
|||||||
{otherUser && (
|
{otherUser && (
|
||||||
<RejectMenu
|
<RejectMenu
|
||||||
label={_(msg`Block or report`)}
|
label={_(msg`Block or report`)}
|
||||||
convo={convoState.convo}
|
convo={convoState.convo.view}
|
||||||
profile={otherUser}
|
profile={otherUser}
|
||||||
color="negative_subtle"
|
color="negative_subtle"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -53,14 +51,14 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
|
|||||||
)}
|
)}
|
||||||
<DeleteChatButton
|
<DeleteChatButton
|
||||||
label={_(msg`Delete`)}
|
label={_(msg`Delete`)}
|
||||||
convo={convoState.convo}
|
convo={convoState.convo.view}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
currentScreen="conversation"
|
currentScreen="conversation"
|
||||||
onPress={leaveConvoControl.open}
|
onPress={leaveConvoControl.open}
|
||||||
/>
|
/>
|
||||||
<LeaveConvoPrompt
|
<LeaveConvoPrompt
|
||||||
convoId={convoState.convo.id}
|
convoId={convoState.convo.view.id}
|
||||||
control={leaveConvoControl}
|
control={leaveConvoControl}
|
||||||
currentScreen="conversation"
|
currentScreen="conversation"
|
||||||
hasMessages={false}
|
hasMessages={false}
|
||||||
@@ -69,7 +67,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
|
|||||||
<View style={[a.w_full, a.flex_row]}>
|
<View style={[a.w_full, a.flex_row]}>
|
||||||
<AcceptChatButton
|
<AcceptChatButton
|
||||||
onAcceptConvo={onAcceptChat}
|
onAcceptConvo={onAcceptChat}
|
||||||
convo={convoState.convo}
|
convo={convoState.convo.view}
|
||||||
color="primary_subtle"
|
color="primary_subtle"
|
||||||
size="small"
|
size="small"
|
||||||
currentScreen="conversation"
|
currentScreen="conversation"
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import {useCallback, useEffect, useState} from 'react'
|
||||||
|
import {type GestureResponderEvent, View} from 'react-native'
|
||||||
|
import Animated, {
|
||||||
|
FadeOutUp,
|
||||||
|
useReducedMotion,
|
||||||
|
ZoomIn,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
import * as Clipboard from 'expo-clipboard'
|
||||||
|
import {Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
|
||||||
|
import {SquareBehindSquare_Stroke2_Corner2_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function CopyTextButton({
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
onPress: onPressProp,
|
||||||
|
...props
|
||||||
|
}: ButtonProps & {value: string}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const [hasBeenCopied, setHasBeenCopied] = useState(false)
|
||||||
|
|
||||||
|
const isReducedMotionEnabled = useReducedMotion()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasBeenCopied) {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => setHasBeenCopied(false),
|
||||||
|
isReducedMotionEnabled ? 2000 : 100,
|
||||||
|
)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [hasBeenCopied, isReducedMotionEnabled])
|
||||||
|
|
||||||
|
const onPress = useCallback(
|
||||||
|
(evt: GestureResponderEvent) => {
|
||||||
|
void Clipboard.setStringAsync(value)
|
||||||
|
setHasBeenCopied(true)
|
||||||
|
onPressProp?.(evt)
|
||||||
|
},
|
||||||
|
[value, onPressProp],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.relative]}>
|
||||||
|
{hasBeenCopied && (
|
||||||
|
<Animated.View
|
||||||
|
entering={ZoomIn.duration(100)}
|
||||||
|
exiting={FadeOutUp.duration(2000)}
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
{bottom: '100%', right: 0},
|
||||||
|
a.justify_center,
|
||||||
|
a.gap_sm,
|
||||||
|
a.z_10,
|
||||||
|
a.pb_sm,
|
||||||
|
]}
|
||||||
|
pointerEvents="none">
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_medium,
|
||||||
|
a.text_right,
|
||||||
|
a.text_sm,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
<Trans>Copied!</Trans>
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
disabled={disabled}
|
||||||
|
style={[a.flex_1, a.justify_between, {borderRadius: 10}, style]}
|
||||||
|
onPress={onPress}
|
||||||
|
{...props}>
|
||||||
|
{context => (
|
||||||
|
<View style={[a.flex_1, a.flex_row, a.justify_between, a.p_md]}>
|
||||||
|
{typeof children === 'function' ? children(context) : children}
|
||||||
|
{disabled ? null : <ButtonIcon icon={CopyIcon} size="lg" />}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
import {Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, type ButtonProps} from '#/components/Button'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function EditTextButton({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
onPress,
|
||||||
|
...props
|
||||||
|
}: ButtonProps & {value: string}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.relative]}>
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.justify_between,
|
||||||
|
a.rounded_full,
|
||||||
|
a.border,
|
||||||
|
t.atoms.bg,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
onPress={onPress}
|
||||||
|
{...props}>
|
||||||
|
{context => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.px_md,
|
||||||
|
a.py_sm,
|
||||||
|
]}>
|
||||||
|
{typeof children === 'function' ? children(context) : children}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.ml_sm,
|
||||||
|
a.rounded_full,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
{paddingHorizontal: 10, paddingVertical: 8},
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
import {useState} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||||
|
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||||
|
import {shareUrl} from '#/lib/sharing'
|
||||||
|
import {useCreateJoinLink} from '#/state/queries/messages/create-join-link'
|
||||||
|
import {useDisableJoinLink} from '#/state/queries/messages/disable-join-link'
|
||||||
|
import {useEditJoinLink} from '#/state/queries/messages/edit-join-link'
|
||||||
|
import {useEnableJoinLink} from '#/state/queries/messages/enable-join-link'
|
||||||
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonIcon,
|
||||||
|
ButtonText,
|
||||||
|
StackedButton,
|
||||||
|
} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||||
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
|
||||||
|
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight'
|
||||||
|
import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink'
|
||||||
|
import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {IS_WEB} from '#/env'
|
||||||
|
import {CopyTextButton} from './CopyTextButton'
|
||||||
|
import {EditTextButton} from './EditTextButton'
|
||||||
|
|
||||||
|
enum Step {
|
||||||
|
INFO,
|
||||||
|
GENERATE,
|
||||||
|
MANAGE,
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: 'numeric',
|
||||||
|
})
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
export function InviteLinkDialog({
|
||||||
|
convo,
|
||||||
|
control,
|
||||||
|
isOwner,
|
||||||
|
}: {
|
||||||
|
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
isOwner: boolean
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
const ownerName = createSanitizedDisplayName(convo.primaryMember)
|
||||||
|
|
||||||
|
const {joinLink} = convo.details
|
||||||
|
const enabledStatus = joinLink?.enabledStatus
|
||||||
|
|
||||||
|
const defaultStep = joinLink ? Step.MANAGE : Step.INFO
|
||||||
|
const defaultWhoCanJoin = joinLink
|
||||||
|
? [
|
||||||
|
`${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`,
|
||||||
|
]
|
||||||
|
: ['anyone']
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>(defaultStep)
|
||||||
|
const [whoCanJoin, setWhoCanJoin] = useState(defaultWhoCanJoin)
|
||||||
|
|
||||||
|
const {openComposer} = useOpenComposer()
|
||||||
|
|
||||||
|
const {mutate: createJoinLink, isPending: isCreating} = useCreateJoinLink(
|
||||||
|
convo.view.id,
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setStep(Step.MANAGE)
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Toast.show(l`Failed to create invite link`, {
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const {mutate: editJoinLink, isPending: isEditing} = useEditJoinLink(
|
||||||
|
convo.view.id,
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setStep(Step.MANAGE)
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Toast.show(l`Failed to edit invite link`, {
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const {mutate: disableJoinLink, isPending: isDisabling} = useDisableJoinLink(
|
||||||
|
convo.view.id,
|
||||||
|
{
|
||||||
|
onError: () => {
|
||||||
|
Toast.show(l`Failed to disable invite link`, {
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const {mutate: enableJoinLink, isPending: isEnabling} = useEnableJoinLink(
|
||||||
|
convo.view.id,
|
||||||
|
{
|
||||||
|
onError: () => {
|
||||||
|
Toast.show(l`Failed to enable invite link`, {
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const isSaving = isCreating || isEditing
|
||||||
|
|
||||||
|
const whoCanJoinOptions = [
|
||||||
|
{
|
||||||
|
name: 'anyone',
|
||||||
|
owner: l`Anyone can join instantly`,
|
||||||
|
member: l`Anyone can join instantly`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'anyone:requireApproval',
|
||||||
|
owner: l`Anyone can request to join`,
|
||||||
|
member: l`Anyone can request to join`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'followedByOwner',
|
||||||
|
owner: l`People I follow can join instantly`,
|
||||||
|
member: l`People ${ownerName} follows can join instantly`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'followedByOwner:requireApproval',
|
||||||
|
owner: l`People I follow can request to join`,
|
||||||
|
member: l`People ${ownerName} follows can request to join`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
let content: React.ReactNode = null
|
||||||
|
let header: string | null = null
|
||||||
|
switch (step) {
|
||||||
|
case Step.INFO:
|
||||||
|
header = l`Invite link`
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
<View>
|
||||||
|
<Text style={[a.text_md, t.atoms.text]}>
|
||||||
|
<Trans>
|
||||||
|
An invite link lets people join this group chat without being
|
||||||
|
added directly. You control who can use the link and whether
|
||||||
|
they need your approval. You can disable the link at any time.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.mt_lg, a.text_md, t.atoms.text]}>
|
||||||
|
<Trans>
|
||||||
|
Your name, avatar, and the name of the group chat will be
|
||||||
|
visible to everyone.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[a.mt_4xl]}>
|
||||||
|
<Button
|
||||||
|
label={l`Get started`}
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
onPress={() => {
|
||||||
|
setStep(Step.GENERATE)
|
||||||
|
}}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Get started</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
<ButtonIcon icon={ArrowRightIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case Step.GENERATE:
|
||||||
|
header = l`Generate invite link`
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
<View>
|
||||||
|
<Text style={[a.text_md, t.atoms.text]}>
|
||||||
|
<Trans>Choose who can join this group chat and how.</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[a.mt_lg]}>
|
||||||
|
<Toggle.Group
|
||||||
|
label={l`Who can join this group chat and how`}
|
||||||
|
type="radio"
|
||||||
|
values={whoCanJoin}
|
||||||
|
onChange={setWhoCanJoin}>
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
{whoCanJoinOptions.map(option => (
|
||||||
|
<Toggle.Item
|
||||||
|
key={option.name}
|
||||||
|
highlightRow={true}
|
||||||
|
label={isOwner ? option.owner : option.member}
|
||||||
|
name={option.name}
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
{({selected}) => (
|
||||||
|
<TargetOption
|
||||||
|
label={isOwner ? option.owner : option.member}
|
||||||
|
selected={selected}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Toggle.Item>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Toggle.Group>
|
||||||
|
</View>
|
||||||
|
<View style={[a.mt_4xl]}>
|
||||||
|
<Button
|
||||||
|
label={l`Generate invite link`}
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
disabled={isSaving}
|
||||||
|
onPress={() => {
|
||||||
|
const parts = whoCanJoin[0].split(':')
|
||||||
|
const joinRule = parts[0]
|
||||||
|
const requireApproval = parts[1] === 'requireApproval'
|
||||||
|
if (joinLink && enabledStatus === 'enabled') {
|
||||||
|
editJoinLink({
|
||||||
|
joinRule,
|
||||||
|
requireApproval,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
createJoinLink({
|
||||||
|
joinRule,
|
||||||
|
requireApproval,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<ButtonText>
|
||||||
|
{joinLink && enabledStatus === 'enabled'
|
||||||
|
? l`Update invite link`
|
||||||
|
: l`Generate invite link`}
|
||||||
|
</ButtonText>
|
||||||
|
<ButtonIcon icon={isSaving ? Loader : ArrowRightIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case Step.MANAGE: {
|
||||||
|
const hasJoinLinkCode = joinLink && joinLink.code !== ''
|
||||||
|
const joinLinkURI = hasJoinLinkCode
|
||||||
|
? `https://bsky.app/chat/${joinLink.code}`
|
||||||
|
: 'https://bsky.app/chat'
|
||||||
|
const createdAt = joinLink ? new Date(joinLink.createdAt) : null
|
||||||
|
const currentOption = whoCanJoinOptions.find(
|
||||||
|
o => o.name === whoCanJoin[0],
|
||||||
|
)
|
||||||
|
const ownerValue = currentOption?.owner ?? whoCanJoinOptions[0].owner
|
||||||
|
const memberValue = currentOption?.member ?? whoCanJoinOptions[0].member
|
||||||
|
header =
|
||||||
|
enabledStatus === 'enabled' ? l`Invite link` : l`Invite link disabled`
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
<View style={[a.mt_lg]}>
|
||||||
|
<CopyTextButton
|
||||||
|
disabled={enabledStatus === 'disabled' || !hasJoinLinkCode}
|
||||||
|
label={l`Invite link`}
|
||||||
|
value={joinLinkURI}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[
|
||||||
|
a.mr_xs,
|
||||||
|
a.text_md,
|
||||||
|
enabledStatus === 'disabled'
|
||||||
|
? t.atoms.text_contrast_low
|
||||||
|
: t.atoms.text,
|
||||||
|
]}>
|
||||||
|
{joinLinkURI}
|
||||||
|
</Text>
|
||||||
|
</CopyTextButton>
|
||||||
|
{createdAt ? (
|
||||||
|
<Text style={[a.mt_xs, a.text_xs, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>
|
||||||
|
Created {timeFormatter.format(createdAt)}{' '}
|
||||||
|
{dateFormatter.format(createdAt)}
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{enabledStatus === 'enabled' ? (
|
||||||
|
<View style={[a.mt_lg]}>
|
||||||
|
{isOwner ? (
|
||||||
|
<EditTextButton
|
||||||
|
label={l`Edit link settings`}
|
||||||
|
value={ownerValue}
|
||||||
|
onPress={() => setStep(Step.GENERATE)}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[
|
||||||
|
a.mr_xs,
|
||||||
|
a.text_md,
|
||||||
|
t.atoms.text,
|
||||||
|
{maxWidth: '80%'},
|
||||||
|
]}>
|
||||||
|
{ownerValue}
|
||||||
|
</Text>
|
||||||
|
</EditTextButton>
|
||||||
|
) : (
|
||||||
|
<Text style={[a.text_sm, t.atoms.text]}>{memberValue}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{enabledStatus === 'enabled' ? (
|
||||||
|
<View style={[a.flex_row, a.justify_between, a.gap_sm, a.mt_lg]}>
|
||||||
|
{isOwner ? (
|
||||||
|
<StackedButton
|
||||||
|
label={l`Disable`}
|
||||||
|
icon={isDisabling ? Loader : ChainLinkBrokenIcon}
|
||||||
|
color="negative_subtle"
|
||||||
|
style={[a.flex_1, a.rounded_full]}
|
||||||
|
disabled={isDisabling}
|
||||||
|
onPress={() => {
|
||||||
|
disableJoinLink()
|
||||||
|
}}>
|
||||||
|
<Trans>Disable</Trans>
|
||||||
|
</StackedButton>
|
||||||
|
) : null}
|
||||||
|
<StackedButton
|
||||||
|
disabled={enabledStatus === 'disabled'}
|
||||||
|
label={l`Post link`}
|
||||||
|
icon={EditIcon}
|
||||||
|
color="primary_subtle"
|
||||||
|
style={[a.flex_1, a.rounded_full]}
|
||||||
|
onPress={() => {
|
||||||
|
control.close(() => {
|
||||||
|
openComposer({
|
||||||
|
text: joinLinkURI,
|
||||||
|
logContext: 'Other',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<Trans>Post link</Trans>
|
||||||
|
</StackedButton>
|
||||||
|
<StackedButton
|
||||||
|
disabled={enabledStatus === 'disabled'}
|
||||||
|
label={l`Share`}
|
||||||
|
icon={ArrowShareRightIcon}
|
||||||
|
color="primary_subtle"
|
||||||
|
style={[a.flex_1, a.rounded_full]}
|
||||||
|
onPress={() => {
|
||||||
|
void shareUrl(joinLinkURI)
|
||||||
|
}}>
|
||||||
|
<Trans>Share</Trans>
|
||||||
|
</StackedButton>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[a.gap_md, a.mt_lg]}>
|
||||||
|
<Button
|
||||||
|
label={l`Re-enable invite link`}
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
disabled={isEnabling}
|
||||||
|
onPress={() => {
|
||||||
|
enableJoinLink()
|
||||||
|
}}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Re-enable link</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{isEnabling && <ButtonIcon icon={Loader} />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
label={l`Generate new invite link`}
|
||||||
|
color="secondary"
|
||||||
|
size="large"
|
||||||
|
onPress={() => setStep(Step.GENERATE)}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Generate new link</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOwner && (!joinLink || joinLink?.enabledStatus === 'disabled')) {
|
||||||
|
header = l`Invite link`
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
<View style={[a.mt_lg]}>
|
||||||
|
<Text style={[a.text_sm, t.atoms.text]}>
|
||||||
|
<Trans>There is no invite link for this group chat.</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[a.gap_md, a.mt_lg]}>
|
||||||
|
<Button
|
||||||
|
label={l`Close`}
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
onPress={() => control.close()}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Outer
|
||||||
|
control={control}
|
||||||
|
onClose={() => {
|
||||||
|
setStep(defaultStep)
|
||||||
|
setWhoCanJoin(defaultWhoCanJoin)
|
||||||
|
}}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
header={
|
||||||
|
<View>
|
||||||
|
<View style={[IS_WEB ? [a.px_2xl, a.pt_xl] : {paddingTop: 10}]}>
|
||||||
|
<Text style={[a.font_bold, a.text_2xl, a.mb_sm, t.atoms.text]}>
|
||||||
|
{header}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Dialog.Close />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
label={l`Group chat invite link dialog`}
|
||||||
|
style={web({maxWidth: 400})}>
|
||||||
|
{content}
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetOption({label, selected}: {label: string; selected: boolean}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText
|
||||||
|
style={[
|
||||||
|
a.font_normal,
|
||||||
|
a.flex_1,
|
||||||
|
a.leading_tight,
|
||||||
|
selected ? t.atoms.text : t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
{label}
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -250,10 +250,8 @@ export function MessagesList({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onStartReached = useCallback(() => {
|
const onStartReached = useCallback(() => {
|
||||||
if (hasScrolled && prevContentHeight.current > layoutHeight.get()) {
|
void convoState.fetchMessageHistory()
|
||||||
void convoState.fetchMessageHistory()
|
}, [convoState])
|
||||||
}
|
|
||||||
}, [convoState, hasScrolled, layoutHeight])
|
|
||||||
|
|
||||||
const onScroll = useCallback(
|
const onScroll = useCallback(
|
||||||
(e: ScrollEvent) => {
|
(e: ScrollEvent) => {
|
||||||
@@ -376,10 +374,7 @@ export function MessagesList({
|
|||||||
return (
|
return (
|
||||||
<MessageItem
|
<MessageItem
|
||||||
item={item}
|
item={item}
|
||||||
profile={convoState.convo.members.find(
|
isGroupChat={convoState.convo.kind === 'group'}
|
||||||
member => member.did === item.message.sender.did,
|
|
||||||
)}
|
|
||||||
isGroupChat={convoState.isGroup()}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else if (item.type === 'deleted-message') {
|
} else if (item.type === 'deleted-message') {
|
||||||
@@ -448,8 +443,9 @@ export function MessagesList({
|
|||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<>
|
<>
|
||||||
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
||||||
{convoState.isGroup() && convoState.hasAllHistory ? (
|
{convoState.convo?.kind === 'group' &&
|
||||||
<MessagesListInfoPanel convoState={convoState} />
|
convoState.hasAllHistory ? (
|
||||||
|
<MessagesListInfoPanel convo={convoState.convo} />
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -577,7 +573,7 @@ function getFooterState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (convoState.convo.status === 'request' && !hasAcceptOverride) {
|
if (convoState.convo.view.status === 'request' && !hasAcceptOverride) {
|
||||||
return 'request'
|
return 'request'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +1,90 @@
|
|||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {type ConvoState} from '#/state/messages/convo/types'
|
import {logger} from '#/logger'
|
||||||
|
import {useAddGroupMembers} from '#/state/queries/messages/add-group-members'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
|
import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
|
||||||
|
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||||
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
|
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
|
||||||
import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
|
import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {InviteLinkDialog} from './InviteLinkDialog'
|
||||||
|
|
||||||
export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
|
export function MessagesListInfoPanel({
|
||||||
|
convo,
|
||||||
|
}: {
|
||||||
|
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||||
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
const addMembersControl = Dialog.useDialogControl()
|
const addMembersControl = Dialog.useDialogControl()
|
||||||
|
const inviteLinkControl = Dialog.useDialogControl()
|
||||||
|
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
const isOwner =
|
const convoId = convo.view.id
|
||||||
currentAccount?.did == null
|
const {mutate: addGroupMembers} = useAddGroupMembers(convoId, {
|
||||||
? false
|
onSuccess: () => {
|
||||||
: convoState.getPrimaryMember?.()?.did === currentAccount.did
|
addMembersControl.close()
|
||||||
// TODO Get this from @api/atproto - dsb
|
},
|
||||||
const isLinkEnabled = false
|
onError: e => {
|
||||||
|
logger.error('Failed to add group chat members', {message: e})
|
||||||
|
Toast.show(l`Failed to add members`, {type: 'error'})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const groupName = convoState.getGroupInfo?.()?.name
|
// TODO Enable this once the feature is working end-to-end. -dsb
|
||||||
|
// const joinLink = groupConvo?.details.joinLink
|
||||||
|
const isJoinLinkEnabled = false
|
||||||
|
// (isOwner && groupConvo) ||
|
||||||
|
// (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled')
|
||||||
|
|
||||||
const members = (convoState?.convo?.members ?? []).filter(
|
const isOwner = convo?.primaryMember.did === currentAccount?.did
|
||||||
|
|
||||||
|
const members = (convo?.members ?? []).filter(
|
||||||
profile => profile.did !== currentAccount?.did,
|
profile => profile.did !== currentAccount?.did,
|
||||||
)
|
)
|
||||||
|
|
||||||
let names: React.ReactNode | null = null
|
let names: React.ReactNode = null
|
||||||
if (members.length === 1) {
|
if (members.length === 1) {
|
||||||
names = <Trans>New chat with {members[0].displayName}</Trans>
|
names = <Trans>New chat with {members[0].displayName}</Trans>
|
||||||
}
|
} else if (members.length === 2) {
|
||||||
if (members.length === 2) {
|
|
||||||
names = (
|
names = (
|
||||||
<Trans>
|
<Trans>
|
||||||
New chat with {members[0].displayName} and {members[1].displayName}
|
New chat with {members[0].displayName} and {members[1].displayName}
|
||||||
</Trans>
|
</Trans>
|
||||||
)
|
)
|
||||||
}
|
} else if (members.length > 2) {
|
||||||
if (members.length > 2) {
|
const memberCount = convo.details.memberCount - 2
|
||||||
names = (
|
names = (
|
||||||
<Trans>
|
<Trans>
|
||||||
New chat with {members[0].displayName}, {members[1].displayName}, and{' '}
|
New chat with {members[0].displayName}, {members[1].displayName}, and{' '}
|
||||||
<Plural
|
<Plural
|
||||||
value={members.length - 2}
|
value={memberCount}
|
||||||
one={`${members.length - 2} more`}
|
one={`${memberCount} more`}
|
||||||
other={`${members.length - 2} more`}
|
other={`${memberCount} more`}
|
||||||
/>
|
/>
|
||||||
.
|
.
|
||||||
</Trans>
|
</Trans>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showButtons = isOwner || isLinkEnabled
|
const showButtons = isOwner || isJoinLinkEnabled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={[a.align_center, a.justify_center]}>
|
<View style={[a.align_center, a.justify_center]}>
|
||||||
<AvatarBubbles animate={true} profiles={members} />
|
<AvatarBubbles animate={true} profiles={convo?.members} />
|
||||||
{groupName ? (
|
{convo.details.name ? (
|
||||||
<Text style={[a.text_2xl, a.font_bold, a.mt_lg, t.atoms.text]}>
|
<Text style={[a.text_2xl, a.font_bold, a.mt_lg, t.atoms.text]}>
|
||||||
{groupName}
|
{convo.details.name}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{names ? (
|
{names ? (
|
||||||
@@ -102,12 +120,16 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
|
|||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{isOwner || isLinkEnabled ? (
|
{isJoinLinkEnabled ? (
|
||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
label={l`Click here to view or create an invite link for this group chat`}
|
label={
|
||||||
onPress={() => {}}>
|
isOwner
|
||||||
|
? l`Click here to create or manage an invite link for this group chat`
|
||||||
|
: l`Click here to view the invite link for this group chat`
|
||||||
|
}
|
||||||
|
onPress={inviteLinkControl.open}>
|
||||||
<ButtonIcon icon={ChainLinkIcon} />
|
<ButtonIcon icon={ChainLinkIcon} />
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Invite link</Trans>
|
<Trans>Invite link</Trans>
|
||||||
@@ -117,17 +139,22 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
<InviteLinkDialog
|
||||||
|
isOwner={isOwner}
|
||||||
|
convo={convo}
|
||||||
|
control={inviteLinkControl}
|
||||||
|
/>
|
||||||
<Dialog.Outer
|
<Dialog.Outer
|
||||||
control={addMembersControl}
|
control={addMembersControl}
|
||||||
testID="addChatMembersDialog"
|
testID="addChatMembersDialog"
|
||||||
nativeOptions={{fullHeight: true}}>
|
nativeOptions={{fullHeight: true}}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
<AddMembersFlow
|
<AddMembersFlow
|
||||||
|
members={members.map(profile => profile.did)}
|
||||||
title={l`Add people`}
|
title={l`Add people`}
|
||||||
onAddMembers={(_dids: string[]) => {
|
onAddMembers={(members, profiles) =>
|
||||||
// TODO Add members here
|
addGroupMembers({members, profiles})
|
||||||
addMembersControl.close()
|
}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Dialog.Outer>
|
</Dialog.Outer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Vendored
+2
@@ -11,6 +11,7 @@ import {findAllProfilesInQueryData as findAllProfilesInContactMatchesQueryData}
|
|||||||
import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers'
|
import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members'
|
import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations'
|
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations'
|
||||||
|
import {findAllProfilesInQueryData as findAllProfilesInMessagesQueryData} from '#/state/queries/messages/list-convo-members'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts'
|
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts'
|
import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed'
|
import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed'
|
||||||
@@ -264,4 +265,5 @@ function* findProfilesInCache(
|
|||||||
yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did)
|
yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did)
|
||||||
yield* findAllProfilesInNotifsQueryData(queryClient, did)
|
yield* findAllProfilesInNotifsQueryData(queryClient, did)
|
||||||
yield* findAllProfilesInContactMatchesQueryData(queryClient, did)
|
yield* findAllProfilesInContactMatchesQueryData(queryClient, did)
|
||||||
|
yield* findAllProfilesInMessagesQueryData(queryClient, did)
|
||||||
}
|
}
|
||||||
|
|||||||
+224
-192
@@ -1,9 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
type AtpAgent,
|
type AtpAgent,
|
||||||
ChatBskyActorDefs,
|
type ChatBskyActorDefs,
|
||||||
ChatBskyConvoDefs,
|
ChatBskyConvoDefs,
|
||||||
type ChatBskyConvoGetLog,
|
type ChatBskyConvoGetLog,
|
||||||
type ChatBskyConvoSendMessage,
|
type ChatBskyConvoSendMessage,
|
||||||
|
type ChatBskyGroupDefs,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {XRPCError} from '@atproto/api'
|
import {XRPCError} from '@atproto/api'
|
||||||
import {EventEmitter} from 'eventemitter3'
|
import {EventEmitter} from 'eventemitter3'
|
||||||
@@ -36,8 +37,12 @@ import {
|
|||||||
} from '#/state/messages/convo/types'
|
} from '#/state/messages/convo/types'
|
||||||
import {type MessagesEventBus} from '#/state/messages/events/agent'
|
import {type MessagesEventBus} from '#/state/messages/events/agent'
|
||||||
import {type MessagesEventBusError} from '#/state/messages/events/types'
|
import {type MessagesEventBusError} from '#/state/messages/events/types'
|
||||||
|
import {
|
||||||
|
type ConvoWithDetails,
|
||||||
|
type GroupConvoMember,
|
||||||
|
parseConvoView,
|
||||||
|
} from '#/components/dms/util'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {IS_NATIVE} from '#/env'
|
||||||
import * as bsky from '#/types/bsky'
|
|
||||||
|
|
||||||
const logger = Logger.create(Logger.Context.ConversationAgent)
|
const logger = Logger.create(Logger.Context.ConversationAgent)
|
||||||
|
|
||||||
@@ -102,10 +107,8 @@ export class Convo {
|
|||||||
{id: string; message: ChatBskyConvoSendMessage.InputSchema['message']}
|
{id: string; message: ChatBskyConvoSendMessage.InputSchema['message']}
|
||||||
> = new Map()
|
> = new Map()
|
||||||
private deletedMessages: Set<string> = new Set()
|
private deletedMessages: Set<string> = new Set()
|
||||||
private systemMessageProfiles: Map<
|
private relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> =
|
||||||
string,
|
new Map()
|
||||||
ChatBskyActorDefs.ProfileViewBasic
|
|
||||||
> = new Map()
|
|
||||||
|
|
||||||
private isProcessingPendingMessages = false
|
private isProcessingPendingMessages = false
|
||||||
|
|
||||||
@@ -114,7 +117,7 @@ export class Convo {
|
|||||||
private emitter = new EventEmitter<{event: [ConvoEvent]}>()
|
private emitter = new EventEmitter<{event: [ConvoEvent]}>()
|
||||||
|
|
||||||
convoId: string
|
convoId: string
|
||||||
convo: ChatBskyConvoDefs.ConvoView | undefined
|
convo: ConvoWithDetails | undefined
|
||||||
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
|
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
|
||||||
recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
|
recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
|
||||||
snapshot: ConvoState | undefined
|
snapshot: ConvoState | undefined
|
||||||
@@ -130,6 +133,7 @@ export class Convo {
|
|||||||
this.setupPlaceholderData(params.placeholderData)
|
this.setupPlaceholderData(params.placeholderData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.setConvo = this.setConvo.bind(this)
|
||||||
this.subscribe = this.subscribe.bind(this)
|
this.subscribe = this.subscribe.bind(this)
|
||||||
this.getSnapshot = this.getSnapshot.bind(this)
|
this.getSnapshot = this.getSnapshot.bind(this)
|
||||||
this.sendMessage = this.sendMessage.bind(this)
|
this.sendMessage = this.sendMessage.bind(this)
|
||||||
@@ -141,10 +145,10 @@ export class Convo {
|
|||||||
this.markConvoAccepted = this.markConvoAccepted.bind(this)
|
this.markConvoAccepted = this.markConvoAccepted.bind(this)
|
||||||
this.addReaction = this.addReaction.bind(this)
|
this.addReaction = this.addReaction.bind(this)
|
||||||
this.removeReaction = this.removeReaction.bind(this)
|
this.removeReaction = this.removeReaction.bind(this)
|
||||||
this.isGroup = this.isGroup.bind(this)
|
|
||||||
this.getGroupInfo = this.getGroupInfo.bind(this)
|
|
||||||
this.getPrimaryMember = this.getPrimaryMember.bind(this)
|
|
||||||
this.updateGroupName = this.updateGroupName.bind(this)
|
this.updateGroupName = this.updateGroupName.bind(this)
|
||||||
|
this.updateGroupMembers = this.updateGroupMembers.bind(this)
|
||||||
|
this.updateJoinLink = this.updateJoinLink.bind(this)
|
||||||
|
this.updateLockStatus = this.updateLockStatus.bind(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private commit() {
|
private commit() {
|
||||||
@@ -172,6 +176,30 @@ export class Convo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generateSnapshot(): ConvoState {
|
private generateSnapshot(): ConvoState {
|
||||||
|
const shared = {
|
||||||
|
isFetchingHistory: this.isFetchingHistory,
|
||||||
|
// Explicit null check since the value is initially undefined.
|
||||||
|
hasAllHistory: this.oldestRev === null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const methods = {
|
||||||
|
deleteMessage: this.deleteMessage,
|
||||||
|
sendMessage: this.sendMessage,
|
||||||
|
fetchMessageHistory: this.fetchMessageHistory,
|
||||||
|
markConvoAccepted: this.markConvoAccepted,
|
||||||
|
addReaction: this.addReaction,
|
||||||
|
removeReaction: this.removeReaction,
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMethods = {
|
||||||
|
deleteMessage: undefined,
|
||||||
|
sendMessage: undefined,
|
||||||
|
fetchMessageHistory: undefined,
|
||||||
|
markConvoAccepted: undefined,
|
||||||
|
addReaction: undefined,
|
||||||
|
removeReaction: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
switch (this.status) {
|
switch (this.status) {
|
||||||
case ConvoStatus.Initializing: {
|
case ConvoStatus.Initializing: {
|
||||||
return {
|
return {
|
||||||
@@ -179,45 +207,48 @@ export class Convo {
|
|||||||
items: [],
|
items: [],
|
||||||
convo: this.convo,
|
convo: this.convo,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
sender: this.sender,
|
...shared,
|
||||||
recipients: this.recipients,
|
...emptyMethods,
|
||||||
isFetchingHistory: this.isFetchingHistory,
|
}
|
||||||
// Explicit null check since the value is initially undefined.
|
}
|
||||||
hasAllHistory: this.oldestRev === null,
|
case ConvoStatus.Disabled: {
|
||||||
deleteMessage: undefined,
|
return {
|
||||||
sendMessage: undefined,
|
status: this.status,
|
||||||
fetchMessageHistory: undefined,
|
items: this.getItems(),
|
||||||
markConvoAccepted: undefined,
|
convo: this.convo!,
|
||||||
addReaction: undefined,
|
error: undefined,
|
||||||
removeReaction: undefined,
|
...shared,
|
||||||
isGroup: this.isGroup,
|
...methods,
|
||||||
getGroupInfo: this.getGroupInfo,
|
}
|
||||||
getPrimaryMember: this.getPrimaryMember,
|
}
|
||||||
|
case ConvoStatus.Suspended: {
|
||||||
|
return {
|
||||||
|
status: this.status,
|
||||||
|
items: this.getItems(),
|
||||||
|
convo: this.convo!,
|
||||||
|
error: undefined,
|
||||||
|
...shared,
|
||||||
|
...methods,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ConvoStatus.Backgrounded: {
|
||||||
|
return {
|
||||||
|
status: this.status,
|
||||||
|
items: this.getItems(),
|
||||||
|
convo: this.convo!,
|
||||||
|
error: undefined,
|
||||||
|
...shared,
|
||||||
|
...methods,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ConvoStatus.Disabled:
|
|
||||||
case ConvoStatus.Suspended:
|
|
||||||
case ConvoStatus.Backgrounded:
|
|
||||||
case ConvoStatus.Ready: {
|
case ConvoStatus.Ready: {
|
||||||
return {
|
return {
|
||||||
status: this.status,
|
status: this.status,
|
||||||
items: this.getItems(),
|
items: this.getItems(),
|
||||||
convo: this.convo!,
|
convo: this.convo!,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
sender: this.sender!,
|
...shared,
|
||||||
recipients: this.recipients!,
|
...methods,
|
||||||
isFetchingHistory: this.isFetchingHistory,
|
|
||||||
// Explicit null check since the value is initially undefined.
|
|
||||||
hasAllHistory: this.oldestRev === null,
|
|
||||||
deleteMessage: this.deleteMessage,
|
|
||||||
sendMessage: this.sendMessage,
|
|
||||||
fetchMessageHistory: this.fetchMessageHistory,
|
|
||||||
markConvoAccepted: this.markConvoAccepted,
|
|
||||||
addReaction: this.addReaction,
|
|
||||||
removeReaction: this.removeReaction,
|
|
||||||
isGroup: this.isGroup,
|
|
||||||
getGroupInfo: this.getGroupInfo,
|
|
||||||
getPrimaryMember: this.getPrimaryMember,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ConvoStatus.Error: {
|
case ConvoStatus.Error: {
|
||||||
@@ -226,19 +257,9 @@ export class Convo {
|
|||||||
items: [],
|
items: [],
|
||||||
convo: undefined,
|
convo: undefined,
|
||||||
error: this.error!,
|
error: this.error!,
|
||||||
sender: undefined,
|
|
||||||
recipients: undefined,
|
|
||||||
isFetchingHistory: false,
|
isFetchingHistory: false,
|
||||||
hasAllHistory: false,
|
hasAllHistory: false,
|
||||||
deleteMessage: undefined,
|
...emptyMethods,
|
||||||
sendMessage: undefined,
|
|
||||||
fetchMessageHistory: undefined,
|
|
||||||
markConvoAccepted: undefined,
|
|
||||||
addReaction: undefined,
|
|
||||||
removeReaction: undefined,
|
|
||||||
isGroup: undefined,
|
|
||||||
getGroupInfo: undefined,
|
|
||||||
getPrimaryMember: undefined,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
@@ -247,20 +268,10 @@ export class Convo {
|
|||||||
items: [],
|
items: [],
|
||||||
convo: this.convo,
|
convo: this.convo,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
sender: this.sender,
|
|
||||||
recipients: this.recipients,
|
|
||||||
isFetchingHistory: false,
|
isFetchingHistory: false,
|
||||||
// Explicit null check since the value is initially undefined.
|
// Explicit null check since the value is initially undefined.
|
||||||
hasAllHistory: this.oldestRev === null,
|
hasAllHistory: this.oldestRev === null,
|
||||||
deleteMessage: undefined,
|
...emptyMethods,
|
||||||
sendMessage: undefined,
|
|
||||||
fetchMessageHistory: undefined,
|
|
||||||
markConvoAccepted: undefined,
|
|
||||||
addReaction: undefined,
|
|
||||||
removeReaction: undefined,
|
|
||||||
isGroup: this.isGroup,
|
|
||||||
getGroupInfo: this.getGroupInfo,
|
|
||||||
getPrimaryMember: this.getPrimaryMember,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,8 +471,6 @@ export class Convo {
|
|||||||
|
|
||||||
private reset() {
|
private reset() {
|
||||||
this.convo = undefined
|
this.convo = undefined
|
||||||
this.sender = undefined
|
|
||||||
this.recipients = undefined
|
|
||||||
this.snapshot = undefined
|
this.snapshot = undefined
|
||||||
|
|
||||||
this.status = ConvoStatus.Uninitialized
|
this.status = ConvoStatus.Uninitialized
|
||||||
@@ -473,7 +482,7 @@ export class Convo {
|
|||||||
this.newMessages = new Map()
|
this.newMessages = new Map()
|
||||||
this.pendingMessages = new Map()
|
this.pendingMessages = new Map()
|
||||||
this.deletedMessages = new Set()
|
this.deletedMessages = new Set()
|
||||||
this.systemMessageProfiles = new Map()
|
this.relatedProfiles = new Map()
|
||||||
|
|
||||||
this.pendingMessageFailure = null
|
this.pendingMessageFailure = null
|
||||||
this.fetchMessageHistoryError = undefined
|
this.fetchMessageHistoryError = undefined
|
||||||
@@ -498,6 +507,26 @@ export class Convo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setConvo(convo: ChatBskyConvoDefs.ConvoView) {
|
||||||
|
this.convo = parseConvoView(convo, this.senderUserDid) ?? this.convo
|
||||||
|
if (this.convo) {
|
||||||
|
for (const member of this.convo.members) {
|
||||||
|
this.relatedProfiles.set(member.did, member)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateConvo(convo: Partial<ChatBskyConvoDefs.ConvoView>) {
|
||||||
|
if (this.convo) {
|
||||||
|
this.convo =
|
||||||
|
parseConvoView({...this.convo.view, ...convo}, this.senderUserDid) ??
|
||||||
|
this.convo
|
||||||
|
for (const member of this.convo.members) {
|
||||||
|
this.relatedProfiles.set(member.did, member)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialises the convo with placeholder data, if provided. We still refetch it before rendering the convo,
|
* Initialises the convo with placeholder data, if provided. We still refetch it before rendering the convo,
|
||||||
* but this allows us to render the convo header immediately.
|
* but this allows us to render the convo header immediately.
|
||||||
@@ -505,20 +534,14 @@ export class Convo {
|
|||||||
private setupPlaceholderData(
|
private setupPlaceholderData(
|
||||||
data: NonNullable<ConvoParams['placeholderData']>,
|
data: NonNullable<ConvoParams['placeholderData']>,
|
||||||
) {
|
) {
|
||||||
this.convo = data.convo
|
this.setConvo(data.convo)
|
||||||
this.sender = data.convo.members.find(m => m.did === this.senderUserDid)
|
|
||||||
this.recipients = data.convo.members.filter(
|
|
||||||
m => m.did !== this.senderUserDid,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setup() {
|
private async setup() {
|
||||||
try {
|
try {
|
||||||
const {convo, sender, recipients} = await this.fetchConvo()
|
const {convo} = await this.fetchConvo()
|
||||||
|
|
||||||
this.convo = convo
|
this.setConvo(convo)
|
||||||
this.sender = sender
|
|
||||||
this.recipients = recipients
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Some validation prior to `Ready` status
|
* Some validation prior to `Ready` status
|
||||||
@@ -526,14 +549,14 @@ export class Convo {
|
|||||||
if (!this.convo) {
|
if (!this.convo) {
|
||||||
throw new Error('could not find convo')
|
throw new Error('could not find convo')
|
||||||
}
|
}
|
||||||
if (!this.sender) {
|
|
||||||
throw new Error('could not find sender in convo')
|
const self = this.convo.members.find(m => m.did === this.senderUserDid)
|
||||||
}
|
|
||||||
if (!this.recipients) {
|
if (!self) {
|
||||||
throw new Error('could not find recipients in convo')
|
throw new Error('could not find self in convo')
|
||||||
}
|
}
|
||||||
|
|
||||||
const userIsDisabled = Boolean(this.sender.chatDisabled)
|
const userIsDisabled = Boolean(self.chatDisabled)
|
||||||
|
|
||||||
if (userIsDisabled) {
|
if (userIsDisabled) {
|
||||||
this.dispatch({event: ConvoDispatchEvent.Disable})
|
this.dispatch({event: ConvoDispatchEvent.Disable})
|
||||||
@@ -602,22 +625,19 @@ export class Convo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private pendingFetchConvo:
|
private pendingFetchConvo:
|
||||||
| Promise<{
|
| Promise<{convo: ChatBskyConvoDefs.ConvoView}>
|
||||||
convo: ChatBskyConvoDefs.ConvoView
|
|
||||||
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
|
|
||||||
recipients: ChatBskyActorDefs.ProfileViewBasic[]
|
|
||||||
}>
|
|
||||||
| undefined
|
| undefined
|
||||||
async fetchConvo() {
|
async fetchConvo() {
|
||||||
if (this.pendingFetchConvo) return this.pendingFetchConvo
|
if (this.pendingFetchConvo) return this.pendingFetchConvo
|
||||||
|
|
||||||
|
// non-blocking
|
||||||
|
void this.fetchMemberList()
|
||||||
|
|
||||||
this.pendingFetchConvo = (async () => {
|
this.pendingFetchConvo = (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await networkRetry(2, () => {
|
const response = await networkRetry(2, () => {
|
||||||
return this.agent.api.chat.bsky.convo.getConvo(
|
return this.agent.chat.bsky.convo.getConvo(
|
||||||
{
|
{convoId: this.convoId},
|
||||||
convoId: this.convoId,
|
|
||||||
},
|
|
||||||
{headers: DM_SERVICE_HEADERS},
|
{headers: DM_SERVICE_HEADERS},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -626,8 +646,6 @@ export class Convo {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
convo,
|
convo,
|
||||||
sender: convo.members.find(m => m.did === this.senderUserDid),
|
|
||||||
recipients: convo.members.filter(m => m.did !== this.senderUserDid),
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.pendingFetchConvo = undefined
|
this.pendingFetchConvo = undefined
|
||||||
@@ -639,11 +657,10 @@ export class Convo {
|
|||||||
|
|
||||||
async refreshConvo() {
|
async refreshConvo() {
|
||||||
try {
|
try {
|
||||||
const {convo, sender, recipients} = await this.fetchConvo()
|
void this.fetchMemberList()
|
||||||
|
const {convo} = await this.fetchConvo()
|
||||||
// throw new Error('UNCOMMENT TO TEST REFRESH FAILURE')
|
// throw new Error('UNCOMMENT TO TEST REFRESH FAILURE')
|
||||||
this.convo = convo || this.convo
|
this.setConvo(convo)
|
||||||
this.sender = sender || this.sender
|
|
||||||
this.recipients = recipients || this.recipients
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error
|
const e = err as Error
|
||||||
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
|
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
|
||||||
@@ -654,11 +671,32 @@ export class Convo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchMessageHistoryError:
|
// purely for populating `this.relatedProfiles` - we do not pipe it
|
||||||
| {
|
// into the ConvoWithDetails. If you want to drive UI based on the member list,
|
||||||
retry: () => void
|
// use `useListConvoMembersQuery`
|
||||||
|
// we shouldn't also block loading off of this - the UI should be resilient
|
||||||
|
async fetchMemberList() {
|
||||||
|
let cursor: string | undefined
|
||||||
|
do {
|
||||||
|
const result = await networkRetry(2, () => {
|
||||||
|
return this.agent.chat.bsky.convo.getConvoMembers(
|
||||||
|
{
|
||||||
|
convoId: this.convoId,
|
||||||
|
limit: 50,
|
||||||
|
cursor,
|
||||||
|
},
|
||||||
|
{headers: DM_SERVICE_HEADERS},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
cursor = result.data.cursor
|
||||||
|
|
||||||
|
for (const member of result.data.members) {
|
||||||
|
this.relatedProfiles.set(member.did, member)
|
||||||
}
|
}
|
||||||
| undefined
|
} while (cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchMessageHistoryError: {retry: () => void} | undefined
|
||||||
async fetchMessageHistory() {
|
async fetchMessageHistory() {
|
||||||
logger.debug('fetch message history', {})
|
logger.debug('fetch message history', {})
|
||||||
|
|
||||||
@@ -700,7 +738,7 @@ export class Convo {
|
|||||||
|
|
||||||
if (relatedProfiles) {
|
if (relatedProfiles) {
|
||||||
for (const profile of relatedProfiles) {
|
for (const profile of relatedProfiles) {
|
||||||
this.systemMessageProfiles.set(profile.did, profile)
|
this.relatedProfiles.set(profile.did, profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,6 +858,12 @@ export class Convo {
|
|||||||
*/
|
*/
|
||||||
this.latestRev = ev.rev
|
this.latestRev = ev.rev
|
||||||
|
|
||||||
|
if ('relatedProfiles' in ev && Array.isArray(ev.relatedProfiles)) {
|
||||||
|
for (const profile of ev.relatedProfiles) {
|
||||||
|
this.relatedProfiles.set(profile.did, profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
ChatBskyConvoDefs.isLogCreateMessage(ev) &&
|
ChatBskyConvoDefs.isLogCreateMessage(ev) &&
|
||||||
ChatBskyConvoDefs.isMessageView(ev.message)
|
ChatBskyConvoDefs.isMessageView(ev.message)
|
||||||
@@ -872,14 +916,6 @@ export class Convo {
|
|||||||
const systemView = toSystemMessageView(ev)
|
const systemView = toSystemMessageView(ev)
|
||||||
if (systemView) {
|
if (systemView) {
|
||||||
this.newMessages.set(systemView.id, systemView)
|
this.newMessages.set(systemView.id, systemView)
|
||||||
if (
|
|
||||||
'relatedProfiles' in ev &&
|
|
||||||
Array.isArray(ev.relatedProfiles)
|
|
||||||
) {
|
|
||||||
for (const profile of ev.relatedProfiles) {
|
|
||||||
this.systemMessageProfiles.set(profile.did, profile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
needsCommit = true
|
needsCommit = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -907,11 +943,10 @@ export class Convo {
|
|||||||
id: tempId,
|
id: tempId,
|
||||||
message,
|
message,
|
||||||
})
|
})
|
||||||
if (this.convo?.status === 'request') {
|
if (this.convo?.view.status === 'request') {
|
||||||
this.convo = {
|
this.updateConvo({
|
||||||
...this.convo,
|
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
this.commit()
|
this.commit()
|
||||||
|
|
||||||
@@ -921,41 +956,79 @@ export class Convo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
markConvoAccepted() {
|
markConvoAccepted() {
|
||||||
if (this.convo) {
|
this.updateConvo({
|
||||||
this.convo = {
|
status: 'accepted',
|
||||||
...this.convo,
|
})
|
||||||
status: 'accepted',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.commit()
|
this.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMuted(muted: boolean) {
|
updateMuted(muted: boolean) {
|
||||||
if (this.convo) {
|
this.updateConvo({
|
||||||
this.convo = {
|
muted,
|
||||||
...this.convo,
|
})
|
||||||
muted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.commit()
|
this.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
updateGroupName(name: string) {
|
updateGroupName(name: string) {
|
||||||
if (
|
if (this.convo?.kind !== 'group') {
|
||||||
this.convo &&
|
throw new Error('updateGroupName can only be called on group convo')
|
||||||
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
|
|
||||||
this.convo.kind,
|
|
||||||
ChatBskyConvoDefs.isGroupConvo,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
this.convo = {
|
|
||||||
...this.convo,
|
|
||||||
kind: {
|
|
||||||
...this.convo.kind,
|
|
||||||
name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateConvo({
|
||||||
|
kind: {
|
||||||
|
...this.convo.details,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGroupMembers(members: GroupConvoMember[], memberCount: number) {
|
||||||
|
if (this.convo?.kind !== 'group') {
|
||||||
|
throw new Error('updateGroupMembers can only be called on group convo')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateConvo({
|
||||||
|
members,
|
||||||
|
kind: {
|
||||||
|
...this.convo.details,
|
||||||
|
memberCount,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateJoinLink(joinLink: ChatBskyGroupDefs.JoinLinkView | undefined) {
|
||||||
|
if (this.convo?.kind !== 'group') {
|
||||||
|
throw new Error('updateJoinLink can only be called on group convo')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateConvo({
|
||||||
|
kind: {
|
||||||
|
...this.convo.details,
|
||||||
|
joinLink,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLockStatus(lockStatus: ChatBskyConvoDefs.ConvoLockStatus) {
|
||||||
|
if (this.convo?.kind !== 'group') {
|
||||||
|
throw new Error('updateLockStatus can only be called on group convo')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateConvo({
|
||||||
|
kind: {
|
||||||
|
...this.convo.details,
|
||||||
|
lockStatus,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
this.commit()
|
this.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,7 +1053,7 @@ export class Convo {
|
|||||||
|
|
||||||
const {id, message} = pendingMessage
|
const {id, message} = pendingMessage
|
||||||
|
|
||||||
const response = await this.agent.api.chat.bsky.convo.sendMessage(
|
const response = await this.agent.chat.bsky.convo.sendMessage(
|
||||||
{
|
{
|
||||||
convoId: this.convoId,
|
convoId: this.convoId,
|
||||||
message,
|
message,
|
||||||
@@ -1023,7 +1096,7 @@ export class Convo {
|
|||||||
this.emitter.emit('event', {
|
this.emitter.emit('event', {
|
||||||
type: 'invalidate-block-state',
|
type: 'invalidate-block-state',
|
||||||
accountDids: [
|
accountDids: [
|
||||||
this.sender!.did,
|
this.senderUserDid,
|
||||||
...this.recipients!.map(r => r.did),
|
...this.recipients!.map(r => r.did),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -1075,7 +1148,7 @@ export class Convo {
|
|||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {data} = await this.agent.api.chat.bsky.convo.sendMessageBatch(
|
const {data} = await this.agent.chat.bsky.convo.sendMessageBatch(
|
||||||
{
|
{
|
||||||
items: messageArray.map(({message}) => ({
|
items: messageArray.map(({message}) => ({
|
||||||
convoId: this.convoId,
|
convoId: this.convoId,
|
||||||
@@ -1117,7 +1190,7 @@ export class Convo {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await networkRetry(2, () => {
|
await networkRetry(2, () => {
|
||||||
return this.agent.api.chat.bsky.convo.deleteMessageForSelf(
|
return this.agent.chat.bsky.convo.deleteMessageForSelf(
|
||||||
{
|
{
|
||||||
convoId: this.convoId,
|
convoId: this.convoId,
|
||||||
messageId,
|
messageId,
|
||||||
@@ -1158,6 +1231,7 @@ export class Convo {
|
|||||||
type: 'message',
|
type: 'message',
|
||||||
key: m.id,
|
key: m.id,
|
||||||
message: m,
|
message: m,
|
||||||
|
relatedProfiles: this.relatedProfiles,
|
||||||
nextMessage: null,
|
nextMessage: null,
|
||||||
prevMessage: null,
|
prevMessage: null,
|
||||||
})
|
})
|
||||||
@@ -1166,6 +1240,7 @@ export class Convo {
|
|||||||
type: 'deleted-message',
|
type: 'deleted-message',
|
||||||
key: m.id,
|
key: m.id,
|
||||||
message: m,
|
message: m,
|
||||||
|
relatedProfiles: this.relatedProfiles,
|
||||||
nextMessage: null,
|
nextMessage: null,
|
||||||
prevMessage: null,
|
prevMessage: null,
|
||||||
})
|
})
|
||||||
@@ -1174,7 +1249,7 @@ export class Convo {
|
|||||||
type: 'system-message',
|
type: 'system-message',
|
||||||
key: m.id,
|
key: m.id,
|
||||||
message: m,
|
message: m,
|
||||||
relatedProfiles: Array.from(this.systemMessageProfiles.values()),
|
relatedProfiles: this.relatedProfiles,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1196,6 +1271,7 @@ export class Convo {
|
|||||||
type: 'message',
|
type: 'message',
|
||||||
key: m.id,
|
key: m.id,
|
||||||
message: m,
|
message: m,
|
||||||
|
relatedProfiles: this.relatedProfiles,
|
||||||
nextMessage: null,
|
nextMessage: null,
|
||||||
prevMessage: null,
|
prevMessage: null,
|
||||||
})
|
})
|
||||||
@@ -1204,6 +1280,7 @@ export class Convo {
|
|||||||
type: 'deleted-message',
|
type: 'deleted-message',
|
||||||
key: m.id,
|
key: m.id,
|
||||||
message: m,
|
message: m,
|
||||||
|
relatedProfiles: this.relatedProfiles,
|
||||||
nextMessage: null,
|
nextMessage: null,
|
||||||
prevMessage: null,
|
prevMessage: null,
|
||||||
})
|
})
|
||||||
@@ -1212,7 +1289,7 @@ export class Convo {
|
|||||||
type: 'system-message',
|
type: 'system-message',
|
||||||
key: m.id,
|
key: m.id,
|
||||||
message: m,
|
message: m,
|
||||||
relatedProfiles: Array.from(this.systemMessageProfiles.values()),
|
relatedProfiles: this.relatedProfiles,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1228,15 +1305,12 @@ export class Convo {
|
|||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
rev: '__fake__',
|
rev: '__fake__',
|
||||||
sentAt: new Date().toISOString(),
|
sentAt: new Date().toISOString(),
|
||||||
/*
|
|
||||||
* `getItems` is only run in "active" status states, where
|
|
||||||
* `this.sender` is defined
|
|
||||||
*/
|
|
||||||
sender: {
|
sender: {
|
||||||
$type: 'chat.bsky.convo.defs#messageViewSender',
|
$type: 'chat.bsky.convo.defs#messageViewSender',
|
||||||
did: this.sender!.did,
|
did: this.senderUserDid,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
relatedProfiles: this.relatedProfiles,
|
||||||
nextMessage: null,
|
nextMessage: null,
|
||||||
prevMessage: null,
|
prevMessage: null,
|
||||||
failed: this.pendingMessageFailure !== null,
|
failed: this.pendingMessageFailure !== null,
|
||||||
@@ -1448,46 +1522,4 @@ export class Convo {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group utilities
|
|
||||||
|
|
||||||
isGroup(): boolean | undefined {
|
|
||||||
if (!this.convo) return undefined
|
|
||||||
const info = this.getGroupInfo()
|
|
||||||
return !!info
|
|
||||||
}
|
|
||||||
|
|
||||||
getGroupInfo(): ChatBskyConvoDefs.GroupConvo | undefined {
|
|
||||||
if (
|
|
||||||
this.convo &&
|
|
||||||
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
|
|
||||||
this.convo.kind,
|
|
||||||
ChatBskyConvoDefs.isGroupConvo,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return this.convo.kind
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
getPrimaryMember(): ChatBskyActorDefs.ProfileViewBasic | undefined {
|
|
||||||
if (this.isGroup()) {
|
|
||||||
return this.convo?.members.find(m => {
|
|
||||||
if (
|
|
||||||
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
|
|
||||||
m.kind,
|
|
||||||
ChatBskyActorDefs.isGroupConvoMember,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return m.kind.role === 'owner'
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
'Expected a GroupConvoMember, got an unknown kind of member',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
return this.recipients?.find(r => r.did !== this.senderUserDid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,19 @@ import {
|
|||||||
import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations'
|
import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations'
|
||||||
import {RQKEY as createProfileQueryKey} from '#/state/queries/profile'
|
import {RQKEY as createProfileQueryKey} from '#/state/queries/profile'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
import {type GroupConvoMember} from '#/components/dms/util'
|
||||||
|
|
||||||
export * from '#/state/messages/convo/util'
|
export * from '#/state/messages/convo/util'
|
||||||
|
|
||||||
|
function membersChanged(
|
||||||
|
a: ChatBskyConvoDefs.ConvoView['members'],
|
||||||
|
b: ChatBskyConvoDefs.ConvoView['members'],
|
||||||
|
) {
|
||||||
|
if (a.length !== b.length) return true
|
||||||
|
const aDids = new Set(a.map(m => m.did))
|
||||||
|
return b.some(m => !aDids.has(m.did))
|
||||||
|
}
|
||||||
|
|
||||||
const ChatContext = createContext<ConvoState | null>(null)
|
const ChatContext = createContext<ConvoState | null>(null)
|
||||||
ChatContext.displayName = 'ChatContext'
|
ChatContext.displayName = 'ChatContext'
|
||||||
|
|
||||||
@@ -107,11 +117,11 @@ export function ConvoProvider({
|
|||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'invalidate-block-state': {
|
case 'invalidate-block-state': {
|
||||||
for (const did of event.accountDids) {
|
for (const did of event.accountDids) {
|
||||||
queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: createProfileQueryKey(did),
|
queryKey: createProfileQueryKey(did),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: [ListConvosQueryKeyRoot],
|
queryKey: [ListConvosQueryKeyRoot],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -127,17 +137,35 @@ export function ConvoProvider({
|
|||||||
const data = event.query.state.data as
|
const data = event.query.state.data as
|
||||||
| ChatBskyConvoDefs.ConvoView
|
| ChatBskyConvoDefs.ConvoView
|
||||||
| undefined
|
| undefined
|
||||||
if (data && convo.convo && data.muted !== convo.convo.muted) {
|
if (data && convo.convo && data.muted !== convo.convo.view.muted) {
|
||||||
convo.updateMuted(data.muted)
|
convo.updateMuted(data.muted)
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
data &&
|
data &&
|
||||||
convo.convo &&
|
|
||||||
ChatBskyConvoDefs.isGroupConvo(data.kind) &&
|
ChatBskyConvoDefs.isGroupConvo(data.kind) &&
|
||||||
ChatBskyConvoDefs.isGroupConvo(convo.convo.kind) &&
|
convo.convo?.kind === 'group'
|
||||||
data.kind.name !== convo.convo.kind.name
|
|
||||||
) {
|
) {
|
||||||
convo.updateGroupName(data.kind.name)
|
if (data.kind.name !== convo.convo.details.name) {
|
||||||
|
convo.updateGroupName(data.kind.name)
|
||||||
|
}
|
||||||
|
if (data.kind.joinLink !== convo.convo.details.joinLink) {
|
||||||
|
convo.updateJoinLink(data.kind.joinLink)
|
||||||
|
}
|
||||||
|
if (data.kind.lockStatus !== convo.convo.details.lockStatus) {
|
||||||
|
convo.updateLockStatus(data.kind.lockStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data &&
|
||||||
|
ChatBskyConvoDefs.isGroupConvo(data.kind) &&
|
||||||
|
convo.convo?.kind === 'group' &&
|
||||||
|
(membersChanged(data.members, convo.convo.members) ||
|
||||||
|
data.kind.memberCount !== convo.convo.details.memberCount)
|
||||||
|
) {
|
||||||
|
convo.updateGroupMembers(
|
||||||
|
data.members as GroupConvoMember[],
|
||||||
|
data.kind.memberCount,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {type MessagesEventBus} from '#/state/messages/events/agent'
|
import {type MessagesEventBus} from '#/state/messages/events/agent'
|
||||||
|
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||||
|
|
||||||
export type ConvoParams = {
|
export type ConvoParams = {
|
||||||
convoId: string
|
convoId: string
|
||||||
@@ -58,34 +59,20 @@ export enum ConvoDispatchEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ConvoDispatch =
|
export type ConvoDispatch =
|
||||||
| {
|
| {event: ConvoDispatchEvent.Init}
|
||||||
event: ConvoDispatchEvent.Init
|
| {event: ConvoDispatchEvent.Ready}
|
||||||
}
|
| {event: ConvoDispatchEvent.Resume}
|
||||||
| {
|
| {event: ConvoDispatchEvent.Background}
|
||||||
event: ConvoDispatchEvent.Ready
|
| {event: ConvoDispatchEvent.Suspend}
|
||||||
}
|
| {event: ConvoDispatchEvent.Error; payload: ConvoError}
|
||||||
| {
|
| {event: ConvoDispatchEvent.Disable}
|
||||||
event: ConvoDispatchEvent.Resume
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
event: ConvoDispatchEvent.Background
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
event: ConvoDispatchEvent.Suspend
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
event: ConvoDispatchEvent.Error
|
|
||||||
payload: ConvoError
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
event: ConvoDispatchEvent.Disable
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ConvoItem =
|
export type ConvoItem =
|
||||||
| {
|
| {
|
||||||
type: 'message'
|
type: 'message'
|
||||||
key: string
|
key: string
|
||||||
message: ChatBskyConvoDefs.MessageView
|
message: ChatBskyConvoDefs.MessageView
|
||||||
|
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||||
nextMessage:
|
nextMessage:
|
||||||
| ChatBskyConvoDefs.MessageView
|
| ChatBskyConvoDefs.MessageView
|
||||||
| ChatBskyConvoDefs.DeletedMessageView
|
| ChatBskyConvoDefs.DeletedMessageView
|
||||||
@@ -99,6 +86,7 @@ export type ConvoItem =
|
|||||||
type: 'pending-message'
|
type: 'pending-message'
|
||||||
key: string
|
key: string
|
||||||
message: ChatBskyConvoDefs.MessageView
|
message: ChatBskyConvoDefs.MessageView
|
||||||
|
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||||
nextMessage:
|
nextMessage:
|
||||||
| ChatBskyConvoDefs.MessageView
|
| ChatBskyConvoDefs.MessageView
|
||||||
| ChatBskyConvoDefs.DeletedMessageView
|
| ChatBskyConvoDefs.DeletedMessageView
|
||||||
@@ -117,6 +105,7 @@ export type ConvoItem =
|
|||||||
type: 'deleted-message'
|
type: 'deleted-message'
|
||||||
key: string
|
key: string
|
||||||
message: ChatBskyConvoDefs.DeletedMessageView
|
message: ChatBskyConvoDefs.DeletedMessageView
|
||||||
|
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||||
nextMessage:
|
nextMessage:
|
||||||
| ChatBskyConvoDefs.MessageView
|
| ChatBskyConvoDefs.MessageView
|
||||||
| ChatBskyConvoDefs.DeletedMessageView
|
| ChatBskyConvoDefs.DeletedMessageView
|
||||||
@@ -130,7 +119,7 @@ export type ConvoItem =
|
|||||||
type: 'system-message'
|
type: 'system-message'
|
||||||
key: string
|
key: string
|
||||||
message: ChatBskyConvoDefs.SystemMessageView
|
message: ChatBskyConvoDefs.SystemMessageView
|
||||||
relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[]
|
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'error'
|
type: 'error'
|
||||||
@@ -150,17 +139,12 @@ type FetchMessageHistory = () => Promise<void>
|
|||||||
type MarkConvoAccepted = () => void
|
type MarkConvoAccepted = () => void
|
||||||
type AddReaction = (messageId: string, reaction: string) => Promise<void>
|
type AddReaction = (messageId: string, reaction: string) => Promise<void>
|
||||||
type RemoveReaction = (messageId: string, reaction: string) => Promise<void>
|
type RemoveReaction = (messageId: string, reaction: string) => Promise<void>
|
||||||
type IsGroup = () => boolean | undefined
|
|
||||||
type GetGroupInfo = () => ChatBskyConvoDefs.GroupConvo | undefined
|
|
||||||
type GetPrimaryMember = () => ChatBskyActorDefs.ProfileViewBasic | undefined
|
|
||||||
|
|
||||||
export type ConvoStateUninitialized = {
|
export type ConvoStateUninitialized = {
|
||||||
status: ConvoStatus.Uninitialized
|
status: ConvoStatus.Uninitialized
|
||||||
items: []
|
items: []
|
||||||
convo: ChatBskyConvoDefs.ConvoView | undefined
|
convo: ConvoWithDetails | undefined
|
||||||
error: undefined
|
error: undefined
|
||||||
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
|
|
||||||
recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
|
|
||||||
isFetchingHistory: false
|
isFetchingHistory: false
|
||||||
hasAllHistory: boolean
|
hasAllHistory: boolean
|
||||||
deleteMessage: undefined
|
deleteMessage: undefined
|
||||||
@@ -169,17 +153,12 @@ export type ConvoStateUninitialized = {
|
|||||||
markConvoAccepted: undefined
|
markConvoAccepted: undefined
|
||||||
addReaction: undefined
|
addReaction: undefined
|
||||||
removeReaction: undefined
|
removeReaction: undefined
|
||||||
isGroup: IsGroup
|
|
||||||
getGroupInfo: GetGroupInfo
|
|
||||||
getPrimaryMember: GetPrimaryMember
|
|
||||||
}
|
}
|
||||||
export type ConvoStateInitializing = {
|
export type ConvoStateInitializing = {
|
||||||
status: ConvoStatus.Initializing
|
status: ConvoStatus.Initializing
|
||||||
items: []
|
items: []
|
||||||
convo: ChatBskyConvoDefs.ConvoView | undefined
|
convo: ConvoWithDetails | undefined
|
||||||
error: undefined
|
error: undefined
|
||||||
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
|
|
||||||
recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
|
|
||||||
isFetchingHistory: boolean
|
isFetchingHistory: boolean
|
||||||
hasAllHistory: boolean
|
hasAllHistory: boolean
|
||||||
deleteMessage: undefined
|
deleteMessage: undefined
|
||||||
@@ -188,17 +167,12 @@ export type ConvoStateInitializing = {
|
|||||||
markConvoAccepted: undefined
|
markConvoAccepted: undefined
|
||||||
addReaction: undefined
|
addReaction: undefined
|
||||||
removeReaction: undefined
|
removeReaction: undefined
|
||||||
isGroup: IsGroup
|
|
||||||
getGroupInfo: GetGroupInfo
|
|
||||||
getPrimaryMember: GetPrimaryMember
|
|
||||||
}
|
}
|
||||||
export type ConvoStateReady = {
|
export type ConvoStateReady = {
|
||||||
status: ConvoStatus.Ready
|
status: ConvoStatus.Ready
|
||||||
items: ConvoItem[]
|
items: ConvoItem[]
|
||||||
convo: ChatBskyConvoDefs.ConvoView
|
convo: ConvoWithDetails
|
||||||
error: undefined
|
error: undefined
|
||||||
sender: ChatBskyActorDefs.ProfileViewBasic
|
|
||||||
recipients: ChatBskyActorDefs.ProfileViewBasic[]
|
|
||||||
isFetchingHistory: boolean
|
isFetchingHistory: boolean
|
||||||
hasAllHistory: boolean
|
hasAllHistory: boolean
|
||||||
deleteMessage: DeleteMessage
|
deleteMessage: DeleteMessage
|
||||||
@@ -207,17 +181,12 @@ export type ConvoStateReady = {
|
|||||||
markConvoAccepted: MarkConvoAccepted
|
markConvoAccepted: MarkConvoAccepted
|
||||||
addReaction: AddReaction
|
addReaction: AddReaction
|
||||||
removeReaction: RemoveReaction
|
removeReaction: RemoveReaction
|
||||||
isGroup: IsGroup
|
|
||||||
getGroupInfo: GetGroupInfo
|
|
||||||
getPrimaryMember: GetPrimaryMember
|
|
||||||
}
|
}
|
||||||
export type ConvoStateBackgrounded = {
|
export type ConvoStateBackgrounded = {
|
||||||
status: ConvoStatus.Backgrounded
|
status: ConvoStatus.Backgrounded
|
||||||
items: ConvoItem[]
|
items: ConvoItem[]
|
||||||
convo: ChatBskyConvoDefs.ConvoView
|
convo: ConvoWithDetails
|
||||||
error: undefined
|
error: undefined
|
||||||
sender: ChatBskyActorDefs.ProfileViewBasic
|
|
||||||
recipients: ChatBskyActorDefs.ProfileViewBasic[]
|
|
||||||
isFetchingHistory: boolean
|
isFetchingHistory: boolean
|
||||||
hasAllHistory: boolean
|
hasAllHistory: boolean
|
||||||
deleteMessage: DeleteMessage
|
deleteMessage: DeleteMessage
|
||||||
@@ -226,17 +195,12 @@ export type ConvoStateBackgrounded = {
|
|||||||
markConvoAccepted: MarkConvoAccepted
|
markConvoAccepted: MarkConvoAccepted
|
||||||
addReaction: AddReaction
|
addReaction: AddReaction
|
||||||
removeReaction: RemoveReaction
|
removeReaction: RemoveReaction
|
||||||
isGroup: IsGroup
|
|
||||||
getGroupInfo: GetGroupInfo
|
|
||||||
getPrimaryMember: GetPrimaryMember
|
|
||||||
}
|
}
|
||||||
export type ConvoStateSuspended = {
|
export type ConvoStateSuspended = {
|
||||||
status: ConvoStatus.Suspended
|
status: ConvoStatus.Suspended
|
||||||
items: ConvoItem[]
|
items: ConvoItem[]
|
||||||
convo: ChatBskyConvoDefs.ConvoView
|
convo: ConvoWithDetails
|
||||||
error: undefined
|
error: undefined
|
||||||
sender: ChatBskyActorDefs.ProfileViewBasic
|
|
||||||
recipients: ChatBskyActorDefs.ProfileViewBasic[]
|
|
||||||
isFetchingHistory: boolean
|
isFetchingHistory: boolean
|
||||||
hasAllHistory: boolean
|
hasAllHistory: boolean
|
||||||
deleteMessage: DeleteMessage
|
deleteMessage: DeleteMessage
|
||||||
@@ -245,17 +209,12 @@ export type ConvoStateSuspended = {
|
|||||||
markConvoAccepted: MarkConvoAccepted
|
markConvoAccepted: MarkConvoAccepted
|
||||||
addReaction: AddReaction
|
addReaction: AddReaction
|
||||||
removeReaction: RemoveReaction
|
removeReaction: RemoveReaction
|
||||||
isGroup: IsGroup
|
|
||||||
getGroupInfo: GetGroupInfo
|
|
||||||
getPrimaryMember: GetPrimaryMember
|
|
||||||
}
|
}
|
||||||
export type ConvoStateError = {
|
export type ConvoStateError = {
|
||||||
status: ConvoStatus.Error
|
status: ConvoStatus.Error
|
||||||
items: []
|
items: []
|
||||||
convo: undefined
|
convo: undefined
|
||||||
error: ConvoError
|
error: ConvoError
|
||||||
sender: undefined
|
|
||||||
recipients: undefined
|
|
||||||
isFetchingHistory: false
|
isFetchingHistory: false
|
||||||
hasAllHistory: false
|
hasAllHistory: false
|
||||||
deleteMessage: undefined
|
deleteMessage: undefined
|
||||||
@@ -264,17 +223,12 @@ export type ConvoStateError = {
|
|||||||
markConvoAccepted: undefined
|
markConvoAccepted: undefined
|
||||||
addReaction: undefined
|
addReaction: undefined
|
||||||
removeReaction: undefined
|
removeReaction: undefined
|
||||||
isGroup: undefined
|
|
||||||
getGroupInfo: undefined
|
|
||||||
getPrimaryMember: undefined
|
|
||||||
}
|
}
|
||||||
export type ConvoStateDisabled = {
|
export type ConvoStateDisabled = {
|
||||||
status: ConvoStatus.Disabled
|
status: ConvoStatus.Disabled
|
||||||
items: ConvoItem[]
|
items: ConvoItem[]
|
||||||
convo: ChatBskyConvoDefs.ConvoView
|
convo: ConvoWithDetails
|
||||||
error: undefined
|
error: undefined
|
||||||
sender: ChatBskyActorDefs.ProfileViewBasic
|
|
||||||
recipients: ChatBskyActorDefs.ProfileViewBasic[]
|
|
||||||
isFetchingHistory: boolean
|
isFetchingHistory: boolean
|
||||||
hasAllHistory: boolean
|
hasAllHistory: boolean
|
||||||
deleteMessage: DeleteMessage
|
deleteMessage: DeleteMessage
|
||||||
@@ -283,9 +237,6 @@ export type ConvoStateDisabled = {
|
|||||||
markConvoAccepted: MarkConvoAccepted
|
markConvoAccepted: MarkConvoAccepted
|
||||||
addReaction: AddReaction
|
addReaction: AddReaction
|
||||||
removeReaction: RemoveReaction
|
removeReaction: RemoveReaction
|
||||||
isGroup: IsGroup
|
|
||||||
getGroupInfo: GetGroupInfo
|
|
||||||
getPrimaryMember: GetPrimaryMember
|
|
||||||
}
|
}
|
||||||
export type ConvoState =
|
export type ConvoState =
|
||||||
| ConvoStateUninitialized
|
| ConvoStateUninitialized
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import {
|
||||||
|
type ChatBskyActorDefs,
|
||||||
|
ChatBskyConvoDefs,
|
||||||
|
type ChatBskyConvoListConvos,
|
||||||
|
type ChatBskyGroupAddMembers,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {
|
||||||
|
type InfiniteData,
|
||||||
|
useMutation,
|
||||||
|
useQueryClient,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
import {RQKEY as CONVO_KEY} from './conversation'
|
||||||
|
import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
|
||||||
|
import {listConvoMembersQueryKey} from './list-convo-members'
|
||||||
|
|
||||||
|
export function useAddGroupMembers(
|
||||||
|
convoId: string | undefined,
|
||||||
|
{
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onSuccess?: (data: ChatBskyGroupAddMembers.OutputSchema) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const {data: myProfile} = useProfileQuery({did: currentAccount?.did})
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
members,
|
||||||
|
}: {
|
||||||
|
members: string[]
|
||||||
|
profiles: bsky.profile.AnyProfileView[]
|
||||||
|
}) => {
|
||||||
|
if (!convoId) throw new Error('No convoId provided')
|
||||||
|
const {data} = await agent.chat.bsky.group.addMembers(
|
||||||
|
{convoId, members},
|
||||||
|
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onMutate: ({profiles}) => {
|
||||||
|
if (!convoId) return
|
||||||
|
|
||||||
|
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
|
||||||
|
CONVO_KEY(convoId),
|
||||||
|
)
|
||||||
|
const prevListEntries = queryClient.getQueriesData<
|
||||||
|
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
|
||||||
|
>({queryKey: [CONVO_LIST_KEY]})
|
||||||
|
const prevMemberList = queryClient.getQueryData<
|
||||||
|
ChatBskyActorDefs.ProfileViewBasic[]
|
||||||
|
>(listConvoMembersQueryKey(convoId))
|
||||||
|
|
||||||
|
const addedBy: ChatBskyActorDefs.ProfileViewBasic | undefined = myProfile
|
||||||
|
? {
|
||||||
|
...myProfile,
|
||||||
|
$type: 'chat.bsky.actor.defs#profileViewBasic',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const optimisticMembers: ChatBskyActorDefs.ProfileViewBasic[] =
|
||||||
|
profiles.map(profile => ({
|
||||||
|
...profile,
|
||||||
|
$type: 'chat.bsky.actor.defs#profileViewBasic',
|
||||||
|
kind: {
|
||||||
|
$type: 'chat.bsky.actor.defs#groupConvoMember',
|
||||||
|
role: 'standard',
|
||||||
|
addedBy,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
|
||||||
|
CONVO_KEY(convoId),
|
||||||
|
prev => {
|
||||||
|
if (!prev) return
|
||||||
|
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return prev
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
members: [...prev.members, ...optimisticMembers],
|
||||||
|
kind: {
|
||||||
|
...prev.kind,
|
||||||
|
memberCount: prev.kind.memberCount + optimisticMembers.length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
queryClient.setQueriesData<
|
||||||
|
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
|
||||||
|
>({queryKey: [CONVO_LIST_KEY]}, prev => {
|
||||||
|
if (!prev?.pages) return
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
pages: prev.pages.map(page => ({
|
||||||
|
...page,
|
||||||
|
convos: page.convos.map(convo => {
|
||||||
|
if (convo.id !== convoId) return convo
|
||||||
|
if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo
|
||||||
|
return {
|
||||||
|
...convo,
|
||||||
|
members: [...convo.members, ...optimisticMembers],
|
||||||
|
kind: {
|
||||||
|
...convo.kind,
|
||||||
|
memberCount:
|
||||||
|
convo.kind.memberCount + optimisticMembers.length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>(
|
||||||
|
listConvoMembersQueryKey(convoId),
|
||||||
|
prev => {
|
||||||
|
if (!prev) return
|
||||||
|
return [...prev, ...optimisticMembers]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {prevConvo, prevListEntries, prevMemberList}
|
||||||
|
},
|
||||||
|
onSuccess: data => {
|
||||||
|
if (convoId) {
|
||||||
|
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
|
||||||
|
CONVO_KEY(convoId),
|
||||||
|
data.convo,
|
||||||
|
)
|
||||||
|
|
||||||
|
queryClient.setQueriesData<
|
||||||
|
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
|
||||||
|
>({queryKey: [CONVO_LIST_KEY]}, prev => {
|
||||||
|
if (!prev?.pages) return
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
pages: prev.pages.map(page => ({
|
||||||
|
...page,
|
||||||
|
convos: page.convos.map(convo =>
|
||||||
|
convo.id === convoId ? data.convo : convo,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSuccess?.(data)
|
||||||
|
},
|
||||||
|
onError: (e, _variables, context) => {
|
||||||
|
logger.error(e)
|
||||||
|
if (context?.prevConvo && convoId) {
|
||||||
|
queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo)
|
||||||
|
}
|
||||||
|
if (context?.prevListEntries) {
|
||||||
|
for (const [key, data] of context.prevListEntries) {
|
||||||
|
queryClient.setQueryData(key, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (context?.prevMemberList && convoId) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
listConvoMembersQueryKey(convoId),
|
||||||
|
context.prevMemberList,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onError?.(e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
RQKEY_ROOT as LIST_CONVOS_KEY,
|
RQKEY_ROOT as LIST_CONVOS_KEY,
|
||||||
} from './list-conversations'
|
} from './list-conversations'
|
||||||
|
|
||||||
const RQKEY_ROOT = 'convo'
|
export const RQKEY_ROOT = 'convo'
|
||||||
export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId]
|
export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId]
|
||||||
|
|
||||||
export function useConvoQuery({convoId}: {convoId: string}) {
|
export function useConvoQuery({convoId}: {convoId: string}) {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
ChatBskyConvoDefs,
|
||||||
|
type ChatBskyGroupCreateJoinLink,
|
||||||
|
type ChatBskyGroupDefs,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {
|
||||||
|
rollbackConvoOptimistic,
|
||||||
|
updateConvoOptimistic,
|
||||||
|
} from './utils/convo-cache'
|
||||||
|
|
||||||
|
export function useCreateJoinLink(
|
||||||
|
convoId: string | undefined,
|
||||||
|
{
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onSuccess?: (data: ChatBskyGroupCreateJoinLink.OutputSchema) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
joinRule,
|
||||||
|
requireApproval,
|
||||||
|
}: {
|
||||||
|
joinRule: ChatBskyGroupDefs.JoinRule
|
||||||
|
requireApproval: boolean
|
||||||
|
}) => {
|
||||||
|
if (!convoId) throw new Error('No convoId provided')
|
||||||
|
const {data} = await agent.chat.bsky.group.createJoinLink(
|
||||||
|
{convoId, joinRule, requireApproval},
|
||||||
|
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onMutate: ({joinRule, requireApproval}) => {
|
||||||
|
if (!convoId) return
|
||||||
|
return updateConvoOptimistic(queryClient, convoId, prev => {
|
||||||
|
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
kind: {
|
||||||
|
...prev.kind,
|
||||||
|
joinLink: {
|
||||||
|
$type: 'chat.bsky.group.defs#joinLinkView',
|
||||||
|
code: '',
|
||||||
|
enabledStatus: 'enabled',
|
||||||
|
joinRule,
|
||||||
|
requireApproval,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: data => {
|
||||||
|
if (convoId) {
|
||||||
|
updateConvoOptimistic(queryClient, convoId, prev => {
|
||||||
|
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
kind: {...prev.kind, joinLink: data.joinLink},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSuccess?.(data)
|
||||||
|
},
|
||||||
|
onError: (e, _variables, context) => {
|
||||||
|
logger.error(e)
|
||||||
|
if (convoId && context) {
|
||||||
|
rollbackConvoOptimistic(queryClient, convoId, context)
|
||||||
|
}
|
||||||
|
onError?.(e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
ChatBskyConvoDefs,
|
||||||
|
type ChatBskyGroupDisableJoinLink,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {
|
||||||
|
rollbackConvoOptimistic,
|
||||||
|
updateConvoOptimistic,
|
||||||
|
} from './utils/convo-cache'
|
||||||
|
|
||||||
|
export function useDisableJoinLink(
|
||||||
|
convoId: string | undefined,
|
||||||
|
{
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onSuccess?: (data: ChatBskyGroupDisableJoinLink.OutputSchema) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!convoId) throw new Error('No convoId provided')
|
||||||
|
const {data} = await agent.chat.bsky.group.disableJoinLink(
|
||||||
|
{convoId},
|
||||||
|
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onMutate: () => {
|
||||||
|
if (!convoId) return
|
||||||
|
return updateConvoOptimistic(queryClient, convoId, prev => {
|
||||||
|
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
kind: {
|
||||||
|
...prev.kind,
|
||||||
|
joinLink: {...prev.kind.joinLink, enabledStatus: 'disabled'},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: data => {
|
||||||
|
if (convoId) {
|
||||||
|
updateConvoOptimistic(queryClient, convoId, prev => {
|
||||||
|
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
kind: {...prev.kind, joinLink: data.joinLink},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSuccess?.(data)
|
||||||
|
},
|
||||||
|
onError: (e, _variables, context) => {
|
||||||
|
logger.error(e)
|
||||||
|
if (convoId && context) {
|
||||||
|
rollbackConvoOptimistic(queryClient, convoId, context)
|
||||||
|
}
|
||||||
|
onError?.(e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {ChatBskyConvoDefs, type ChatBskyGroupEditGroup} from '@atproto/api'
|
||||||
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {
|
||||||
|
rollbackConvoOptimistic,
|
||||||
|
updateConvoOptimistic,
|
||||||
|
} from './utils/convo-cache'
|
||||||
|
|
||||||
|
export function useEditGroupChatName(
|
||||||
|
convoId: string | undefined,
|
||||||
|
{
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onSuccess?: (data: ChatBskyGroupEditGroup.OutputSchema) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({name: groupName}: {name: string}) => {
|
||||||
|
if (!convoId) throw new Error('No convoId provided')
|
||||||
|
const {data} = await agent.chat.bsky.group.editGroup(
|
||||||
|
{convoId, name: groupName},
|
||||||
|
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onMutate: ({name: groupName}) => {
|
||||||
|
if (!convoId) return
|
||||||
|
return updateConvoOptimistic(queryClient, convoId, prev => {
|
||||||
|
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
kind: {...prev.kind, name: groupName},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: data => {
|
||||||
|
onSuccess?.(data)
|
||||||
|
},
|
||||||
|
onError: (e, _variables, context) => {
|
||||||
|
logger.error(e)
|
||||||
|
if (convoId && context) {
|
||||||
|
rollbackConvoOptimistic(queryClient, convoId, context)
|
||||||
|
}
|
||||||
|
onError?.(e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
ChatBskyConvoDefs,
|
||||||
|
type ChatBskyGroupDefs,
|
||||||
|
type ChatBskyGroupEditJoinLink,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {
|
||||||
|
rollbackConvoOptimistic,
|
||||||
|
updateConvoOptimistic,
|
||||||
|
} from './utils/convo-cache'
|
||||||
|
|
||||||
|
export function useEditJoinLink(
|
||||||
|
convoId: string | undefined,
|
||||||
|
{
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onSuccess?: (data: ChatBskyGroupEditJoinLink.OutputSchema) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
joinRule,
|
||||||
|
requireApproval,
|
||||||
|
}: {
|
||||||
|
joinRule: ChatBskyGroupDefs.JoinRule
|
||||||
|
requireApproval: boolean
|
||||||
|
}) => {
|
||||||
|
if (!convoId) throw new Error('No convoId provided')
|
||||||
|
const {data} = await agent.chat.bsky.group.editJoinLink(
|
||||||
|
{convoId, joinRule, requireApproval},
|
||||||
|
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onMutate: ({joinRule, requireApproval}) => {
|
||||||
|
if (!convoId) return
|
||||||
|
return updateConvoOptimistic(queryClient, convoId, prev => {
|
||||||
|
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
kind: {
|
||||||
|
...prev.kind,
|
||||||
|
joinLink: {...prev.kind.joinLink, joinRule, requireApproval},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: data => {
|
||||||
|
if (convoId) {
|
||||||
|
updateConvoOptimistic(queryClient, convoId, prev => {
|
||||||
|
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
kind: {...prev.kind, joinLink: data.joinLink},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSuccess?.(data)
|
||||||
|
},
|
||||||
|
onError: (e, _variables, context) => {
|
||||||
|
logger.error(e)
|
||||||
|
if (convoId && context) {
|
||||||
|
rollbackConvoOptimistic(queryClient, convoId, context)
|
||||||
|
}
|
||||||
|
onError?.(e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {ChatBskyConvoDefs, type ChatBskyGroupEnableJoinLink} from '@atproto/api'
|
||||||
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {
|
||||||
|
rollbackConvoOptimistic,
|
||||||
|
updateConvoOptimistic,
|
||||||
|
} from './utils/convo-cache'
|
||||||
|
|
||||||
|
export function useEnableJoinLink(
|
||||||
|
convoId: string | undefined,
|
||||||
|
{
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onSuccess?: (data: ChatBskyGroupEnableJoinLink.OutputSchema) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!convoId) throw new Error('No convoId provided')
|
||||||
|
const {data} = await agent.chat.bsky.group.enableJoinLink(
|
||||||
|
{convoId},
|
||||||
|
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onMutate: () => {
|
||||||
|
if (!convoId) return
|
||||||
|
return updateConvoOptimistic(queryClient, convoId, prev => {
|
||||||
|
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
kind: {
|
||||||
|
...prev.kind,
|
||||||
|
joinLink: {...prev.kind.joinLink, enabledStatus: 'enabled'},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: data => {
|
||||||
|
if (convoId) {
|
||||||
|
updateConvoOptimistic(queryClient, convoId, prev => {
|
||||||
|
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
kind: {...prev.kind, joinLink: data.joinLink},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSuccess?.(data)
|
||||||
|
},
|
||||||
|
onError: (e, _variables, context) => {
|
||||||
|
logger.error(e)
|
||||||
|
if (convoId && context) {
|
||||||
|
rollbackConvoOptimistic(queryClient, convoId, context)
|
||||||
|
}
|
||||||
|
onError?.(e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,7 +7,10 @@ import {STALE} from '..'
|
|||||||
const RQKEY_ROOT = 'convo-availability'
|
const RQKEY_ROOT = 'convo-availability'
|
||||||
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
||||||
|
|
||||||
export function useGetConvoAvailabilityQuery(did: string) {
|
export function useGetConvoAvailabilityQuery(
|
||||||
|
did: string,
|
||||||
|
{enabled = true}: {enabled?: boolean} = {},
|
||||||
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -21,5 +24,6 @@ export function useGetConvoAvailabilityQuery(did: string) {
|
|||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
staleTime: STALE.INFINITY,
|
staleTime: STALE.INFINITY,
|
||||||
|
enabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function useLeaveConvo(
|
|||||||
return {prevPages}
|
return {prevPages}
|
||||||
},
|
},
|
||||||
onSuccess: data => {
|
onSuccess: data => {
|
||||||
queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
|
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
|
||||||
onSuccess?.(data)
|
onSuccess?.(data)
|
||||||
},
|
},
|
||||||
onError: (error, _, context) => {
|
onError: (error, _, context) => {
|
||||||
@@ -89,7 +89,7 @@ export function useLeaveConvo(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
|
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
|
||||||
onError?.(error)
|
onError?.(error)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ export function ListConvosProviderInner({
|
|||||||
|
|
||||||
const debouncedRefetch = useMemo(() => {
|
const debouncedRefetch = useMemo(() => {
|
||||||
const refetchAndInvalidate = () => {
|
const refetchAndInvalidate = () => {
|
||||||
refetch()
|
void refetch()
|
||||||
queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
|
void queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
|
||||||
}
|
}
|
||||||
return throttle(refetchAndInvalidate, 500, {
|
return throttle(refetchAndInvalidate, 500, {
|
||||||
leading: true,
|
leading: true,
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import {useEffect} from 'react'
|
||||||
|
import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
|
||||||
|
import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
|
import {useMessagesEventBus} from '#/state/messages/events'
|
||||||
|
import {STALE} from '#/state/queries'
|
||||||
|
import {createQueryKey} from '#/state/queries/util'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
const RQKEY_ROOT = 'listConvoMembers'
|
||||||
|
export const listConvoMembersQueryKey = (convoId: string) =>
|
||||||
|
createQueryKey(RQKEY_ROOT, {convoId})
|
||||||
|
|
||||||
|
// group chat size is 50, so should fetch the whole list in one go
|
||||||
|
const LIMIT = 50
|
||||||
|
|
||||||
|
export function useListConvoMembersQuery({
|
||||||
|
convoId,
|
||||||
|
placeholderData,
|
||||||
|
}: {
|
||||||
|
convoId: string
|
||||||
|
placeholderData?: ChatBskyActorDefs.ProfileViewBasic[]
|
||||||
|
}) {
|
||||||
|
const agent = useAgent()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const messagesBus = useMessagesEventBus()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = messagesBus.on(
|
||||||
|
ev => {
|
||||||
|
if (ev.type !== 'logs') return
|
||||||
|
|
||||||
|
function mutateList(
|
||||||
|
fn: (
|
||||||
|
update: ChatBskyActorDefs.ProfileViewBasic[],
|
||||||
|
) => ChatBskyActorDefs.ProfileViewBasic[],
|
||||||
|
) {
|
||||||
|
queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>(
|
||||||
|
listConvoMembersQueryKey(convoId),
|
||||||
|
old => {
|
||||||
|
if (!old) return // query doesn't exist yet, skip
|
||||||
|
return fn(old)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const log of ev.logs) {
|
||||||
|
if (ChatBskyConvoDefs.isLogAddMember(log)) {
|
||||||
|
const data = log.message.data
|
||||||
|
if (
|
||||||
|
bsky.dangerousIsType<ChatBskyConvoDefs.SystemMessageDataAddMember>(
|
||||||
|
data,
|
||||||
|
ChatBskyConvoDefs.isSystemMessageDataAddMember,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const newMember = log.relatedProfiles.find(
|
||||||
|
r => r.did === data.member.did,
|
||||||
|
)
|
||||||
|
if (newMember) {
|
||||||
|
mutateList(list => list.concat(newMember))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (ChatBskyConvoDefs.isLogRemoveMember(log)) {
|
||||||
|
const data = log.message.data
|
||||||
|
if (
|
||||||
|
bsky.dangerousIsType<ChatBskyConvoDefs.SystemMessageDataRemoveMember>(
|
||||||
|
data,
|
||||||
|
ChatBskyConvoDefs.isSystemMessageDataRemoveMember,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mutateList(list => list.filter(m => m.did !== data.member.did))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{convoId},
|
||||||
|
)
|
||||||
|
return () => unsub()
|
||||||
|
}, [convoId, messagesBus, queryClient])
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: listConvoMembersQueryKey(convoId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const members = []
|
||||||
|
let cursor
|
||||||
|
|
||||||
|
do {
|
||||||
|
const {data} = await agent.chat.bsky.convo.getConvoMembers(
|
||||||
|
{convoId, cursor, limit: LIMIT},
|
||||||
|
{headers: DM_SERVICE_HEADERS},
|
||||||
|
)
|
||||||
|
members.push(...data.members)
|
||||||
|
cursor = data.cursor
|
||||||
|
} while (cursor)
|
||||||
|
|
||||||
|
return members
|
||||||
|
},
|
||||||
|
staleTime: STALE.MINUTES.THIRTY,
|
||||||
|
placeholderData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* findAllProfilesInQueryData(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
did: string,
|
||||||
|
): Generator<ChatBskyActorDefs.ProfileViewBasic, void> {
|
||||||
|
const queryDatas = queryClient.getQueriesData<
|
||||||
|
ChatBskyActorDefs.ProfileViewBasic[]
|
||||||
|
>({
|
||||||
|
queryKey: [RQKEY_ROOT],
|
||||||
|
})
|
||||||
|
for (const [_queryKey, queryData] of queryDatas) {
|
||||||
|
if (!queryData) continue
|
||||||
|
for (const member of queryData) {
|
||||||
|
if (member.did === did) {
|
||||||
|
yield member
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {ChatBskyConvoDefs, type ChatBskyConvoLockConvo} from '@atproto/api'
|
||||||
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {
|
||||||
|
rollbackConvoOptimistic,
|
||||||
|
updateConvoOptimistic,
|
||||||
|
} from './utils/convo-cache'
|
||||||
|
|
||||||
|
export function useLockConvo(
|
||||||
|
convoId: string | undefined,
|
||||||
|
{
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onSuccess?: (data: ChatBskyConvoLockConvo.OutputSchema) => void
|
||||||
|
onError?: (error: Error, variables: {lock: boolean}) => void
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({lock}: {lock: boolean}) => {
|
||||||
|
if (!convoId) throw new Error('No convoId provided')
|
||||||
|
if (lock) {
|
||||||
|
const {data} = await agent.chat.bsky.convo.lockConvo(
|
||||||
|
{convoId},
|
||||||
|
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
} else {
|
||||||
|
const {data} = await agent.chat.bsky.convo.unlockConvo(
|
||||||
|
{convoId},
|
||||||
|
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMutate: ({lock}) => {
|
||||||
|
if (!convoId) return
|
||||||
|
return updateConvoOptimistic(queryClient, convoId, prev => {
|
||||||
|
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
kind: {
|
||||||
|
...prev.kind,
|
||||||
|
lockStatus: lock ? 'locked' : 'unlocked',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: data => {
|
||||||
|
onSuccess?.(data)
|
||||||
|
},
|
||||||
|
onError: (e, variables, context) => {
|
||||||
|
if (convoId && context) {
|
||||||
|
rollbackConvoOptimistic(queryClient, convoId, context)
|
||||||
|
}
|
||||||
|
onError?.(e, variables)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
import {
|
import {type ChatBskyConvoMuteConvo} from '@atproto/api'
|
||||||
type ChatBskyConvoDefs,
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
type ChatBskyConvoListConvos,
|
|
||||||
type ChatBskyConvoMuteConvo,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {
|
|
||||||
type InfiniteData,
|
|
||||||
useMutation,
|
|
||||||
useQueryClient,
|
|
||||||
} from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {RQKEY as CONVO_KEY} from './conversation'
|
import {
|
||||||
import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
|
rollbackConvoOptimistic,
|
||||||
|
updateConvoOptimistic,
|
||||||
|
} from './utils/convo-cache'
|
||||||
|
|
||||||
export function useMuteConvo(
|
export function useMuteConvo(
|
||||||
convoId: string | undefined,
|
convoId: string | undefined,
|
||||||
@@ -46,59 +40,17 @@ export function useMuteConvo(
|
|||||||
},
|
},
|
||||||
onMutate: ({mute}) => {
|
onMutate: ({mute}) => {
|
||||||
if (!convoId) return
|
if (!convoId) return
|
||||||
|
return updateConvoOptimistic(queryClient, convoId, prev => ({
|
||||||
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
|
...prev,
|
||||||
CONVO_KEY(convoId),
|
muted: mute,
|
||||||
)
|
}))
|
||||||
const prevListEntries = queryClient.getQueriesData<
|
|
||||||
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
|
|
||||||
>({queryKey: [CONVO_LIST_KEY]})
|
|
||||||
|
|
||||||
// Update for a single chat thread
|
|
||||||
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
|
|
||||||
CONVO_KEY(convoId),
|
|
||||||
prev => {
|
|
||||||
if (!prev) return
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
muted: mute,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Update for the chat list
|
|
||||||
queryClient.setQueriesData<
|
|
||||||
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
|
|
||||||
>({queryKey: [CONVO_LIST_KEY]}, prev => {
|
|
||||||
if (!prev?.pages) return
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
pages: prev.pages.map(page => ({
|
|
||||||
...page,
|
|
||||||
convos: page.convos.map(convo => {
|
|
||||||
if (convo.id !== convoId) return convo
|
|
||||||
return {
|
|
||||||
...convo,
|
|
||||||
muted: mute,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {prevConvo, prevListEntries}
|
|
||||||
},
|
},
|
||||||
onSuccess: data => {
|
onSuccess: data => {
|
||||||
onSuccess?.(data)
|
onSuccess?.(data)
|
||||||
},
|
},
|
||||||
onError: (e, _variables, context) => {
|
onError: (e, _variables, context) => {
|
||||||
if (context?.prevConvo && convoId) {
|
if (convoId && context) {
|
||||||
queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo)
|
rollbackConvoOptimistic(queryClient, convoId, context)
|
||||||
}
|
|
||||||
if (context?.prevListEntries) {
|
|
||||||
for (const [key, data] of context.prevListEntries) {
|
|
||||||
queryClient.setQueryData(key, data)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
onError?.(e)
|
onError?.(e)
|
||||||
},
|
},
|
||||||
|
|||||||
+30
-21
@@ -1,7 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
ChatBskyConvoDefs,
|
type ChatBskyActorDefs,
|
||||||
|
type ChatBskyConvoDefs,
|
||||||
type ChatBskyConvoListConvos,
|
type ChatBskyConvoListConvos,
|
||||||
type ChatBskyGroupEditGroup,
|
type ChatBskyGroupRemoveMembers,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
type InfiniteData,
|
type InfiniteData,
|
||||||
@@ -14,14 +15,15 @@ import {logger} from '#/logger'
|
|||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {RQKEY as CONVO_KEY} from './conversation'
|
import {RQKEY as CONVO_KEY} from './conversation'
|
||||||
import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
|
import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
|
||||||
|
import {listConvoMembersQueryKey} from './list-convo-members'
|
||||||
|
|
||||||
export function useEditGroupName(
|
export function useRemoveFromGroupChat(
|
||||||
convoId: string | undefined,
|
convoId: string | undefined,
|
||||||
{
|
{
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
onSuccess?: (data: ChatBskyGroupEditGroup.OutputSchema) => void
|
onSuccess?: (data: ChatBskyGroupRemoveMembers.OutputSchema) => void
|
||||||
onError?: (error: Error) => void
|
onError?: (error: Error) => void
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -29,15 +31,15 @@ export function useEditGroupName(
|
|||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({name: groupName}: {name: string}) => {
|
mutationFn: async ({members}: {members: string[]}) => {
|
||||||
if (!convoId) throw new Error('No convoId provided')
|
if (!convoId) throw new Error('No convoId provided')
|
||||||
const {data} = await agent.chat.bsky.group.editGroup(
|
const {data} = await agent.chat.bsky.group.removeMembers(
|
||||||
{convoId, name: groupName},
|
{convoId, members},
|
||||||
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
onMutate: ({name: groupName}) => {
|
onMutate: ({members}) => {
|
||||||
if (!convoId) return
|
if (!convoId) return
|
||||||
|
|
||||||
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
|
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
|
||||||
@@ -46,24 +48,21 @@ export function useEditGroupName(
|
|||||||
const prevListEntries = queryClient.getQueriesData<
|
const prevListEntries = queryClient.getQueriesData<
|
||||||
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
|
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
|
||||||
>({queryKey: [CONVO_LIST_KEY]})
|
>({queryKey: [CONVO_LIST_KEY]})
|
||||||
|
const prevMemberList = queryClient.getQueryData<
|
||||||
|
ChatBskyActorDefs.ProfileViewBasic[]
|
||||||
|
>(listConvoMembersQueryKey(convoId))
|
||||||
|
|
||||||
// Update for a single chat thread
|
|
||||||
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
|
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
|
||||||
CONVO_KEY(convoId),
|
CONVO_KEY(convoId),
|
||||||
prev => {
|
prev => {
|
||||||
if (!prev) return
|
if (!prev) return
|
||||||
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return prev
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
kind: {
|
members: prev.members.filter(m => !members.includes(m.did)),
|
||||||
...prev.kind,
|
|
||||||
name: groupName,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update for the chat list
|
|
||||||
queryClient.setQueriesData<
|
queryClient.setQueriesData<
|
||||||
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
|
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
|
||||||
>({queryKey: [CONVO_LIST_KEY]}, prev => {
|
>({queryKey: [CONVO_LIST_KEY]}, prev => {
|
||||||
@@ -74,20 +73,24 @@ export function useEditGroupName(
|
|||||||
...page,
|
...page,
|
||||||
convos: page.convos.map(convo => {
|
convos: page.convos.map(convo => {
|
||||||
if (convo.id !== convoId) return convo
|
if (convo.id !== convoId) return convo
|
||||||
if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo
|
|
||||||
return {
|
return {
|
||||||
...convo,
|
...convo,
|
||||||
kind: {
|
members: convo.members.filter(m => !members.includes(m.did)),
|
||||||
...convo.kind,
|
|
||||||
name: groupName,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {prevConvo, prevListEntries}
|
queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>(
|
||||||
|
listConvoMembersQueryKey(convoId),
|
||||||
|
prev => {
|
||||||
|
if (!prev) return
|
||||||
|
return prev.filter(m => !members.includes(m.did))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {prevConvo, prevListEntries, prevMemberList}
|
||||||
},
|
},
|
||||||
onSuccess: data => {
|
onSuccess: data => {
|
||||||
onSuccess?.(data)
|
onSuccess?.(data)
|
||||||
@@ -102,6 +105,12 @@ export function useEditGroupName(
|
|||||||
queryClient.setQueryData(key, data)
|
queryClient.setQueryData(key, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (context?.prevMemberList && convoId) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
listConvoMembersQueryKey(convoId),
|
||||||
|
context.prevMemberList,
|
||||||
|
)
|
||||||
|
}
|
||||||
onError?.(e)
|
onError?.(e)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
type ChatBskyConvoDefs,
|
||||||
|
type ChatBskyConvoListConvos,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {
|
||||||
|
type InfiniteData,
|
||||||
|
type QueryClient,
|
||||||
|
type QueryKey,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {RQKEY as CONVO_KEY} from '../conversation'
|
||||||
|
import {RQKEY_ROOT as CONVO_LIST_KEY} from '../list-conversations'
|
||||||
|
|
||||||
|
type ConvoUpdater = (
|
||||||
|
prev: ChatBskyConvoDefs.ConvoView,
|
||||||
|
) => ChatBskyConvoDefs.ConvoView | undefined
|
||||||
|
|
||||||
|
export type ConvoCacheSnapshot = {
|
||||||
|
prevConvo: ChatBskyConvoDefs.ConvoView | undefined
|
||||||
|
prevListEntries: Array<
|
||||||
|
[QueryKey, InfiniteData<ChatBskyConvoListConvos.OutputSchema> | undefined]
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes an optimistic update to a convo across both the single-convo and
|
||||||
|
* convo-list caches. The updater receives the current ConvoView and returns
|
||||||
|
* the next one - return undefined to bail out (e.g. when the convo's kind
|
||||||
|
* doesn't match what the mutation requires). Returns a snapshot that can be
|
||||||
|
* passed to `rollbackConvoOptimistic`.
|
||||||
|
*/
|
||||||
|
export function updateConvoOptimistic(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
convoId: string,
|
||||||
|
updater: ConvoUpdater,
|
||||||
|
): ConvoCacheSnapshot {
|
||||||
|
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
|
||||||
|
CONVO_KEY(convoId),
|
||||||
|
)
|
||||||
|
const prevListEntries = queryClient.getQueriesData<
|
||||||
|
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
|
||||||
|
>({queryKey: [CONVO_LIST_KEY]})
|
||||||
|
|
||||||
|
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
|
||||||
|
CONVO_KEY(convoId),
|
||||||
|
prev => {
|
||||||
|
if (!prev) return
|
||||||
|
const next = updater(prev)
|
||||||
|
return next ?? prev
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
queryClient.setQueriesData<
|
||||||
|
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
|
||||||
|
>({queryKey: [CONVO_LIST_KEY]}, prev => {
|
||||||
|
if (!prev?.pages) return
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
pages: prev.pages.map(page => ({
|
||||||
|
...page,
|
||||||
|
convos: page.convos.map(convo => {
|
||||||
|
if (convo.id !== convoId) return convo
|
||||||
|
const next = updater(convo)
|
||||||
|
return next ?? convo
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {prevConvo, prevListEntries}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores the caches to the state captured by `updateConvoOptimistic`.
|
||||||
|
*/
|
||||||
|
export function rollbackConvoOptimistic(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
convoId: string,
|
||||||
|
snapshot: ConvoCacheSnapshot,
|
||||||
|
) {
|
||||||
|
if (snapshot.prevConvo) {
|
||||||
|
queryClient.setQueryData(CONVO_KEY(convoId), snapshot.prevConvo)
|
||||||
|
}
|
||||||
|
for (const [key, data] of snapshot.prevListEntries) {
|
||||||
|
queryClient.setQueryData(key, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user