diff --git a/app.config.js b/app.config.js index 5b32a2e296..1f8971209a 100644 --- a/app.config.js +++ b/app.config.js @@ -66,6 +66,7 @@ module.exports = function (_config) { infoPlist: { CADisableMinimumFrameDurationOnPhone: true, UIBackgroundModes: ['remote-notification'], + NSUserActivityTypes: ['INSendMessageIntent'], NSCameraUsageDescription: 'Used for profile pictures, posts, and other kinds of content.', NSMicrophoneUsageDescription: @@ -123,6 +124,7 @@ module.exports = function (_config) { 'com.apple.developer.kernel.increased-memory-limit': true, 'com.apple.developer.kernel.extended-virtual-addressing': true, 'com.apple.security.application-groups': 'group.app.bsky', + 'com.apple.developer.usernotifications.communication': true, // 'com.apple.developer.device-information.user-assigned-device-name': true, }, privacyManifests: { diff --git a/assets/icons/editBig_stroke2_corner2_rounded.svg b/assets/icons/editBig_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..7adbd1cfb1 --- /dev/null +++ b/assets/icons/editBig_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg b/assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..69c48eead1 --- /dev/null +++ b/assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/unlock_stroke2_corner2_rounded.svg b/assets/icons/unlock_stroke2_corner2_rounded.svg index a9fefda12e..941a6ef2e5 100644 --- a/assets/icons/unlock_stroke2_corner2_rounded.svg +++ b/assets/icons/unlock_stroke2_corner2_rounded.svg @@ -1 +1 @@ - + diff --git a/modules/BlueskyNSE/Info.plist b/modules/BlueskyNSE/Info.plist index c2dd7eda69..e9271925e0 100644 --- a/modules/BlueskyNSE/Info.plist +++ b/modules/BlueskyNSE/Info.plist @@ -8,6 +8,13 @@ com.apple.usernotifications.service NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).NotificationService + NSExtensionAttributes + + IntentsSupported + + INSendMessageIntent + + MainAppScheme bluesky diff --git a/modules/BlueskyNSE/NotificationService.swift b/modules/BlueskyNSE/NotificationService.swift index 481402890f..b441f48a91 100644 --- a/modules/BlueskyNSE/NotificationService.swift +++ b/modules/BlueskyNSE/NotificationService.swift @@ -1,5 +1,6 @@ import UserNotifications import UIKit +import Intents let APP_GROUP = "group.app.bsky" typealias ContentHandler = (UNNotificationContent) -> Void @@ -40,17 +41,18 @@ class NotificationService: UNNotificationServiceExtension { } self.bestAttempt = bestAttempt - if reason == "chat-message" { + + if reason == "chat-message" || reason == "chat-reaction" { mutateWithChatMessage(bestAttempt) + let finalContent = createCommunicationNotification( + from: bestAttempt, + userInfo: request.content.userInfo + ) + contentHandler(finalContent) } else { mutateWithBadge(bestAttempt) + contentHandler(bestAttempt) } - - // Any image downloading (or other network tasks) should be handled at the end - // of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire - // gets called, we might not have all the needed mutations completed in time. - - contentHandler(bestAttempt) } override func serviceExtensionTimeWillExpire() { @@ -61,6 +63,81 @@ class NotificationService: UNNotificationServiceExtension { contentHandler(bestAttempt) } + // MARK: Communication Notification + + func createCommunicationNotification( + from content: UNMutableNotificationContent, + userInfo: [AnyHashable: Any] + ) -> UNNotificationContent { + let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown" + let convoId = userInfo["convoId"] as? String + var avatarImage: INImage? = nil + if let avatarUrlString = userInfo["senderAvatarUrl"] as? String { + avatarImage = downloadAvatarImage(from: avatarUrlString) + } + + let senderHandleValue = userInfo["senderHandle"] as? String + let senderHandle = INPersonHandle(value: senderHandleValue, type: .unknown) + let sender = INPerson( + personHandle: senderHandle, + nameComponents: nil, + displayName: senderDisplayName, + image: avatarImage, + contactIdentifier: nil, + customIdentifier: nil + ) + + let intent = INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: content.body, + speakableGroupName: nil, + conversationIdentifier: convoId, + serviceName: nil, + sender: sender, + attachments: nil + ) + + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + interaction.donate(completion: nil) + + do { + return try content.updating(from: intent) + } catch { + return content + } + } + + func downloadAvatarImage(from urlString: String) -> INImage? { + let thumbnailUrlString = urlString.replacingOccurrences( + of: "/img/avatar/", + with: "/img/avatar_thumbnail/" + ) + + guard let url = URL(string: thumbnailUrlString) else { return nil } + + var request = URLRequest(url: url) + request.timeoutInterval = 5 + + var imageData: Data? = nil + let semaphore = DispatchSemaphore(value: 0) + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let data = data, + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 { + imageData = data + } + semaphore.signal() + } + task.resume() + semaphore.wait() + + guard let data = imageData else { return nil } + return INImage(imageData: data) + } + // MARK: Mutations func mutateWithBadge(_ content: UNMutableNotificationContent) { diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt index 4f8a6b892a..fba23dfa0c 100644 --- a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt +++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt @@ -13,7 +13,7 @@ class BackgroundNotificationHandler( return } - if (remoteMessage.data["reason"] == "chat-message") { + if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") { mutateWithChatMessage(remoteMessage) } else { mutateWithOtherReason(remoteMessage) diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index df0d08c52b..40da5021c5 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -556,7 +556,11 @@ export type Events = { | 'FindContacts' } 'chat:create': { - logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' + logContext: + | 'ProfileHeader' + | 'NewChatDialog' + | 'SendViaChatDialog' + | 'ConvoSettings' } 'chat:open': { logContext: @@ -564,6 +568,7 @@ export type Events = { | 'NewChatDialog' | 'ChatsList' | 'SendViaChatDialog' + | 'ConvoSettings' } 'groupchat:create': { logContext: 'NewChatDialog' diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx index ff6f4ba1b9..d3c7b249c9 100644 --- a/src/components/AvatarBubbles.tsx +++ b/src/components/AvatarBubbles.tsx @@ -1,8 +1,7 @@ -import {useCallback, useEffect} from 'react' -import {type StyleProp, View, type ViewStyle} from 'react-native' +import {useEffect} from 'react' +import {View} from 'react-native' import Animated, { Easing, - interpolate, type SharedValue, useAnimatedStyle, useSharedValue, @@ -16,44 +15,32 @@ import {atoms as a, useTheme} from '#/alf' import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' import type * as bsky from '#/types/bsky' +type Layout = { + size: number + x: number + y: number + zIndex?: number + border?: boolean +} + type Props = { animate?: boolean profiles: bsky.profile.AnyProfileView[] - size?: 'small' | 'medium' | 'large' | number + size?: number } export function AvatarBubbles({ animate = false, profiles: allProfiles, - size = 'large', + size = 120, }: Props) { const {currentAccount} = useSession() const profiles = allProfiles.length > 2 ? allProfiles.filter(p => p.did !== currentAccount?.did) : allProfiles - const containerSize = - typeof size === 'number' - ? size - : size === 'small' - ? 40 - : size === 'medium' - ? 56 - : 120 - const scale = - typeof size === 'number' - ? size / 120 - : size === 'small' - ? 40 / 120 - : size === 'medium' - ? 56 / 120 - : 1 - const marginOffset = - (typeof size === 'number' && size < 120) || - size === 'small' || - size === 'medium' - ? -2 - : 0 + const scale = profiles.length <= 1 ? 1 : size / 120 + const marginOffset = size < 120 ? -2 : 0 const initialValue = animate ? 0 : 1 const p0 = useSharedValue(initialValue) @@ -61,130 +48,50 @@ export function AvatarBubbles({ const p2 = useSharedValue(initialValue) const p3 = useSharedValue(initialValue) - const animateScale = (p: Animated.SharedValue, index: number) => { - p.set(0) - p.set(() => - withDelay( - 500 + index * 100, - withTiming(1, { - duration: 250, - easing: Easing.out(Easing.back(1.75)), - }), - ), - ) - } - - const playScaleAnimation = useCallback(() => { - animateScale(p0, 0) - animateScale(p1, 1) - animateScale(p2, 2) - animateScale(p3, 3) - }, [p0, p1, p2, p3]) - useEffect(() => { if (!animate) return - playScaleAnimation() - }, [animate, playScaleAnimation]) + const animateBubble = (p: SharedValue, i: number) => { + p.set(0) + p.set(() => + withDelay( + 500 + i * 100, + withTiming(1, { + duration: 250, + easing: Easing.out(Easing.back(1.75)), + }), + ), + ) + } + animateBubble(p0, 0) + animateBubble(p1, 1) + animateBubble(p2, 2) + animateBubble(p3, 3) + }, [animate, p0, p1, p2, p3]) - let avatars = ( - <> - - - - ) - - if (profiles.length === 3) { - avatars = ( - <> - - - - - ) - } - - if (profiles.length >= 4) { - avatars = ( - <> - - - - - - ) - } + const scales = [p0, p1, p2, p3] + const layouts = getLayouts(profiles.length) return ( - + - {avatars} + style={{ + marginTop: marginOffset, + marginLeft: marginOffset, + transform: [{scale}], + transformOrigin: 'top left', + }}> + {layouts.map((layout, i) => ( + + ))} ) @@ -194,27 +101,23 @@ function AvatarBubble({ profile, scale, size, - style, x, y, + zIndex, includeProfileBorder, }: { profile?: bsky.profile.AnyProfileView scale: SharedValue size: number - style?: StyleProp x: number y: number + zIndex?: number includeProfileBorder?: boolean }) { const t = useTheme() const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - {translateX: x}, - {translateY: y}, - {scale: interpolate(scale.get(), [0, 1], [0, 1])}, - ], + transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}], })) return ( @@ -227,11 +130,17 @@ function AvatarBubble({ borderColor: t.atoms.text_inverted.color, borderWidth: 2, }, - style, + zIndex != null && {zIndex}, animatedStyle, ]}> {profile ? ( - + ) : ( )} @@ -239,25 +148,7 @@ function AvatarBubble({ ) } -function Avatar({ - profile, - size = 76, -}: { - profile: bsky.profile.AnyProfileView - size?: number -}) { - return ( - - ) -} - -function AvatarPlaceholder({size = 76}: {size?: number}) { +function AvatarPlaceholder({size}: {size: number}) { const t = useTheme() return ( @@ -267,10 +158,7 @@ function AvatarPlaceholder({size = 76}: {size?: number}) { a.justify_center, a.rounded_full, t.atoms.bg_contrast_200, - { - width: size, - height: size, - }, + {width: size, height: size}, ]}> ) } + +function getLayouts(count: number): Layout[] { + if (count === 3) { + return [ + {size: 68, x: -2, y: -2}, + {size: 56, x: 38, y: 62}, + {size: 46, x: 71, y: 18}, + ] + } + if (count >= 4) { + return [ + {size: 68, x: -2, y: -2}, + {size: 56, x: 60, y: 49}, + {size: 42, x: 14, y: 74}, + {size: 32, x: 72, y: 9}, + ] + } + return [ + {size: 76, x: -2, y: -2, zIndex: 20, border: true}, + {size: 76, x: 42, y: 42, zIndex: 10, border: true}, + ] +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 9168adfafe..45c54d4a7a 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -77,6 +77,10 @@ export type ButtonState = { focused: boolean pressed: boolean disabled: boolean + /** + * Alias for hovered || focused || pressed + */ + interacting: boolean } export type ButtonContext = VariantProps & ButtonState @@ -120,6 +124,7 @@ const Context = createContext({ focused: false, pressed: false, disabled: false, + interacting: false, }) Context.displayName = 'ButtonContext' @@ -536,6 +541,7 @@ export const Button = forwardRef( const context = useMemo( () => ({ ...state, + interacting: state.hovered || state.focused || state.pressed, variant, color, size, diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 8c82e774c4..21c18e2769 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -201,7 +201,7 @@ export function AvatarPlaceholder({size = 40}: {size?: number}) { @@ -600,7 +600,7 @@ export function FollowButtonPlaceholder({style}: ViewStyleProp) { {convo.kind === 'group' ? ( - + ) : ( void }) { const {t: l} = useLingui() return ( - + {trigger => // will always be true, since this file is platform split trigger.IS_NATIVE && ( diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx index 05df7b0324..12ccea03ab 100644 --- a/src/components/dms/ActionsWrapper.web.tsx +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -10,6 +10,7 @@ import {MessageContextMenu} from '#/components/dms/MessageContextMenu' import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import * as Toast from '#/components/Toast' +import type * as bsky from '#/types/bsky' import {EmojiReactionPicker} from './EmojiReactionPicker' import {hasReachedReactionLimit} from './util' @@ -17,12 +18,14 @@ export function ActionsWrapper({ message, hasReactions, isFromSelf, + senderProfile, children, onTap, }: { message: ChatBskyConvoDefs.MessageView hasReactions?: boolean isFromSelf: boolean + senderProfile?: bsky.profile.AnyProfileView children: React.ReactNode onTap?: () => void }) { @@ -114,7 +117,7 @@ export function ActionsWrapper({ ) }} - + {({props, state, IS_NATIVE, control}) => { // always false, file is platform split if (IS_NATIVE) return null diff --git a/src/components/dms/AddMembersFlow.tsx b/src/components/dms/AddMembersFlow.tsx index 103ad0aca4..bc3313b07c 100644 --- a/src/components/dms/AddMembersFlow.tsx +++ b/src/components/dms/AddMembersFlow.tsx @@ -98,11 +98,16 @@ function reducer(state: State, action: Action): State { } export function AddMembersFlow({ + members, title, onAddMembers, }: { + members: string[] title: string - onAddMembers: (dids: string[]) => void + onAddMembers: ( + dids: string[], + profiles: bsky.profile.AnyProfileView[], + ) => void }) { const t = useTheme() const {t: l} = useLingui() @@ -154,7 +159,11 @@ export function AddMembersFlow({ } else if (searchText.length) { if (results?.length) { for (const profile of results) { - if (profile.did === currentAccount?.did) continue + if ( + profile.did === currentAccount?.did || + members.includes(profile.did) + ) + continue _items.push({ type: 'profile', key: profile.did, @@ -202,7 +211,7 @@ export function AddMembersFlow({ } return _items - }, [isError, searchText, l, results, currentAccount?.did, follows]) + }, [isError, searchText, l, results, currentAccount?.did, members, follows]) if (searchText && !isFetching && !items.length && !isError) { items.push({type: 'empty', key: 'empty', message: l`No results`}) @@ -213,8 +222,8 @@ export function AddMembersFlow({ }, [control]) const handlePressAdd = useCallback(() => { - onAddMembers(groupChatDids) - }, [groupChatDids, onAddMembers]) + onAddMembers(groupChatDids, groupChatProfiles) + }, [groupChatDids, groupChatProfiles, onAddMembers]) const renderItems = useCallback( ({item}: {item: Item}) => { diff --git a/src/components/dms/DateDivider.tsx b/src/components/dms/DateDivider.tsx index 35dcd8b850..6e898b8f48 100644 --- a/src/components/dms/DateDivider.tsx +++ b/src/components/dms/DateDivider.tsx @@ -4,7 +4,7 @@ import {Trans, useLingui} from '@lingui/react/macro' import {subDays} from 'date-fns' import {atoms as a, useTheme} from '#/alf' -import {Text} from '../Typography' +import {Text} from '#/components/Typography' import {localDateString} from './util' const timeFormatter = new Intl.DateTimeFormat(undefined, { diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index 3a923133f1..bc18eae21a 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -25,15 +25,18 @@ import {usePromptControl} from '#/components/Prompt' import * as Toast from '#/components/Toast' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' +import type * as bsky from '#/types/bsky' import {EmojiReactionPicker} from './EmojiReactionPicker' import {hasReachedReactionLimit} from './util' export let MessageContextMenu = ({ message, + senderProfile, children, onTap, }: { message: ChatBskyConvoDefs.MessageView + senderProfile?: bsky.profile.AnyProfileView children: TriggerProps['children'] onTap?: () => void }): React.ReactNode => { @@ -110,9 +113,7 @@ export let MessageContextMenu = ({ [l, convo, message, currentAccount?.did], ) - const sender = convo.convo.members.find( - member => member.did === message.sender.did, - ) + const sender = senderProfile return ( <> @@ -183,7 +184,7 @@ export let MessageContextMenu = ({ control={reportControl} subject={{ view: 'message', - convoId: convo.convo.id, + convoId: convo.convo.view.id, message, }} onAfterSubmit={() => { @@ -197,7 +198,7 @@ export let MessageContextMenu = ({ control={blockOrDeleteControl} currentScreen="conversation" params={{ - convoId: convo.convo.id, + convoId: convo.convo.view.id, message, }} /> diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index afbd4bfcd2..7706e9cec8 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -30,7 +30,6 @@ import {useQueryClient} from '@tanstack/react-query' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {makeProfileLink} from '#/lib/routes/links' -import {useConvoActive} from '#/state/messages/convo' import {type ConvoItem} from '#/state/messages/convo/types' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' @@ -43,7 +42,6 @@ import {InlineLinkText, Link} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' -import type * as bsky from '#/types/bsky' import {DateDivider} from './DateDivider' import {useDateDividerToggle} from './DateDividerToggle' import {MessageItemEmbed} from './MessageItemEmbed' @@ -93,19 +91,18 @@ function isWithinClusterBoundary({ let MessageItem = ({ item, isGroupChat = false, - profile, }: { item: ConvoItem & {type: 'message' | 'pending-message'} isGroupChat?: boolean - profile?: bsky.profile.AnyProfileView }): React.ReactNode => { const t = useTheme() const {currentAccount} = useSession() const {t: l} = useLingui() - const {convo} = useConvoActive() const moderationOpts = useModerationOpts() const queryClient = useQueryClient() + const profile = item.relatedProfiles.get(item.message.sender.did) + const reactionsControl = useDialogControl() const reactionTapRef = useRef(false) @@ -277,9 +274,7 @@ let MessageItem = ({ return l`You reacted ${reaction.value}` } else { const senderDid = reaction.sender.did - const memberSender = convo.members.find( - member => member.did === senderDid, - ) + const memberSender = item.relatedProfiles.get(senderDid) if (memberSender) { return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}` } @@ -290,7 +285,13 @@ let MessageItem = ({ one: '# person', other: '# people', })} reacted – ${groupedReactions.map(g => g.value).join(' ')}` - }, [reactions, groupedReactions, currentAccount?.did, convo.members, l]) + }, [ + reactions, + groupedReactions, + currentAccount?.did, + item.relatedProfiles, + l, + ]) const appliedReactions = ( @@ -375,7 +376,7 @@ let MessageItem = ({ ) : null} - {(hasLargeGapFromPrev || isDateDividerToggled) && ( - - - - )} + + {(hasLargeGapFromPrev || isDateDividerToggled) && ( + + + + )} + {showAvatar ? ( @@ -434,6 +437,7 @@ let MessageItem = ({ hasReactions={hasReactions} isFromSelf={isFromSelf} message={message} + senderProfile={profile} onTap={() => { if (reactionTapRef.current) return if (!hasLargeGapFromPrev) { diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index 0806f3a116..3f5344faea 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -161,11 +161,13 @@ function GroupHeaderReady({ }) } + const lockStatus = convo.details.lockStatus + return ( - + {convo.details.name} @@ -175,6 +177,7 @@ function GroupHeaderReady({ settings={ - - {text} - - - ) -} - -function SettingsButtonPlaceholder() { - const t = useTheme() - const {t: l} = useLingui() - - return ( - - - - … - - - ) -} - -function EditNamePrompt({ - control, - value, - onChangeText, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - value: string - onChangeText: (value: string) => void - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - <> - - - Edit group name - - - - - - - - - - - - - - ) -} - -function InviteLinkPrompt({ - control, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - ) -} - -function LockChatPrompt({ - control, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - ) -} - -function LeaveChatPrompt({ - control, - groupName, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - groupName: string - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - ) -} - -function BlockMemberPrompt({ - control, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - ) -} - -function SubtleHoverWrapper({children}: React.PropsWithChildren) { - const { - state: hover, - onIn: onHoverIn, - onOut: onHoverOut, - } = useInteractionState() - - return ( - - - {children} - - ) -} diff --git a/src/screens/Messages/ConversationSettings/AddMembersLink.tsx b/src/screens/Messages/ConversationSettings/AddMembersLink.tsx new file mode 100644 index 0000000000..67c32f6a77 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/AddMembersLink.tsx @@ -0,0 +1,108 @@ +import {View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' + +import {logger} from '#/logger' +import {useAddGroupMembers} from '#/state/queries/messages/add-group-members' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {AddMembersFlow} from '#/components/dms/AddMembersFlow' +import {type ConvoWithDetails} from '#/components/dms/util' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' +import {Loader} from '#/components/Loader' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' + +export function AddMembersLink({ + convo, + members, +}: { + convo: ConvoWithDetails + members: string[] +}) { + const t = useTheme() + const {t: l} = useLingui() + + const addMembersControl = Dialog.useDialogControl() + + const convoId = convo.view.id + const {mutate: addGroupMembers, isPending: isAddPending} = useAddGroupMembers( + convoId, + { + onSuccess: () => { + addMembersControl.close() + }, + onError: e => { + logger.error('Failed to add group chat members', {message: e}) + Toast.show(l`Failed to add members`, {type: 'error'}) + }, + }, + ) + + return ( + <> + + + + + { + addGroupMembers({members, profiles}) + }} + /> + + + ) +} diff --git a/src/screens/Messages/ConversationSettings/Member.tsx b/src/screens/Messages/ConversationSettings/Member.tsx new file mode 100644 index 0000000000..5daefce761 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/Member.tsx @@ -0,0 +1,129 @@ +import {View} from 'react-native' +import {moderateProfile} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' + +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useSession} from '#/state/session' +import {atoms as a, native, useTheme, web} from '#/alf' +import { + type ConvoWithDetails, + type GroupConvoMember, +} from '#/components/dms/util' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import {MemberMenu} from './MemberMenu' +import {StatusBadge} from './StatusBadge' +import {SubtleHoverWrapper} from './SubtleHoverWrapper' + +const outerStyles = [a.px_xl, a.py_sm, a.flex_row, a.align_center, a.gap_sm] + +export function Member({ + convo, + profile: profileUnshadowed, + status, + isOwner, +}: { + convo: ConvoWithDetails + profile: GroupConvoMember + status: 'owner' | 'standard' | 'invited' + isOwner: boolean +}) { + const t = useTheme() + const {t: l} = useLingui() + + const profile = useProfileShadow(profileUnshadowed) + const {currentAccount} = useSession() + const moderationOpts = useModerationOpts() + + if (!moderationOpts) { + return + } + + const moderation = moderateProfile(profile, moderationOpts) + + const isDeletedAccount = profile.handle === 'missing.invalid' + const displayName = isDeletedAccount + ? l`Deleted Account` + : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) + const isProfileOwner = profile.did === convo.primaryMember.did + const isSelf = currentAccount?.did === profile.did + let statusBadge: React.ReactNode | null = null + if (isSelf) { + if (status === 'owner') { + statusBadge = + } + } else { + statusBadge = ( + + ) + } + + const joinedReason = profile.kind?.addedBy + ? l`Added by ${createSanitizedDisplayName( + profile.kind.addedBy, + true, + moderateProfile(profile.kind.addedBy, moderationOpts).ui('displayName'), + )}` + : `Added by invite link` + + return ( + + + + + + + + + + {!isProfileOwner && ( + + {joinedReason} + + )} + + + + + {statusBadge} + + + ) +} + +export function MemberPlaceholder() { + return ( + + + + + + + + + ) +} diff --git a/src/screens/Messages/ConversationSettings/MemberMenu.tsx b/src/screens/Messages/ConversationSettings/MemberMenu.tsx new file mode 100644 index 0000000000..127a474239 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/MemberMenu.tsx @@ -0,0 +1,252 @@ +import {useState} from 'react' +import {Pressable} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' +import {useNavigation} from '@react-navigation/native' + +import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' +import {type NavigationProp} from '#/lib/routes/types' +import {logger} from '#/logger' +import {type Shadow} from '#/state/cache/types' +import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' +import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' +import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group' +import {useProfileBlockMutationQueue} from '#/state/queries/profile' +import {atoms as a, useTheme} from '#/alf' +import {type ConvoWithDetails} from '#/components/dms/util' +import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' +import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' +import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' +import { + Person_Stroke2_Corner2_Rounded as PersonIcon, + PersonX_Stroke2_Corner0_Rounded as PersonXIcon, +} from '#/components/icons/Person' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import * as Toast from '#/components/Toast' +import {useAnalytics} from '#/analytics' +import type * as bsky from '#/types/bsky' +import {BlockMemberPrompt} from './prompts' +import {StatusBadge} from './StatusBadge' + +export function MemberMenu({ + convo, + profile, + displayName, + type, + isOwner, +}: { + convo: ConvoWithDetails + profile: Shadow + type: 'owner' | 'standard' | 'invited' + displayName: string + isOwner: boolean +}) { + const navigation = useNavigation() + const t = useTheme() + const {t: l} = useLingui() + const ax = useAnalytics() + + const requireEmailVerification = useRequireEmailVerification() + + const blockMemberPrompt = Prompt.usePromptControl() + + const [menuDidOpen, setMenuDidOpen] = useState(false) + const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, { + enabled: menuDidOpen, + }) + const {mutate: initiateConvo} = useGetConvoForMembers({ + onSuccess: ({convo}) => { + ax.metric('chat:open', {logContext: 'ConvoSettings'}) + navigation.navigate('MessagesConversation', {conversation: convo.id}) + }, + onError: () => { + Toast.show(l`Failed to create conversation`, {type: 'error'}) + }, + }) + const convoId = convo.view.id + const {mutate: removeMembers} = useRemoveFromGroupChat(convoId, { + onError: e => { + logger.error('Failed to remove group chat member', {message: e}) + Toast.show(l`Failed to remove group chat member`, {type: 'error'}) + }, + }) + const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) + + const messageMember = () => { + if (!convoAvailability?.canChat) { + return + } + + if (convoAvailability.convo) { + ax.metric('chat:open', {logContext: 'ConvoSettings'}) + navigation.navigate('MessagesConversation', { + conversation: convoAvailability.convo.id, + }) + } else { + ax.metric('chat:create', {logContext: 'ConvoSettings'}) + initiateConvo([profile.did]) + } + } + + const handleMessageMember = requireEmailVerification(messageMember, { + instructions: [ + + Before you can message another user, you must first verify your email. + , + ], + }) + + const handleBlockMember = async () => { + if (profile.viewer?.blocking) { + try { + await queueUnblock() + Toast.show(l({message: 'Account unblocked', context: 'toast'})) + } catch (err) { + const e = err as Error + if (e?.name !== 'AbortError') { + logger.error('Failed to unblock account', {message: e}) + Toast.show(l`There was an issue! ${e.toString()}`, { + type: 'error', + }) + } + } + } else { + try { + await queueBlock() + Toast.show(l({message: 'Account blocked', context: 'toast'})) + } catch (err) { + const e = err as Error + if (e?.name !== 'AbortError') { + logger.error('Failed to block account', {message: e}) + Toast.show(l`There was an issue! ${e.toString()}`, { + type: 'error', + }) + } + } + } + } + + const canBlockMember = type === 'owner' || type === 'standard' + const canRemoveMember = isOwner && type !== 'invited' + // TODO Need to integrate this. -dsb + const canUninviteMember = false + // const canUninviteMember = isOwner && type === 'invited' + + return ( + <> + + + {({props, state, control: menuControl}) => { + const isActive = + state.hovered || state.pressed || menuControl.isOpen + const triggerProps = { + ...props, + onPress: () => { + setMenuDidOpen(true) + props.onPress() + }, + } + return type === 'owner' || type === 'invited' ? ( + + ) : ( + + + + ) + }} + + + + { + navigation.navigate('Profile', {name: profile.did}) + }}> + + Go to profile + + + + + + Message + + + + + + + {canBlockMember ? ( + + + Block + + + + ) : null} + {canRemoveMember ? ( + removeMembers({members: [profile.did]})}> + + Remove from chat + + + + ) : null} + {canUninviteMember ? ( + {}}> + + Uninvite + + + + ) : null} + + + + void handleBlockMember()} + /> + + ) +} diff --git a/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx b/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx new file mode 100644 index 0000000000..fe11dfeb44 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx @@ -0,0 +1,65 @@ +import {View} from 'react-native' +import {plural} from '@lingui/core/macro' +import {Trans, useLingui} from '@lingui/react/macro' + +import {atoms as a, useTheme} from '#/alf' +import {InlineLinkText} from '#/components/Link' +import {Text} from '#/components/Typography' +import {MEMBER_LIMIT} from './constants' + +export function MembersAndRequests({ + memberCount, + requestCount, + hasMoreRequests, + isOwner, +}: { + memberCount: number + requestCount: number + hasMoreRequests: boolean + isOwner: boolean +}) { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + Members + + + + {l({ + message: `${memberCount}/${MEMBER_LIMIT}`, + comment: + 'The number of group chat members out of the total number of permitted users.', + })} + + + + {isOwner && requestCount > 0 ? ( + + {hasMoreRequests + ? l({ + message: `${requestCount}+ requests`, + comment: + 'Displayed when there are more than 50 requests to join a group chat', + }) + : l({ + message: plural(requestCount, { + one: '# request', + other: '# requests', + }), + comment: 'The number of requests to join a group chat.', + })} + + ) : null} + + ) +} diff --git a/src/screens/Messages/ConversationSettings/StatusBadge.tsx b/src/screens/Messages/ConversationSettings/StatusBadge.tsx new file mode 100644 index 0000000000..e5416bd033 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/StatusBadge.tsx @@ -0,0 +1,44 @@ +import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {type TriggerChildProps} from '#/components/Menu/types' +import {Text} from '#/components/Typography' + +export function StatusBadge({ + label, + style, + pressableProps, +}: { + label: string + style?: StyleProp + pressableProps?: TriggerChildProps['props'] +}) { + const t = useTheme() + + const badgeStyle = [ + a.rounded_xs, + t.atoms.bg_contrast_50, + { + paddingTop: 3, + paddingBottom: 3, + paddingLeft: 6, + paddingRight: 6, + }, + style, + ] + + const labelText = ( + + {label} + + ) + + if (pressableProps) { + return ( + + {labelText} + + ) + } + return {labelText} +} diff --git a/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx b/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx new file mode 100644 index 0000000000..d7f032b06e --- /dev/null +++ b/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx @@ -0,0 +1,27 @@ +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {SubtleHover} from '#/components/SubtleHover' + +export function SubtleHoverWrapper({ + children, +}: React.PropsWithChildren) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + + return ( + + + {children} + + ) +} diff --git a/src/screens/Messages/ConversationSettings/constants.ts b/src/screens/Messages/ConversationSettings/constants.ts new file mode 100644 index 0000000000..8e1058c05f --- /dev/null +++ b/src/screens/Messages/ConversationSettings/constants.ts @@ -0,0 +1 @@ +export const MEMBER_LIMIT = 50 diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx new file mode 100644 index 0000000000..6e88146f77 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/index.tsx @@ -0,0 +1,637 @@ +import {useState} from 'react' +import {View} from 'react-native' +import {type ChatBskyConvoDefs} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' +import {StackActions, useNavigation} from '@react-navigation/native' + +import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, + type NavigationProp, +} from '#/lib/routes/types' +import {logger} from '#/logger' +import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo' +import {ConvoStatus} from '#/state/messages/convo/types' +import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name' +import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' +import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members' +import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests' +import {useLockConvo} from '#/state/queries/messages/lock-conversation' +import {useMuteConvo} from '#/state/queries/messages/mute-conversation' +import {useSession} from '#/state/session' +import {List} from '#/view/com/util/List' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' +import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import { + type ConvoWithDetails, + type GroupConvoMember, +} from '#/components/dms/util' +import {Error} from '#/components/Error' +import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' +import { + Bell2_Stroke2_Corner0_Rounded as BellIcon, + Bell2Off_Stroke2_Corner0_Rounded as BellOffIcon, +} from '#/components/icons/Bell2' +import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' +import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig' +import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' +import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' +import * as Layout from '#/components/Layout' +import {Loader} from '#/components/Loader' +import * as Prompt from '#/components/Prompt' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import {InviteLinkDialog} from '../components/InviteLinkDialog' +import {AddMembersLink} from './AddMembersLink' +import {Member, MemberPlaceholder} from './Member' +import {MembersAndRequests} from './MembersAndRequests' +import {EditNamePrompt, LeaveChatPrompt, LockChatPrompt} from './prompts' + +const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: 'long', + day: 'numeric', + year: 'numeric', +}) + +type Item = + | {type: 'MEMBERS_AND_REQUESTS'; key: string} + | {type: 'ADD_MEMBERS_LINK'; key: string} + | { + type: 'CHAT_MEMBER' + key: string + profile: GroupConvoMember + status: 'owner' | 'standard' | 'invited' + } + | { + type: 'CHAT_MEMBER_PLACEHOLDER' + key: string + } + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'MessagesConversationSettings' +> + +export function MessagesConversationSettingsScreen({route}: Props) { + const {gtTablet} = useBreakpoints() + + const convoId = route.params.conversation + + return ( + + + + + + Group chat settings + + + + + + + + + ) +} + +function SettingsInner() { + const {t: l} = useLingui() + const convoState = useConvo() + const navigation = useNavigation() + + if (convoState.status === ConvoStatus.Error) { + return ( + convoState.error.retry()} + sideBorders={false} + /> + ) + } + + if (!isConvoActive(convoState)) { + return ( + + + + + + ) + } + + if (convoState.convo?.kind !== 'group') { + return ( + { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.replace('Messages', {animation: 'pop'}) + } + }} + /> + ) + } + + return +} + +function keyExtractor(item: Item) { + return item.key +} + +function GroupSettings({ + convo, +}: { + convo: Extract +}) { + const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) + const bottomBarOffset = useBottomBarOffset() + + const {currentAccount} = useSession() + + const primaryMember = convo?.primaryMember + const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did + + const {data: memberListData = [], isPending} = useListConvoMembersQuery({ + convoId: convo.view.id, + placeholderData: convo?.members, + }) + + // TODO Need this data in order to populate this array. -dsb + const invites: string[] = [] + + const {data: joinRequestsData, hasNextPage: hasMoreRequests} = + useListJoinRequestsQuery({ + convoId: convo.view.id, + enabled: isOwner, + }) + const requestCount = + joinRequestsData?.pages.reduce( + (sum, page) => sum + page.requests.length, + 0, + ) ?? 0 + + const items: Item[] = [ + { + type: 'MEMBERS_AND_REQUESTS', + key: 'members-and-requests', + }, + ...(isOwner + ? [{type: 'ADD_MEMBERS_LINK', key: 'add-members-link'} as const] + : []), + ] + if (isPending) { + // should never be pending if we correctly set the query cache data + Array.from({length: 5}).forEach((_, i) => + items.push({ + type: 'CHAT_MEMBER_PLACEHOLDER', + key: `chat-member-placeholder-${i}`, + }), + ) + } else { + items.push( + ...memberListData + .sort((a, b) => { + const aIsOwner = a.did === primaryMember?.did + const bIsOwner = b.did === primaryMember?.did + const aIsSelf = a.did === currentAccount?.did + const bIsSelf = b.did === currentAccount?.did + if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1 + if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 + return 0 + }) + .map( + (profile): Item => ({ + type: 'CHAT_MEMBER', + key: profile.did, + profile: profile as GroupConvoMember, + status: + primaryMember?.did === profile.did + ? 'owner' + : invites.includes(profile.did) + ? 'invited' + : 'standard', + }), + ), + ) + } + + function renderItem({item}: {item: Item}) { + switch (item.type) { + case 'MEMBERS_AND_REQUESTS': + return ( + + ) + case 'ADD_MEMBERS_LINK': + return convo ? ( + profile.did)} + /> + ) : null + case 'CHAT_MEMBER': + return convo ? ( + + ) : null + case 'CHAT_MEMBER_PLACEHOLDER': + return + default: + return null + } + } + + return ( + + ) : ( + + ) + } + renderItem={renderItem} + sideBorders={false} + windowSize={11} + /> + ) +} + +function SettingsHeader({ + convo, + isOwner, +}: { + convo: Extract + isOwner: boolean +}) { + const t = useTheme() + const {t: l} = useLingui() + + const navigation = useNavigation() + + const groupName = convo.details.name + const [newGroupName, setNewGroupName] = useState(groupName) + + const lockStatus = convo.details.lockStatus + + // TODO Enable this once the feature is working end-to-end. -dsb + // const {joinLink} = convo.details + const isJoinLinkEnabled = false + // const isJoinLinkEnabled = + // isOwner || (!isOwner && joinLink?.enabledStatus === 'enabled') + + // TODO Enable this once the feature is working end-to-end. -dsb + const isReportLinkEnabled = false + + const {mutate: editGroupName} = useEditGroupChatName(convo.view.id, { + onError: e => { + setNewGroupName(groupName) + logger.error('Failed to edit group chat name', {message: e}) + Toast.show(l`Failed to edit group chat name`, {type: 'error'}) + }, + }) + + const {mutate: muteConvo} = useMuteConvo(convo.view.id, { + onSuccess: data => { + if (data.convo.muted) { + Toast.show(l({message: 'Group chat muted', context: 'toast'})) + } else { + Toast.show(l({message: 'Group chat unmuted', context: 'toast'})) + } + }, + onError: e => { + logger.error('Failed to mute group chat', {message: e}) + Toast.show(l`Failed to mute group chat`, {type: 'error'}) + }, + }) + + const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, { + onSuccess: () => { + // Settings > Chat > Chat list + navigation.dispatch(StackActions.pop(2)) + }, + onError: e => { + logger.error('Failed to leave group chat', {message: e}) + Toast.show(l({message: 'Failed to leave group chat', context: 'toast'}), { + type: 'error', + }) + }, + }) + + const {mutate: lockConvo} = useLockConvo(convo.view.id, { + onSuccess: data => { + const kind = data.convo.kind as ChatBskyConvoDefs.GroupConvo + if (kind.lockStatus === 'locked') { + Toast.show(l({message: 'Group chat locked', context: 'toast'})) + } else { + Toast.show(l({message: 'Group chat unlocked', context: 'toast'})) + } + }, + onError: (e, {lock}) => { + if (lock) { + logger.error('Failed to lock group chat', {message: e}) + Toast.show(l`Failed to lock group chat`, {type: 'error'}) + } else { + logger.error('Failed to unlock group chat', {message: e}) + Toast.show(l`Failed to unlock group chat`, {type: 'error'}) + } + }, + }) + + const inviteLinkDialog = Dialog.useDialogControl() + const editNamePrompt = Prompt.usePromptControl() + const lockChatPrompt = Prompt.usePromptControl() + const leaveChatPrompt = Prompt.usePromptControl() + + const handleToggleMute = () => { + muteConvo({mute: !convo.view.muted}) + } + + // TODO Need to implement this when the backend is ready. -dsb + const handleReportChat = () => {} + + const handlePromptName = () => { + setNewGroupName(groupName) + editNamePrompt.open() + } + + const handleEditName = () => { + editGroupName({name: newGroupName}) + } + + const handleConfirmLock = () => { + lockConvo({lock: true}) + } + + const handleUnlock = () => { + lockConvo({lock: false}) + } + + // TODO The creation date doesn't exist yet. -dsb + const showCreatedAt = false + const createdAt = new Date() + + const canLockGroupChat = isOwner && lockStatus !== 'locked-permanently' + + return ( + <> + + + + + + {groupName} + + {showCreatedAt ? ( + + Created {dateFormatter.format(createdAt)} + + ) : null} + + + {isOwner ? ( + + ) : null} + {isJoinLinkEnabled ? ( + + ) : null} + {canLockGroupChat ? ( + + ) : null} + {isOwner ? null : isReportLinkEnabled ? ( + + ) : null} + {isOwner ? null : ( + + )} + + + + + + + + ) +} + +function SettingsHeaderPlaceholder() { + const t = useTheme() + + return ( + + + + + + … + + + … + + + + + + + + + ) +} + +function SettingsButton({ + color = 'secondary', + disabled, + icon, + label, + text, + onPress, +}: { + color?: ButtonColor + disabled?: boolean + icon: React.ComponentType + label: string + text: string + onPress: () => void +}) { + const t = useTheme() + + return ( + + + + {text} + + + ) +} + +function SettingsButtonPlaceholder() { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + … + + + ) +} diff --git a/src/screens/Messages/ConversationSettings/prompts.tsx b/src/screens/Messages/ConversationSettings/prompts.tsx new file mode 100644 index 0000000000..f5b980b990 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/prompts.tsx @@ -0,0 +1,119 @@ +import {View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' + +import {atoms as a} from '#/alf' +import type * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import * as Prompt from '#/components/Prompt' + +export function EditNamePrompt({ + control, + value, + onChangeText, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + value: string + onChangeText: (value: string) => void + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + <> + + + Edit group name + + + + + + + + + + + + + + ) +} + +export function LockChatPrompt({ + control, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} + +export function LeaveChatPrompt({ + control, + groupName, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + groupName: string + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} + +export function BlockMemberPrompt({ + control, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index 7cedb5d4be..842332dede 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -298,7 +298,10 @@ function BaseChatItem({ // System message if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) { - const info = getSystemMessageInfo(convo.lastMessage.data, convo.members) + const info = getSystemMessageInfo( + convo.lastMessage.data, + new Map(convo.members.map(m => [m.did, m])), + ) if (info) { lastMessage = i18n._(info.message) lastMessageSentAt = convo.lastMessage.sentAt diff --git a/src/screens/Messages/components/ChatStatusInfo.tsx b/src/screens/Messages/components/ChatStatusInfo.tsx index ca2b1f3685..8f27c55b46 100644 --- a/src/screens/Messages/components/ChatStatusInfo.tsx +++ b/src/screens/Messages/components/ChatStatusInfo.tsx @@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react' import {type ActiveConvoStates} from '#/state/messages/convo' import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {KnownFollowers} from '#/components/KnownFollowers' @@ -16,16 +15,15 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) { const t = useTheme() const {_} = useLingui() const moderationOpts = useModerationOpts() - const {currentAccount} = useSession() const leaveConvoControl = usePromptControl() const onAcceptChat = useCallback(() => { convoState.markConvoAccepted() }, [convoState]) - const otherUser = convoState.recipients.find( - user => user.did !== currentAccount?.did, - ) + // either the other person, or the chat owner + // if we ever allow someone other than the owner to invite people, this will need to change + const otherUser = convoState.convo.primaryMember if (!moderationOpts) { return null @@ -44,7 +42,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) { {otherUser && ( { + if (hasBeenCopied) { + const timeout = setTimeout( + () => setHasBeenCopied(false), + isReducedMotionEnabled ? 2000 : 100, + ) + return () => clearTimeout(timeout) + } + }, [hasBeenCopied, isReducedMotionEnabled]) + + const onPress = useCallback( + (evt: GestureResponderEvent) => { + void Clipboard.setStringAsync(value) + setHasBeenCopied(true) + onPressProp?.(evt) + }, + [value, onPressProp], + ) + + return ( + + {hasBeenCopied && ( + + + Copied! + + + )} + + + ) +} diff --git a/src/screens/Messages/components/EditTextButton.tsx b/src/screens/Messages/components/EditTextButton.tsx new file mode 100644 index 0000000000..8a1ccc620a --- /dev/null +++ b/src/screens/Messages/components/EditTextButton.tsx @@ -0,0 +1,59 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/react/macro' + +import {atoms as a, useTheme} from '#/alf' +import {Button, type ButtonProps} from '#/components/Button' +import {Text} from '#/components/Typography' + +export function EditTextButton({ + children, + style, + onPress, + ...props +}: ButtonProps & {value: string}) { + const t = useTheme() + + return ( + + + + ) +} diff --git a/src/screens/Messages/components/InviteLinkDialog.tsx b/src/screens/Messages/components/InviteLinkDialog.tsx new file mode 100644 index 0000000000..843f6a833e --- /dev/null +++ b/src/screens/Messages/components/InviteLinkDialog.tsx @@ -0,0 +1,462 @@ +import {useState} from 'react' +import {View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' + +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {shareUrl} from '#/lib/sharing' +import {useCreateJoinLink} from '#/state/queries/messages/create-join-link' +import {useDisableJoinLink} from '#/state/queries/messages/disable-join-link' +import {useEditJoinLink} from '#/state/queries/messages/edit-join-link' +import {useEnableJoinLink} from '#/state/queries/messages/enable-join-link' +import {atoms as a, useTheme, web} from '#/alf' +import { + Button, + ButtonIcon, + ButtonText, + StackedButton, +} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {type ConvoWithDetails} from '#/components/dms/util' +import * as Toggle from '#/components/forms/Toggle' +import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' +import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' +import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink' +import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig' +import {Loader} from '#/components/Loader' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import {IS_WEB} from '#/env' +import {CopyTextButton} from './CopyTextButton' +import {EditTextButton} from './EditTextButton' + +enum Step { + INFO, + GENERATE, + MANAGE, +} + +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: 'numeric', +}) +const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: 'long', + day: 'numeric', + year: 'numeric', +}) + +export function InviteLinkDialog({ + convo, + control, + isOwner, +}: { + convo: Extract + control: Dialog.DialogOuterProps['control'] + isOwner: boolean +}) { + const t = useTheme() + const {t: l} = useLingui() + + const ownerName = createSanitizedDisplayName(convo.primaryMember) + + const {joinLink} = convo.details + const enabledStatus = joinLink?.enabledStatus + + const defaultStep = joinLink ? Step.MANAGE : Step.INFO + const defaultWhoCanJoin = joinLink + ? [ + `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`, + ] + : ['anyone'] + + const [step, setStep] = useState(defaultStep) + const [whoCanJoin, setWhoCanJoin] = useState(defaultWhoCanJoin) + + const {openComposer} = useOpenComposer() + + const {mutate: createJoinLink, isPending: isCreating} = useCreateJoinLink( + convo.view.id, + { + onSuccess: () => { + setStep(Step.MANAGE) + }, + onError: () => { + Toast.show(l`Failed to create invite link`, { + type: 'error', + }) + }, + }, + ) + const {mutate: editJoinLink, isPending: isEditing} = useEditJoinLink( + convo.view.id, + { + onSuccess: () => { + setStep(Step.MANAGE) + }, + onError: () => { + Toast.show(l`Failed to edit invite link`, { + type: 'error', + }) + }, + }, + ) + const {mutate: disableJoinLink, isPending: isDisabling} = useDisableJoinLink( + convo.view.id, + { + onError: () => { + Toast.show(l`Failed to disable invite link`, { + type: 'error', + }) + }, + }, + ) + const {mutate: enableJoinLink, isPending: isEnabling} = useEnableJoinLink( + convo.view.id, + { + onError: () => { + Toast.show(l`Failed to enable invite link`, { + type: 'error', + }) + }, + }, + ) + const isSaving = isCreating || isEditing + + const whoCanJoinOptions = [ + { + name: 'anyone', + owner: l`Anyone can join instantly`, + member: l`Anyone can join instantly`, + }, + { + name: 'anyone:requireApproval', + owner: l`Anyone can request to join`, + member: l`Anyone can request to join`, + }, + { + name: 'followedByOwner', + owner: l`People I follow can join instantly`, + member: l`People ${ownerName} follows can join instantly`, + }, + { + name: 'followedByOwner:requireApproval', + owner: l`People I follow can request to join`, + member: l`People ${ownerName} follows can request to join`, + }, + ] + + let content: React.ReactNode = null + let header: string | null = null + switch (step) { + case Step.INFO: + header = l`Invite link` + content = ( + <> + + + + An invite link lets people join this group chat without being + added directly. You control who can use the link and whether + they need your approval. You can disable the link at any time. + + + + + Your name, avatar, and the name of the group chat will be + visible to everyone. + + + + + + + + ) + break + case Step.GENERATE: + header = l`Generate invite link` + content = ( + <> + + + Choose who can join this group chat and how. + + + + + + {whoCanJoinOptions.map(option => ( + + {({selected}) => ( + + )} + + ))} + + + + + + + + ) + break + case Step.MANAGE: { + const hasJoinLinkCode = joinLink && joinLink.code !== '' + const joinLinkURI = hasJoinLinkCode + ? `https://bsky.app/chat/${joinLink.code}` + : 'https://bsky.app/chat' + const createdAt = joinLink ? new Date(joinLink.createdAt) : null + const currentOption = whoCanJoinOptions.find( + o => o.name === whoCanJoin[0], + ) + const ownerValue = currentOption?.owner ?? whoCanJoinOptions[0].owner + const memberValue = currentOption?.member ?? whoCanJoinOptions[0].member + header = + enabledStatus === 'enabled' ? l`Invite link` : l`Invite link disabled` + content = ( + <> + + + + {joinLinkURI} + + + {createdAt ? ( + + + Created {timeFormatter.format(createdAt)}{' '} + {dateFormatter.format(createdAt)} + + + ) : null} + + {enabledStatus === 'enabled' ? ( + + {isOwner ? ( + setStep(Step.GENERATE)}> + + {ownerValue} + + + ) : ( + {memberValue} + )} + + ) : null} + {enabledStatus === 'enabled' ? ( + + {isOwner ? ( + { + disableJoinLink() + }}> + Disable + + ) : null} + { + control.close(() => { + openComposer({ + text: joinLinkURI, + logContext: 'Other', + }) + }) + }}> + Post link + + { + void shareUrl(joinLinkURI) + }}> + Share + + + ) : ( + + + + + )} + + ) + break + } + } + + if (!isOwner && (!joinLink || joinLink?.enabledStatus === 'disabled')) { + header = l`Invite link` + content = ( + <> + + + There is no invite link for this group chat. + + + + + + + ) + } + + return ( + { + setStep(defaultStep) + setWhoCanJoin(defaultWhoCanJoin) + }}> + + + + + {header} + + + + + } + label={l`Group chat invite link dialog`} + style={web({maxWidth: 400})}> + {content} + + + ) +} + +function TargetOption({label, selected}: {label: string; selected: boolean}) { + const t = useTheme() + + return ( + + + + {label} + + + ) +} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 5d480db312..e8263e423d 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -250,10 +250,8 @@ export function MessagesList({ ) const onStartReached = useCallback(() => { - if (hasScrolled && prevContentHeight.current > layoutHeight.get()) { - void convoState.fetchMessageHistory() - } - }, [convoState, hasScrolled, layoutHeight]) + void convoState.fetchMessageHistory() + }, [convoState]) const onScroll = useCallback( (e: ScrollEvent) => { @@ -376,10 +374,7 @@ export function MessagesList({ return ( member.did === item.message.sender.did, - )} - isGroupChat={convoState.isGroup()} + isGroupChat={convoState.convo.kind === 'group'} /> ) } else if (item.type === 'deleted-message') { @@ -448,8 +443,9 @@ export function MessagesList({ ListHeaderComponent={ <> - {convoState.isGroup() && convoState.hasAllHistory ? ( - + {convoState.convo?.kind === 'group' && + convoState.hasAllHistory ? ( + ) : null} } @@ -577,7 +573,7 @@ function getFooterState( } } - if (convoState.convo.status === 'request' && !hasAcceptOverride) { + if (convoState.convo.view.status === 'request' && !hasAcceptOverride) { return 'request' } diff --git a/src/screens/Messages/components/MessagesListInfoPanel.tsx b/src/screens/Messages/components/MessagesListInfoPanel.tsx index 9f1a82dbc4..a5c035dfc8 100644 --- a/src/screens/Messages/components/MessagesListInfoPanel.tsx +++ b/src/screens/Messages/components/MessagesListInfoPanel.tsx @@ -1,72 +1,90 @@ import {View} from 'react-native' import {Plural, Trans, useLingui} from '@lingui/react/macro' -import {type ConvoState} from '#/state/messages/convo/types' +import {logger} from '#/logger' +import {useAddGroupMembers} from '#/state/queries/messages/add-group-members' import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import {AvatarBubbles} from '#/components/AvatarBubbles' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {AddMembersFlow} from '#/components/dms/AddMembersFlow' +import {type ConvoWithDetails} from '#/components/dms/util' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' +import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {InviteLinkDialog} from './InviteLinkDialog' -export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) { +export function MessagesListInfoPanel({ + convo, +}: { + convo: Extract +}) { const t = useTheme() const {t: l} = useLingui() const addMembersControl = Dialog.useDialogControl() + const inviteLinkControl = Dialog.useDialogControl() const {currentAccount} = useSession() - const isOwner = - currentAccount?.did == null - ? false - : convoState.getPrimaryMember?.()?.did === currentAccount.did - // TODO Get this from @api/atproto - dsb - const isLinkEnabled = false + const convoId = convo.view.id + const {mutate: addGroupMembers} = useAddGroupMembers(convoId, { + onSuccess: () => { + addMembersControl.close() + }, + onError: e => { + logger.error('Failed to add group chat members', {message: e}) + Toast.show(l`Failed to add members`, {type: 'error'}) + }, + }) - const groupName = convoState.getGroupInfo?.()?.name + // TODO Enable this once the feature is working end-to-end. -dsb + // const joinLink = groupConvo?.details.joinLink + const isJoinLinkEnabled = false + // (isOwner && groupConvo) || + // (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled') - const members = (convoState?.convo?.members ?? []).filter( + const isOwner = convo?.primaryMember.did === currentAccount?.did + + const members = (convo?.members ?? []).filter( profile => profile.did !== currentAccount?.did, ) - let names: React.ReactNode | null = null + let names: React.ReactNode = null if (members.length === 1) { names = New chat with {members[0].displayName} - } - if (members.length === 2) { + } else if (members.length === 2) { names = ( New chat with {members[0].displayName} and {members[1].displayName} ) - } - if (members.length > 2) { + } else if (members.length > 2) { + const memberCount = convo.details.memberCount - 2 names = ( New chat with {members[0].displayName}, {members[1].displayName}, and{' '} . ) } - const showButtons = isOwner || isLinkEnabled + const showButtons = isOwner || isJoinLinkEnabled return ( <> - - {groupName ? ( + + {convo.details.name ? ( - {groupName} + {convo.details.name} ) : null} {names ? ( @@ -102,12 +120,16 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) { ) : null} - {isOwner || isLinkEnabled ? ( + {isJoinLinkEnabled ? (