diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx index d545e6d7b..082f4d1c4 100644 --- a/src/screens/Messages/components/MessageInput.tsx +++ b/src/screens/Messages/components/MessageInput.tsx @@ -136,7 +136,8 @@ export function MessageInput({ scrollEnabled: isInputScrollable.get(), })) - const submitDisabled = needsEmailVerification || message.trim().length === 0 + const submitDisabled = + needsEmailVerification || (!hasEmbed && message.trim().length === 0) const blur = useCallback(() => { inputRef.current?.blur() -- 2.51.2 From e8327913676c11ce779b2eeec623b5baf93f78c4 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 24 Apr 2026 16:48:57 +0300 Subject: [PATCH 02/14] [Chat] Improve gate (#10359) --- src/components/Prompt.tsx | 27 ++++++++++++++++++++++----- src/screens/Messages/Conversation.tsx | 17 +++++++++++------ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 80ece0c0c..25b7f1884 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -3,7 +3,7 @@ import {type GestureResponderEvent, View} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' -import {atoms as a, useTheme, type ViewStyleProp, web} from '#/alf' +import {atoms as a, type TextStyleProp, useTheme, web} from '#/alf' import { Button, type ButtonColor, @@ -34,6 +34,8 @@ export function Outer({ control, testID, nativeOptions, + webOptions, + onClose, }: React.PropsWithChildren<{ control: Dialog.DialogControlProps testID?: string @@ -41,6 +43,13 @@ export function Outer({ * Native-specific options for the prompt. Extends `BottomSheetViewProps` */ nativeOptions?: Omit + /** + * Web-specific options for the prompt + */ + webOptions?: { + onBackgroundPress?: (e: GestureResponderEvent) => void + } + onClose?: () => void }>) { const titleId = useId() const descriptionId = useId() @@ -54,7 +63,8 @@ export function Outer({ @@ -72,7 +82,7 @@ export function Outer({ export function TitleText({ children, style, -}: React.PropsWithChildren) { +}: React.PropsWithChildren) { const {titleId} = useContext(Context) return ( ) { + style, +}: React.PropsWithChildren<{selectable?: boolean} & TextStyleProp>) { const t = useTheme() const {descriptionId} = useContext(Context) return ( + style={[ + a.text_md, + a.leading_snug, + t.atoms.text_contrast_high, + a.pb_lg, + style, + ]}> {children} ) diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index e0a6fd079..6ff295501 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useMemo, useState} from 'react' +import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {type LayoutChangeEvent, View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {moderateProfile} from '@atproto/api' @@ -319,7 +319,10 @@ function GroupChatGate() { ax.features.GroupChatsHasBeenReleased, ) + const isAlreadyGoingBackRef = useRef(false) const onGoBack = () => { + if (isAlreadyGoingBackRef.current) return + isAlreadyGoingBackRef.current = true if (navigation.canGoBack()) { navigation.goBack() } else { @@ -330,27 +333,29 @@ function GroupChatGate() { return ( - - + + 🐴 - + {hasBeenReleased ? ( Group chats are now available ) : ( Group chats are not yet available )} - + {hasBeenReleased ? ( Update your app to the latest version to join in! ) : ( - This feature isn't available to you yet. Please check back later. + Hold your horses! This feature isn't available to you yet. Please + check back later. )} -- 2.51.2 From cdb8d4bfb876ce679ba1b38f17c9820124d05982 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 24 Apr 2026 13:38:50 -0500 Subject: [PATCH 03/14] Group Clops Feature Branch (#10360) Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Co-authored-by: Samuel Newman Co-authored-by: Claude Opus 4.6 (1M context) --- app.config.js | 2 + .../icons/editBig_stroke2_corner2_rounded.svg | 1 + ...reBehindSquare_stroke2_corner0_rounded.svg | 1 + .../icons/unlock_stroke2_corner2_rounded.svg | 2 +- modules/BlueskyNSE/Info.plist | 7 + modules/BlueskyNSE/NotificationService.swift | 91 +- .../BackgroundNotificationHandler.kt | 2 +- src/analytics/metrics/types.ts | 7 +- src/components/AvatarBubbles.tsx | 266 ++-- src/components/Button.tsx | 6 + src/components/ProfileCard.tsx | 12 +- .../dialogs/SearchablePeopleList.tsx | 2 +- src/components/dms/ActionsWrapper.tsx | 8 +- src/components/dms/ActionsWrapper.web.tsx | 5 +- src/components/dms/AddMembersFlow.tsx | 19 +- src/components/dms/DateDivider.tsx | 2 +- src/components/dms/MessageContextMenu.tsx | 11 +- src/components/dms/MessageItem.tsx | 34 +- src/components/dms/MessagesListHeader.tsx | 5 +- src/components/dms/ReactionsDialog.tsx | 8 +- src/components/dms/getSystemMessageInfo.ts | 6 +- src/components/dms/util.ts | 4 +- src/components/forms/Toggle/index.tsx | 12 +- src/components/icons/EditBig.tsx | 7 + src/components/icons/Lock.tsx | 2 +- src/components/icons/SquareBehindSquare4.tsx | 7 + src/lib/hooks/useNotificationHandler.ts | 23 +- src/screens/Messages/ConversationSettings.tsx | 1202 ----------------- .../ConversationSettings/AddMembersLink.tsx | 108 ++ .../Messages/ConversationSettings/Member.tsx | 129 ++ .../ConversationSettings/MemberMenu.tsx | 252 ++++ .../MembersAndRequests.tsx | 65 + .../ConversationSettings/StatusBadge.tsx | 44 + .../SubtleHoverWrapper.tsx | 27 + .../ConversationSettings/constants.ts | 1 + .../Messages/ConversationSettings/index.tsx | 637 +++++++++ .../Messages/ConversationSettings/prompts.tsx | 119 ++ .../Messages/components/ChatListItem.tsx | 5 +- .../Messages/components/ChatStatusInfo.tsx | 16 +- .../Messages/components/CopyTextButton.tsx | 90 ++ .../Messages/components/EditTextButton.tsx | 59 + .../Messages/components/InviteLinkDialog.tsx | 462 +++++++ .../Messages/components/MessagesList.tsx | 18 +- .../components/MessagesListInfoPanel.tsx | 85 +- src/state/cache/profile-shadow.ts | 2 + src/state/messages/convo/agent.ts | 416 +++--- src/state/messages/convo/index.tsx | 42 +- src/state/messages/convo/types.ts | 85 +- .../queries/messages/add-group-members.ts | 176 +++ src/state/queries/messages/conversation.ts | 2 +- .../queries/messages/create-join-link.ts | 84 ++ .../queries/messages/disable-join-link.ts | 72 + .../queries/messages/edit-group-chat-name.ts | 55 + src/state/queries/messages/edit-join-link.ts | 79 ++ .../queries/messages/enable-join-link.ts | 69 + .../messages/get-convo-availability.ts | 6 +- .../queries/messages/leave-conversation.ts | 4 +- .../queries/messages/list-conversations.tsx | 4 +- .../queries/messages/list-convo-members.ts | 122 ++ .../queries/messages/lock-conversation.ts | 64 + .../queries/messages/mute-conversation.ts | 72 +- ...dit-group-name.ts => remove-from-group.ts} | 51 +- .../queries/messages/utils/convo-cache.ts | 87 ++ 63 files changed, 3518 insertions(+), 1845 deletions(-) create mode 100644 assets/icons/editBig_stroke2_corner2_rounded.svg create mode 100644 assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg delete mode 100644 src/screens/Messages/ConversationSettings.tsx create mode 100644 src/screens/Messages/ConversationSettings/AddMembersLink.tsx create mode 100644 src/screens/Messages/ConversationSettings/Member.tsx create mode 100644 src/screens/Messages/ConversationSettings/MemberMenu.tsx create mode 100644 src/screens/Messages/ConversationSettings/MembersAndRequests.tsx create mode 100644 src/screens/Messages/ConversationSettings/StatusBadge.tsx create mode 100644 src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx create mode 100644 src/screens/Messages/ConversationSettings/constants.ts create mode 100644 src/screens/Messages/ConversationSettings/index.tsx create mode 100644 src/screens/Messages/ConversationSettings/prompts.tsx create mode 100644 src/screens/Messages/components/CopyTextButton.tsx create mode 100644 src/screens/Messages/components/EditTextButton.tsx create mode 100644 src/screens/Messages/components/InviteLinkDialog.tsx create mode 100644 src/state/queries/messages/add-group-members.ts create mode 100644 src/state/queries/messages/create-join-link.ts create mode 100644 src/state/queries/messages/disable-join-link.ts create mode 100644 src/state/queries/messages/edit-group-chat-name.ts create mode 100644 src/state/queries/messages/edit-join-link.ts create mode 100644 src/state/queries/messages/enable-join-link.ts create mode 100644 src/state/queries/messages/list-convo-members.ts create mode 100644 src/state/queries/messages/lock-conversation.ts rename src/state/queries/messages/{edit-group-name.ts => remove-from-group.ts} (64%) create mode 100644 src/state/queries/messages/utils/convo-cache.ts diff --git a/app.config.js b/app.config.js index 5b32a2e29..1f8971209 100644 --- a/app.config.js +++ b/app.config.js @@ -66,6 +66,7 @@ module.exports = function (_config) { infoPlist: { CADisableMinimumFrameDurationOnPhone: true, UIBackgroundModes: ['remote-notification'], + NSUserActivityTypes: ['INSendMessageIntent'], NSCameraUsageDescription: 'Used for profile pictures, posts, and other kinds of content.', NSMicrophoneUsageDescription: @@ -123,6 +124,7 @@ module.exports = function (_config) { 'com.apple.developer.kernel.increased-memory-limit': true, 'com.apple.developer.kernel.extended-virtual-addressing': true, 'com.apple.security.application-groups': 'group.app.bsky', + 'com.apple.developer.usernotifications.communication': true, // 'com.apple.developer.device-information.user-assigned-device-name': true, }, privacyManifests: { diff --git a/assets/icons/editBig_stroke2_corner2_rounded.svg b/assets/icons/editBig_stroke2_corner2_rounded.svg new file mode 100644 index 000000000..7adbd1cfb --- /dev/null +++ b/assets/icons/editBig_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg b/assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..69c48eead --- /dev/null +++ b/assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/unlock_stroke2_corner2_rounded.svg b/assets/icons/unlock_stroke2_corner2_rounded.svg index a9fefda12..941a6ef2e 100644 --- a/assets/icons/unlock_stroke2_corner2_rounded.svg +++ b/assets/icons/unlock_stroke2_corner2_rounded.svg @@ -1 +1 @@ - + diff --git a/modules/BlueskyNSE/Info.plist b/modules/BlueskyNSE/Info.plist index c2dd7eda6..e9271925e 100644 --- a/modules/BlueskyNSE/Info.plist +++ b/modules/BlueskyNSE/Info.plist @@ -8,6 +8,13 @@ com.apple.usernotifications.service NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).NotificationService + NSExtensionAttributes + + IntentsSupported + + INSendMessageIntent + + MainAppScheme bluesky diff --git a/modules/BlueskyNSE/NotificationService.swift b/modules/BlueskyNSE/NotificationService.swift index 481402890..b441f48a9 100644 --- a/modules/BlueskyNSE/NotificationService.swift +++ b/modules/BlueskyNSE/NotificationService.swift @@ -1,5 +1,6 @@ import UserNotifications import UIKit +import Intents let APP_GROUP = "group.app.bsky" typealias ContentHandler = (UNNotificationContent) -> Void @@ -40,17 +41,18 @@ class NotificationService: UNNotificationServiceExtension { } self.bestAttempt = bestAttempt - if reason == "chat-message" { + + if reason == "chat-message" || reason == "chat-reaction" { mutateWithChatMessage(bestAttempt) + let finalContent = createCommunicationNotification( + from: bestAttempt, + userInfo: request.content.userInfo + ) + contentHandler(finalContent) } else { mutateWithBadge(bestAttempt) + contentHandler(bestAttempt) } - - // Any image downloading (or other network tasks) should be handled at the end - // of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire - // gets called, we might not have all the needed mutations completed in time. - - contentHandler(bestAttempt) } override func serviceExtensionTimeWillExpire() { @@ -61,6 +63,81 @@ class NotificationService: UNNotificationServiceExtension { contentHandler(bestAttempt) } + // MARK: Communication Notification + + func createCommunicationNotification( + from content: UNMutableNotificationContent, + userInfo: [AnyHashable: Any] + ) -> UNNotificationContent { + let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown" + let convoId = userInfo["convoId"] as? String + var avatarImage: INImage? = nil + if let avatarUrlString = userInfo["senderAvatarUrl"] as? String { + avatarImage = downloadAvatarImage(from: avatarUrlString) + } + + let senderHandleValue = userInfo["senderHandle"] as? String + let senderHandle = INPersonHandle(value: senderHandleValue, type: .unknown) + let sender = INPerson( + personHandle: senderHandle, + nameComponents: nil, + displayName: senderDisplayName, + image: avatarImage, + contactIdentifier: nil, + customIdentifier: nil + ) + + let intent = INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: content.body, + speakableGroupName: nil, + conversationIdentifier: convoId, + serviceName: nil, + sender: sender, + attachments: nil + ) + + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + interaction.donate(completion: nil) + + do { + return try content.updating(from: intent) + } catch { + return content + } + } + + func downloadAvatarImage(from urlString: String) -> INImage? { + let thumbnailUrlString = urlString.replacingOccurrences( + of: "/img/avatar/", + with: "/img/avatar_thumbnail/" + ) + + guard let url = URL(string: thumbnailUrlString) else { return nil } + + var request = URLRequest(url: url) + request.timeoutInterval = 5 + + var imageData: Data? = nil + let semaphore = DispatchSemaphore(value: 0) + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let data = data, + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 { + imageData = data + } + semaphore.signal() + } + task.resume() + semaphore.wait() + + guard let data = imageData else { return nil } + return INImage(imageData: data) + } + // MARK: Mutations func mutateWithBadge(_ content: UNMutableNotificationContent) { diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt index 4f8a6b892..fba23dfa0 100644 --- a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt +++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt @@ -13,7 +13,7 @@ class BackgroundNotificationHandler( return } - if (remoteMessage.data["reason"] == "chat-message") { + if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") { mutateWithChatMessage(remoteMessage) } else { mutateWithOtherReason(remoteMessage) diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index df0d08c52..40da5021c 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -556,7 +556,11 @@ export type Events = { | 'FindContacts' } 'chat:create': { - logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' + logContext: + | 'ProfileHeader' + | 'NewChatDialog' + | 'SendViaChatDialog' + | 'ConvoSettings' } 'chat:open': { logContext: @@ -564,6 +568,7 @@ export type Events = { | 'NewChatDialog' | 'ChatsList' | 'SendViaChatDialog' + | 'ConvoSettings' } 'groupchat:create': { logContext: 'NewChatDialog' diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx index ff6f4ba1b..d3c7b249c 100644 --- a/src/components/AvatarBubbles.tsx +++ b/src/components/AvatarBubbles.tsx @@ -1,8 +1,7 @@ -import {useCallback, useEffect} from 'react' -import {type StyleProp, View, type ViewStyle} from 'react-native' +import {useEffect} from 'react' +import {View} from 'react-native' import Animated, { Easing, - interpolate, type SharedValue, useAnimatedStyle, useSharedValue, @@ -16,44 +15,32 @@ import {atoms as a, useTheme} from '#/alf' import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' import type * as bsky from '#/types/bsky' +type Layout = { + size: number + x: number + y: number + zIndex?: number + border?: boolean +} + type Props = { animate?: boolean profiles: bsky.profile.AnyProfileView[] - size?: 'small' | 'medium' | 'large' | number + size?: number } export function AvatarBubbles({ animate = false, profiles: allProfiles, - size = 'large', + size = 120, }: Props) { const {currentAccount} = useSession() const profiles = allProfiles.length > 2 ? allProfiles.filter(p => p.did !== currentAccount?.did) : allProfiles - const containerSize = - typeof size === 'number' - ? size - : size === 'small' - ? 40 - : size === 'medium' - ? 56 - : 120 - const scale = - typeof size === 'number' - ? size / 120 - : size === 'small' - ? 40 / 120 - : size === 'medium' - ? 56 / 120 - : 1 - const marginOffset = - (typeof size === 'number' && size < 120) || - size === 'small' || - size === 'medium' - ? -2 - : 0 + const scale = profiles.length <= 1 ? 1 : size / 120 + const marginOffset = size < 120 ? -2 : 0 const initialValue = animate ? 0 : 1 const p0 = useSharedValue(initialValue) @@ -61,130 +48,50 @@ export function AvatarBubbles({ const p2 = useSharedValue(initialValue) const p3 = useSharedValue(initialValue) - const animateScale = (p: Animated.SharedValue, index: number) => { - p.set(0) - p.set(() => - withDelay( - 500 + index * 100, - withTiming(1, { - duration: 250, - easing: Easing.out(Easing.back(1.75)), - }), - ), - ) - } - - const playScaleAnimation = useCallback(() => { - animateScale(p0, 0) - animateScale(p1, 1) - animateScale(p2, 2) - animateScale(p3, 3) - }, [p0, p1, p2, p3]) - useEffect(() => { if (!animate) return - playScaleAnimation() - }, [animate, playScaleAnimation]) - - let avatars = ( - <> - - - - ) - - if (profiles.length === 3) { - avatars = ( - <> - - - - - ) - } - - if (profiles.length >= 4) { - avatars = ( - <> - - - - - - ) - } + const animateBubble = (p: SharedValue, i: number) => { + p.set(0) + p.set(() => + withDelay( + 500 + i * 100, + withTiming(1, { + duration: 250, + easing: Easing.out(Easing.back(1.75)), + }), + ), + ) + } + animateBubble(p0, 0) + animateBubble(p1, 1) + animateBubble(p2, 2) + animateBubble(p3, 3) + }, [animate, p0, p1, p2, p3]) + + const scales = [p0, p1, p2, p3] + const layouts = getLayouts(profiles.length) return ( - + - {avatars} + style={{ + marginTop: marginOffset, + marginLeft: marginOffset, + transform: [{scale}], + transformOrigin: 'top left', + }}> + {layouts.map((layout, i) => ( + + ))} ) @@ -194,27 +101,23 @@ function AvatarBubble({ profile, scale, size, - style, x, y, + zIndex, includeProfileBorder, }: { profile?: bsky.profile.AnyProfileView scale: SharedValue size: number - style?: StyleProp x: number y: number + zIndex?: number includeProfileBorder?: boolean }) { const t = useTheme() const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - {translateX: x}, - {translateY: y}, - {scale: interpolate(scale.get(), [0, 1], [0, 1])}, - ], + transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}], })) return ( @@ -227,11 +130,17 @@ function AvatarBubble({ borderColor: t.atoms.text_inverted.color, borderWidth: 2, }, - style, + zIndex != null && {zIndex}, animatedStyle, ]}> {profile ? ( - + ) : ( )} @@ -239,25 +148,7 @@ function AvatarBubble({ ) } -function Avatar({ - profile, - size = 76, -}: { - profile: bsky.profile.AnyProfileView - size?: number -}) { - return ( - - ) -} - -function AvatarPlaceholder({size = 76}: {size?: number}) { +function AvatarPlaceholder({size}: {size: number}) { const t = useTheme() return ( @@ -267,10 +158,7 @@ function AvatarPlaceholder({size = 76}: {size?: number}) { a.justify_center, a.rounded_full, t.atoms.bg_contrast_200, - { - width: size, - height: size, - }, + {width: size, height: size}, ]}> ) } + +function getLayouts(count: number): Layout[] { + if (count === 3) { + return [ + {size: 68, x: -2, y: -2}, + {size: 56, x: 38, y: 62}, + {size: 46, x: 71, y: 18}, + ] + } + if (count >= 4) { + return [ + {size: 68, x: -2, y: -2}, + {size: 56, x: 60, y: 49}, + {size: 42, x: 14, y: 74}, + {size: 32, x: 72, y: 9}, + ] + } + return [ + {size: 76, x: -2, y: -2, zIndex: 20, border: true}, + {size: 76, x: 42, y: 42, zIndex: 10, border: true}, + ] +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 9168adfaf..45c54d4a7 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -77,6 +77,10 @@ export type ButtonState = { focused: boolean pressed: boolean disabled: boolean + /** + * Alias for hovered || focused || pressed + */ + interacting: boolean } export type ButtonContext = VariantProps & ButtonState @@ -120,6 +124,7 @@ const Context = createContext({ focused: false, pressed: false, disabled: false, + interacting: false, }) Context.displayName = 'ButtonContext' @@ -536,6 +541,7 @@ export const Button = forwardRef( const context = useMemo( () => ({ ...state, + interacting: state.hovered || state.focused || state.pressed, variant, color, size, diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 8c82e774c..21c18e276 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -201,7 +201,7 @@ export function AvatarPlaceholder({size = 40}: {size?: number}) { @@ -600,7 +600,7 @@ export function FollowButtonPlaceholder({style}: ViewStyleProp) { {convo.kind === 'group' ? ( - + ) : ( void }) { const {t: l} = useLingui() return ( - + {trigger => // will always be true, since this file is platform split trigger.IS_NATIVE && ( diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx index 05df7b032..12ccea03a 100644 --- a/src/components/dms/ActionsWrapper.web.tsx +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -10,6 +10,7 @@ import {MessageContextMenu} from '#/components/dms/MessageContextMenu' import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import * as Toast from '#/components/Toast' +import type * as bsky from '#/types/bsky' import {EmojiReactionPicker} from './EmojiReactionPicker' import {hasReachedReactionLimit} from './util' @@ -17,12 +18,14 @@ export function ActionsWrapper({ message, hasReactions, isFromSelf, + senderProfile, children, onTap, }: { message: ChatBskyConvoDefs.MessageView hasReactions?: boolean isFromSelf: boolean + senderProfile?: bsky.profile.AnyProfileView children: React.ReactNode onTap?: () => void }) { @@ -114,7 +117,7 @@ export function ActionsWrapper({ ) }} - + {({props, state, IS_NATIVE, control}) => { // always false, file is platform split if (IS_NATIVE) return null diff --git a/src/components/dms/AddMembersFlow.tsx b/src/components/dms/AddMembersFlow.tsx index 103ad0aca..bc3313b07 100644 --- a/src/components/dms/AddMembersFlow.tsx +++ b/src/components/dms/AddMembersFlow.tsx @@ -98,11 +98,16 @@ function reducer(state: State, action: Action): State { } export function AddMembersFlow({ + members, title, onAddMembers, }: { + members: string[] title: string - onAddMembers: (dids: string[]) => void + onAddMembers: ( + dids: string[], + profiles: bsky.profile.AnyProfileView[], + ) => void }) { const t = useTheme() const {t: l} = useLingui() @@ -154,7 +159,11 @@ export function AddMembersFlow({ } else if (searchText.length) { if (results?.length) { for (const profile of results) { - if (profile.did === currentAccount?.did) continue + if ( + profile.did === currentAccount?.did || + members.includes(profile.did) + ) + continue _items.push({ type: 'profile', key: profile.did, @@ -202,7 +211,7 @@ export function AddMembersFlow({ } return _items - }, [isError, searchText, l, results, currentAccount?.did, follows]) + }, [isError, searchText, l, results, currentAccount?.did, members, follows]) if (searchText && !isFetching && !items.length && !isError) { items.push({type: 'empty', key: 'empty', message: l`No results`}) @@ -213,8 +222,8 @@ export function AddMembersFlow({ }, [control]) const handlePressAdd = useCallback(() => { - onAddMembers(groupChatDids) - }, [groupChatDids, onAddMembers]) + onAddMembers(groupChatDids, groupChatProfiles) + }, [groupChatDids, groupChatProfiles, onAddMembers]) const renderItems = useCallback( ({item}: {item: Item}) => { diff --git a/src/components/dms/DateDivider.tsx b/src/components/dms/DateDivider.tsx index 35dcd8b85..6e898b8f4 100644 --- a/src/components/dms/DateDivider.tsx +++ b/src/components/dms/DateDivider.tsx @@ -4,7 +4,7 @@ import {Trans, useLingui} from '@lingui/react/macro' import {subDays} from 'date-fns' import {atoms as a, useTheme} from '#/alf' -import {Text} from '../Typography' +import {Text} from '#/components/Typography' import {localDateString} from './util' const timeFormatter = new Intl.DateTimeFormat(undefined, { diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index 3a923133f..bc18eae21 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -25,15 +25,18 @@ import {usePromptControl} from '#/components/Prompt' import * as Toast from '#/components/Toast' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' +import type * as bsky from '#/types/bsky' import {EmojiReactionPicker} from './EmojiReactionPicker' import {hasReachedReactionLimit} from './util' export let MessageContextMenu = ({ message, + senderProfile, children, onTap, }: { message: ChatBskyConvoDefs.MessageView + senderProfile?: bsky.profile.AnyProfileView children: TriggerProps['children'] onTap?: () => void }): React.ReactNode => { @@ -110,9 +113,7 @@ export let MessageContextMenu = ({ [l, convo, message, currentAccount?.did], ) - const sender = convo.convo.members.find( - member => member.did === message.sender.did, - ) + const sender = senderProfile return ( <> @@ -183,7 +184,7 @@ export let MessageContextMenu = ({ control={reportControl} subject={{ view: 'message', - convoId: convo.convo.id, + convoId: convo.convo.view.id, message, }} onAfterSubmit={() => { @@ -197,7 +198,7 @@ export let MessageContextMenu = ({ control={blockOrDeleteControl} currentScreen="conversation" params={{ - convoId: convo.convo.id, + convoId: convo.convo.view.id, message, }} /> diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index afbd4bfcd..7706e9cec 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -30,7 +30,6 @@ import {useQueryClient} from '@tanstack/react-query' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {makeProfileLink} from '#/lib/routes/links' -import {useConvoActive} from '#/state/messages/convo' import {type ConvoItem} from '#/state/messages/convo/types' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' @@ -43,7 +42,6 @@ import {InlineLinkText, Link} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' -import type * as bsky from '#/types/bsky' import {DateDivider} from './DateDivider' import {useDateDividerToggle} from './DateDividerToggle' import {MessageItemEmbed} from './MessageItemEmbed' @@ -93,19 +91,18 @@ function isWithinClusterBoundary({ let MessageItem = ({ item, isGroupChat = false, - profile, }: { item: ConvoItem & {type: 'message' | 'pending-message'} isGroupChat?: boolean - profile?: bsky.profile.AnyProfileView }): React.ReactNode => { const t = useTheme() const {currentAccount} = useSession() const {t: l} = useLingui() - const {convo} = useConvoActive() const moderationOpts = useModerationOpts() const queryClient = useQueryClient() + const profile = item.relatedProfiles.get(item.message.sender.did) + const reactionsControl = useDialogControl() const reactionTapRef = useRef(false) @@ -277,9 +274,7 @@ let MessageItem = ({ return l`You reacted ${reaction.value}` } else { const senderDid = reaction.sender.did - const memberSender = convo.members.find( - member => member.did === senderDid, - ) + const memberSender = item.relatedProfiles.get(senderDid) if (memberSender) { return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}` } @@ -290,7 +285,13 @@ let MessageItem = ({ one: '# person', other: '# people', })} reacted – ${groupedReactions.map(g => g.value).join(' ')}` - }, [reactions, groupedReactions, currentAccount?.did, convo.members, l]) + }, [ + reactions, + groupedReactions, + currentAccount?.did, + item.relatedProfiles, + l, + ]) const appliedReactions = ( @@ -375,7 +376,7 @@ let MessageItem = ({ ) : null} - {(hasLargeGapFromPrev || isDateDividerToggled) && ( - - - - )} + + {(hasLargeGapFromPrev || isDateDividerToggled) && ( + + + + )} + {showAvatar ? ( @@ -434,6 +437,7 @@ let MessageItem = ({ hasReactions={hasReactions} isFromSelf={isFromSelf} message={message} + senderProfile={profile} onTap={() => { if (reactionTapRef.current) return if (!hasLargeGapFromPrev) { diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index 0806f3a11..3f5344fae 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -161,11 +161,13 @@ function GroupHeaderReady({ }) } + const lockStatus = convo.details.lockStatus + return ( - + {convo.details.name} @@ -175,6 +177,7 @@ function GroupHeaderReady({ settings={ - - {text} - - - ) -} - -function SettingsButtonPlaceholder() { - const t = useTheme() - const {t: l} = useLingui() - - return ( - - - - … - - - ) -} - -function EditNamePrompt({ - control, - value, - onChangeText, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - value: string - onChangeText: (value: string) => void - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - <> - - - Edit group name - - - - - - - - - - - - - - ) -} - -function InviteLinkPrompt({ - control, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - ) -} - -function LockChatPrompt({ - control, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - ) -} - -function LeaveChatPrompt({ - control, - groupName, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - groupName: string - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - ) -} - -function BlockMemberPrompt({ - control, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - ) -} - -function SubtleHoverWrapper({children}: React.PropsWithChildren) { - const { - state: hover, - onIn: onHoverIn, - onOut: onHoverOut, - } = useInteractionState() - - return ( - - - {children} - - ) -} diff --git a/src/screens/Messages/ConversationSettings/AddMembersLink.tsx b/src/screens/Messages/ConversationSettings/AddMembersLink.tsx new file mode 100644 index 000000000..67c32f6a7 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/AddMembersLink.tsx @@ -0,0 +1,108 @@ +import {View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' + +import {logger} from '#/logger' +import {useAddGroupMembers} from '#/state/queries/messages/add-group-members' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {AddMembersFlow} from '#/components/dms/AddMembersFlow' +import {type ConvoWithDetails} from '#/components/dms/util' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' +import {Loader} from '#/components/Loader' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' + +export function AddMembersLink({ + convo, + members, +}: { + convo: ConvoWithDetails + members: string[] +}) { + const t = useTheme() + const {t: l} = useLingui() + + const addMembersControl = Dialog.useDialogControl() + + const convoId = convo.view.id + const {mutate: addGroupMembers, isPending: isAddPending} = useAddGroupMembers( + convoId, + { + onSuccess: () => { + addMembersControl.close() + }, + onError: e => { + logger.error('Failed to add group chat members', {message: e}) + Toast.show(l`Failed to add members`, {type: 'error'}) + }, + }, + ) + + return ( + <> + + + + + { + addGroupMembers({members, profiles}) + }} + /> + + + ) +} diff --git a/src/screens/Messages/ConversationSettings/Member.tsx b/src/screens/Messages/ConversationSettings/Member.tsx new file mode 100644 index 000000000..5daefce76 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/Member.tsx @@ -0,0 +1,129 @@ +import {View} from 'react-native' +import {moderateProfile} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' + +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useSession} from '#/state/session' +import {atoms as a, native, useTheme, web} from '#/alf' +import { + type ConvoWithDetails, + type GroupConvoMember, +} from '#/components/dms/util' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import {MemberMenu} from './MemberMenu' +import {StatusBadge} from './StatusBadge' +import {SubtleHoverWrapper} from './SubtleHoverWrapper' + +const outerStyles = [a.px_xl, a.py_sm, a.flex_row, a.align_center, a.gap_sm] + +export function Member({ + convo, + profile: profileUnshadowed, + status, + isOwner, +}: { + convo: ConvoWithDetails + profile: GroupConvoMember + status: 'owner' | 'standard' | 'invited' + isOwner: boolean +}) { + const t = useTheme() + const {t: l} = useLingui() + + const profile = useProfileShadow(profileUnshadowed) + const {currentAccount} = useSession() + const moderationOpts = useModerationOpts() + + if (!moderationOpts) { + return + } + + const moderation = moderateProfile(profile, moderationOpts) + + const isDeletedAccount = profile.handle === 'missing.invalid' + const displayName = isDeletedAccount + ? l`Deleted Account` + : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) + const isProfileOwner = profile.did === convo.primaryMember.did + const isSelf = currentAccount?.did === profile.did + let statusBadge: React.ReactNode | null = null + if (isSelf) { + if (status === 'owner') { + statusBadge = + } + } else { + statusBadge = ( + + ) + } + + const joinedReason = profile.kind?.addedBy + ? l`Added by ${createSanitizedDisplayName( + profile.kind.addedBy, + true, + moderateProfile(profile.kind.addedBy, moderationOpts).ui('displayName'), + )}` + : `Added by invite link` + + return ( + + + + + + + + + + {!isProfileOwner && ( + + {joinedReason} + + )} + + + + + {statusBadge} + + + ) +} + +export function MemberPlaceholder() { + return ( + + + + + + + + + ) +} diff --git a/src/screens/Messages/ConversationSettings/MemberMenu.tsx b/src/screens/Messages/ConversationSettings/MemberMenu.tsx new file mode 100644 index 000000000..127a47423 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/MemberMenu.tsx @@ -0,0 +1,252 @@ +import {useState} from 'react' +import {Pressable} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' +import {useNavigation} from '@react-navigation/native' + +import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' +import {type NavigationProp} from '#/lib/routes/types' +import {logger} from '#/logger' +import {type Shadow} from '#/state/cache/types' +import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' +import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' +import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group' +import {useProfileBlockMutationQueue} from '#/state/queries/profile' +import {atoms as a, useTheme} from '#/alf' +import {type ConvoWithDetails} from '#/components/dms/util' +import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' +import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' +import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' +import { + Person_Stroke2_Corner2_Rounded as PersonIcon, + PersonX_Stroke2_Corner0_Rounded as PersonXIcon, +} from '#/components/icons/Person' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import * as Toast from '#/components/Toast' +import {useAnalytics} from '#/analytics' +import type * as bsky from '#/types/bsky' +import {BlockMemberPrompt} from './prompts' +import {StatusBadge} from './StatusBadge' + +export function MemberMenu({ + convo, + profile, + displayName, + type, + isOwner, +}: { + convo: ConvoWithDetails + profile: Shadow + type: 'owner' | 'standard' | 'invited' + displayName: string + isOwner: boolean +}) { + const navigation = useNavigation() + const t = useTheme() + const {t: l} = useLingui() + const ax = useAnalytics() + + const requireEmailVerification = useRequireEmailVerification() + + const blockMemberPrompt = Prompt.usePromptControl() + + const [menuDidOpen, setMenuDidOpen] = useState(false) + const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, { + enabled: menuDidOpen, + }) + const {mutate: initiateConvo} = useGetConvoForMembers({ + onSuccess: ({convo}) => { + ax.metric('chat:open', {logContext: 'ConvoSettings'}) + navigation.navigate('MessagesConversation', {conversation: convo.id}) + }, + onError: () => { + Toast.show(l`Failed to create conversation`, {type: 'error'}) + }, + }) + const convoId = convo.view.id + const {mutate: removeMembers} = useRemoveFromGroupChat(convoId, { + onError: e => { + logger.error('Failed to remove group chat member', {message: e}) + Toast.show(l`Failed to remove group chat member`, {type: 'error'}) + }, + }) + const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) + + const messageMember = () => { + if (!convoAvailability?.canChat) { + return + } + + if (convoAvailability.convo) { + ax.metric('chat:open', {logContext: 'ConvoSettings'}) + navigation.navigate('MessagesConversation', { + conversation: convoAvailability.convo.id, + }) + } else { + ax.metric('chat:create', {logContext: 'ConvoSettings'}) + initiateConvo([profile.did]) + } + } + + const handleMessageMember = requireEmailVerification(messageMember, { + instructions: [ + + Before you can message another user, you must first verify your email. + , + ], + }) + + const handleBlockMember = async () => { + if (profile.viewer?.blocking) { + try { + await queueUnblock() + Toast.show(l({message: 'Account unblocked', context: 'toast'})) + } catch (err) { + const e = err as Error + if (e?.name !== 'AbortError') { + logger.error('Failed to unblock account', {message: e}) + Toast.show(l`There was an issue! ${e.toString()}`, { + type: 'error', + }) + } + } + } else { + try { + await queueBlock() + Toast.show(l({message: 'Account blocked', context: 'toast'})) + } catch (err) { + const e = err as Error + if (e?.name !== 'AbortError') { + logger.error('Failed to block account', {message: e}) + Toast.show(l`There was an issue! ${e.toString()}`, { + type: 'error', + }) + } + } + } + } + + const canBlockMember = type === 'owner' || type === 'standard' + const canRemoveMember = isOwner && type !== 'invited' + // TODO Need to integrate this. -dsb + const canUninviteMember = false + // const canUninviteMember = isOwner && type === 'invited' + + return ( + <> + + + {({props, state, control: menuControl}) => { + const isActive = + state.hovered || state.pressed || menuControl.isOpen + const triggerProps = { + ...props, + onPress: () => { + setMenuDidOpen(true) + props.onPress() + }, + } + return type === 'owner' || type === 'invited' ? ( + + ) : ( + + + + ) + }} + + + + { + navigation.navigate('Profile', {name: profile.did}) + }}> + + Go to profile + + + + + + Message + + + + + + + {canBlockMember ? ( + + + Block + + + + ) : null} + {canRemoveMember ? ( + removeMembers({members: [profile.did]})}> + + Remove from chat + + + + ) : null} + {canUninviteMember ? ( + {}}> + + Uninvite + + + + ) : null} + + + + void handleBlockMember()} + /> + + ) +} diff --git a/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx b/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx new file mode 100644 index 000000000..fe11dfeb4 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx @@ -0,0 +1,65 @@ +import {View} from 'react-native' +import {plural} from '@lingui/core/macro' +import {Trans, useLingui} from '@lingui/react/macro' + +import {atoms as a, useTheme} from '#/alf' +import {InlineLinkText} from '#/components/Link' +import {Text} from '#/components/Typography' +import {MEMBER_LIMIT} from './constants' + +export function MembersAndRequests({ + memberCount, + requestCount, + hasMoreRequests, + isOwner, +}: { + memberCount: number + requestCount: number + hasMoreRequests: boolean + isOwner: boolean +}) { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + Members + + + + {l({ + message: `${memberCount}/${MEMBER_LIMIT}`, + comment: + 'The number of group chat members out of the total number of permitted users.', + })} + + + + {isOwner && requestCount > 0 ? ( + + {hasMoreRequests + ? l({ + message: `${requestCount}+ requests`, + comment: + 'Displayed when there are more than 50 requests to join a group chat', + }) + : l({ + message: plural(requestCount, { + one: '# request', + other: '# requests', + }), + comment: 'The number of requests to join a group chat.', + })} + + ) : null} + + ) +} diff --git a/src/screens/Messages/ConversationSettings/StatusBadge.tsx b/src/screens/Messages/ConversationSettings/StatusBadge.tsx new file mode 100644 index 000000000..e5416bd03 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/StatusBadge.tsx @@ -0,0 +1,44 @@ +import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {type TriggerChildProps} from '#/components/Menu/types' +import {Text} from '#/components/Typography' + +export function StatusBadge({ + label, + style, + pressableProps, +}: { + label: string + style?: StyleProp + pressableProps?: TriggerChildProps['props'] +}) { + const t = useTheme() + + const badgeStyle = [ + a.rounded_xs, + t.atoms.bg_contrast_50, + { + paddingTop: 3, + paddingBottom: 3, + paddingLeft: 6, + paddingRight: 6, + }, + style, + ] + + const labelText = ( + + {label} + + ) + + if (pressableProps) { + return ( + + {labelText} + + ) + } + return {labelText} +} diff --git a/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx b/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx new file mode 100644 index 000000000..d7f032b06 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx @@ -0,0 +1,27 @@ +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {SubtleHover} from '#/components/SubtleHover' + +export function SubtleHoverWrapper({ + children, +}: React.PropsWithChildren) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + + return ( + + + {children} + + ) +} diff --git a/src/screens/Messages/ConversationSettings/constants.ts b/src/screens/Messages/ConversationSettings/constants.ts new file mode 100644 index 000000000..8e1058c05 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/constants.ts @@ -0,0 +1 @@ +export const MEMBER_LIMIT = 50 diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx new file mode 100644 index 000000000..6e88146f7 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/index.tsx @@ -0,0 +1,637 @@ +import {useState} from 'react' +import {View} from 'react-native' +import {type ChatBskyConvoDefs} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' +import {StackActions, useNavigation} from '@react-navigation/native' + +import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, + type NavigationProp, +} from '#/lib/routes/types' +import {logger} from '#/logger' +import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo' +import {ConvoStatus} from '#/state/messages/convo/types' +import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name' +import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' +import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members' +import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests' +import {useLockConvo} from '#/state/queries/messages/lock-conversation' +import {useMuteConvo} from '#/state/queries/messages/mute-conversation' +import {useSession} from '#/state/session' +import {List} from '#/view/com/util/List' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' +import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import { + type ConvoWithDetails, + type GroupConvoMember, +} from '#/components/dms/util' +import {Error} from '#/components/Error' +import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' +import { + Bell2_Stroke2_Corner0_Rounded as BellIcon, + Bell2Off_Stroke2_Corner0_Rounded as BellOffIcon, +} from '#/components/icons/Bell2' +import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' +import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig' +import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' +import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' +import * as Layout from '#/components/Layout' +import {Loader} from '#/components/Loader' +import * as Prompt from '#/components/Prompt' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import {InviteLinkDialog} from '../components/InviteLinkDialog' +import {AddMembersLink} from './AddMembersLink' +import {Member, MemberPlaceholder} from './Member' +import {MembersAndRequests} from './MembersAndRequests' +import {EditNamePrompt, LeaveChatPrompt, LockChatPrompt} from './prompts' + +const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: 'long', + day: 'numeric', + year: 'numeric', +}) + +type Item = + | {type: 'MEMBERS_AND_REQUESTS'; key: string} + | {type: 'ADD_MEMBERS_LINK'; key: string} + | { + type: 'CHAT_MEMBER' + key: string + profile: GroupConvoMember + status: 'owner' | 'standard' | 'invited' + } + | { + type: 'CHAT_MEMBER_PLACEHOLDER' + key: string + } + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'MessagesConversationSettings' +> + +export function MessagesConversationSettingsScreen({route}: Props) { + const {gtTablet} = useBreakpoints() + + const convoId = route.params.conversation + + return ( + + + + + + Group chat settings + + + + + + + + + ) +} + +function SettingsInner() { + const {t: l} = useLingui() + const convoState = useConvo() + const navigation = useNavigation() + + if (convoState.status === ConvoStatus.Error) { + return ( + convoState.error.retry()} + sideBorders={false} + /> + ) + } + + if (!isConvoActive(convoState)) { + return ( + + + + + + ) + } + + if (convoState.convo?.kind !== 'group') { + return ( + { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.replace('Messages', {animation: 'pop'}) + } + }} + /> + ) + } + + return +} + +function keyExtractor(item: Item) { + return item.key +} + +function GroupSettings({ + convo, +}: { + convo: Extract +}) { + const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) + const bottomBarOffset = useBottomBarOffset() + + const {currentAccount} = useSession() + + const primaryMember = convo?.primaryMember + const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did + + const {data: memberListData = [], isPending} = useListConvoMembersQuery({ + convoId: convo.view.id, + placeholderData: convo?.members, + }) + + // TODO Need this data in order to populate this array. -dsb + const invites: string[] = [] + + const {data: joinRequestsData, hasNextPage: hasMoreRequests} = + useListJoinRequestsQuery({ + convoId: convo.view.id, + enabled: isOwner, + }) + const requestCount = + joinRequestsData?.pages.reduce( + (sum, page) => sum + page.requests.length, + 0, + ) ?? 0 + + const items: Item[] = [ + { + type: 'MEMBERS_AND_REQUESTS', + key: 'members-and-requests', + }, + ...(isOwner + ? [{type: 'ADD_MEMBERS_LINK', key: 'add-members-link'} as const] + : []), + ] + if (isPending) { + // should never be pending if we correctly set the query cache data + Array.from({length: 5}).forEach((_, i) => + items.push({ + type: 'CHAT_MEMBER_PLACEHOLDER', + key: `chat-member-placeholder-${i}`, + }), + ) + } else { + items.push( + ...memberListData + .sort((a, b) => { + const aIsOwner = a.did === primaryMember?.did + const bIsOwner = b.did === primaryMember?.did + const aIsSelf = a.did === currentAccount?.did + const bIsSelf = b.did === currentAccount?.did + if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1 + if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 + return 0 + }) + .map( + (profile): Item => ({ + type: 'CHAT_MEMBER', + key: profile.did, + profile: profile as GroupConvoMember, + status: + primaryMember?.did === profile.did + ? 'owner' + : invites.includes(profile.did) + ? 'invited' + : 'standard', + }), + ), + ) + } + + function renderItem({item}: {item: Item}) { + switch (item.type) { + case 'MEMBERS_AND_REQUESTS': + return ( + + ) + case 'ADD_MEMBERS_LINK': + return convo ? ( + profile.did)} + /> + ) : null + case 'CHAT_MEMBER': + return convo ? ( + + ) : null + case 'CHAT_MEMBER_PLACEHOLDER': + return + default: + return null + } + } + + return ( + + ) : ( + + ) + } + renderItem={renderItem} + sideBorders={false} + windowSize={11} + /> + ) +} + +function SettingsHeader({ + convo, + isOwner, +}: { + convo: Extract + isOwner: boolean +}) { + const t = useTheme() + const {t: l} = useLingui() + + const navigation = useNavigation() + + const groupName = convo.details.name + const [newGroupName, setNewGroupName] = useState(groupName) + + const lockStatus = convo.details.lockStatus + + // TODO Enable this once the feature is working end-to-end. -dsb + // const {joinLink} = convo.details + const isJoinLinkEnabled = false + // const isJoinLinkEnabled = + // isOwner || (!isOwner && joinLink?.enabledStatus === 'enabled') + + // TODO Enable this once the feature is working end-to-end. -dsb + const isReportLinkEnabled = false + + const {mutate: editGroupName} = useEditGroupChatName(convo.view.id, { + onError: e => { + setNewGroupName(groupName) + logger.error('Failed to edit group chat name', {message: e}) + Toast.show(l`Failed to edit group chat name`, {type: 'error'}) + }, + }) + + const {mutate: muteConvo} = useMuteConvo(convo.view.id, { + onSuccess: data => { + if (data.convo.muted) { + Toast.show(l({message: 'Group chat muted', context: 'toast'})) + } else { + Toast.show(l({message: 'Group chat unmuted', context: 'toast'})) + } + }, + onError: e => { + logger.error('Failed to mute group chat', {message: e}) + Toast.show(l`Failed to mute group chat`, {type: 'error'}) + }, + }) + + const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, { + onSuccess: () => { + // Settings > Chat > Chat list + navigation.dispatch(StackActions.pop(2)) + }, + onError: e => { + logger.error('Failed to leave group chat', {message: e}) + Toast.show(l({message: 'Failed to leave group chat', context: 'toast'}), { + type: 'error', + }) + }, + }) + + const {mutate: lockConvo} = useLockConvo(convo.view.id, { + onSuccess: data => { + const kind = data.convo.kind as ChatBskyConvoDefs.GroupConvo + if (kind.lockStatus === 'locked') { + Toast.show(l({message: 'Group chat locked', context: 'toast'})) + } else { + Toast.show(l({message: 'Group chat unlocked', context: 'toast'})) + } + }, + onError: (e, {lock}) => { + if (lock) { + logger.error('Failed to lock group chat', {message: e}) + Toast.show(l`Failed to lock group chat`, {type: 'error'}) + } else { + logger.error('Failed to unlock group chat', {message: e}) + Toast.show(l`Failed to unlock group chat`, {type: 'error'}) + } + }, + }) + + const inviteLinkDialog = Dialog.useDialogControl() + const editNamePrompt = Prompt.usePromptControl() + const lockChatPrompt = Prompt.usePromptControl() + const leaveChatPrompt = Prompt.usePromptControl() + + const handleToggleMute = () => { + muteConvo({mute: !convo.view.muted}) + } + + // TODO Need to implement this when the backend is ready. -dsb + const handleReportChat = () => {} + + const handlePromptName = () => { + setNewGroupName(groupName) + editNamePrompt.open() + } + + const handleEditName = () => { + editGroupName({name: newGroupName}) + } + + const handleConfirmLock = () => { + lockConvo({lock: true}) + } + + const handleUnlock = () => { + lockConvo({lock: false}) + } + + // TODO The creation date doesn't exist yet. -dsb + const showCreatedAt = false + const createdAt = new Date() + + const canLockGroupChat = isOwner && lockStatus !== 'locked-permanently' + + return ( + <> + + + + + + {groupName} + + {showCreatedAt ? ( + + Created {dateFormatter.format(createdAt)} + + ) : null} + + + {isOwner ? ( + + ) : null} + {isJoinLinkEnabled ? ( + + ) : null} + {canLockGroupChat ? ( + + ) : null} + {isOwner ? null : isReportLinkEnabled ? ( + + ) : null} + {isOwner ? null : ( + + )} + + + + + + + + ) +} + +function SettingsHeaderPlaceholder() { + const t = useTheme() + + return ( + + + + + + … + + + … + + + + + + + + + ) +} + +function SettingsButton({ + color = 'secondary', + disabled, + icon, + label, + text, + onPress, +}: { + color?: ButtonColor + disabled?: boolean + icon: React.ComponentType + label: string + text: string + onPress: () => void +}) { + const t = useTheme() + + return ( + + + + {text} + + + ) +} + +function SettingsButtonPlaceholder() { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + … + + + ) +} diff --git a/src/screens/Messages/ConversationSettings/prompts.tsx b/src/screens/Messages/ConversationSettings/prompts.tsx new file mode 100644 index 000000000..f5b980b99 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/prompts.tsx @@ -0,0 +1,119 @@ +import {View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' + +import {atoms as a} from '#/alf' +import type * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import * as Prompt from '#/components/Prompt' + +export function EditNamePrompt({ + control, + value, + onChangeText, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + value: string + onChangeText: (value: string) => void + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + <> + + + Edit group name + + + + + + + + + + + + + + ) +} + +export function LockChatPrompt({ + control, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} + +export function LeaveChatPrompt({ + control, + groupName, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + groupName: string + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} + +export function BlockMemberPrompt({ + control, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index 7cedb5d4b..842332ded 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -298,7 +298,10 @@ function BaseChatItem({ // System message if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) { - const info = getSystemMessageInfo(convo.lastMessage.data, convo.members) + const info = getSystemMessageInfo( + convo.lastMessage.data, + new Map(convo.members.map(m => [m.did, m])), + ) if (info) { lastMessage = i18n._(info.message) lastMessageSentAt = convo.lastMessage.sentAt diff --git a/src/screens/Messages/components/ChatStatusInfo.tsx b/src/screens/Messages/components/ChatStatusInfo.tsx index ca2b1f368..8f27c55b4 100644 --- a/src/screens/Messages/components/ChatStatusInfo.tsx +++ b/src/screens/Messages/components/ChatStatusInfo.tsx @@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react' import {type ActiveConvoStates} from '#/state/messages/convo' import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {KnownFollowers} from '#/components/KnownFollowers' @@ -16,16 +15,15 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) { const t = useTheme() const {_} = useLingui() const moderationOpts = useModerationOpts() - const {currentAccount} = useSession() const leaveConvoControl = usePromptControl() const onAcceptChat = useCallback(() => { convoState.markConvoAccepted() }, [convoState]) - const otherUser = convoState.recipients.find( - user => user.did !== currentAccount?.did, - ) + // either the other person, or the chat owner + // if we ever allow someone other than the owner to invite people, this will need to change + const otherUser = convoState.convo.primaryMember if (!moderationOpts) { return null @@ -44,7 +42,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) { {otherUser && ( { + if (hasBeenCopied) { + const timeout = setTimeout( + () => setHasBeenCopied(false), + isReducedMotionEnabled ? 2000 : 100, + ) + return () => clearTimeout(timeout) + } + }, [hasBeenCopied, isReducedMotionEnabled]) + + const onPress = useCallback( + (evt: GestureResponderEvent) => { + void Clipboard.setStringAsync(value) + setHasBeenCopied(true) + onPressProp?.(evt) + }, + [value, onPressProp], + ) + + return ( + + {hasBeenCopied && ( + + + Copied! + + + )} + + + ) +} diff --git a/src/screens/Messages/components/EditTextButton.tsx b/src/screens/Messages/components/EditTextButton.tsx new file mode 100644 index 000000000..8a1ccc620 --- /dev/null +++ b/src/screens/Messages/components/EditTextButton.tsx @@ -0,0 +1,59 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/react/macro' + +import {atoms as a, useTheme} from '#/alf' +import {Button, type ButtonProps} from '#/components/Button' +import {Text} from '#/components/Typography' + +export function EditTextButton({ + children, + style, + onPress, + ...props +}: ButtonProps & {value: string}) { + const t = useTheme() + + return ( + + + + ) +} diff --git a/src/screens/Messages/components/InviteLinkDialog.tsx b/src/screens/Messages/components/InviteLinkDialog.tsx new file mode 100644 index 000000000..843f6a833 --- /dev/null +++ b/src/screens/Messages/components/InviteLinkDialog.tsx @@ -0,0 +1,462 @@ +import {useState} from 'react' +import {View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' + +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {shareUrl} from '#/lib/sharing' +import {useCreateJoinLink} from '#/state/queries/messages/create-join-link' +import {useDisableJoinLink} from '#/state/queries/messages/disable-join-link' +import {useEditJoinLink} from '#/state/queries/messages/edit-join-link' +import {useEnableJoinLink} from '#/state/queries/messages/enable-join-link' +import {atoms as a, useTheme, web} from '#/alf' +import { + Button, + ButtonIcon, + ButtonText, + StackedButton, +} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {type ConvoWithDetails} from '#/components/dms/util' +import * as Toggle from '#/components/forms/Toggle' +import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' +import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' +import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink' +import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig' +import {Loader} from '#/components/Loader' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import {IS_WEB} from '#/env' +import {CopyTextButton} from './CopyTextButton' +import {EditTextButton} from './EditTextButton' + +enum Step { + INFO, + GENERATE, + MANAGE, +} + +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: 'numeric', +}) +const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: 'long', + day: 'numeric', + year: 'numeric', +}) + +export function InviteLinkDialog({ + convo, + control, + isOwner, +}: { + convo: Extract + control: Dialog.DialogOuterProps['control'] + isOwner: boolean +}) { + const t = useTheme() + const {t: l} = useLingui() + + const ownerName = createSanitizedDisplayName(convo.primaryMember) + + const {joinLink} = convo.details + const enabledStatus = joinLink?.enabledStatus + + const defaultStep = joinLink ? Step.MANAGE : Step.INFO + const defaultWhoCanJoin = joinLink + ? [ + `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`, + ] + : ['anyone'] + + const [step, setStep] = useState(defaultStep) + const [whoCanJoin, setWhoCanJoin] = useState(defaultWhoCanJoin) + + const {openComposer} = useOpenComposer() + + const {mutate: createJoinLink, isPending: isCreating} = useCreateJoinLink( + convo.view.id, + { + onSuccess: () => { + setStep(Step.MANAGE) + }, + onError: () => { + Toast.show(l`Failed to create invite link`, { + type: 'error', + }) + }, + }, + ) + const {mutate: editJoinLink, isPending: isEditing} = useEditJoinLink( + convo.view.id, + { + onSuccess: () => { + setStep(Step.MANAGE) + }, + onError: () => { + Toast.show(l`Failed to edit invite link`, { + type: 'error', + }) + }, + }, + ) + const {mutate: disableJoinLink, isPending: isDisabling} = useDisableJoinLink( + convo.view.id, + { + onError: () => { + Toast.show(l`Failed to disable invite link`, { + type: 'error', + }) + }, + }, + ) + const {mutate: enableJoinLink, isPending: isEnabling} = useEnableJoinLink( + convo.view.id, + { + onError: () => { + Toast.show(l`Failed to enable invite link`, { + type: 'error', + }) + }, + }, + ) + const isSaving = isCreating || isEditing + + const whoCanJoinOptions = [ + { + name: 'anyone', + owner: l`Anyone can join instantly`, + member: l`Anyone can join instantly`, + }, + { + name: 'anyone:requireApproval', + owner: l`Anyone can request to join`, + member: l`Anyone can request to join`, + }, + { + name: 'followedByOwner', + owner: l`People I follow can join instantly`, + member: l`People ${ownerName} follows can join instantly`, + }, + { + name: 'followedByOwner:requireApproval', + owner: l`People I follow can request to join`, + member: l`People ${ownerName} follows can request to join`, + }, + ] + + let content: React.ReactNode = null + let header: string | null = null + switch (step) { + case Step.INFO: + header = l`Invite link` + content = ( + <> + + + + An invite link lets people join this group chat without being + added directly. You control who can use the link and whether + they need your approval. You can disable the link at any time. + + + + + Your name, avatar, and the name of the group chat will be + visible to everyone. + + + + + + + + ) + break + case Step.GENERATE: + header = l`Generate invite link` + content = ( + <> + + + Choose who can join this group chat and how. + + + + + + {whoCanJoinOptions.map(option => ( + + {({selected}) => ( + + )} + + ))} + + + + + + + + ) + break + case Step.MANAGE: { + const hasJoinLinkCode = joinLink && joinLink.code !== '' + const joinLinkURI = hasJoinLinkCode + ? `https://bsky.app/chat/${joinLink.code}` + : 'https://bsky.app/chat' + const createdAt = joinLink ? new Date(joinLink.createdAt) : null + const currentOption = whoCanJoinOptions.find( + o => o.name === whoCanJoin[0], + ) + const ownerValue = currentOption?.owner ?? whoCanJoinOptions[0].owner + const memberValue = currentOption?.member ?? whoCanJoinOptions[0].member + header = + enabledStatus === 'enabled' ? l`Invite link` : l`Invite link disabled` + content = ( + <> + + + + {joinLinkURI} + + + {createdAt ? ( + + + Created {timeFormatter.format(createdAt)}{' '} + {dateFormatter.format(createdAt)} + + + ) : null} + + {enabledStatus === 'enabled' ? ( + + {isOwner ? ( + setStep(Step.GENERATE)}> + + {ownerValue} + + + ) : ( + {memberValue} + )} + + ) : null} + {enabledStatus === 'enabled' ? ( + + {isOwner ? ( + { + disableJoinLink() + }}> + Disable + + ) : null} + { + control.close(() => { + openComposer({ + text: joinLinkURI, + logContext: 'Other', + }) + }) + }}> + Post link + + { + void shareUrl(joinLinkURI) + }}> + Share + + + ) : ( + + + + + )} + + ) + break + } + } + + if (!isOwner && (!joinLink || joinLink?.enabledStatus === 'disabled')) { + header = l`Invite link` + content = ( + <> + + + There is no invite link for this group chat. + + + + + + + ) + } + + return ( + { + setStep(defaultStep) + setWhoCanJoin(defaultWhoCanJoin) + }}> + + + + + {header} + + + + + } + label={l`Group chat invite link dialog`} + style={web({maxWidth: 400})}> + {content} + + + ) +} + +function TargetOption({label, selected}: {label: string; selected: boolean}) { + const t = useTheme() + + return ( + + + + {label} + + + ) +} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 5d480db31..e8263e423 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -250,10 +250,8 @@ export function MessagesList({ ) const onStartReached = useCallback(() => { - if (hasScrolled && prevContentHeight.current > layoutHeight.get()) { - void convoState.fetchMessageHistory() - } - }, [convoState, hasScrolled, layoutHeight]) + void convoState.fetchMessageHistory() + }, [convoState]) const onScroll = useCallback( (e: ScrollEvent) => { @@ -376,10 +374,7 @@ export function MessagesList({ return ( member.did === item.message.sender.did, - )} - isGroupChat={convoState.isGroup()} + isGroupChat={convoState.convo.kind === 'group'} /> ) } else if (item.type === 'deleted-message') { @@ -448,8 +443,9 @@ export function MessagesList({ ListHeaderComponent={ <> - {convoState.isGroup() && convoState.hasAllHistory ? ( - + {convoState.convo?.kind === 'group' && + convoState.hasAllHistory ? ( + ) : null} } @@ -577,7 +573,7 @@ function getFooterState( } } - if (convoState.convo.status === 'request' && !hasAcceptOverride) { + if (convoState.convo.view.status === 'request' && !hasAcceptOverride) { return 'request' } diff --git a/src/screens/Messages/components/MessagesListInfoPanel.tsx b/src/screens/Messages/components/MessagesListInfoPanel.tsx index 9f1a82dbc..a5c035dfc 100644 --- a/src/screens/Messages/components/MessagesListInfoPanel.tsx +++ b/src/screens/Messages/components/MessagesListInfoPanel.tsx @@ -1,72 +1,90 @@ import {View} from 'react-native' import {Plural, Trans, useLingui} from '@lingui/react/macro' -import {type ConvoState} from '#/state/messages/convo/types' +import {logger} from '#/logger' +import {useAddGroupMembers} from '#/state/queries/messages/add-group-members' import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import {AvatarBubbles} from '#/components/AvatarBubbles' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {AddMembersFlow} from '#/components/dms/AddMembersFlow' +import {type ConvoWithDetails} from '#/components/dms/util' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' +import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {InviteLinkDialog} from './InviteLinkDialog' -export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) { +export function MessagesListInfoPanel({ + convo, +}: { + convo: Extract +}) { const t = useTheme() const {t: l} = useLingui() const addMembersControl = Dialog.useDialogControl() + const inviteLinkControl = Dialog.useDialogControl() const {currentAccount} = useSession() - const isOwner = - currentAccount?.did == null - ? false - : convoState.getPrimaryMember?.()?.did === currentAccount.did - // TODO Get this from @api/atproto - dsb - const isLinkEnabled = false + const convoId = convo.view.id + const {mutate: addGroupMembers} = useAddGroupMembers(convoId, { + onSuccess: () => { + addMembersControl.close() + }, + onError: e => { + logger.error('Failed to add group chat members', {message: e}) + Toast.show(l`Failed to add members`, {type: 'error'}) + }, + }) - const groupName = convoState.getGroupInfo?.()?.name + // TODO Enable this once the feature is working end-to-end. -dsb + // const joinLink = groupConvo?.details.joinLink + const isJoinLinkEnabled = false + // (isOwner && groupConvo) || + // (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled') - const members = (convoState?.convo?.members ?? []).filter( + const isOwner = convo?.primaryMember.did === currentAccount?.did + + const members = (convo?.members ?? []).filter( profile => profile.did !== currentAccount?.did, ) - let names: React.ReactNode | null = null + let names: React.ReactNode = null if (members.length === 1) { names = New chat with {members[0].displayName} - } - if (members.length === 2) { + } else if (members.length === 2) { names = ( New chat with {members[0].displayName} and {members[1].displayName} ) - } - if (members.length > 2) { + } else if (members.length > 2) { + const memberCount = convo.details.memberCount - 2 names = ( New chat with {members[0].displayName}, {members[1].displayName}, and{' '} . ) } - const showButtons = isOwner || isLinkEnabled + const showButtons = isOwner || isJoinLinkEnabled return ( <> - - {groupName ? ( + + {convo.details.name ? ( - {groupName} + {convo.details.name} ) : null} {names ? ( @@ -102,12 +120,16 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) { ) : null} - {isOwner || isLinkEnabled ? ( + {isJoinLinkEnabled ? ( - {/* Web-only X button in top left */} - + ) } + +function DialogInner() { + return ( + <> + {/* Native-only drag handle */} + + + Title + + Dialog content here + + {/* Web-only X button in top left */} + + + ) +} ``` ### Menu Component @@ -345,6 +401,7 @@ import {Button, ButtonText, ButtonIcon} from '#/components/Button' ``` **Button Props:** + - `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'` - `size`: `'tiny'` | `'small'` | `'large'` - `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'` @@ -384,39 +441,66 @@ import * as TextField from '#/components/forms/TextField' ## Internationalization (i18n) -All user-facing strings must be wrapped for translation using Lingui. +All user-facing strings must be wrapped for translation using Lingui. Include `comment` and/or `context` props when necessary to avoid ambiguity, e.g., “Post” as a noun vs a verb. + +Prefer using `t` via `import {useLingui} '@lingui/react/macro'` vs `_` via `import {useLingui} from '@lingui/react'`. Alias `t` to `l` to avoid collisions with `const t = useTheme()`. Refactor existing uses of ``_(msg`foo`)`` to use `` l`foo` ``. + +Prefer Unicode punctuation over keyboard punctuation, e.g., `“quote”` over `"quote"`. Prefer en dashes preceded by a non-breaking space over em dashes, e.g., `one – two` over `one—two`. ```tsx -import {msg, plural} from '@lingui/core/macro' -import {Trans} from '@lingui/react/macro' -import {useLingui} from '@lingui/react' +import {plural} from '@lingui/core/macro' +import {Trans, useLingui} from '@lingui/react/macro' function MyComponent() { - const {_} = useLingui() - - // Simple strings - use msg() with _() function - const title = _(msg`Settings`) - const errorMessage = _(msg`Something went wrong`) + const {t: l} = useLingui() + + // Simple strings - use the l macro + const title = l`Settings` + const errorMessage = l({ + message: 'Something went wrong', + comment: 'Generic error message for unknown/unhandled errors.', + context: 'Toast', + }) // Strings with variables - const greeting = _(msg`Hello, ${name}!`) + const greeting = l`Hello, ${name}!` // Pluralization - const countLabel = _(plural(count, { + const countLabel = plural(count, { one: '# item', other: '# items', - })) + }) // JSX content - use Trans component return ( - Welcome to Bluesky + + Welcome to Bluesky, {name}! + ) } ``` +Prefer `i18n.date` for date and time formatting. This ensures formatting is re-applied when the language changes at runtime. Refactor existing uses of `Intl.DateTimeFormat` to use `i18n.date`. + +```tsx +import {useLingui} from '@lingui/react/macro' + +function MyComponent() { + const {i18n} = useLingui() + + const createdAt = new Date() + + return i18n.date(createdAt, { + dateStyle: 'medium', + timeStyle: 'medium', + }) +} +``` + **Commands:** + ```bash # DO NOT run these commands - extraction and compilation are handled by a nightly CI job yarn intl:extract # Extract new strings to locale files @@ -473,7 +557,7 @@ export function useProfileMutation() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (data) => { + mutationFn: async data => { // Update logic }, onSuccess: (_, variables) => { @@ -481,7 +565,7 @@ export function useProfileMutation() { queryKey: createProfileQueryKey({did: variables.did}), }) }, - onError: (error) => { + onError: error => { if (isNetworkError(error)) { // don't log, but inform user } else if (error instanceof AppBskyExampleProcedure.ExampleError) { @@ -490,7 +574,7 @@ export function useProfileMutation() { // Log unexpected errors to Sentry logger.error('Error updating profile', {safeMessage: error}) } - } + }, }) } @@ -505,21 +589,25 @@ export function useProfileCacheMutation() { const queryClient = useQueryClient() return (data: Partial) => { - queryClient.setQueryData(createProfileQueryKey({did: data.did}), oldData => { - if (!oldData) return oldData - return {...oldData, ...data} - }) + queryClient.setQueryData( + createProfileQueryKey({did: data.did}), + oldData => { + if (!oldData) return oldData + return {...oldData, ...data} + }, + ) } } ``` **Stale Time Constants** (from `src/state/queries/index.ts`): + ```tsx -STALE.SECONDS.FIFTEEN // 15 seconds -STALE.MINUTES.ONE // 1 minute -STALE.MINUTES.FIVE // 5 minutes -STALE.HOURS.ONE // 1 hour -STALE.INFINITY // Never stale +STALE.SECONDS.FIFTEEN // 15 seconds +STALE.MINUTES.ONE // 1 minute +STALE.MINUTES.FIVE // 5 minutes +STALE.HOURS.ONE // 1 hour +STALE.INFINITY // Never stale ``` **Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these: @@ -565,12 +653,7 @@ function SettingsScreen() { const autoplayDisabled = useAutoplayDisabled() const setAutoplayDisabled = useSetAutoplayDisabled() - return ( - - ) + return } ``` @@ -604,13 +687,9 @@ import {type CommonNavigatorParams} from '#/lib/routes/types' type Props = NativeStackScreenProps export function ProfileScreen({route, navigation}: Props) { - const {name} = route.params // Type-safe params + const {name} = route.params // Type-safe params - return ( - - {/* Screen content */} - - ) + return {/* Screen content */} } // Programmatic navigation @@ -637,8 +716,9 @@ Component.android.tsx # Android-only ``` Example from Dialog: -- `src/components/Dialog/index.tsx` - Native (uses BottomSheet) -- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives) + +- `src/components/Dialog/index.tsx` – Native (uses BottomSheet) +- `src/components/Dialog/index.web.tsx` – Web (uses modal with Radix primitives) **Important:** The bundler automatically resolves platform-specific files. Just import normally: @@ -653,6 +733,7 @@ const storage = IS_NATIVE ``` Platform detection (for runtime logic, not imports): + ```tsx import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env' @@ -687,13 +768,13 @@ Common pitfalls to avoid in this codebase: // WRONG - causes bugs with state updates, navigation, opening other dialogs const onConfirm = () => { control.close() - navigation.navigate('Home') // May race with dialog animation + navigation.navigate('Home') // May race with dialog animation } // WRONG - same problem const onConfirm = () => { control.close() - otherDialogControl.open() // Will likely fail or cause visual glitches + otherDialogControl.open() // Will likely fail or cause visual glitches } // CORRECT - action runs after dialog fully closes @@ -720,12 +801,13 @@ const onConfirm = () => { ``` This applies to: + - Navigation (`navigation.navigate()`, `navigation.push()`) - Opening other dialogs or menus - State updates that affect UI (`setState`, `queryClient.invalidateQueries`) - Callbacks passed from parent components -The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`. +The Menu component on iOS specifically uses this pattern – see `src/components/Menu/index.tsx:151`. ### Controlled vs Uncontrolled Inputs @@ -748,10 +830,11 @@ Prefer `defaultValue` over `value` for TextInput on the old architecture: ### Platform-Specific Behavior Some components behave differently across platforms: -- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet) -- `Dialog.Close` - Only renders on web (X button) -- `Menu.Divider` - Only renders on web -- `Menu.ContainerItem` - Only works on native + +- `Dialog.Handle` – Only renders on native (drag handle for bottom sheet) +- `Dialog.Close` – Only renders on web (X button) +- `Menu.Divider` – Only renders on web +- `Menu.ContainerItem` – Only works on native Always test on multiple platforms when using these components. @@ -772,6 +855,7 @@ const handlePress = () => { ``` Only use `useMemo`/`useCallback` when you have a specific reason, such as: + - The value is immediately used in an effect's dependency array - You're passing a callback to a non-React library that needs referential stability @@ -779,7 +863,7 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as: 1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful -2. **Translations**: Wrap ALL user-facing strings with `msg()` or `` +2. **Translations**: Wrap ALL user-facing strings with ` `l` `` or `` 3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles @@ -793,14 +877,14 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as: ## Key Files Reference -| Purpose | Location | -|---------|----------| -| Theme definitions | `src/alf/themes.ts` | -| Design tokens | `src/alf/tokens.ts` | -| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) | -| Navigation config | `src/Navigation.tsx` | -| Route definitions | `src/routes.ts` | -| Route types | `src/lib/routes/types.ts` | -| Query hooks | `src/state/queries/*.ts` | -| Session state | `src/state/session/index.tsx` | -| i18n setup | `src/locale/i18n.ts` | +| Purpose | Location | +| ----------------- | -------------------------------------------- | +| Theme definitions | `src/alf/themes.ts` | +| Design tokens | `src/alf/tokens.ts` | +| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) | +| Navigation config | `src/Navigation.tsx` | +| Route definitions | `src/routes.ts` | +| Route types | `src/lib/routes/types.ts` | +| Query hooks | `src/state/queries/*.ts` | +| Session state | `src/state/session/index.tsx` | +| i18n setup | `src/locale/i18n.ts` | -- 2.51.2 From 736c9625eddfa60f65819ebd3a4fe5941b5c0067 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 29 Apr 2026 15:19:23 +0100 Subject: [PATCH 12/14] Fix end of feed border color (#10393) --- src/screens/Profile/Sections/Feed.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx index dc9b46356..37ced06bb 100644 --- a/src/screens/Profile/Sections/Feed.tsx +++ b/src/screens/Profile/Sections/Feed.tsx @@ -126,8 +126,7 @@ function ProfileEndOfFeed() { const t = useTheme() return ( - + End of feed -- 2.51.2 From f1764f1d618dfa78492cf11e51b8ff1fc7047a29 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:49:47 +0100 Subject: [PATCH 13/14] Move useListConvoMembersQuery inside AddMembersFlow (#10386) --- src/components/dms/AddMembersFlow.tsx | 116 +++++++++++++----- .../ConversationSettings/AddMembersLink.tsx | 6 +- .../Messages/ConversationSettings/index.tsx | 7 +- .../components/MessagesListInfoPanel.tsx | 2 +- 4 files changed, 87 insertions(+), 44 deletions(-) diff --git a/src/components/dms/AddMembersFlow.tsx b/src/components/dms/AddMembersFlow.tsx index bc3313b07..68f48bcd4 100644 --- a/src/components/dms/AddMembersFlow.tsx +++ b/src/components/dms/AddMembersFlow.tsx @@ -11,16 +11,18 @@ import {Trans, useLingui} from '@lingui/react/macro' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' +import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members' import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useSession} from '#/state/session' import {type ListMethods} from '#/view/com/util/List' import {android, atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {canBeMessaged} from '#/components/dms/util' +import {canBeMessaged, type ConvoWithDetails} from '#/components/dms/util' import * as Toggle from '#/components/forms/Toggle' import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {IS_NATIVE, IS_WEB} from '#/env' import type * as bsky from '#/types/bsky' @@ -54,12 +56,12 @@ type PlaceholderItem = { key: string } -type ErrorItem = { - type: 'error' +type LoadingItem = { + type: 'loading' key: string } -type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem +type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | LoadingItem export type State = { groupChatDids: string[] @@ -98,11 +100,11 @@ function reducer(state: State, action: Action): State { } export function AddMembersFlow({ - members, + convo, title, onAddMembers, }: { - members: string[] + convo: Extract title: string onAddMembers: ( dids: string[], @@ -112,21 +114,32 @@ export function AddMembersFlow({ const t = useTheme() const {t: l} = useLingui() const moderationOpts = useModerationOpts() + const {currentAccount} = useSession() + const control = Dialog.useDialogContext() + const [headerHeight, setHeaderHeight] = useState(0) const [footerHeight, setFooterHeight] = useState(0) + const [searchText, setSearchText] = useState('') + const listRef = useRef(null) - const {currentAccount} = useSession() const inputRef = useRef(null) - const [searchText, setSearchText] = useState('') - const { - data: results, + data: autocompleteResults, isError, - isFetching, + isFetching: isAutocompleteFetching, } = useActorAutocompleteQuery(searchText, true, 12) const {data: follows} = useProfileFollowsQuery(currentAccount?.did) + const {data: memberListData = [], isPending: isMemberListPending} = + useListConvoMembersQuery({ + convoId: convo.view.id, + placeholderData: convo.members, + }) + const memberDidSet = useMemo( + () => new Set(memberListData.map(profile => profile.did)), + [memberListData], + ) const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, { groupChatDids: [], @@ -147,8 +160,13 @@ export function AddMembersFlow({ [groupChatDids, groupChatProfiles], ) - const items = useMemo(() => { - let _items: Item[] = [] + const items = useMemo(() => { + if (isMemberListPending) { + // Still fetching chat member DIDs for filtering, so force the loading state. + return [] + } + + const _items: Item[] = [] if (isError) { _items.push({ @@ -157,11 +175,11 @@ export function AddMembersFlow({ message: l`We’re having network issues, try again`, }) } else if (searchText.length) { - if (results?.length) { - for (const profile of results) { + if (autocompleteResults?.length) { + for (const profile of autocompleteResults) { if ( profile.did === currentAccount?.did || - members.includes(profile.did) + memberDidSet.has(profile.did) ) continue _items.push({ @@ -171,18 +189,11 @@ export function AddMembersFlow({ }) } - _items = _items.sort(item => { + _items.sort(item => { return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 }) } } else { - const placeholders: Item[] = Array(10) - .fill(0) - .map((__, i) => ({ - type: 'placeholder', - key: i + '', - })) - if (follows) { for (const page of follows.pages) { for (const profile of page.follows) { @@ -194,11 +205,13 @@ export function AddMembersFlow({ } } - _items = _items.sort(item => { + _items.sort(item => { return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 }) } else { - _items.push(...placeholders) + for (let i = 0; i < 10; i++) { + _items.push({type: 'placeholder', key: i + ''}) + } } } @@ -210,12 +223,31 @@ export function AddMembersFlow({ }) } - return _items - }, [isError, searchText, l, results, currentAccount?.did, members, follows]) + if (searchText && isAutocompleteFetching && _items.length > 0) { + // Stale results are still showing while autocomplete refetches - + // append an inline indicator so the user sees that work is happening. + _items.push({type: 'loading', key: 'loading'}) + } else if ( + searchText && + !isAutocompleteFetching && + !_items.length && + !isError + ) { + _items.push({type: 'empty', key: 'empty', message: l`No results`}) + } - if (searchText && !isFetching && !items.length && !isError) { - items.push({type: 'empty', key: 'empty', message: l`No results`}) - } + return _items + }, [ + autocompleteResults, + currentAccount?.did, + follows, + isAutocompleteFetching, + isError, + isMemberListPending, + l, + memberDidSet, + searchText, + ]) const handlePressBack = useCallback(() => { control.close() @@ -243,6 +275,13 @@ export function AddMembersFlow({ case 'placeholder': { return } + case 'loading': { + return ( + + + + ) + } case 'empty': { return } @@ -436,12 +475,24 @@ export function AddMembersFlow({ renderItem={renderItems} ListHeaderComponent={listHeader} stickyHeaderIndices={[0]} + ListEmptyComponent={ + isMemberListPending || isAutocompleteFetching ? ( + + + + ) : null + } keyExtractor={(item: Item) => item.key} style={[ web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), native({height: '100%'}), ]} - webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]} + contentContainerStyle={items.length === 0 ? {flexGrow: 1} : undefined} + webInnerContentContainerStyle={[ + a.py_0, + {paddingBottom: footerHeight}, + items.length === 0 && {flexGrow: 1}, + ]} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} keyboardDismissMode="on-drag" @@ -457,7 +508,6 @@ export function AddMembersFlow({ onPress={handlePressBack}> - {' '} Back diff --git a/src/screens/Messages/ConversationSettings/AddMembersLink.tsx b/src/screens/Messages/ConversationSettings/AddMembersLink.tsx index 67c32f6a7..58674d830 100644 --- a/src/screens/Messages/ConversationSettings/AddMembersLink.tsx +++ b/src/screens/Messages/ConversationSettings/AddMembersLink.tsx @@ -16,10 +16,8 @@ import {Text} from '#/components/Typography' export function AddMembersLink({ convo, - members, }: { - convo: ConvoWithDetails - members: string[] + convo: Extract }) { const t = useTheme() const {t: l} = useLingui() @@ -96,7 +94,7 @@ export function AddMembersLink({ nativeOptions={{fullHeight: true}}> { addGroupMembers({members, profiles}) diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx index 6e88146f7..d770b4a5c 100644 --- a/src/screens/Messages/ConversationSettings/index.tsx +++ b/src/screens/Messages/ConversationSettings/index.tsx @@ -239,12 +239,7 @@ function GroupSettings({ /> ) case 'ADD_MEMBERS_LINK': - return convo ? ( - profile.did)} - /> - ) : null + return convo ? : null case 'CHAT_MEMBER': return convo ? ( profile.did)} + convo={convo} title={l`Add people`} onAddMembers={(members, profiles) => addGroupMembers({members, profiles}) -- 2.51.2 From 85c7cefd7cddb4cae5911fd33d4e5e379c5c0b60 Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Thu, 30 Apr 2026 03:17:28 +0000 Subject: [PATCH 14/14] Nightly source-language update --- src/locale/locales/en/messages.po | 88 +++++++++++++++---------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 8445052cc..b2ec9928f 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -880,7 +880,7 @@ msgstr "" #: src/components/dialogs/MutedWords.tsx:337 #: src/components/dialogs/StarterPackDialog.tsx:376 #: src/components/dialogs/StarterPackDialog.tsx:388 -#: src/components/dms/AddMembersFlow.tsx:350 +#: src/components/dms/AddMembersFlow.tsx:389 #: src/view/com/modals/UserAddRemoveLists.tsx:236 msgid "Add" msgstr "" @@ -960,7 +960,7 @@ msgstr "Add automation label to account" msgid "Add emoji reaction" msgstr "" -#: src/components/dms/AddMembersFlow.tsx:431 +#: src/components/dms/AddMembersFlow.tsx:470 msgid "Add group chat members" msgstr "Add group chat members" @@ -973,9 +973,9 @@ msgstr "" msgid "Add media to post" msgstr "" -#: src/screens/Messages/ConversationSettings/AddMembersLink.tsx:47 -#: src/screens/Messages/ConversationSettings/AddMembersLink.tsx:81 -#: src/screens/Messages/ConversationSettings/AddMembersLink.tsx:100 +#: src/screens/Messages/ConversationSettings/AddMembersLink.tsx:45 +#: src/screens/Messages/ConversationSettings/AddMembersLink.tsx:79 +#: src/screens/Messages/ConversationSettings/AddMembersLink.tsx:98 msgid "Add members" msgstr "Add members" @@ -1664,9 +1664,9 @@ msgstr "" msgid "Available" msgstr "" -#: src/components/dms/AddMembersFlow.tsx:299 -#: src/components/dms/AddMembersFlow.tsx:454 -#: src/components/dms/AddMembersFlow.tsx:461 +#: src/components/dms/AddMembersFlow.tsx:338 +#: src/components/dms/AddMembersFlow.tsx:505 +#: src/components/dms/AddMembersFlow.tsx:511 #: src/components/dms/InitiateChatFlow.tsx:489 #: src/components/dms/InitiateChatFlow.tsx:674 #: src/components/dms/InitiateChatFlow.tsx:681 @@ -2466,7 +2466,7 @@ msgstr "" #: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:199 #: src/components/dialogs/SearchablePeopleList.tsx:339 #: src/components/dialogs/StarterPackDialog.tsx:187 -#: src/components/dms/AddMembersFlow.tsx:324 +#: src/components/dms/AddMembersFlow.tsx:363 #: src/components/dms/AfterReportDialog.tsx:93 #: src/components/dms/AfterReportDialog.tsx:98 #: src/components/dms/AfterReportDialog.tsx:212 @@ -2777,7 +2777,7 @@ msgstr "" msgid "Continue thread..." msgstr "" -#: src/components/dms/AddMembersFlow.tsx:264 +#: src/components/dms/AddMembersFlow.tsx:303 #: src/components/dms/InitiateChatFlow.tsx:441 msgid "Continue to group name" msgstr "Continue to group name" @@ -3086,7 +3086,7 @@ msgstr "" msgid "Create new account" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:464 +#: src/screens/Messages/ConversationSettings/index.tsx:459 msgid "Create or modify an invite link for this group chat" msgstr "Create or modify an invite link for this group chat" @@ -3108,7 +3108,7 @@ msgstr "" #. placeholder {0}: dateFormatter.format(createdAt) #. placeholder {0}: i18n.date(appPassword.createdAt, { year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', }) -#: src/screens/Messages/ConversationSettings/index.tsx:429 +#: src/screens/Messages/ConversationSettings/index.tsx:424 #: src/screens/Settings/AppPasswords.tsx:174 msgid "Created {0}" msgstr "" @@ -3729,7 +3729,7 @@ msgstr "" msgid "Edit My Feeds" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:455 +#: src/screens/Messages/ConversationSettings/index.tsx:450 msgid "Edit name" msgstr "Edit name" @@ -3763,7 +3763,7 @@ msgstr "" msgid "Edit starter pack" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:454 +#: src/screens/Messages/ConversationSettings/index.tsx:449 msgid "Edit this group chat’s name" msgstr "Edit this group chat’s name" @@ -3910,7 +3910,7 @@ msgstr "" msgid "Enabled" msgstr "" -#: src/screens/Profile/Sections/Feed.tsx:132 +#: src/screens/Profile/Sections/Feed.tsx:131 msgid "End of feed" msgstr "" @@ -4164,7 +4164,7 @@ msgid "Failed to add emoji reaction" msgstr "" #: src/screens/Messages/components/MessagesListInfoPanel.tsx:39 -#: src/screens/Messages/ConversationSettings/AddMembersLink.tsx:38 +#: src/screens/Messages/ConversationSettings/AddMembersLink.tsx:36 msgid "Failed to add members" msgstr "Failed to add members" @@ -4221,7 +4221,7 @@ msgstr "Failed to disable invite link" msgid "Failed to disconnect Germ DM. Error: {0}" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:317 +#: src/screens/Messages/ConversationSettings/index.tsx:312 msgid "Failed to edit group chat name" msgstr "Failed to edit group chat name" @@ -4249,7 +4249,7 @@ msgstr "" msgid "Failed to launch SMS app" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:342 +#: src/screens/Messages/ConversationSettings/index.tsx:337 msgctxt "toast" msgid "Failed to leave group chat" msgstr "Failed to leave group chat" @@ -4313,7 +4313,7 @@ msgstr "" msgid "Failed to load suggested follows" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:360 +#: src/screens/Messages/ConversationSettings/index.tsx:355 msgid "Failed to lock group chat" msgstr "Failed to lock group chat" @@ -4322,7 +4322,7 @@ msgstr "Failed to lock group chat" msgid "Failed to mark all requests as read" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:331 +#: src/screens/Messages/ConversationSettings/index.tsx:326 msgid "Failed to mute group chat" msgstr "Failed to mute group chat" @@ -4404,7 +4404,7 @@ msgstr "" msgid "Failed to toggle thread mute, please try again" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:363 +#: src/screens/Messages/ConversationSettings/index.tsx:358 msgid "Failed to unlock group chat" msgstr "Failed to unlock group chat" @@ -5143,12 +5143,12 @@ msgstr "" msgid "Group chat invite link dialog" msgstr "Group chat invite link dialog" -#: src/screens/Messages/ConversationSettings/index.tsx:352 +#: src/screens/Messages/ConversationSettings/index.tsx:347 msgctxt "toast" msgid "Group chat locked" msgstr "Group chat locked" -#: src/screens/Messages/ConversationSettings/index.tsx:324 +#: src/screens/Messages/ConversationSettings/index.tsx:319 msgctxt "toast" msgid "Group chat muted" msgstr "Group chat muted" @@ -5158,12 +5158,12 @@ msgstr "Group chat muted" msgid "Group chat settings" msgstr "Group chat settings" -#: src/screens/Messages/ConversationSettings/index.tsx:354 +#: src/screens/Messages/ConversationSettings/index.tsx:349 msgctxt "toast" msgid "Group chat unlocked" msgstr "Group chat unlocked" -#: src/screens/Messages/ConversationSettings/index.tsx:326 +#: src/screens/Messages/ConversationSettings/index.tsx:321 msgctxt "toast" msgid "Group chat unmuted" msgstr "Group chat unmuted" @@ -5795,7 +5795,7 @@ msgstr "" #: src/screens/Messages/components/InviteLinkDialog.tsx:273 #: src/screens/Messages/components/InviteLinkDialog.tsx:395 #: src/screens/Messages/components/MessagesListInfoPanel.tsx:135 -#: src/screens/Messages/ConversationSettings/index.tsx:467 +#: src/screens/Messages/ConversationSettings/index.tsx:462 msgid "Invite link" msgstr "Invite link" @@ -6034,7 +6034,7 @@ msgid "Learn more." msgstr "" #: src/components/dms/LeaveConvoPrompt.tsx:52 -#: src/screens/Messages/ConversationSettings/index.tsx:500 +#: src/screens/Messages/ConversationSettings/index.tsx:495 msgid "Leave" msgstr "" @@ -6055,7 +6055,7 @@ msgstr "" msgid "Leave group chat" msgstr "Leave group chat" -#: src/screens/Messages/ConversationSettings/index.tsx:499 +#: src/screens/Messages/ConversationSettings/index.tsx:494 msgid "Leave this group chat" msgstr "Leave this group chat" @@ -6349,11 +6349,11 @@ msgstr "" msgid "Loading..." msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:621 +#: src/screens/Messages/ConversationSettings/index.tsx:616 msgid "Loading…" msgstr "Loading…" -#: src/screens/Messages/ConversationSettings/index.tsx:480 +#: src/screens/Messages/ConversationSettings/index.tsx:475 msgid "Lock" msgstr "Lock" @@ -6365,11 +6365,11 @@ msgstr "Lock group chat" msgid "Lock group chat?" msgstr "Lock group chat?" -#: src/screens/Messages/ConversationSettings/index.tsx:478 +#: src/screens/Messages/ConversationSettings/index.tsx:473 msgid "Lock this group chat" msgstr "Lock this group chat" -#: src/screens/Messages/ConversationSettings/index.tsx:480 +#: src/screens/Messages/ConversationSettings/index.tsx:475 msgid "Locked" msgstr "Locked" @@ -6692,7 +6692,7 @@ msgstr "" msgid "Music" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:448 +#: src/screens/Messages/ConversationSettings/index.tsx:443 msgid "Mute" msgstr "Mute" @@ -6737,7 +6737,7 @@ msgstr "" msgid "Mute these accounts?" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:446 +#: src/screens/Messages/ConversationSettings/index.tsx:441 msgid "Mute this group chat" msgstr "Mute this group chat" @@ -6775,7 +6775,7 @@ msgstr "" msgid "Mute words & tags" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:448 +#: src/screens/Messages/ConversationSettings/index.tsx:443 msgid "Muted" msgstr "Muted" @@ -7000,7 +7000,7 @@ msgstr "" #: src/components/contacts/screens/ViewMatches.tsx:395 #: src/components/contacts/screens/ViewMatches.tsx:410 -#: src/components/dms/AddMembersFlow.tsx:265 +#: src/components/dms/AddMembersFlow.tsx:304 #: src/components/dms/InitiateChatFlow.tsx:442 #: src/screens/Login/ForgotPasswordForm.tsx:149 #: src/screens/Login/ForgotPasswordForm.tsx:156 @@ -7158,7 +7158,7 @@ msgid "No result" msgstr "" #: src/components/dialogs/SearchablePeopleList.tsx:249 -#: src/components/dms/AddMembersFlow.tsx:217 +#: src/components/dms/AddMembersFlow.tsx:236 #: src/components/dms/InitiateChatFlow.tsx:338 #: src/components/ProgressGuide/FollowDialog.tsx:221 msgid "No results" @@ -8855,7 +8855,7 @@ msgstr "" #: src/components/dms/MessagesListBlockedFooter.tsx:86 #: src/components/dms/MessagesListBlockedFooter.tsx:93 #: src/features/liveNow/components/LiveStatusDialog.tsx:266 -#: src/screens/Messages/ConversationSettings/index.tsx:491 +#: src/screens/Messages/ConversationSettings/index.tsx:486 msgid "Report" msgstr "" @@ -8914,7 +8914,7 @@ msgstr "" msgid "Report this feed" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:490 +#: src/screens/Messages/ConversationSettings/index.tsx:485 msgid "Report this group chat" msgstr "Report this group chat" @@ -10402,7 +10402,7 @@ msgstr "" msgid "Successfully verified" msgstr "" -#: src/components/dms/AddMembersFlow.tsx:209 +#: src/components/dms/AddMembersFlow.tsx:222 #: src/components/dms/InitiateChatFlow.tsx:317 msgid "Suggested" msgstr "Suggested" @@ -11517,7 +11517,7 @@ msgstr "" msgid "Unlike ({0, plural, one {# like} other {# likes}})" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:477 +#: src/screens/Messages/ConversationSettings/index.tsx:472 msgid "Unlock this group chat" msgstr "Unlock this group chat" @@ -11554,7 +11554,7 @@ msgstr "" msgid "Unmute list" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:445 +#: src/screens/Messages/ConversationSettings/index.tsx:440 msgid "Unmute this group chat" msgstr "Unmute this group chat" @@ -12108,7 +12108,7 @@ msgstr "View profile banner" msgid "View the avatar" msgstr "" -#: src/screens/Messages/ConversationSettings/index.tsx:465 +#: src/screens/Messages/ConversationSettings/index.tsx:460 msgid "View the invite link for this group chat" msgstr "View the invite link for this group chat" @@ -12337,7 +12337,7 @@ msgstr "" msgid "We're having network issues, try again" msgstr "" -#: src/components/dms/AddMembersFlow.tsx:157 +#: src/components/dms/AddMembersFlow.tsx:175 #: src/components/dms/InitiateChatFlow.tsx:254 msgid "We’re having network issues, try again" msgstr "We’re having network issues, try again"