diff --git a/assets/icons/editBig_stroke2_corner2_rounded.svg b/assets/icons/editBig_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..d6ba5cba70 --- /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/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/forms/Toggle/index.tsx b/src/components/forms/Toggle/index.tsx index d25028ff9f..76ab62df66 100644 --- a/src/components/forms/Toggle/index.tsx +++ b/src/components/forms/Toggle/index.tsx @@ -81,6 +81,7 @@ export type ItemProps = ViewStyleProp & { isInvalid?: boolean children: ((props: ItemState) => React.ReactNode) | React.ReactNode hitSlop?: PressableProps['hitSlop'] + highlightRow?: boolean } export function useItemContext() { @@ -160,8 +161,11 @@ export function Item({ style, type = 'checkbox', label, + highlightRow, ...rest }: ItemProps) { + const t = useTheme() + const { values: selectedValues, type: groupType, @@ -207,6 +211,12 @@ export function Item({ [name, selected, disabled, hovered, pressed, focused, isInvalid], ) + const highlightStyle = highlightRow + ? selected + ? [a.rounded_full, a.p_md, {backgroundColor: t.palette.primary_50}] + : [a.rounded_full, a.p_md] + : null + return ( + style={[a.flex_row, a.align_center, a.gap_sm, highlightStyle, style]}> {typeof children === 'function' ? children(state) : children} diff --git a/src/components/icons/EditBig.tsx b/src/components/icons/EditBig.tsx index 3065f99a74..35c59687c8 100644 --- a/src/components/icons/EditBig.tsx +++ b/src/components/icons/EditBig.tsx @@ -5,6 +5,13 @@ export const EditBig_Stroke1_Corner0_Rounded = createSinglePathSVG({ path: 'M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z', }) +/** + * @deprecated Use EditBig_Stroke2_Corner2_Rounded + */ export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z', }) + +export const EditBig_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M3 16.782v-9.64c0-.546-.001-1.015.03-1.4.033-.397.105-.792.299-1.172a3 3 0 0 1 1.315-1.316c.38-.194.775-.266 1.172-.298.386-.032.856-.03 1.402-.03h3.815a1.004 1.004 0 1 1 0 2.008H7.218c-.58 0-.953 0-1.238.023-.273.023-.374.06-.424.086-.189.096-.342.25-.438.439-.025.05-.063.15-.085.424-.023.284-.025.658-.025 1.236v9.64c0 .58.002.953.025 1.238.022.272.06.374.085.424.096.188.25.342.438.438.05.025.151.063.424.085.285.023.659.025 1.238.025h9.639c.579 0 .953-.002 1.238-.025.272-.022.373-.06.423-.085.189-.096.342-.25.438-.438.026-.05.063-.152.086-.424.023-.285.024-.659.024-1.238v-3.815a1.004 1.004 0 1 1 2.009 0v3.815c0 .546 0 1.016-.03 1.4-.033.398-.106.793-.299 1.173a3 3 0 0 1-1.316 1.317c-.38.193-.775.264-1.172.297-.385.031-.855.031-1.401.031h-9.64c-.545 0-1.015 0-1.4-.031-.398-.033-.793-.104-1.173-.297a3 3 0 0 1-1.315-1.317c-.194-.38-.266-.775-.299-1.172-.031-.385-.03-.855-.03-1.4m13.685-13.9a3.01 3.01 0 0 1 4.26 0l.173.172a3.013 3.013 0 0 1 0 4.26l-8.077 8.077a2 2 0 0 1-1.42.588H9.026c-.555 0-1.004-.45-1.004-1.004V12.38c0-.533.211-1.044.588-1.42zM10.03 13.971h1.592l8.077-8.076a1.004 1.004 0 0 0 0-1.42l-.173-.173a1.004 1.004 0 0 0-1.42 0L10.03 12.38z', +}) diff --git a/src/components/icons/SquareBehindSquare4.tsx b/src/components/icons/SquareBehindSquare4.tsx index 425599cbce..7f693ec475 100644 --- a/src/components/icons/SquareBehindSquare4.tsx +++ b/src/components/icons/SquareBehindSquare4.tsx @@ -1,5 +1,12 @@ import {createSinglePathSVG} from './TEMPLATE' +/** + * @deprecated Use SquareBehindSquare_Stroke2_Corner2_Rounded + */ export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z', }) + +export const SquareBehindSquare_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z', +}) diff --git a/src/screens/Messages/ConversationSettings.tsx b/src/screens/Messages/ConversationSettings.tsx index 04794611cc..0e1a01a639 100644 --- a/src/screens/Messages/ConversationSettings.tsx +++ b/src/screens/Messages/ConversationSettings.tsx @@ -1,4 +1,4 @@ -import {useMemo, useState} from 'react' +import {useState} from 'react' import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' import {moderateProfile} from '@atproto/api' import {plural} from '@lingui/core/macro' @@ -8,14 +8,15 @@ import {StackActions, useNavigation} from '@react-navigation/native' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import { type CommonNavigatorParams, type NativeStackScreenProps, type NavigationProp, } from '#/lib/routes/types' -import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' +import {useProfileShadow} from '#/state/cache/profile-shadow' import {type Shadow} from '#/state/cache/types' import {ConvoProvider, useConvo} from '#/state/messages/convo' import {ConvoStatus} from '#/state/messages/convo/types' @@ -50,7 +51,7 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron' import {type Props as SVGIconProps} from '#/components/icons/common' import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' -import {EditBig_Stroke2_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig' +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 {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' @@ -70,10 +71,17 @@ import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' import type * as bsky from '#/types/bsky' +import {InviteLinkDialog} from './components/InviteLinkDialog' const MEMBER_LIMIT = 50 const ROW_SPACING = 10 +const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: 'long', + day: 'numeric', + year: 'numeric', +}) + type Item = | { type: 'MEMBERS_AND_REQUESTS' @@ -83,7 +91,7 @@ type Item = } | { type: 'CHAT_MEMBER' - profile: Shadow + profile: bsky.profile.AnyProfileView status: 'owner' | 'standard' | 'invited' } @@ -92,9 +100,6 @@ type Props = NativeStackScreenProps< 'MessagesConversationSettings' > -/** - * TODO This is just layout for now. - */ export function MessagesConversationSettingsScreen({route}: Props) { const {gtTablet} = useBreakpoints() @@ -138,6 +143,8 @@ function SettingsInner({convoId}: {convoId: string}) { const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did const data: bsky.profile.AnyProfileView[] = convo?.members ?? [] + // TODO Need this data in order to populate this array. -dsb + const invites: string[] = [] const {data: joinRequestsData, hasNextPage: hasMoreRequests} = useListJoinRequestsQuery({ @@ -150,7 +157,7 @@ function SettingsInner({convoId}: {convoId: string}) { 0, ) ?? 0 - const items = [ + const items: Item[] = [ { type: 'MEMBERS_AND_REQUESTS', }, @@ -167,11 +174,18 @@ function SettingsInner({convoId}: {convoId: string}) { if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 return 0 }) - .map(profile => ({ - type: 'CHAT_MEMBER', - profile, - status: primaryMember?.did === profile.did ? 'owner' : 'standard', - })), + .map( + (profile): Item => ({ + type: 'CHAT_MEMBER', + profile, + status: + primaryMember?.did === profile.did + ? 'owner' + : invites.includes(profile.did) + ? 'invited' + : 'standard', + }), + ), ] function renderItem({item}: {item: Item}) { @@ -194,14 +208,14 @@ function SettingsInner({convoId}: {convoId: string}) { /> ) case 'CHAT_MEMBER': - return ( + return convo ? ( - ) + ) : null default: return null } @@ -209,14 +223,12 @@ function SettingsInner({convoId}: {convoId: string}) { if (convoState.status === ConvoStatus.Error) { return ( - <> - convoState.error.retry()} - sideBorders={false} - /> - + convoState.error.retry()} + sideBorders={false} + /> ) } @@ -228,7 +240,7 @@ function SettingsInner({convoId}: {convoId: string}) { initialNumToRender={initialNumToRender} keyExtractor={keyExtractor} ListHeaderComponent={ - convo ? ( + convo?.kind === 'group' ? ( ) : ( @@ -258,9 +270,9 @@ function MembersAndRequests({ return ( - + - Members{' '} + Members @@ -275,6 +287,7 @@ function MembersAndRequests({ {hasMoreRequests ? l({ @@ -311,6 +324,9 @@ function AddMembersLink({ 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'}) @@ -393,7 +409,6 @@ function AddMembersLink({ title={l`Add members`} onAddMembers={members => { addGroupMembers({members}) - addMembersControl.close() }} /> @@ -403,12 +418,12 @@ function AddMembersLink({ function Member({ convo, - profile, + profile: profileUnshadowed, status, isOwner, }: { - convo: ConvoWithDetails | null - profile: Shadow + convo: ConvoWithDetails + profile: bsky.profile.AnyProfileView status: 'owner' | 'standard' | 'invited' isOwner: boolean }) { @@ -416,36 +431,32 @@ function Member({ const t = useTheme() const {t: l} = useLingui() + const profile = useProfileShadow(profileUnshadowed) const {currentAccount} = useSession() const moderationOpts = useModerationOpts() - const moderation = useMemo( - () => - moderationOpts ? moderateProfile(profile, moderationOpts) : undefined, - [profile, moderationOpts], - ) + const moderation = moderationOpts + ? moderateProfile(profile, moderationOpts) + : undefined if (!moderation) return null const isDeletedAccount = profile.handle === 'missing.invalid' const displayName = isDeletedAccount ? l`Deleted Account` - : sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) + : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) + const isSelf = currentAccount?.did === profile.did let statusBadge: React.ReactNode | null = null - if (currentAccount?.did === profile.did) { - switch (status) { - case 'owner': - statusBadge = - break + if (isSelf) { + if (status === 'owner') { + statusBadge = } } else { statusBadge = convo ? ( @@ -454,42 +465,46 @@ function Member({ return ( - { - navigation.navigate('Profile', {name: profile.did}) - }}> - - - - - - {displayName} - - - {sanitizeHandle(profile.handle, '@')} - - + ]}> + { + navigation.navigate('Profile', {name: profile.did}) + }}> + + + + {displayName} + + + {sanitizeHandle(profile.handle, '@')} + - {statusBadge} - - + + {statusBadge} + ) } @@ -497,72 +512,53 @@ function Member({ function StatusBadge({ label, style, + pressableProps, }: { label: string style?: StyleProp + pressableProps?: TriggerChildProps['props'] }) { const t = useTheme() - return ( - - - {label} - - - ) -} + const badgeStyle = [ + a.rounded_xs, + t.atoms.bg_contrast_50, + { + paddingTop: 3, + paddingBottom: 3, + paddingLeft: 6, + paddingRight: 6, + }, + style, + ] -function StatusButton({ - label, - style, - ...rest -}: { - label: string - style?: StyleProp -} & TriggerChildProps['props']) { - const t = useTheme() - - return ( - - - {label} - - + const labelText = ( + + {label} + ) + + if (pressableProps) { + return ( + + {labelText} + + ) + } + return {labelText} } function MemberMenu({ convo, profile, + displayName, type, isOwner, }: { convo: ConvoWithDetails profile: Shadow type: 'owner' | 'standard' | 'invited' + displayName: string isOwner: boolean }) { const navigation = useNavigation() @@ -574,10 +570,13 @@ function MemberMenu({ const blockMemberPrompt = Prompt.usePromptControl() - const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) + const [menuDidOpen, setMenuDidOpen] = useState(false) + const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, { + enabled: menuDidOpen, + }) const {mutate: initiateConvo} = useGetConvoForMembers({ onSuccess: ({convo}) => { - ax.metric('chat:open', {logContext: 'ProfileHeader'}) + ax.metric('chat:open', {logContext: 'ConvoSettings'}) navigation.navigate('MessagesConversation', {conversation: convo.id}) }, onError: () => { @@ -599,12 +598,12 @@ function MemberMenu({ } if (convoAvailability.convo) { - ax.metric('chat:open', {logContext: 'ProfileHeader'}) + ax.metric('chat:open', {logContext: 'ConvoSettings'}) navigation.navigate('MessagesConversation', { conversation: convoAvailability.convo.id, }) } else { - ax.metric('chat:create', {logContext: 'ProfileHeader'}) + ax.metric('chat:create', {logContext: 'ConvoSettings'}) initiateConvo([profile.did]) } } @@ -625,7 +624,7 @@ function MemberMenu({ } catch (err) { const e = err as Error if (e?.name !== 'AbortError') { - ax.logger.error('Failed to unblock account', {message: e}) + logger.error('Failed to unblock account', {message: e}) Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) @@ -638,7 +637,7 @@ function MemberMenu({ } catch (err) { const e = err as Error if (e?.name !== 'AbortError') { - ax.logger.error('Failed to block account', {message: e}) + logger.error('Failed to block account', {message: e}) Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) @@ -647,32 +646,22 @@ function MemberMenu({ } } - const moderationOpts = useModerationOpts() - const moderation = useMemo( - () => - moderationOpts ? moderateProfile(profile, moderationOpts) : undefined, - [profile, moderationOpts], - ) - - if (!moderation) return null - - const isDeletedAccount = profile.handle === 'missing.invalid' - const displayName = isDeletedAccount - ? l`Deleted Account` - : sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) - return ( <> - {({props, state, control: menuControl}) => - type === 'owner' || type === 'invited' ? ( - { + const triggerProps = { + ...props, + onPress: () => { + setMenuDidOpen(true) + props.onPress() + }, + } + return type === 'owner' || type === 'invited' ? ( + ) : ( ) - } + }} @@ -738,7 +727,7 @@ function MemberMenu({ ) : null} - {isOwner ? ( + {isOwner && type !== 'invited' ? ( removeMembers({members: [profile.did]})}> @@ -751,6 +740,7 @@ function MemberMenu({ {isOwner && type === 'invited' ? ( {}}> Uninvite @@ -773,7 +763,7 @@ function SettingsHeader({ convo, isOwner, }: { - convo: ConvoWithDetails + convo: Extract isOwner: boolean }) { const t = useTheme() @@ -781,11 +771,20 @@ function SettingsHeader({ const navigation = useNavigation() - const groupName = convo.kind === 'group' ? convo.details.name : '' + const groupName = convo.details.name const [newGroupName, setNewGroupName] = useState(groupName) const [isLocked, setIsLocked] = useState(false) + // 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) @@ -813,7 +812,7 @@ function SettingsHeader({ }) const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, { - onMutate: () => { + onSuccess: () => { navigation.dispatch(StackActions.pop(2)) }, onError: e => { @@ -824,8 +823,8 @@ function SettingsHeader({ }, }) + const inviteLinkDialog = Dialog.useDialogControl() const editNamePrompt = Prompt.usePromptControl() - const inviteLinkPrompt = Prompt.usePromptControl() const lockChatPrompt = Prompt.usePromptControl() const leaveChatPrompt = Prompt.usePromptControl() @@ -833,31 +832,16 @@ function SettingsHeader({ muteConvo({mute: !convo.view.muted}) } - const handleLeaveChat = () => { - leaveChatPrompt.open() - } - + // TODO Need to implement this when the backend is ready. -dsb const handleReportChat = () => {} const handlePromptName = () => { + setNewGroupName(groupName) editNamePrompt.open() } const handleEditName = () => { editGroupName({name: newGroupName}) - editNamePrompt.close() - } - - const handlePromptInviteLink = () => { - inviteLinkPrompt.open() - } - - const handleConfirmInviteLink = () => { - inviteLinkPrompt.close() - } - - const handlePromptLock = () => { - lockChatPrompt.open() } const handleConfirmLock = () => { @@ -868,6 +852,9 @@ function SettingsHeader({ setIsLocked(false) } + // TODO The creation date doesn't exist yet. -dsb + const createdAt = new Date() + return ( <> - Created April 2, 2026 + Created {dateFormatter.format(createdAt)} ) : null} - + {isJoinLinkEnabled ? ( + + ) : null} {isOwner ? ( ) : null} - {isOwner ? null : ( + {isOwner ? null : isReportLinkEnabled ? ( - )} + ) : null} {isOwner ? null : ( )} @@ -965,9 +958,10 @@ function SettingsHeader({ onChangeText={setNewGroupName} onConfirm={handleEditName} /> - - + … - + @@ -1132,27 +1122,6 @@ function EditNamePrompt({ ) } -function InviteLinkPrompt({ - control, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - ) -} - function LockChatPrompt({ control, onConfirm, diff --git a/src/screens/Messages/components/CopyTextButton.tsx b/src/screens/Messages/components/CopyTextButton.tsx new file mode 100644 index 0000000000..29c815f2d6 --- /dev/null +++ b/src/screens/Messages/components/CopyTextButton.tsx @@ -0,0 +1,90 @@ +import {useCallback, useEffect, useState} from 'react' +import {type GestureResponderEvent, View} from 'react-native' +import Animated, { + FadeOutUp, + useReducedMotion, + ZoomIn, +} from 'react-native-reanimated' +import * as Clipboard from 'expo-clipboard' +import {Trans} from '@lingui/react/macro' + +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon, type ButtonProps} from '#/components/Button' +import {SquareBehindSquare_Stroke2_Corner2_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4' +import {Text} from '#/components/Typography' + +export function CopyTextButton({ + children, + disabled, + style, + value, + onPress: onPressProp, + ...props +}: ButtonProps & {value: string}) { + const t = useTheme() + + const [hasBeenCopied, setHasBeenCopied] = useState(false) + + const isReducedMotionEnabled = useReducedMotion() + + useEffect(() => { + if (hasBeenCopied) { + const timeout = setTimeout( + () => setHasBeenCopied(false), + isReducedMotionEnabled ? 2000 : 100, + ) + return () => clearTimeout(timeout) + } + }, [hasBeenCopied, isReducedMotionEnabled]) + + const onPress = useCallback( + (evt: GestureResponderEvent) => { + void Clipboard.setStringAsync(value) + setHasBeenCopied(true) + onPressProp?.(evt) + }, + [value, onPressProp], + ) + + return ( + + {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/MessagesListInfoPanel.tsx b/src/screens/Messages/components/MessagesListInfoPanel.tsx index ec42341012..02064d111c 100644 --- a/src/screens/Messages/components/MessagesListInfoPanel.tsx +++ b/src/screens/Messages/components/MessagesListInfoPanel.tsx @@ -10,16 +10,19 @@ 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 {parseConvoView} 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}) { const t = useTheme() const {t: l} = useLingui() const addMembersControl = Dialog.useDialogControl() + const inviteLinkControl = Dialog.useDialogControl() const {currentAccount} = useSession() @@ -30,12 +33,21 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) { Toast.show(l`Failed to add members`, {type: 'error'}) }, }) + const convo = convoState.convo + ? parseConvoView(convoState.convo, currentAccount?.did) + : null + const groupConvo = convo?.kind === 'group' ? convo : null + // 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 isOwner = currentAccount?.did == null ? false : convoState.getPrimaryMember?.()?.did === currentAccount.did - // TODO Get this from @api/atproto - dsb + // TODO Get this from @api/atproto -dsb const isLinkEnabled = false const groupName = convoState.getGroupInfo?.()?.name @@ -113,12 +125,16 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) { ) : null} - {isOwner || isLinkEnabled ? ( + {isJoinLinkEnabled ? (